From fda1208660b53550a50115a5fd2bcfa1f360e55d Mon Sep 17 00:00:00 2001 From: German Date: Mon, 21 Aug 2023 16:14:39 -0300 Subject: [PATCH 01/79] feat: add xpert summaries configuration by default for units (#567) * feat: add xpert summaries configuration by default for units --- src/index.scss | 1 + .../PagesAndResources.scss | 1 + src/pages-and-resources/data/selectors.js | 1 + src/pages-and-resources/data/slice.js | 5 + .../XpertUnitSummarySettings.jsx | 2 + .../XpertUnitSummarySettings.test.jsx | 111 ++++++++++++- .../xpert-unit-summary/data/api.js | 8 + .../xpert-unit-summary/data/thunks.js | 55 ++++++- .../settings-modal/SettingsModal.jsx | 149 ++++++++++++++++-- .../settings-modal/SettingsModal.scss | 31 ++++ .../settings-modal/messages.js | 16 ++ 11 files changed, 359 insertions(+), 21 deletions(-) create mode 100644 src/pages-and-resources/PagesAndResources.scss create mode 100644 src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss diff --git a/src/index.scss b/src/index.scss index 62da213ee0..83c5dac165 100755 --- a/src/index.scss +++ b/src/index.scss @@ -13,3 +13,4 @@ @import "grading-settings/scss/GradingSettings"; @import "generic/styles"; @import "schedule-and-details/ScheduleAndDetails"; +@import "pages-and-resources/PagesAndResources"; diff --git a/src/pages-and-resources/PagesAndResources.scss b/src/pages-and-resources/PagesAndResources.scss new file mode 100644 index 0000000000..b577fe0f4b --- /dev/null +++ b/src/pages-and-resources/PagesAndResources.scss @@ -0,0 +1 @@ +@import "./xpert-unit-summary/settings-modal/SettingsModal"; diff --git a/src/pages-and-resources/data/selectors.js b/src/pages-and-resources/data/selectors.js index 9e97e10af6..c3d93a0801 100644 --- a/src/pages-and-resources/data/selectors.js +++ b/src/pages-and-resources/data/selectors.js @@ -6,3 +6,4 @@ export const getCourseAppsApiStatus = (state) => state.pagesAndResources.courseA export const getCourseAppSettingValue = (setting) => (state) => ( state.pagesAndResources.courseAppSettings[setting]?.value ); +export const getResetStatus = (state) => state.pagesAndResources.resetStatus; diff --git a/src/pages-and-resources/data/slice.js b/src/pages-and-resources/data/slice.js index 771e9cf6c1..95379cd34a 100644 --- a/src/pages-and-resources/data/slice.js +++ b/src/pages-and-resources/data/slice.js @@ -9,6 +9,7 @@ const slice = createSlice({ courseAppIds: [], loadingStatus: RequestStatus.IN_PROGRESS, savingStatus: '', + resetStatus: '', courseAppsApiStatus: {}, courseAppSettings: {}, }, @@ -22,6 +23,9 @@ const slice = createSlice({ updateSavingStatus: (state, { payload }) => { state.savingStatus = payload.status; }, + updateResetStatus: (state, { payload }) => { + state.resetStatus = payload.status; + }, updateCourseAppsApiStatus: (state, { payload }) => { state.courseAppsApiStatus = payload.status; }, @@ -38,6 +42,7 @@ export const { fetchCourseAppsSuccess, updateLoadingStatus, updateSavingStatus, + updateResetStatus, updateCourseAppsApiStatus, fetchCourseAppsSettingsSuccess, updateCourseAppsSettingsSuccess, diff --git a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx b/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx index b99e422571..c90d3a22bd 100644 --- a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx +++ b/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx @@ -44,6 +44,8 @@ const XpertUnitSummarySettings = ({ intl }) => { } enableAppLabel={intl.formatMessage(messages.enableXpertUnitSummaryLabel)} learnMoreText={intl.formatMessage(messages.enableXpertUnitSummaryLink)} + allUnitsEnabledText={intl.formatMessage(messages.allUnitsEnabledByDefault)} + noUnitsEnabledText={intl.formatMessage(messages.noUnitsEnabledByDefault)} onClose={handleClose} /> ); diff --git a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.test.jsx b/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.test.jsx index c6f2e98b6b..bd682d96ab 100644 --- a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.test.jsx +++ b/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.test.jsx @@ -112,12 +112,12 @@ describe('XpertUnitSummarySettings', () => { renderComponent(); }); - test('Shows enabled if enabled from backend', async () => { + test('Shows switch on if enabled from backend', async () => { expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy(); expect(queryByTestId(container, 'enable-badge')).toBeTruthy(); }); - test('Does not show enabled if disabled from backend', async () => { + test('Shows switch on if disabled from backend', async () => { axiosMock.onGet(API.getXpertSettingsUrl(courseId)) .reply(200, generateCourseLevelAPIRepsonse({ success: true, @@ -126,8 +126,25 @@ describe('XpertUnitSummarySettings', () => { renderComponent(); await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy()); - expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).not.toBeTruthy(); - expect(queryByTestId(container, 'enable-badge')).not.toBeTruthy(); + expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy(); + expect(queryByTestId(container, 'enable-badge')).toBeTruthy(); + }); + + test('Shows enable radio selected if enabled from backend', async () => { + await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy()); + expect(queryByTestId(container, 'enable-radio').checked).toBeTruthy(); + }); + + test('Shows disable radio selected if enabled from backend', async () => { + axiosMock.onGet(API.getXpertSettingsUrl(courseId)) + .reply(200, generateCourseLevelAPIRepsonse({ + success: true, + enabled: false, + })); + + renderComponent(); + await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy()); + expect(queryByTestId(container, 'disable-radio').checked).toBeTruthy(); }); }); @@ -136,7 +153,7 @@ describe('XpertUnitSummarySettings', () => { axiosMock.onGet(API.getXpertSettingsUrl(courseId)) .reply(400, generateCourseLevelAPIRepsonse({ success: false, - enabled: false, + enabled: undefined, })); renderComponent(); @@ -151,6 +168,12 @@ describe('XpertUnitSummarySettings', () => { describe('saving configuration changes', () => { beforeEach(() => { + axiosMock.onGet(API.getXpertSettingsUrl(courseId)) + .reply(200, generateCourseLevelAPIRepsonse({ + success: true, + enabled: false, + })); + axiosMock.onPost(API.getXpertSettingsUrl(courseId)) .reply(200, generateCourseLevelAPIRepsonse({ success: true, @@ -164,8 +187,10 @@ describe('XpertUnitSummarySettings', () => { jest.spyOn(API, 'postXpertSettings'); await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy()); + expect(queryByTestId(container, 'disable-radio').checked).toBeTruthy(); + fireEvent.click(queryByTestId(container, 'enable-radio')); fireEvent.click(getByText(container, 'Save')); - await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).not.toBeTruthy()); + await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy()); expect(API.postXpertSettings).toBeCalled(); }); }); @@ -186,4 +211,78 @@ describe('XpertUnitSummarySettings', () => { expect(API.getXpertPluginConfigurable).toBeCalled(); }); }); + + describe('removing course configuration', () => { + beforeEach(() => { + axiosMock.onGet(API.getXpertSettingsUrl(courseId)) + .reply(200, generateCourseLevelAPIRepsonse({ + success: true, + enabled: true, + })); + + axiosMock.onDelete(API.getXpertSettingsUrl(courseId)) + .reply(200, generateCourseLevelAPIRepsonse({ + success: true, + enabled: undefined, + })); + + renderComponent(); + }); + + test('Deleting course configuration', async () => { + jest.spyOn(API, 'deleteXpertSettings'); + + await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy()); + fireEvent.click(container.querySelector('#enable-xpert-unit-summary-toggle')); + fireEvent.click(getByText(container, 'Save')); + await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy()); + expect(API.deleteXpertSettings).toBeCalled(); + }); + }); + + describe('resetting course units', () => { + test('reset all units to be enabled', async () => { + axiosMock.onGet(API.getXpertSettingsUrl(courseId)) + .reply(200, generateCourseLevelAPIRepsonse({ + success: true, + enabled: true, + })); + + axiosMock.onPost(API.getXpertSettingsUrl(courseId)) + .reply(200, generateCourseLevelAPIRepsonse({ + success: true, + enabled: true, + })); + + renderComponent(); + + jest.spyOn(API, 'postXpertSettings'); + + await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy()); + fireEvent.click(queryByTestId(container, 'reset-units')); + expect(API.postXpertSettings).toBeCalledWith(courseId, { reset: true, enabled: true }); + }); + + test('reset all units to be disabled', async () => { + axiosMock.onGet(API.getXpertSettingsUrl(courseId)) + .reply(200, generateCourseLevelAPIRepsonse({ + success: true, + enabled: false, + })); + + axiosMock.onPost(API.getXpertSettingsUrl(courseId)) + .reply(200, generateCourseLevelAPIRepsonse({ + success: true, + enabled: false, + })); + + renderComponent(); + + jest.spyOn(API, 'postXpertSettings'); + + await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy()); + fireEvent.click(queryByTestId(container, 'reset-units')); + expect(API.postXpertSettings).toBeCalledWith(courseId, { reset: true, enabled: false }); + }); + }); }); diff --git a/src/pages-and-resources/xpert-unit-summary/data/api.js b/src/pages-and-resources/xpert-unit-summary/data/api.js index b7b489861a..32233ac2b8 100644 --- a/src/pages-and-resources/xpert-unit-summary/data/api.js +++ b/src/pages-and-resources/xpert-unit-summary/data/api.js @@ -20,6 +20,7 @@ export async function postXpertSettings(courseId, state) { const { data } = await getAuthenticatedHttpClient() .post(getXpertSettingsUrl(courseId), { enabled: state.enabled, + reset: state.reset, }); return data; @@ -31,3 +32,10 @@ export async function getXpertPluginConfigurable(courseId) { return data; } + +export async function deleteXpertSettings(courseId) { + const { data } = await getAuthenticatedHttpClient() + .delete(getXpertSettingsUrl(courseId)); + + return data; +} diff --git a/src/pages-and-resources/xpert-unit-summary/data/thunks.js b/src/pages-and-resources/xpert-unit-summary/data/thunks.js index c10fb7529a..b79a36ccb1 100644 --- a/src/pages-and-resources/xpert-unit-summary/data/thunks.js +++ b/src/pages-and-resources/xpert-unit-summary/data/thunks.js @@ -1,6 +1,8 @@ -import { getXpertSettings, postXpertSettings, getXpertPluginConfigurable } from './api'; +import { + getXpertSettings, postXpertSettings, getXpertPluginConfigurable, deleteXpertSettings, +} from './api'; -import { updateSavingStatus, updateLoadingStatus } from '../../data/slice'; +import { updateSavingStatus, updateLoadingStatus, updateResetStatus } from '../../data/slice'; import { RequestStatus } from '../../../data/constants'; import { addModel, updateModel } from '../../../generic/model-store'; @@ -27,13 +29,13 @@ export function updateXpertSettings(courseId, state) { export function fetchXpertPluginConfigurable(courseId) { return async (dispatch) => { - let enabled = false; + let enabled; dispatch(updateLoadingStatus({ status: RequestStatus.PENDING })); try { const { response } = await getXpertPluginConfigurable(courseId); enabled = response?.enabled; } catch (e) { - enabled = false; + enabled = undefined; } dispatch(addModel({ @@ -48,14 +50,14 @@ export function fetchXpertPluginConfigurable(courseId) { export function fetchXpertSettings(courseId) { return async (dispatch) => { - let enabled = false; + let enabled; dispatch(updateLoadingStatus({ status: RequestStatus.PENDING })); try { const { response } = await getXpertSettings(courseId); enabled = response?.enabled; } catch (e) { - enabled = false; + enabled = undefined; } dispatch(addModel({ @@ -69,3 +71,44 @@ export function fetchXpertSettings(courseId) { dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); }; } + +export function removeXpertSettings(courseId) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + + try { + const { response } = await deleteXpertSettings(courseId); + const { success } = response; + if (success) { + const model = { id: 'xpert-unit-summary', enabled: undefined }; + dispatch(updateModel({ modelType: 'XpertSettings', model })); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + return true; + } + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + return false; + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + return false; + } + }; +} + +export function resetXpertSettings(courseId, state) { + return async (dispatch) => { + dispatch(updateResetStatus({ status: RequestStatus.PENDING })); + try { + const { response } = await postXpertSettings(courseId, state); + const { success } = response; + if (success) { + dispatch(updateResetStatus({ status: RequestStatus.SUCCESSFUL })); + return true; + } + dispatch(updateResetStatus({ status: RequestStatus.FAILED })); + return false; + } catch (error) { + dispatch(updateResetStatus({ status: RequestStatus.FAILED })); + return false; + } + }; +} diff --git a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx b/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx index b75b7f3742..733c09308b 100644 --- a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx +++ b/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx @@ -4,12 +4,17 @@ import { Alert, Badge, Form, + Icon, ModalDialog, + OverlayTrigger, StatefulButton, + Tooltip, TransitionReplace, Hyperlink, } from '@edx/paragon'; -import { Info } from '@edx/paragon/icons'; +import { + Info, CheckCircleOutline, RotateLeft, SpinnerSimple, +} from '@edx/paragon/icons'; import { Formik } from 'formik'; import PropTypes from 'prop-types'; @@ -26,9 +31,9 @@ import Loading from '../../../generic/Loading'; import { useModel } from '../../../generic/model-store'; import PermissionDeniedAlert from '../../../generic/PermissionDeniedAlert'; import { useIsMobile } from '../../../utils'; -import { getLoadingStatus, getSavingStatus } from '../../data/selectors'; -import { updateSavingStatus } from '../../data/slice'; -import { updateXpertSettings } from '../data/thunks'; +import { getLoadingStatus, getSavingStatus, getResetStatus } from '../../data/selectors'; +import { updateSavingStatus, updateResetStatus } from '../../data/slice'; +import { updateXpertSettings, resetXpertSettings, removeXpertSettings } from '../data/thunks'; import AppConfigFormDivider from '../../discussions/app-config-form/apps/shared/AppConfigFormDivider'; import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider'; import messages from './messages'; @@ -105,6 +110,84 @@ SettingsModalBase.defaultProps = { footer: null, }; +const ResetUnitsButton = ({ + intl, + courseId, + checked, + visible, +}) => { + const resetStatusRequestStatus = useSelector(getResetStatus); + const dispatch = useDispatch(); + + useEffect(() => { + if (resetStatusRequestStatus === RequestStatus.SUCCESSFUL) { + setTimeout(() => { + dispatch(updateResetStatus({ status: '' })); + }, 2000); + } + }, [resetStatusRequestStatus]); + + const handleResetUnits = () => { + dispatch(resetXpertSettings(courseId, { enabled: checked === 'true', reset: true })); + }; + + const getResetButtonState = () => { + switch (resetStatusRequestStatus) { + case RequestStatus.PENDING: + return 'pending'; + case RequestStatus.SUCCESSFUL: + return 'finish'; + default: + return 'default'; + } + }; + + if (!visible) { return null; } + + const messageKey = checked === 'true' ? 'resetAllUnitsTooltipChecked' : 'resetAllUnitsTooltipUnchecked'; + + return ( + + {intl.formatMessage(messages[messageKey])} + + )} + > + , + pending: , + finish: , + }} + state={getResetButtonState()} + onClick={handleResetUnits} + disabledStates={['pending', 'finish']} + variant="outline" + data-testid="reset-units" + /> + + ); +}; + +ResetUnitsButton.propTypes = { + intl: intlShape.isRequired, + courseId: PropTypes.string.isRequired, + checked: PropTypes.oneOf(['true', 'false']).isRequired, + visible: PropTypes.bool, +}; + +ResetUnitsButton.defaultProps = { + visible: false, +}; + const SettingsModal = ({ intl, appId, @@ -119,6 +202,8 @@ const SettingsModal = ({ enableAppHelp, learnMoreText, enableReinitialize, + allUnitsEnabledText, + noUnitsEnabledText, }) => { const { courseId } = useContext(PagesAndResourcesContext); const loadingStatus = useSelector(getLoadingStatus); @@ -139,9 +224,15 @@ const SettingsModal = ({ } }, [updateSettingsRequestStatus]); - const handleFormSubmit = async (values) => { - let success = true; - success = await dispatch(updateXpertSettings(courseId, values)); + const handleFormSubmit = async ({ enabled, checked, ...rest }) => { + let success; + const values = { ...rest, enabled: enabled ? checked === 'true' : undefined }; + + if (enabled) { + success = await dispatch(updateXpertSettings(courseId, values)); + } else { + success = await dispatch(removeXpertSettings(courseId)); + } if (onSettingsSave) { success = success && await onSettingsSave(values); @@ -174,13 +265,15 @@ const SettingsModal = ({ return ( )} > @@ -220,7 +314,7 @@ const SettingsModal = ({ formikProps.handleChange(event)} + onChange={formikProps.handleChange} onBlur={formikProps.handleBlur} checked={formikProps.values.enabled} label={( @@ -240,6 +334,41 @@ const SettingsModal = ({ )} /> + {(formikProps.values.enabled || configureBeforeEnable) && ( + + + {allUnitsEnabledText} + + + + {noUnitsEnabledText} + + + + )} {(formikProps.values.enabled || configureBeforeEnable) && children && } @@ -281,6 +410,8 @@ SettingsModal.propTypes = { enableAppLabel: PropTypes.string.isRequired, enableAppHelp: PropTypes.string.isRequired, learnMoreText: PropTypes.string.isRequired, + allUnitsEnabledText: PropTypes.string.isRequired, + noUnitsEnabledText: PropTypes.string.isRequired, configureBeforeEnable: PropTypes.bool, enableReinitialize: PropTypes.bool, }; diff --git a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss b/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss new file mode 100644 index 0000000000..c37d257dbb --- /dev/null +++ b/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss @@ -0,0 +1,31 @@ +.summary-radio { + display: flex; + align-items: center; + width: 100%; + border-width: $border-width; + border-color: $border-color; + border-radius: $border-radius; + border-style: solid; + + &:has(input:checked) { + border-width: 3px; + border-color: theme-color("primary"); + } + + > div { + flex: 1; + + > label { + height: 80px; + } + } +} + +.reset-units-button { + color: $link-color; + border-width: $border-width; + border-color: $border-color; + border-radius: $border-radius; + border-style: solid; + margin-left: auto; +} diff --git a/src/pages-and-resources/xpert-unit-summary/settings-modal/messages.js b/src/pages-and-resources/xpert-unit-summary/settings-modal/messages.js index b5586e993d..5dd2c2f4b8 100644 --- a/src/pages-and-resources/xpert-unit-summary/settings-modal/messages.js +++ b/src/pages-and-resources/xpert-unit-summary/settings-modal/messages.js @@ -29,6 +29,22 @@ const messages = defineMessages({ id: 'course-authoring.pages-resources.app-settings-modal.badge.disabled', defaultMessage: 'Disabled', }, + resetAllUnits: { + id: 'course-authoring.pages-resources.app-settings-modal.reset-all-units', + defaultMessage: 'Reset all units', + }, + resetAllUnitsTooltipChecked: { + id: 'course-authoring.pages-resources.app-settings-modal.reset-all-units-tooltip.checked', + defaultMessage: 'Immediately reset any unit-level changes and checked "Enable summaries" on all units.', + }, + resetAllUnitsTooltipUnchecked: { + id: 'course-authoring.pages-resources.app-settings-modal.reset-all-units-tooltip.unchecked', + defaultMessage: 'Immediately reset any unit-level changes and unchecked "Enable summaries" on all units.', + }, + reset: { + id: 'course-authoring.pages-resources.app-settings-modal.reset', + defaultMessage: 'Reset', + }, errorSavingTitle: { id: 'course-authoring.pages-resources.app-settings-modal.save-error.title', defaultMessage: 'We couldn\'t apply your changes.', From be74de2b22fa24799e04c9c2eef5f6884dfd2dd5 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Mon, 21 Aug 2023 16:47:52 -0400 Subject: [PATCH 02/79] fix: file info bugs (#571) --- src/files-and-uploads/FileInfo.jsx | 21 +++++++++++++++++---- src/files-and-uploads/FileThumbnail.jsx | 14 +++++++++++--- src/files-and-uploads/data/utils.js | 6 ++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/files-and-uploads/FileInfo.jsx b/src/files-and-uploads/FileInfo.jsx index a1ee502adc..59f3a11ce7 100644 --- a/src/files-and-uploads/FileInfo.jsx +++ b/src/files-and-uploads/FileInfo.jsx @@ -1,7 +1,11 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; - +import { + injectIntl, + FormattedMessage, + FormattedDate, + intlShape, +} from '@edx/frontend-platform/i18n'; import { ModalDialog, Stack, @@ -13,6 +17,7 @@ import { CheckboxControl, } from '@edx/paragon'; import { ContentCopy, InfoOutline } from '@edx/paragon/icons'; +import { getUtcDateTime } from './data/utils'; import AssetThumbnail from './FileThumbnail'; import messages from './messages'; @@ -31,6 +36,7 @@ const FileInfo = ({ setLockedState(locked); handleLockedAsset(asset.id, locked); }; + const dateAdded = getUtcDateTime(asset.dateAdded); return (
-
+
- {asset.dateAdded} +
diff --git a/src/files-and-uploads/FileThumbnail.jsx b/src/files-and-uploads/FileThumbnail.jsx index ca0aeb309c..d304d0f4e6 100644 --- a/src/files-and-uploads/FileThumbnail.jsx +++ b/src/files-and-uploads/FileThumbnail.jsx @@ -19,11 +19,19 @@ const AssetThumbnail = ({ }); return ( -
+
{thumbnail ? ( - {`Thumbnail + {`Thumbnail ) : ( -
+
)} diff --git a/src/files-and-uploads/data/utils.js b/src/files-and-uploads/data/utils.js index ffa74fb87d..1416fdfdfb 100644 --- a/src/files-and-uploads/data/utils.js +++ b/src/files-and-uploads/data/utils.js @@ -39,3 +39,9 @@ export const getSrc = ({ thumbnail, wrapperType, externalUrl }) => { return InsertDriveFile; } }; + +export const getUtcDateTime = (date) => { + const utcDateString = date.replace(/\bat\b/g, ''); + const utcDateTime = new Date(utcDateString); + return utcDateTime; +}; From c1976ce4d3432dd03cd4f7d24cc3ad1926897726 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Mon, 21 Aug 2023 17:28:49 -0400 Subject: [PATCH 03/79] feat: add delete confirmation modal (#570) --- src/custom-pages/messages.js | 2 +- src/files-and-uploads/ApiStatusToast.jsx | 6 +-- src/files-and-uploads/FileInput.jsx | 4 +- src/files-and-uploads/FileMenu.jsx | 9 ++-- src/files-and-uploads/FilesAndUploads.jsx | 51 ++++++++++++++----- .../FilesAndUploads.test.jsx | 17 +++++-- src/files-and-uploads/messages.js | 16 ++++++ .../table-components/GalleryCard.jsx | 9 ++-- .../table-components/ListCard.jsx | 9 ++-- .../table-components/TableActions.jsx | 7 +-- 10 files changed, 90 insertions(+), 40 deletions(-) diff --git a/src/custom-pages/messages.js b/src/custom-pages/messages.js index af6b7ca6d4..60e8da9e04 100644 --- a/src/custom-pages/messages.js +++ b/src/custom-pages/messages.js @@ -95,7 +95,7 @@ const messages = defineMessages({ }, deletePageLabel: { id: 'course-authoring.custom-pages.deleteConfirmation.deletePage.label', - defaultMessage: 'Ok', + defaultMessage: 'Delete', }, deletingPageBodyLabel: { id: 'course-authoring.custom-pages.deleteConfirmation.deletingPage.label', diff --git a/src/files-and-uploads/ApiStatusToast.jsx b/src/files-and-uploads/ApiStatusToast.jsx index 4d997b7e05..ade1d2aa16 100644 --- a/src/files-and-uploads/ApiStatusToast.jsx +++ b/src/files-and-uploads/ApiStatusToast.jsx @@ -9,12 +9,12 @@ const ApiStatusToast = ({ selectedRowCount, isOpen, setClose, - setSelectedRowCount, + setSelectedRows, // injected intl, }) => { const handleClose = () => { - setSelectedRowCount(0); + setSelectedRows([]); setClose(); }; @@ -33,7 +33,7 @@ ApiStatusToast.propTypes = { selectedRowCount: PropTypes.number.isRequired, isOpen: PropTypes.bool.isRequired, setClose: PropTypes.func.isRequired, - setSelectedRowCount: PropTypes.func.isRequired, + setSelectedRows: PropTypes.func.isRequired, // injected intl: intlShape.isRequired, }; diff --git a/src/files-and-uploads/FileInput.jsx b/src/files-and-uploads/FileInput.jsx index 9b0b8510d8..b7be691f11 100644 --- a/src/files-and-uploads/FileInput.jsx +++ b/src/files-and-uploads/FileInput.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; export const fileInput = ({ onAddFile, - setSelectedRowCount, + setSelectedRows, setAddOpen, }) => { // eslint-disable-next-line react-hooks/rules-of-hooks @@ -11,7 +11,7 @@ export const fileInput = ({ const click = () => ref.current.click(); const addFile = (e) => { const { files } = e.target; - setSelectedRowCount(files.length); + setSelectedRows(files); Object.values(files).forEach(file => { onAddFile(file); setAddOpen(); diff --git a/src/files-and-uploads/FileMenu.jsx b/src/files-and-uploads/FileMenu.jsx index 0bfab16fb6..46b9defe10 100644 --- a/src/files-and-uploads/FileMenu.jsx +++ b/src/files-and-uploads/FileMenu.jsx @@ -10,10 +10,10 @@ import messages from './messages'; const FileMenu = ({ externalUrl, - handleDelete, handleLock, locked, openAssetInfo, + openDeleteConfirmation, portableUrl, iconSrc, id, @@ -50,7 +50,10 @@ const FileMenu = ({ {intl.formatMessage(messages.infoTitle)} - + {intl.formatMessage(messages.deleteTitle)} @@ -59,10 +62,10 @@ const FileMenu = ({ FileMenu.propTypes = { externalUrl: PropTypes.string.isRequired, - handleDelete: PropTypes.func.isRequired, handleLock: PropTypes.func.isRequired, locked: PropTypes.bool.isRequired, openAssetInfo: PropTypes.func.isRequired, + openDeleteConfirmation: PropTypes.func.isRequired, portableUrl: PropTypes.string.isRequired, iconSrc: PropTypes.func.isRequired, id: PropTypes.string.isRequired, diff --git a/src/files-and-uploads/FilesAndUploads.jsx b/src/files-and-uploads/FilesAndUploads.jsx index 416866fbbf..e7494ec0be 100644 --- a/src/files-and-uploads/FilesAndUploads.jsx +++ b/src/files-and-uploads/FilesAndUploads.jsx @@ -10,6 +10,9 @@ import { Dropzone, CardView, useToggle, + AlertModal, + ActionRow, + Button, } from '@edx/paragon'; import Placeholder, { ErrorAlert } from '@edx/frontend-lib-content-components'; @@ -48,7 +51,8 @@ const FilesAndUploads = ({ const [currentView, setCurrentView] = useState(defaultVal); const [isDeleteOpen, setDeleteOpen, setDeleteClose] = useToggle(false); const [isAddOpen, setAddOpen, setAddClose] = useToggle(false); - const [selectedRowCount, setSelectedRowCount] = useState(0); + const [selectedRows, setSelectedRows] = useState([]); + const [isDeleteConfirmationOpen, openDeleteConfirmation, closeDeleteConfirmation] = useToggle(false); useEffect(() => { dispatch(fetchAssets(courseId)); @@ -64,7 +68,7 @@ const FilesAndUploads = ({ const errorMessages = useSelector(state => state.assets.errors); const fileInputControl = fileInput({ onAddFile: (file) => dispatch(addAssetFile(courseId, file, totalCount)), - setSelectedRowCount, + setSelectedRows, setAddOpen, }); const assets = useModels('assets', assetIds); @@ -78,10 +82,10 @@ const FilesAndUploads = ({ } }; - const handleBulkDelete = (selectedFlatRows) => { - setSelectedRowCount(selectedFlatRows.length); + const handleBulkDelete = () => { + closeDeleteConfirmation(); setDeleteOpen(); - const assetIdsToDelete = selectedFlatRows.map(row => row.original.id); + const assetIdsToDelete = selectedRows.map(row => row.original.id); assetIdsToDelete.forEach(id => dispatch(deleteAssetFile(courseId, id, totalCount))); }; @@ -103,13 +107,18 @@ const FilesAndUploads = ({ dispatch(updateAssetLock({ courseId, assetId, locked })); }; + const handleOpenDeleteConfirmation = (selectedFlatRows) => { + setSelectedRows(selectedFlatRows); + openDeleteConfirmation(); + }; + const headerActions = ({ selectedFlatRows }) => ( ); @@ -119,8 +128,8 @@ const FilesAndUploads = ({ return (
)} + + + + + + )} + > + {intl.formatMessage(messages.deleteConfirmationMessage, { fileNumber: selectedRows.length })} + ); diff --git a/src/files-and-uploads/FilesAndUploads.test.jsx b/src/files-and-uploads/FilesAndUploads.test.jsx index 0815025899..dbd76a5f74 100644 --- a/src/files-and-uploads/FilesAndUploads.test.jsx +++ b/src/files-and-uploads/FilesAndUploads.test.jsx @@ -194,9 +194,12 @@ describe('FilesAndUploads', () => { const deleteButton = screen.getByText(messages.deleteTitle.defaultMessage).closest('a'); expect(deleteButton).not.toHaveClass('disabled'); axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(204); - await act(async () => { + await waitFor(() => { fireEvent.click(deleteButton); - await executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch); + expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible(); + fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage)); + expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull(); + executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch); }); const deleteStatus = store.getState().assets.deletingStatus; expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL); @@ -284,7 +287,10 @@ describe('FilesAndUploads', () => { await waitFor(() => { axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(204); fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); - fireEvent.click(screen.getByText('Delete')); + fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); + expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible(); + fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage)); + expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull(); executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch); }); const deleteStatus = store.getState().assets.deletingStatus; @@ -330,7 +336,10 @@ describe('FilesAndUploads', () => { await waitFor(() => { axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(404); fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); - fireEvent.click(screen.getByText('Delete')); + fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); + expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible(); + fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage)); + expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull(); executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch); }); const deleteStatus = store.getState().assets.deletingStatus; diff --git a/src/files-and-uploads/messages.js b/src/files-and-uploads/messages.js index a1f7c7bb2b..21cd6196e9 100644 --- a/src/files-and-uploads/messages.js +++ b/src/files-and-uploads/messages.js @@ -101,6 +101,22 @@ const messages = defineMessages({ id: 'course-authoring.files-and-uploads.cardMenu.deleteTitle', defaultMessage: 'Delete', }, + deleteConfirmationTitle: { + id: 'course-authoring.files-and-uploads..deleteConfirmation.title', + defaultMessage: 'Delete File(s) Confirmation', + }, + deleteConfirmationMessage: { + id: 'course-authoring.files-and-uploads..deleteConfirmation.message', + defaultMessage: 'Are you sure you want to delete {fileNumber} file(s)? This action cannot be undone.', + }, + deleteFileButtonLabel: { + id: 'course-authoring.files-and-uploads.deleteConfirmation.deleteFile.label', + defaultMessage: 'Delete', + }, + cancelButtonLabel: { + id: 'course-authoring.files-and-uploads.deleteConfirmation.cancelButton.label', + defaultMessage: 'Cancel', + }, }); export default messages; diff --git a/src/files-and-uploads/table-components/GalleryCard.jsx b/src/files-and-uploads/table-components/GalleryCard.jsx index 7d69de6d52..dde7c2f86c 100644 --- a/src/files-and-uploads/table-components/GalleryCard.jsx +++ b/src/files-and-uploads/table-components/GalleryCard.jsx @@ -19,13 +19,10 @@ import { getSrc } from '../data/utils'; const GalleryCard = ({ className, original, - handleBulkDelete, handleLockedAsset, + handleOpenDeleteConfirmation, }) => { const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false); - const deleteAsset = () => { - handleBulkDelete([{ original }]); - }; const lockAsset = () => { const { locked } = original; handleLockedAsset(original.id, !locked); @@ -43,13 +40,13 @@ const GalleryCard = ({ handleOpenDeleteConfirmation([{ original }])} /> )} @@ -99,7 +96,7 @@ GalleryCard.propTypes = { portableUrl: PropTypes.string.isRequired, }).isRequired, handleLockedAsset: PropTypes.func.isRequired, - handleBulkDelete: PropTypes.func.isRequired, + handleOpenDeleteConfirmation: PropTypes.func.isRequired, }; export default GalleryCard; diff --git a/src/files-and-uploads/table-components/ListCard.jsx b/src/files-and-uploads/table-components/ListCard.jsx index a2dd0cfab0..5f987f76d7 100644 --- a/src/files-and-uploads/table-components/ListCard.jsx +++ b/src/files-and-uploads/table-components/ListCard.jsx @@ -19,13 +19,10 @@ import { getSrc } from '../data/utils'; const ListCard = ({ className, original, - handleBulkDelete, handleLockedAsset, + handleOpenDeleteConfirmation, }) => { const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false); - const deleteAsset = () => { - handleBulkDelete([{ original }]); - }; const lockAsset = () => { const { locked } = original; handleLockedAsset(original.id, !locked); @@ -65,13 +62,13 @@ const ListCard = ({ handleOpenDeleteConfirmation([{ original }])} /> @@ -101,7 +98,7 @@ ListCard.propTypes = { portableUrl: PropTypes.string.isRequired, }).isRequired, handleLockedAsset: PropTypes.func.isRequired, - handleBulkDelete: PropTypes.func.isRequired, + handleOpenDeleteConfirmation: PropTypes.func.isRequired, }; export default ListCard; diff --git a/src/files-and-uploads/table-components/TableActions.jsx b/src/files-and-uploads/table-components/TableActions.jsx index db4ccdddf5..76d6ae4180 100644 --- a/src/files-and-uploads/table-components/TableActions.jsx +++ b/src/files-and-uploads/table-components/TableActions.jsx @@ -9,8 +9,8 @@ import messages from '../messages'; const TableActions = ({ selectedFlatRows, fileInputControl, - handleBulkDelete, handleBulkDownload, + handleOpenDeleteConfirmation, }) => ( <> @@ -30,7 +30,8 @@ const TableActions = ({ handleBulkDelete(selectedFlatRows)} + data-testid="open-delete-confirmation-button" + onClick={() => handleOpenDeleteConfirmation(selectedFlatRows)} disabled={_.isEmpty(selectedFlatRows)} > @@ -63,7 +64,7 @@ TableActions.propTypes = { fileInputControl: PropTypes.shape({ click: PropTypes.func.isRequired, }).isRequired, - handleBulkDelete: PropTypes.func.isRequired, + handleOpenDeleteConfirmation: PropTypes.func.isRequired, handleBulkDownload: PropTypes.func.isRequired, }; From 9c997ab845148a0ede1ca60c63d2d8c41070d850 Mon Sep 17 00:00:00 2001 From: Jesper Hodge <19345795+jesperhodge@users.noreply.github.com> Date: Tue, 22 Aug 2023 13:28:30 -0400 Subject: [PATCH 04/79] fix: Pass correct prop to TinyMceWidget and update FLCC (#575) * fix: Pass correct prop to TinyMceWidget * chore: update flcc * fix: lockfile --- package-lock.json | 8 ++++---- package.json | 2 +- src/generic/WysiwygEditor.jsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index ee028fa967..bfe7cf8d03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/frontend-component-footer": "12.0.0", "@edx/frontend-enterprise-hotjar": "^1.2.1", - "@edx/frontend-lib-content-components": "^1.169.0", + "@edx/frontend-lib-content-components": "^1.169.3", "@edx/frontend-platform": "4.2.0", "@edx/paragon": "^20.45.4", "@fortawesome/fontawesome-svg-core": "1.2.28", @@ -2298,9 +2298,9 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "1.169.0", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.169.0.tgz", - "integrity": "sha512-PNs7KntOdF/oyL82dG/GprdKwE6kfO5UnNPJVK2Dh4WBO/rUWuQUfTewxVI61Qyegg+SJKseBHYfxyp9COCN4Q==", + "version": "1.169.3", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.169.3.tgz", + "integrity": "sha512-CgqMs9vEkx9rw6DBlLThKB/omJzMD+mntZGRevF547G/Gl8dt/KSquNZrOiNph1PImXTD1eb2DtSyN7VbYNBaA==", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", diff --git a/package.json b/package.json index 9f26e4f24f..3524e700d5 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/frontend-component-footer": "12.0.0", "@edx/frontend-enterprise-hotjar": "^1.2.1", - "@edx/frontend-lib-content-components": "^1.169.0", + "@edx/frontend-lib-content-components": "^1.169.3", "@edx/frontend-platform": "4.2.0", "@edx/paragon": "^20.45.4", "@fortawesome/fontawesome-svg-core": "1.2.28", diff --git a/src/generic/WysiwygEditor.jsx b/src/generic/WysiwygEditor.jsx index 0f6c7d087f..e89b3b6859 100644 --- a/src/generic/WysiwygEditor.jsx +++ b/src/generic/WysiwygEditor.jsx @@ -73,7 +73,7 @@ export const WysiwygEditor = ({ minHeight={minHeight} editorContentHtml={initialValue} setEditorRef={setEditorRef} - updateContent={handleUpdate} + onChange={handleUpdate} initializeEditor={() => ({})} /> From d768bfc97af0af359a11a615bea389c4102b0865 Mon Sep 17 00:00:00 2001 From: German Date: Tue, 22 Aug 2023 15:43:17 -0300 Subject: [PATCH 05/79] fix: xpert unit sumamries settings ui fixes (#576) 1. https://jira.2u.com/browse/ACADEMIC-16289 2. https://jira.2u.com/browse/ACADEMIC-16422 --- src/pages-and-resources/PagesAndResources.jsx | 2 +- .../XpertUnitSummarySettings.jsx | 18 +------- .../settings-modal/ResetIcon.jsx | 21 +++++++++ .../settings-modal/SettingsModal.jsx | 46 ++++++++++++++----- .../settings-modal/SettingsModal.scss | 15 +++++- 5 files changed, 71 insertions(+), 31 deletions(-) create mode 100644 src/pages-and-resources/xpert-unit-summary/settings-modal/ResetIcon.jsx diff --git a/src/pages-and-resources/PagesAndResources.jsx b/src/pages-and-resources/PagesAndResources.jsx index 07719a54e5..3147741cc5 100644 --- a/src/pages-and-resources/PagesAndResources.jsx +++ b/src/pages-and-resources/PagesAndResources.jsx @@ -47,7 +47,7 @@ const PagesAndResources = ({ courseId, intl }) => { const xpertSettings = useModel('XpertSettings', 'xpert-unit-summary'); const permissonPages = [{ ...XpertAppInfo, - enabled: xpertSettings?.enabled, + enabled: xpertSettings?.enabled !== undefined, }]; if (loadingStatus === RequestStatus.IN_PROGRESS) { diff --git a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx b/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx index c90d3a22bd..34bb30f404 100644 --- a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx +++ b/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx @@ -1,6 +1,5 @@ import React, { useCallback, useContext, useEffect } from 'react'; import { history } from '@edx/frontend-platform'; -import { Hyperlink } from '@edx/paragon'; import { useDispatch } from 'react-redux'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; @@ -27,21 +26,8 @@ const XpertUnitSummarySettings = ({ intl }) => { - {intl.formatMessage(messages.enableXpertUnitSummaryHelp)} - {' '} - - {intl.formatMessage(messages.enableXpertUnitSummaryHelpPrivacyLink)} - - - ) - } + enableAppHelp={intl.formatMessage(messages.enableXpertUnitSummaryHelp)} + helpPrivacyText={intl.formatMessage(messages.enableXpertUnitSummaryHelpPrivacyLink)} enableAppLabel={intl.formatMessage(messages.enableXpertUnitSummaryLabel)} learnMoreText={intl.formatMessage(messages.enableXpertUnitSummaryLink)} allUnitsEnabledText={intl.formatMessage(messages.allUnitsEnabledByDefault)} diff --git a/src/pages-and-resources/xpert-unit-summary/settings-modal/ResetIcon.jsx b/src/pages-and-resources/xpert-unit-summary/settings-modal/ResetIcon.jsx new file mode 100644 index 0000000000..ad58059768 --- /dev/null +++ b/src/pages-and-resources/xpert-unit-summary/settings-modal/ResetIcon.jsx @@ -0,0 +1,21 @@ +const ResetIcon = (props) => ( + +); + +export default ResetIcon; diff --git a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx b/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx index 733c09308b..5cf183dd1d 100644 --- a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx +++ b/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx @@ -13,7 +13,7 @@ import { Hyperlink, } from '@edx/paragon'; import { - Info, CheckCircleOutline, RotateLeft, SpinnerSimple, + Info, CheckCircleOutline, SpinnerSimple, } from '@edx/paragon/icons'; import { Formik } from 'formik'; @@ -38,6 +38,7 @@ import AppConfigFormDivider from '../../discussions/app-config-form/apps/shared/ import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider'; import messages from './messages'; import appInfo from '../appInfo'; +import ResetIcon from './ResetIcon'; const AppSettingsForm = ({ formikProps, children, showForm, @@ -150,7 +151,10 @@ const ResetUnitsButton = ({ + {intl.formatMessage(messages[messageKey])} )} @@ -163,7 +167,7 @@ const ResetUnitsButton = ({ finish: intl.formatMessage(messages.reset), }} icons={{ - default: , + default: , pending: , finish: , }} @@ -201,6 +205,7 @@ const SettingsModal = ({ enableAppLabel, enableAppHelp, learnMoreText, + helpPrivacyText, enableReinitialize, allUnitsEnabledText, noUnitsEnabledText, @@ -251,14 +256,29 @@ const SettingsModal = ({ }; const learnMoreLink = appInfo.documentationLinks?.learnMoreConfiguration && ( - - {learnMoreText} - +
+ + {learnMoreText} + +
+ ); + + const helpPrivacyLink = ( +
+ + {helpPrivacyText} + +
); if (loadingStatus === RequestStatus.SUCCESSFUL) { @@ -330,7 +350,8 @@ const SettingsModal = ({ helpText={(

{enableAppHelp}

- {learnMoreLink} + {helpPrivacyLink} + {learnMoreLink}
)} /> @@ -410,6 +431,7 @@ SettingsModal.propTypes = { enableAppLabel: PropTypes.string.isRequired, enableAppHelp: PropTypes.string.isRequired, learnMoreText: PropTypes.string.isRequired, + helpPrivacyText: PropTypes.string.isRequired, allUnitsEnabledText: PropTypes.string.isRequired, noUnitsEnabledText: PropTypes.string.isRequired, configureBeforeEnable: PropTypes.bool, diff --git a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss b/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss index c37d257dbb..82942e2105 100644 --- a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss +++ b/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss @@ -16,7 +16,9 @@ flex: 1; > label { - height: 80px; + min-height: 80px; + flex-wrap: wrap; + justify-content: space-between; } } } @@ -27,5 +29,14 @@ border-color: $border-color; border-radius: $border-radius; border-style: solid; - margin-left: auto; +} + +.reset-tooltip { + .arrow::before { + border-right-color: #00262B; + } + + .tooltip-inner { + background-color: #00262B; + } } From 2e8eed7504b5b78156e96384b6ecd393f29ecd46 Mon Sep 17 00:00:00 2001 From: vladislavkeblysh <138868841+vladislavkeblysh@users.noreply.github.com> Date: Wed, 23 Aug 2023 16:21:43 +0300 Subject: [PATCH 06/79] feat: Created Course Team (#564) --- src/CourseAuthoringRoutes.jsx | 6 +- src/constants.js | 10 + src/course-team/CourseTeam.jsx | 163 +++++++++++++ src/course-team/CourseTeam.scss | 23 ++ src/course-team/CourseTeam.test.jsx | 222 ++++++++++++++++++ src/course-team/__mocks__/courseTeam.js | 24 ++ .../__mocks__/courseTeamWithOneUser.js | 12 + .../__mocks__/courseTeamWithoutUsers.js | 5 + src/course-team/__mocks__/index.js | 3 + .../add-team-member/AddTeamMember.jsx | 39 +++ .../add-team-member/AddTeamMember.scss | 17 ++ .../add-team-member/AddTeamMember.test.jsx | 46 ++++ src/course-team/add-team-member/messages.js | 18 ++ src/course-team/add-user-form/AddUserForm.jsx | 66 ++++++ .../add-user-form/AddUserForm.scss | 42 ++++ .../add-user-form/AddUserForm.test.jsx | 128 ++++++++++ src/course-team/add-user-form/messages.js | 31 +++ src/course-team/constants.js | 7 + .../course-team-member/CourseTeamMember.jsx | 78 ++++++ .../course-team-member/CourseTeamMember.scss | 63 +++++ .../CourseTeamMember.test.jsx | 91 +++++++ .../course-team-member/messages.js | 30 +++ .../CourseTeamSideBar.test.jsx | 52 ++++ .../course-team-sidebar/CourseTeamSidebar.jsx | 68 ++++++ .../CourseTeamSidebar.scss | 11 + .../course-team-sidebar/messages.js | 34 +++ src/course-team/data/api.js | 54 +++++ src/course-team/data/selectors.js | 6 + src/course-team/data/slice.js | 46 ++++ src/course-team/data/thunk.js | 87 +++++++ src/course-team/hooks.jsx | 139 +++++++++++ src/course-team/info-modal/InfoModal.jsx | 75 ++++++ src/course-team/info-modal/InfoModal.test.jsx | 85 +++++++ src/course-team/info-modal/messages.js | 42 ++++ src/course-team/messages.js | 18 ++ src/course-team/utils.js | 53 +++++ src/generic/FormikControl.jsx | 2 +- .../internet-connection-alert/index.jsx | 9 +- src/generic/sub-header/SubHeader.jsx | 12 +- src/generic/sub-header/SubHeader.scss | 9 + src/i18n/messages/ar.json | 72 ++++++ src/i18n/messages/de.json | 72 ++++++ src/i18n/messages/de_DE.json | 72 ++++++ src/i18n/messages/es_419.json | 72 ++++++ src/i18n/messages/fr.json | 72 ++++++ src/i18n/messages/fr_CA.json | 72 ++++++ src/i18n/messages/hi.json | 72 ++++++ src/i18n/messages/it.json | 72 ++++++ src/i18n/messages/it_IT.json | 72 ++++++ src/i18n/messages/pt.json | 72 ++++++ src/i18n/messages/pt_PT.json | 72 ++++++ src/i18n/messages/ru.json | 72 ++++++ src/i18n/messages/uk.json | 72 ++++++ src/i18n/messages/zh_CN.json | 72 ++++++ src/index.scss | 1 + src/store.js | 2 + 56 files changed, 2925 insertions(+), 12 deletions(-) create mode 100644 src/course-team/CourseTeam.jsx create mode 100644 src/course-team/CourseTeam.scss create mode 100644 src/course-team/CourseTeam.test.jsx create mode 100644 src/course-team/__mocks__/courseTeam.js create mode 100644 src/course-team/__mocks__/courseTeamWithOneUser.js create mode 100644 src/course-team/__mocks__/courseTeamWithoutUsers.js create mode 100644 src/course-team/__mocks__/index.js create mode 100644 src/course-team/add-team-member/AddTeamMember.jsx create mode 100644 src/course-team/add-team-member/AddTeamMember.scss create mode 100644 src/course-team/add-team-member/AddTeamMember.test.jsx create mode 100644 src/course-team/add-team-member/messages.js create mode 100644 src/course-team/add-user-form/AddUserForm.jsx create mode 100644 src/course-team/add-user-form/AddUserForm.scss create mode 100644 src/course-team/add-user-form/AddUserForm.test.jsx create mode 100644 src/course-team/add-user-form/messages.js create mode 100644 src/course-team/constants.js create mode 100644 src/course-team/course-team-member/CourseTeamMember.jsx create mode 100644 src/course-team/course-team-member/CourseTeamMember.scss create mode 100644 src/course-team/course-team-member/CourseTeamMember.test.jsx create mode 100644 src/course-team/course-team-member/messages.js create mode 100644 src/course-team/course-team-sidebar/CourseTeamSideBar.test.jsx create mode 100644 src/course-team/course-team-sidebar/CourseTeamSidebar.jsx create mode 100644 src/course-team/course-team-sidebar/CourseTeamSidebar.scss create mode 100644 src/course-team/course-team-sidebar/messages.js create mode 100644 src/course-team/data/api.js create mode 100644 src/course-team/data/selectors.js create mode 100644 src/course-team/data/slice.js create mode 100644 src/course-team/data/thunk.js create mode 100644 src/course-team/hooks.jsx create mode 100644 src/course-team/info-modal/InfoModal.jsx create mode 100644 src/course-team/info-modal/InfoModal.test.jsx create mode 100644 src/course-team/info-modal/messages.js create mode 100644 src/course-team/messages.js create mode 100644 src/course-team/utils.js diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 29bb15ae16..854c0ee08c 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -13,6 +13,7 @@ import FilesAndUploads from './files-and-uploads'; import { AdvancedSettings } from './advanced-settings'; import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; +import CourseTeam from './course-team/CourseTeam'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -94,10 +95,7 @@ const CourseAuthoringRoutes = ({ courseId }) => { - {process.env.ENABLE_NEW_COURSE_TEAM_PAGE === 'true' - && ( - - )} + diff --git a/src/constants.js b/src/constants.js index aeb09d1a63..e0d0481bbd 100644 --- a/src/constants.js +++ b/src/constants.js @@ -7,3 +7,13 @@ export const STATEFUL_BUTTON_STATES = { pending: 'pending', default: 'default', }; + +export const USER_ROLES = { + admin: 'instructor', + staff: 'staff', +}; + +export const BADGE_STATES = { + danger: 'danger', + secondary: 'secondary', +}; diff --git a/src/course-team/CourseTeam.jsx b/src/course-team/CourseTeam.jsx new file mode 100644 index 0000000000..d0a2ba5739 --- /dev/null +++ b/src/course-team/CourseTeam.jsx @@ -0,0 +1,163 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl, injectIntl } from '@edx/frontend-platform/i18n'; +import { + Button, + Container, + Layout, +} from '@edx/paragon'; +import { Add as IconAdd } from '@edx/paragon/icons'; + +import InternetConnectionAlert from '../generic/internet-connection-alert'; +import SubHeader from '../generic/sub-header/SubHeader'; +import { USER_ROLES } from '../constants'; +import messages from './messages'; +import CourseTeamSideBar from './course-team-sidebar/CourseTeamSidebar'; +import AddUserForm from './add-user-form/AddUserForm'; +import AddTeamMember from './add-team-member/AddTeamMember'; +import CourseTeamMember from './course-team-member/CourseTeamMember'; +import InfoModal from './info-modal/InfoModal'; +import { useCourseTeam } from './hooks'; + +const CourseTeam = ({ courseId }) => { + const intl = useIntl(); + + const { + modalType, + errorMessage, + courseName, + currentEmail, + courseTeamUsers, + currentUserEmail, + isLoading, + isSingleAdmin, + isFormVisible, + isQueryPending, + isAllowActions, + isInfoModalOpen, + isOwnershipHint, + isShowAddTeamMember, + isShowInitialSidebar, + isShowUserFilledSidebar, + isInternetConnectionAlertFailed, + openForm, + hideForm, + closeInfoModal, + handleAddUserSubmit, + handleOpenDeleteModal, + handleDeleteUserSubmit, + handleChangeRoleUserSubmit, + handleInternetConnectionFailed, + } = useCourseTeam({ intl, courseId }); + + if (isLoading) { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; + } + + return ( + <> + +
+ + +
+
+ + {intl.formatMessage(messages.addNewMemberButton)} + + )} + /> +
+
+ {isFormVisible && ( + + )} + {courseTeamUsers.length ? courseTeamUsers.map(({ username, role, email }) => ( + + )) : null} + {isShowAddTeamMember && ( + + )} +
+ {isShowInitialSidebar && ( +
+ +
+ )} + +
+
+
+
+ + {isShowUserFilledSidebar && ( + + )} + +
+
+
+
+ +
+ + ); +}; + +CourseTeam.propTypes = { + courseId: PropTypes.string.isRequired, +}; + +export default injectIntl(CourseTeam); diff --git a/src/course-team/CourseTeam.scss b/src/course-team/CourseTeam.scss new file mode 100644 index 0000000000..e6c973ff14 --- /dev/null +++ b/src/course-team/CourseTeam.scss @@ -0,0 +1,23 @@ +@import "./course-team-sidebar/CourseTeamSidebar"; +@import "./add-user-form/AddUserForm"; +@import "./add-team-member/AddTeamMember"; +@import "./course-team-member/CourseTeamMember"; + +.course-team-section { + .sidebar-container { + width: 30%; + + .help-sidebar { + margin-top: 0; + + hr { + display: none; + } + } + } + + .members-container { + flex-grow: 1; + padding-top: 1.25rem; + } +} diff --git a/src/course-team/CourseTeam.test.jsx b/src/course-team/CourseTeam.test.jsx new file mode 100644 index 0000000000..4f33788744 --- /dev/null +++ b/src/course-team/CourseTeam.test.jsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { + render, + fireEvent, + cleanup, + waitFor, +} from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import initializeStore from '../store'; +import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__'; +import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api'; +import CourseTeam from './CourseTeam'; +import messages from './messages'; +import { USER_ROLES } from '../constants'; +import { executeThunk } from '../utils'; +import { changeRoleTeamUserQuery, deleteCourseTeamQuery } from './data/thunk'; + +let axiosMock; +let store; +const mockPathname = '/foo-bar'; +const courseId = '123'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + pathname: mockPathname, + }), +})); + +const RootWrapper = () => ( + + + + + +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('render CourseTeam component with 3 team members correctly', async () => { + axiosMock + .onGet(getCourseTeamApiUrl(courseId)) + .reply(200, courseTeamMock); + + const { + getByText, getByRole, getByTestId, queryAllByTestId, + } = render(); + + await waitFor(() => { + expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument(); + expect(getByTestId('course-team-sidebar')).toBeInTheDocument(); + expect(queryAllByTestId('course-team-member')).toHaveLength(3); + }); + }); + + it('render CourseTeam component with 1 team member correctly', async () => { + cleanup(); + axiosMock + .onGet(getCourseTeamApiUrl(courseId)) + .reply(200, courseTeamWithOneUser); + + const { + getByText, getByRole, getByTestId, getAllByTestId, + } = render(); + + await waitFor(() => { + expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument(); + expect(getByTestId('course-team-sidebar')).toBeInTheDocument(); + expect(getAllByTestId('course-team-member')).toHaveLength(1); + }); + }); + + it('render CourseTeam component without team member correctly', async () => { + cleanup(); + axiosMock + .onGet(getCourseTeamApiUrl(courseId)) + .reply(200, courseTeamWithoutUsers); + + const { + getByText, getByRole, getByTestId, queryAllByTestId, + } = render(); + + await waitFor(() => { + expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument(); + expect(getByTestId('course-team-sidebar__initial')).toBeInTheDocument(); + expect(queryAllByTestId('course-team-member')).toHaveLength(0); + }); + }); + + it('render CourseTeam component with initial sidebar correctly', async () => { + cleanup(); + axiosMock + .onGet(getCourseTeamApiUrl(courseId)) + .reply(200, courseTeamWithoutUsers); + + const { getByTestId, queryByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('course-team-sidebar__initial')).toBeInTheDocument(); + expect(queryByTestId('course-team-sidebar')).not.toBeInTheDocument(); + }); + }); + + it('render CourseTeam component without initial sidebar correctly', async () => { + cleanup(); + axiosMock + .onGet(getCourseTeamApiUrl(courseId)) + .reply(200, courseTeamMock); + + const { getByTestId, queryByTestId } = render(); + + await waitFor(() => { + expect(queryByTestId('course-team-sidebar__initial')).not.toBeInTheDocument(); + expect(getByTestId('course-team-sidebar')).toBeInTheDocument(); + }); + }); + + it('displays AddUserForm when clicking the "Add New Member" button', async () => { + cleanup(); + axiosMock + .onGet(getCourseTeamApiUrl(courseId)) + .reply(200, courseTeamWithOneUser); + + const { getByRole, queryByTestId } = render(); + + await waitFor(() => { + expect(queryByTestId('add-user-form')).not.toBeInTheDocument(); + const addButton = getByRole('button', { name: messages.addNewMemberButton.defaultMessage }); + fireEvent.click(addButton); + expect(queryByTestId('add-user-form')).toBeInTheDocument(); + }); + }); + + it('displays AddUserForm when clicking the "Add a New Team member" button', async () => { + cleanup(); + axiosMock + .onGet(getCourseTeamApiUrl(courseId)) + .reply(200, courseTeamWithOneUser); + + const { getByRole, queryByTestId } = render(); + + await waitFor(() => { + expect(queryByTestId('add-user-form')).not.toBeInTheDocument(); + const addButton = getByRole('button', { name: 'Add a new team member' }); + fireEvent.click(addButton); + expect(queryByTestId('add-user-form')).toBeInTheDocument(); + }); + }); + + it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false', async () => { + cleanup(); + axiosMock + .onGet(getCourseTeamApiUrl(courseId)) + .reply(200, { + ...courseTeamWithOneUser, + allowActions: false, + }); + + const { queryByRole, queryByTestId } = render(); + + await waitFor(() => { + expect(queryByRole('button', { name: messages.addNewMemberButton.defaultMessage })).not.toBeInTheDocument(); + expect(queryByTestId('add-team-member')).not.toBeInTheDocument(); + }); + }); + + it('should delete user', async () => { + cleanup(); + axiosMock + .onGet(getCourseTeamApiUrl(courseId)) + .reply(200, courseTeamMock); + + const { queryByText } = render(); + + axiosMock + .onDelete(updateCourseTeamUserApiUrl(courseId, 'staff@example.com')) + .reply(200); + + await executeThunk(deleteCourseTeamQuery(courseId, 'staff@example.com'), store.dispatch); + expect(queryByText('staff@example.com')).not.toBeInTheDocument(); + }); + + it('should change role user', async () => { + cleanup(); + axiosMock + .onGet(getCourseTeamApiUrl(courseId)) + .reply(200, courseTeamMock); + + const { getAllByText } = render(); + + axiosMock + .onPut(updateCourseTeamUserApiUrl(courseId, 'staff@example.com', { role: USER_ROLES.admin })) + .reply(200, { role: USER_ROLES.admin }); + + await executeThunk(changeRoleTeamUserQuery(courseId, 'staff@example.com', { role: USER_ROLES.admin }), store.dispatch); + expect(getAllByText('Admin')).toHaveLength(1); + }); +}); diff --git a/src/course-team/__mocks__/courseTeam.js b/src/course-team/__mocks__/courseTeam.js new file mode 100644 index 0000000000..3a7d479a2d --- /dev/null +++ b/src/course-team/__mocks__/courseTeam.js @@ -0,0 +1,24 @@ +module.exports = { + showTransferOwnershipHint: true, + users: [ + { + email: 'staff@example.com', + id: '2', + role: 'staff', + username: 'Staff_Name', + }, + { + email: 'audit@example.com', + id: '3', + role: 'staff', + username: 'Audit_Name', + }, + { + email: 'vladislav.keblysh@raccoongang.com', + id: '1', + role: 'instructor', + username: 'Vladislav_Keblysh', + }, + ], + allowActions: true, +}; diff --git a/src/course-team/__mocks__/courseTeamWithOneUser.js b/src/course-team/__mocks__/courseTeamWithOneUser.js new file mode 100644 index 0000000000..136e1f7317 --- /dev/null +++ b/src/course-team/__mocks__/courseTeamWithOneUser.js @@ -0,0 +1,12 @@ +module.exports = { + showTransferOwnershipHint: true, + users: [ + { + email: 'staff@example.com', + id: '2', + role: 'staff', + username: 'Staff_Name', + }, + ], + allowActions: true, +}; diff --git a/src/course-team/__mocks__/courseTeamWithoutUsers.js b/src/course-team/__mocks__/courseTeamWithoutUsers.js new file mode 100644 index 0000000000..357caf9565 --- /dev/null +++ b/src/course-team/__mocks__/courseTeamWithoutUsers.js @@ -0,0 +1,5 @@ +module.exports = { + showTransferOwnershipHint: true, + users: [], + allowActions: true, +}; diff --git a/src/course-team/__mocks__/index.js b/src/course-team/__mocks__/index.js new file mode 100644 index 0000000000..787cfc14de --- /dev/null +++ b/src/course-team/__mocks__/index.js @@ -0,0 +1,3 @@ +export { default as courseTeamMock } from './courseTeam'; +export { default as courseTeamWithOneUser } from './courseTeamWithOneUser'; +export { default as courseTeamWithoutUsers } from './courseTeamWithoutUsers'; diff --git a/src/course-team/add-team-member/AddTeamMember.jsx b/src/course-team/add-team-member/AddTeamMember.jsx new file mode 100644 index 0000000000..427534dc0e --- /dev/null +++ b/src/course-team/add-team-member/AddTeamMember.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Add as IconAdd } from '@edx/paragon/icons'; +import { Button } from '@edx/paragon'; + +import messages from './messages'; + +const AddTeamMember = ({ onFormOpen, isButtonDisable }) => { + const intl = useIntl(); + + return ( +
+
+

{intl.formatMessage(messages.title)}

+ {intl.formatMessage(messages.description)} +
+ +
+ ); +}; + +AddTeamMember.propTypes = { + onFormOpen: PropTypes.func.isRequired, + isButtonDisable: PropTypes.bool, +}; + +AddTeamMember.defaultProps = { + isButtonDisable: false, +}; + +export default AddTeamMember; diff --git a/src/course-team/add-team-member/AddTeamMember.scss b/src/course-team/add-team-member/AddTeamMember.scss new file mode 100644 index 0000000000..6859a2dced --- /dev/null +++ b/src/course-team/add-team-member/AddTeamMember.scss @@ -0,0 +1,17 @@ +.add-team-member { + display: flex; + align-items: center; + justify-content: space-between; + border: .0625rem solid $gray-200; + border-radius: .375rem; + box-shadow: inset inset 0 1px .125rem 1px $gray-200; + padding: 1.25rem 1.875rem; + + .add-team-member-info { + width: 60%; + } + + .add-team-member-title { + font-size: $spacer; + } +} diff --git a/src/course-team/add-team-member/AddTeamMember.test.jsx b/src/course-team/add-team-member/AddTeamMember.test.jsx new file mode 100644 index 0000000000..bb20a93ebf --- /dev/null +++ b/src/course-team/add-team-member/AddTeamMember.test.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import AddTeamMember from './AddTeamMember'; +import messages from './messages'; + +const onFormOpenMock = jest.fn(); + +const renderComponent = (props) => render( + + + , +); + +describe('', () => { + it('render AddTeamMember component correctly', () => { + const { getByText, getByRole } = renderComponent(); + + expect(getByText(messages.title.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.description.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.button.defaultMessage })).toBeInTheDocument(); + }); + + it('calls onFormOpen when the button is clicked', () => { + const { getByText } = renderComponent(); + + const addButton = getByText(messages.button.defaultMessage); + fireEvent.click(addButton); + expect(onFormOpenMock).toHaveBeenCalledTimes(1); + }); + + it('"Add a New Team member" button is disabled when isButtonDisable is true', () => { + const { getByRole } = renderComponent({ isButtonDisable: true }); + + const addButton = getByRole('button', { name: messages.button.defaultMessage }); + expect(addButton).toBeDisabled(); + }); + + it('"Add a New Team member" button is not disabled when isButtonDisable is false', () => { + const { getByRole } = renderComponent(); + + const addButton = getByRole('button', { name: messages.button.defaultMessage }); + expect(addButton).not.toBeDisabled(); + }); +}); diff --git a/src/course-team/add-team-member/messages.js b/src/course-team/add-team-member/messages.js new file mode 100644 index 0000000000..3def097045 --- /dev/null +++ b/src/course-team/add-team-member/messages.js @@ -0,0 +1,18 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + title: { + id: 'course-authoring.course-team.add-team-member.title', + defaultMessage: 'Add team members to this course', + }, + description: { + id: 'course-authoring.course-team.add-team-member.description', + defaultMessage: 'Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.', + }, + button: { + id: 'course-authoring.course-team.add-team-member.button', + defaultMessage: 'Add a new team member', + }, +}); + +export default messages; diff --git a/src/course-team/add-user-form/AddUserForm.jsx b/src/course-team/add-user-form/AddUserForm.jsx new file mode 100644 index 0000000000..284288c86f --- /dev/null +++ b/src/course-team/add-user-form/AddUserForm.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, + Form, + ActionRow, +} from '@edx/paragon'; +import { Formik } from 'formik'; + +import messages from './messages'; +import FormikControl from '../../generic/FormikControl'; +import { EXAMPLE_USER_EMAIL } from '../constants'; + +const AddUserForm = ({ onSubmit, onCancel }) => { + const intl = useIntl(); + + return ( +
+ + {({ handleSubmit, values }) => ( + <> + +

{intl.formatMessage(messages.formTitle)}

+ + {intl.formatMessage(messages.formLabel)} + + + + {intl.formatMessage(messages.formHelperText)} + +
+ + + + + + )} +
+
+ ); +}; + +AddUserForm.propTypes = { + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, +}; + +export default AddUserForm; diff --git a/src/course-team/add-user-form/AddUserForm.scss b/src/course-team/add-user-form/AddUserForm.scss new file mode 100644 index 0000000000..0286372cae --- /dev/null +++ b/src/course-team/add-user-form/AddUserForm.scss @@ -0,0 +1,42 @@ +.add-user-form { + display: flex; + flex-direction: column; + border: .0625rem solid $gray-200; + border-radius: .375rem; + box-shadow: 0 1px 1px $gray-200; + margin-bottom: 1.25rem; + background-color: $white; + + .form-title { + font-size: 1.5rem; + margin-bottom: 1.875rem; + } + + .form-field { + padding: 1.25rem 1.875rem; + margin-bottom: $spacer; + + .pgn__form-group { + margin-bottom: 0; + } + } + + .form-label { + position: relative; + + &::after { + margin-left: .3125rem; + content: "*"; + } + } + + .form-helper-text { + font-size: $font-size-xs; + } + + .pgn__action-row { + padding: $spacer 1.875rem; + border-top: .0625rem solid $gray-200; + justify-content: flex-start; + } +} diff --git a/src/course-team/add-user-form/AddUserForm.test.jsx b/src/course-team/add-user-form/AddUserForm.test.jsx new file mode 100644 index 0000000000..d3716b14c1 --- /dev/null +++ b/src/course-team/add-user-form/AddUserForm.test.jsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { + render, + fireEvent, + act, + waitFor, +} from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { EXAMPLE_USER_EMAIL } from '../constants'; +import initializeStore from '../../store'; +import { USER_ROLES } from '../../constants'; +import { updateCourseTeamUserApiUrl } from '../data/api'; +import { createCourseTeamQuery } from '../data/thunk'; +import { executeThunk } from '../../utils'; +import AddUserForm from './AddUserForm'; +import messages from './messages'; + +let axiosMock; +let store; +const mockPathname = '/foo-bar'; +const courseId = '123'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + pathname: mockPathname, + }), +})); + +const onSubmitMock = jest.fn(); +const onCancelMock = jest.fn(); + +const RootWrapper = () => ( + + + + + +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('render AddUserForm component correctly', () => { + const { getByText, getByPlaceholderText } = render(); + + expect(getByText(messages.formTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.formLabel.defaultMessage)).toBeInTheDocument(); + expect(getByPlaceholderText(messages.formPlaceholder.defaultMessage + .replace('{email}', EXAMPLE_USER_EMAIL))).toBeInTheDocument(); + expect(getByText(messages.cancelButton.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.addUserButton.defaultMessage)).toBeInTheDocument(); + }); + + it('calls onSubmit when the "Add User" button is clicked with a valid email', async () => { + const { getByPlaceholderText, getByRole } = render(); + + const emailInput = getByPlaceholderText(messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL)); + const addUserButton = getByRole('button', { name: messages.addUserButton.defaultMessage }); + + fireEvent.change(emailInput, { target: { value: EXAMPLE_USER_EMAIL } }); + + await act(async () => { + fireEvent.click(addUserButton); + }); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalledTimes(1); + expect(onSubmitMock).toHaveBeenCalledWith( + { email: EXAMPLE_USER_EMAIL }, + expect.objectContaining({ submitForm: expect.any(Function) }), + ); + }); + + axiosMock + .onPost(updateCourseTeamUserApiUrl(courseId, EXAMPLE_USER_EMAIL), { role: USER_ROLES.staff }) + .reply(200, { role: USER_ROLES.staff }); + + await executeThunk(createCourseTeamQuery(courseId, EXAMPLE_USER_EMAIL), store.dispatch); + }); + + it('calls onCancel when the "Cancel" button is clicked', () => { + const { getByText } = render(); + + const cancelButton = getByText(messages.cancelButton.defaultMessage); + fireEvent.click(cancelButton); + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + + it('"Add User" button is disabled when the email input field is empty', () => { + const { getByText } = render(); + + const addUserButton = getByText(messages.addUserButton.defaultMessage); + expect(addUserButton).toBeDisabled(); + }); + + it('"Add User" button is not disabled when the email input field is not empty', () => { + const { getByPlaceholderText, getByText } = render(); + + const emailInput = getByPlaceholderText( + messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL), + ); + const addUserButton = getByText(messages.addUserButton.defaultMessage); + + fireEvent.change(emailInput, { target: { value: 'user@example.com' } }); + expect(addUserButton).not.toBeDisabled(); + }); +}); diff --git a/src/course-team/add-user-form/messages.js b/src/course-team/add-user-form/messages.js new file mode 100644 index 0000000000..c61543b170 --- /dev/null +++ b/src/course-team/add-user-form/messages.js @@ -0,0 +1,31 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + formTitle: { + id: 'course-authoring.course-team.form.title', + defaultMessage: 'Add a user to your course\'s team', + }, + formLabel: { + id: 'course-authoring.course-team.form.label', + defaultMessage: 'User\'s email address', + }, + formPlaceholder: { + id: 'course-authoring.course-team.form.placeholder', + defaultMessage: 'example: {email}', + }, + formHelperText: { + id: 'course-authoring.course-team.form.helperText', + defaultMessage: 'Provide the email address of the user you want to add as Staff', + }, + addUserButton: { + id: 'course-authoring.course-team.form.button.addUser', + defaultMessage: 'Add user', + }, + cancelButton: { + id: 'course-authoring.course-team.form.button.cancel', + defaultMessage: 'Cancel', + }, + +}); + +export default messages; diff --git a/src/course-team/constants.js b/src/course-team/constants.js new file mode 100644 index 0000000000..f6b603a177 --- /dev/null +++ b/src/course-team/constants.js @@ -0,0 +1,7 @@ +export const MODAL_TYPES = { + error: 'error', + delete: 'delete', + warning: 'warning', +}; + +export const EXAMPLE_USER_EMAIL = 'username@domain.com'; diff --git a/src/course-team/course-team-member/CourseTeamMember.jsx b/src/course-team/course-team-member/CourseTeamMember.jsx new file mode 100644 index 0000000000..333be2ba2c --- /dev/null +++ b/src/course-team/course-team-member/CourseTeamMember.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Badge, Button, MailtoLink } from '@edx/paragon'; +import { DeleteOutline as DeleteOutlineIcon } from '@edx/paragon/icons'; + +import messages from './messages'; +import { USER_ROLES, BADGE_STATES } from '../../constants'; + +const CourseTeamMember = ({ + userName, + role, + email, + onChangeRole, + onDelete, + currentUserEmail, + isHideActions, + isAllowActions, +}) => { + const intl = useIntl(); + const isAdminRole = role === USER_ROLES.admin; + + return ( +
+
+ + {isAdminRole + ? intl.formatMessage(messages.roleAdmin) + : intl.formatMessage(messages.roleStaff)} + {currentUserEmail === email && ( + {intl.formatMessage(messages.roleYou)} + )} + + {userName} + {email} +
+ {/* eslint-disable-next-line no-nested-ternary */} + {isAllowActions && ( + !isHideActions ? ( +
+ +
+ ) : ( +
+ {intl.formatMessage(messages.hint)} +
+ ) + )} +
+ ); +}; + +CourseTeamMember.propTypes = { + userName: PropTypes.string.isRequired, + role: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, + onChangeRole: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + currentUserEmail: PropTypes.string.isRequired, + isHideActions: PropTypes.bool.isRequired, + isAllowActions: PropTypes.bool.isRequired, +}; + +export default CourseTeamMember; diff --git a/src/course-team/course-team-member/CourseTeamMember.scss b/src/course-team/course-team-member/CourseTeamMember.scss new file mode 100644 index 0000000000..0285f13a92 --- /dev/null +++ b/src/course-team/course-team-member/CourseTeamMember.scss @@ -0,0 +1,63 @@ +.course-team-container { + margin-top: 3rem; +} + +.course-team-member { + display: flex; + align-items: center; + justify-content: space-between; + position: relative; + padding: 1.563rem 1.875rem 1.25rem; + background-color: $white; + border: .0625rem solid $gray-200; + border-radius: .375rem; + box-shadow: 0 1px 1px $gray-200; + + &:not(:last-child) { + margin-bottom: 1.25rem; + } + + .member-info { + position: relative; + display: flex; + flex-direction: column; + + .badge { + position: absolute; + top: -2.25rem; + left: -.25rem; + } + + .badge-current-user { + color: $gray-100; + margin-left: .25rem; + } + + .member-info-name { + font-size: 1.5rem; + margin-bottom: .25rem; + } + } + + .member-hint { + width: 45%; + margin-right: 3.875rem; + color: $gray-300; + font-size: $font-size-sm; + } + + .member-actions { + display: flex; + gap: $spacer; + + .delete-button { + display: flex; + align-items: center; + justify-content: center; + + & > span { + margin: 0; + } + } + } +} diff --git a/src/course-team/course-team-member/CourseTeamMember.test.jsx b/src/course-team/course-team-member/CourseTeamMember.test.jsx new file mode 100644 index 0000000000..be6719ac6a --- /dev/null +++ b/src/course-team/course-team-member/CourseTeamMember.test.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { USER_ROLES } from '../../constants'; +import CourseTeamMember from './CourseTeamMember'; +import messages from './messages'; + +const userNameMock = 'User'; +const emailMock = 'user@example.com'; +const currentUserEmailMock = 'user@example.com'; +const onChangeRoleMock = jest.fn(); +const onDeleteMock = jest.fn(); + +const renderComponent = (props) => render( + + + , +); + +describe('', () => { + it('render CourseTeamMember component correctly', () => { + const { getByText, getByRole, getByTestId } = renderComponent(); + + expect(getByText(userNameMock)).toBeInTheDocument(); + expect(getByText(emailMock)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.removeButton.defaultMessage })).toBeInTheDocument(); + expect(getByTestId('delete-button')).toBeInTheDocument(); + expect(getByText(messages.roleAdmin.defaultMessage)).toBeInTheDocument(); + }); + + it('displays correct badge and "You" label for the current user', () => { + const { getByText } = renderComponent({ + role: USER_ROLES.staff, + currentUserEmail: currentUserEmailMock, + }); + + expect(getByText(messages.roleStaff.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.roleYou.defaultMessage)).toBeInTheDocument(); + }); + + it('not displays actions when isAllowActions is false', () => { + const { queryByRole, queryByTestId } = renderComponent({ + role: USER_ROLES.admin, + currentUserEmail: currentUserEmailMock, + isAllowActions: false, + }); + + expect(queryByRole('button', { name: messages.removeButton.defaultMessage })).not.toBeInTheDocument(); + expect(queryByTestId('delete-button')).not.toBeInTheDocument(); + }); + + it('calls onChangeRole when the "Add"/"Remove" button is clicked', () => { + const { getByRole } = renderComponent({ + role: USER_ROLES.staff, + }); + + const addButton = getByRole('button', { name: messages.addButton.defaultMessage }); + fireEvent.click(addButton); + expect(onChangeRoleMock).toHaveBeenCalledTimes(1); + expect(onChangeRoleMock).toHaveBeenCalledWith(emailMock, USER_ROLES.admin); + }); + + it('calls onDelete when the "Delete" button is clicked', () => { + const { getByTestId } = renderComponent(); + + const deleteButton = getByTestId('delete-button'); + fireEvent.click(deleteButton); + expect(onDeleteMock).toHaveBeenCalledTimes(1); + }); + + it('renders the "Hint" when isHideActions is true', () => { + const { getByText, queryByRole, queryByTestId } = renderComponent({ + isHideActions: true, + }); + + expect(queryByRole('button', { name: messages.addButton.defaultMessage })).not.toBeInTheDocument(); + expect(queryByTestId('delete-button')).not.toBeInTheDocument(); + expect(getByText(messages.hint.defaultMessage)).toBeInTheDocument(); + }); +}); diff --git a/src/course-team/course-team-member/messages.js b/src/course-team/course-team-member/messages.js new file mode 100644 index 0000000000..784319b35a --- /dev/null +++ b/src/course-team/course-team-member/messages.js @@ -0,0 +1,30 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + roleAdmin: { + id: 'course-authoring.course-team.member.role.admin', + defaultMessage: 'Admin', + }, + roleStaff: { + id: 'course-authoring.course-team.member.role.staff', + defaultMessage: 'Staff', + }, + roleYou: { + id: 'course-authoring.course-team.member.role.you', + defaultMessage: 'You!', + }, + hint: { + id: 'course-authoring.course-team.member.hint', + defaultMessage: 'Promote another member to Admin to remove your admin rights', + }, + addButton: { + id: 'course-authoring.course-team.member.button.add', + defaultMessage: 'Add admin access', + }, + removeButton: { + id: 'course-authoring.course-team.member.button.remove', + defaultMessage: 'Remove admin access', + }, +}); + +export default messages; diff --git a/src/course-team/course-team-sidebar/CourseTeamSideBar.test.jsx b/src/course-team/course-team-sidebar/CourseTeamSideBar.test.jsx new file mode 100644 index 0000000000..7218ba40b5 --- /dev/null +++ b/src/course-team/course-team-sidebar/CourseTeamSideBar.test.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { AppProvider } from '@edx/frontend-platform/react'; + +import CourseTeamSidebar from './CourseTeamSidebar'; +import messages from './messages'; +import initializeStore from '../../store'; + +const courseId = 'course-123'; +let store; + +const renderComponent = (props) => render( + + + + + , +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + it('render CourseTeamSidebar component correctly', () => { + const { getByText } = renderComponent(); + + expect(getByText(messages.sidebarTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.sidebarAbout_1.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.sidebarAbout_2.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.sidebarAbout_3.defaultMessage)).toBeInTheDocument(); + }); + + it('render CourseTeamSidebar when isOwnershipHint is true', () => { + const { getByText } = renderComponent({ isOwnershipHint: true }); + + expect(getByText(messages.ownershipTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText( + 'Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click to make another user the Admin, then ask that user to remove you from the Course Team list.', + )).toBeInTheDocument(); + }); +}); diff --git a/src/course-team/course-team-sidebar/CourseTeamSidebar.jsx b/src/course-team/course-team-sidebar/CourseTeamSidebar.jsx new file mode 100644 index 0000000000..7da9be0f26 --- /dev/null +++ b/src/course-team/course-team-sidebar/CourseTeamSidebar.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import HelpSidebar from '../../generic/help-sidebar'; +import messages from './messages'; + +const CourseTeamSideBar = ({ courseId, isOwnershipHint, isShowInitialSidebar }) => { + const intl = useIntl(); + + return ( +
+ +

+ {intl.formatMessage(messages.sidebarTitle)} +

+

+ {intl.formatMessage(messages.sidebarAbout_1)} +

+

+ {intl.formatMessage(messages.sidebarAbout_2)} +

+

+ {intl.formatMessage(messages.sidebarAbout_3)} +

+
+ {isOwnershipHint && ( + <> +
+ +

+ {intl.formatMessage(messages.ownershipTitle)} +

+

+ {intl.formatMessage( + messages.ownershipDescription, + { strong: {intl.formatMessage(messages.addAdminAccess)} }, + )} +

+
+ + )} +
+ ); +}; + +CourseTeamSideBar.defaultProps = { + isShowInitialSidebar: false, +}; + +CourseTeamSideBar.propTypes = { + courseId: PropTypes.string.isRequired, + isOwnershipHint: PropTypes.bool.isRequired, + isShowInitialSidebar: PropTypes.bool, +}; + +export default CourseTeamSideBar; diff --git a/src/course-team/course-team-sidebar/CourseTeamSidebar.scss b/src/course-team/course-team-sidebar/CourseTeamSidebar.scss new file mode 100644 index 0000000000..42a8acf52a --- /dev/null +++ b/src/course-team/course-team-sidebar/CourseTeamSidebar.scss @@ -0,0 +1,11 @@ +.course-team-sidebar { + .help-sidebar { + &:not(:first-child) { + margin-top: 0; + } + + hr { + display: none; + } + } +} diff --git a/src/course-team/course-team-sidebar/messages.js b/src/course-team/course-team-sidebar/messages.js new file mode 100644 index 0000000000..a1934c19b2 --- /dev/null +++ b/src/course-team/course-team-sidebar/messages.js @@ -0,0 +1,34 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + sidebarTitle: { + id: 'course-authoring.course-team.sidebar.title', + defaultMessage: 'Course team roles', + }, + sidebarAbout_1: { + id: 'course-authoring.course-team.sidebar.about-1', + defaultMessage: 'Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.', + }, + sidebarAbout_2: { + id: 'course-authoring.course-team.sidebar.about-2', + defaultMessage: 'Admins are course team members who can add and remove other course team members.', + }, + sidebarAbout_3: { + id: 'course-authoring.course-team.sidebar.about-3', + defaultMessage: 'All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.', + }, + ownershipTitle: { + id: 'course-authoring.course-team.sidebar.ownership.title', + defaultMessage: 'Transferring ownership', + }, + ownershipDescription: { + id: 'course-authoring.course-team.sidebar.ownership.description', + defaultMessage: 'Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.', + }, + addAdminAccess: { + id: 'course-authoring.course-team.sidebar.ownership.addAdminAccess', + defaultMessage: 'Add admin access', + }, +}); + +export default messages; diff --git a/src/course-team/data/api.js b/src/course-team/data/api.js new file mode 100644 index 0000000000..321671600d --- /dev/null +++ b/src/course-team/data/api.js @@ -0,0 +1,54 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { USER_ROLES } from '../../constants'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +export const getCourseTeamApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_team/${courseId}`; +export const updateCourseTeamUserApiUrl = (courseId, email) => `${getApiBaseUrl()}/course_team/${courseId}/${email}`; + +/** + * Get course team. + * @param {string} courseId + * @returns {Promise} + */ +export async function getCourseTeam(courseId) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseTeamApiUrl(courseId)); + + return camelCaseObject(data); +} + +/** + * Create course team user. + * @param {string} courseId + * @param {string} email + * @returns {Promise} + */ +export async function createTeamUser(courseId, email) { + await getAuthenticatedHttpClient() + .post(updateCourseTeamUserApiUrl(courseId, email), { role: USER_ROLES.staff }); +} + +/** + * Change role course team user. + * @param {string} courseId + * @param {string} email + * @param {string} role + * @returns {Promise} + */ +export async function changeRoleTeamUser(courseId, email, role) { + await getAuthenticatedHttpClient() + .put(updateCourseTeamUserApiUrl(courseId, email), { role }); +} + +/** + * Delete course team user. + * @param {string} courseId + * @param {string} email + * @returns {Promise} + */ +export async function deleteTeamUser(courseId, email) { + await getAuthenticatedHttpClient() + .delete(updateCourseTeamUserApiUrl(courseId, email)); +} diff --git a/src/course-team/data/selectors.js b/src/course-team/data/selectors.js new file mode 100644 index 0000000000..99c602fdad --- /dev/null +++ b/src/course-team/data/selectors.js @@ -0,0 +1,6 @@ +export const getCourseTeamUsers = (state) => state.courseTeam.users; +export const getCourseTeamLoadingStatus = (state) => state.courseTeam.loadingCourseTeamStatus; +export const getErrorMessage = (state) => state.courseTeam.errorMessage; +export const getIsAllowActions = (state) => state.courseTeam.allowActions; +export const getIsOwnershipHint = (state) => state.courseTeam.showTransferOwnershipHint; +export const getSavingStatus = (state) => state.courseTeam.savingStatus; diff --git a/src/course-team/data/slice.js b/src/course-team/data/slice.js new file mode 100644 index 0000000000..374210b034 --- /dev/null +++ b/src/course-team/data/slice.js @@ -0,0 +1,46 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; +import { RequestStatus } from '../../data/constants'; + +const slice = createSlice({ + name: 'courseTeam', + initialState: { + loadingCourseTeamStatus: RequestStatus.IN_PROGRESS, + savingStatus: '', + users: [], + showTransferOwnershipHint: false, + allowActions: false, + errorMessage: '', + }, + reducers: { + fetchCourseTeamSuccess: (state, { payload }) => { + state.users = payload.users; + state.showTransferOwnershipHint = payload.showTransferOwnershipHint; + state.allowActions = payload.allowActions; + }, + updateLoadingCourseTeamStatus: (state, { payload }) => { + state.loadingCourseTeamStatus = payload.status; + }, + deleteCourseTeamUser: (state, { payload }) => { + state.users = state.users.filter((user) => user.email !== payload); + }, + updateSavingStatus: (state, { payload }) => { + state.savingStatus = payload.status; + }, + setErrorMessage: (state, { payload }) => { + state.errorMessage = payload; + }, + }, +}); + +export const { + fetchCourseTeamSuccess, + updateLoadingCourseTeamStatus, + deleteCourseTeamUser, + updateSavingStatus, + setErrorMessage, +} = slice.actions; + +export const { + reducer, +} = slice; diff --git a/src/course-team/data/thunk.js b/src/course-team/data/thunk.js new file mode 100644 index 0000000000..78012870f6 --- /dev/null +++ b/src/course-team/data/thunk.js @@ -0,0 +1,87 @@ +import { RequestStatus } from '../../data/constants'; +import { + getCourseTeam, + deleteTeamUser, + createTeamUser, + changeRoleTeamUser, +} from './api'; +import { + fetchCourseTeamSuccess, + updateLoadingCourseTeamStatus, + deleteCourseTeamUser, + updateSavingStatus, + setErrorMessage, +} from './slice'; + +export function fetchCourseTeamQuery(courseId) { + return async (dispatch) => { + dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + const courseTeam = await getCourseTeam(courseId); + dispatch(fetchCourseTeamSuccess(courseTeam)); + + dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.FAILED })); + return false; + } + }; +} + +export function createCourseTeamQuery(courseId, email) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + await createTeamUser(courseId, email); + const courseTeam = await getCourseTeam(courseId); + dispatch(fetchCourseTeamSuccess(courseTeam)); + + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + const message = error?.response?.data?.error || ''; + dispatch(setErrorMessage(message)); + + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + return false; + } + }; +} + +export function changeRoleTeamUserQuery(courseId, email, role) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + await changeRoleTeamUser(courseId, email, role); + const courseTeam = await getCourseTeam(courseId); + dispatch(fetchCourseTeamSuccess(courseTeam)); + + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch ({ message }) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + return false; + } + }; +} + +export function deleteCourseTeamQuery(courseId, email) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + await deleteTeamUser(courseId, email); + dispatch(deleteCourseTeamUser(email)); + + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + return false; + } + }; +} diff --git a/src/course-team/hooks.jsx b/src/course-team/hooks.jsx new file mode 100644 index 0000000000..6fceb5aac9 --- /dev/null +++ b/src/course-team/hooks.jsx @@ -0,0 +1,139 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { useEffect, useState } from 'react'; +import { useToggle } from '@edx/paragon'; + +import { USER_ROLES } from '../constants'; +import { RequestStatus } from '../data/constants'; +import { useModel } from '../generic/model-store'; +import { + changeRoleTeamUserQuery, + createCourseTeamQuery, + deleteCourseTeamQuery, + fetchCourseTeamQuery, +} from './data/thunk'; +import { + getCourseTeamLoadingStatus, + getCourseTeamUsers, + getErrorMessage, + getIsAllowActions, + getIsOwnershipHint, getSavingStatus, +} from './data/selectors'; +import { setErrorMessage } from './data/slice'; +import { MODAL_TYPES } from './constants'; + +const useCourseTeam = ({ courseId }) => { + const dispatch = useDispatch(); + + const { email: currentUserEmail } = getAuthenticatedUser(); + const courseDetails = useModel('courseDetails', courseId); + + const [modalType, setModalType] = useState(MODAL_TYPES.delete); + const [isInfoModalOpen, openInfoModal, closeInfoModal] = useToggle(false); + const [isFormVisible, openForm, hideForm] = useToggle(false); + const [currentEmail, setCurrentEmail] = useState(''); + const [isQueryPending, setIsQueryPending] = useState(false); + const courseTeamUsers = useSelector(getCourseTeamUsers); + const errorMessage = useSelector(getErrorMessage); + const savingStatus = useSelector(getSavingStatus); + const isAllowActions = useSelector(getIsAllowActions); + const isOwnershipHint = useSelector(getIsOwnershipHint); + const loadingCourseTeamStatus = useSelector(getCourseTeamLoadingStatus); + + const isSingleAdmin = courseTeamUsers.filter((user) => user.role === USER_ROLES.admin).length === 1; + + const handleOpenInfoModal = (type, email) => { + setCurrentEmail(email); + setModalType(type); + openInfoModal(); + }; + + const handleCloseInfoModal = () => { + dispatch(setErrorMessage('')); + closeInfoModal(); + }; + + const handleAddUserSubmit = (data) => { + setIsQueryPending(true); + + const { email } = data; + const isUserContains = courseTeamUsers.some((user) => user.email === email); + + if (isUserContains) { + handleOpenInfoModal(MODAL_TYPES.warning, email); + return; + } + + dispatch(createCourseTeamQuery(courseId, email)).then((result) => { + if (result) { + hideForm(); + dispatch(setErrorMessage('')); + return; + } + + handleOpenInfoModal(MODAL_TYPES.error, email); + }); + }; + + const handleDeleteUserSubmit = () => { + setIsQueryPending(true); + dispatch(deleteCourseTeamQuery(courseId, currentEmail)); + handleCloseInfoModal(); + }; + + const handleChangeRoleUserSubmit = (email, role) => { + setIsQueryPending(true); + dispatch(changeRoleTeamUserQuery(courseId, email, role)); + }; + + const handleInternetConnectionFailed = () => { + setIsQueryPending(false); + }; + + const handleOpenDeleteModal = (email) => { + handleOpenInfoModal(MODAL_TYPES.delete, email); + }; + + useEffect(() => { + dispatch(fetchCourseTeamQuery(courseId)); + }, [courseId]); + + useEffect(() => { + if (savingStatus === RequestStatus.SUCCESSFUL) { + setIsQueryPending(false); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }, [savingStatus]); + + return { + modalType, + errorMessage, + courseName: courseDetails?.name || '', + currentEmail, + courseTeamUsers, + currentUserEmail, + isLoading: loadingCourseTeamStatus === RequestStatus.IN_PROGRESS, + isSingleAdmin, + isFormVisible, + isAllowActions, + isInfoModalOpen, + isOwnershipHint, + isQueryPending, + isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED, + isShowAddTeamMember: courseTeamUsers.length === 1 && isAllowActions, + isShowInitialSidebar: !courseTeamUsers.length && !isFormVisible, + isShowUserFilledSidebar: Boolean(courseTeamUsers.length) || isFormVisible, + openForm, + hideForm, + closeInfoModal, + handleAddUserSubmit, + handleOpenInfoModal, + handleOpenDeleteModal, + handleDeleteUserSubmit, + handleChangeRoleUserSubmit, + handleInternetConnectionFailed, + }; +}; + +// eslint-disable-next-line import/prefer-default-export +export { useCourseTeam }; diff --git a/src/course-team/info-modal/InfoModal.jsx b/src/course-team/info-modal/InfoModal.jsx new file mode 100644 index 0000000000..4b9f5a9952 --- /dev/null +++ b/src/course-team/info-modal/InfoModal.jsx @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + ActionRow, + Button, + AlertModal, +} from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { MODAL_TYPES } from '../constants'; +import { getInfoModalSettings } from '../utils'; + +const InfoModal = ({ + modalType, + isOpen, + close, + onDeleteSubmit, + currentEmail, + errorMessage, + courseName, +}) => { + const intl = useIntl(); + + const { + title, + message, + variant, + closeButtonText, + submitButtonText, + closeButtonVariant, + } = getInfoModalSettings(modalType, currentEmail, errorMessage, courseName, intl); + + const isEmptyErrorMessage = modalType === MODAL_TYPES.error && !errorMessage; + + return ( + + + {modalType === MODAL_TYPES.delete && ( + + )} + + )} + > +

{message}

+
+ ); +}; + +InfoModal.propTypes = { + modalType: PropTypes.string.isRequired, + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, + currentEmail: PropTypes.string.isRequired, + errorMessage: PropTypes.string.isRequired, + courseName: PropTypes.string.isRequired, + onDeleteSubmit: PropTypes.func.isRequired, +}; + +export default InfoModal; diff --git a/src/course-team/info-modal/InfoModal.test.jsx b/src/course-team/info-modal/InfoModal.test.jsx new file mode 100644 index 0000000000..f97f8a0baf --- /dev/null +++ b/src/course-team/info-modal/InfoModal.test.jsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { MODAL_TYPES } from '../constants'; +import InfoModal from './InfoModal'; +import messages from './messages'; + +const closeMock = jest.fn(); +const onDeleteSubmitMock = jest.fn(); +const currentEmailMock = 'user@example.com'; +const errorMessageMock = 'Error text error@example.com'; +const courseNameMock = 'Course Name'; + +const renderComponent = (props) => render( + + + , +); + +describe('', () => { + it('render InfoModal component with type delete correctly', () => { + const { getByText, getByRole } = renderComponent({ + modalType: MODAL_TYPES.delete, + }); + + expect(getByText(messages.deleteModalTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText( + messages.deleteModalMessage.defaultMessage + .replace('{email}', currentEmailMock) + .replace('{courseName}', courseNameMock), + )).toBeInTheDocument(); + expect(getByRole('button', { name: messages.deleteModalCancelButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.deleteModalDeleteButton.defaultMessage })).toBeInTheDocument(); + }); + + it('render InfoModal component with type error correctly', () => { + const { getByText, getByRole } = renderComponent({ + modalType: MODAL_TYPES.error, + }); + + expect(getByText(messages.errorModalTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(errorMessageMock)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.errorModalOkButton.defaultMessage })).toBeInTheDocument(); + }); + + it('render InfoModal component with type warning correctly', () => { + const { getByText, getByRole } = renderComponent({ + modalType: MODAL_TYPES.warning, + }); + + expect(getByText(messages.warningModalTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText( + messages.warningModalMessage.defaultMessage + .replace('{email}', currentEmailMock) + .replace('{courseName}', courseNameMock), + )).toBeInTheDocument(); + expect(getByRole('button', { name: messages.warningModalReturnButton.defaultMessage })).toBeInTheDocument(); + }); + + it('calls close handler when the close button is clicked', () => { + const { getByRole } = renderComponent(); + + const closeButton = getByRole('button', { name: messages.deleteModalCancelButton.defaultMessage }); + fireEvent.click(closeButton); + expect(closeMock).toHaveBeenCalledTimes(1); + }); + + it('calls onDeleteSubmit handler when the delete button is clicked', () => { + const { getByRole } = renderComponent(); + + const deleteButton = getByRole('button', { name: messages.deleteModalDeleteButton.defaultMessage }); + fireEvent.click(deleteButton); + expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/course-team/info-modal/messages.js b/src/course-team/info-modal/messages.js new file mode 100644 index 0000000000..9a3dd74be2 --- /dev/null +++ b/src/course-team/info-modal/messages.js @@ -0,0 +1,42 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + deleteModalTitle: { + id: 'course-authoring.course-team.member.button.remove', + defaultMessage: 'Are you sure?', + }, + deleteModalMessage: { + id: 'course-authoring.course-team.delete-modal.message', + defaultMessage: 'Are you sure you want to delete {email} from the course team for “{courseName}”?', + }, + deleteModalDeleteButton: { + id: 'course-authoring.course-team.delete-modal.button.delete', + defaultMessage: 'Delete', + }, + deleteModalCancelButton: { + id: 'course-authoring.course-team.delete-modal.button.cancel', + defaultMessage: 'Cancel', + }, + errorModalTitle: { + id: 'course-authoring.course-team.error-modal.title', + defaultMessage: 'Error adding user', + }, + errorModalOkButton: { + id: 'course-authoring.course-team.error-modal.button.ok', + defaultMessage: 'Ok', + }, + warningModalTitle: { + id: 'course-authoring.course-team.warning-modal.title', + defaultMessage: 'Already a course team member', + }, + warningModalMessage: { + id: 'course-authoring.course-team.warning-modal.message', + defaultMessage: '{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.', + }, + warningModalReturnButton: { + id: 'course-authoring.course-team.warning-modal.button.return', + defaultMessage: 'Return to team listing', + }, +}); + +export default messages; diff --git a/src/course-team/messages.js b/src/course-team/messages.js new file mode 100644 index 0000000000..c6a6ebc059 --- /dev/null +++ b/src/course-team/messages.js @@ -0,0 +1,18 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + headingTitle: { + id: 'course-authoring.course-team.headingTitle', + defaultMessage: 'Course team', + }, + headingSubtitle: { + id: 'course-authoring.course-team.subTitle', + defaultMessage: 'Settings', + }, + addNewMemberButton: { + id: 'course-authoring.course-team.button.new-team-member', + defaultMessage: 'New team member', + }, +}); + +export default messages; diff --git a/src/course-team/utils.js b/src/course-team/utils.js new file mode 100644 index 0000000000..53604cf72d --- /dev/null +++ b/src/course-team/utils.js @@ -0,0 +1,53 @@ +import { MODAL_TYPES } from './constants'; +import messages from './info-modal/messages'; + +/** + * Create an info modal settings dependent on modal type + * @param {typeof MODAL_TYPES} modalType - one of MODAL_TYPES + * @param {string} currentEmail - email in current user + * @param {string} errorEmail - email from wrong request + * @param {string} courseName - current course name + * @returns {{ + * title: string, + * message: string, + * variant: string, + * closeButtonText: string, + * submitButtonText: string, + * closeButtonVariant: string + * }} + */ + +const getInfoModalSettings = (modalType, currentEmail, errorMessage, courseName, intl) => { + switch (modalType) { + case MODAL_TYPES.delete: + return { + title: intl.formatMessage(messages.deleteModalTitle), + message: intl.formatMessage(messages.deleteModalMessage, { email: currentEmail, courseName }), + variant: 'danger', + closeButtonText: intl.formatMessage(messages.deleteModalCancelButton), + submitButtonText: intl.formatMessage(messages.deleteModalDeleteButton), + closeButtonVariant: 'tertiary', + }; + case MODAL_TYPES.error: + return { + title: intl.formatMessage(messages.errorModalTitle), + message: errorMessage, + variant: 'danger', + closeButtonText: intl.formatMessage(messages.errorModalOkButton), + closeButtonVariant: 'danger', + }; + case MODAL_TYPES.warning: + return { + title: intl.formatMessage(messages.warningModalTitle), + message: intl.formatMessage(messages.warningModalMessage, { email: currentEmail, courseName }), + variant: 'warning', + closeButtonText: intl.formatMessage(messages.warningModalReturnButton), + mainButtonVariant: 'primary', + }; + default: + return ''; + } +}; + +// eslint-disable-next-line import/prefer-default-export +export { getInfoModalSettings }; diff --git a/src/generic/FormikControl.jsx b/src/generic/FormikControl.jsx index 2d2ab5e7d0..981e3be025 100644 --- a/src/generic/FormikControl.jsx +++ b/src/generic/FormikControl.jsx @@ -39,7 +39,7 @@ const FormikControl = ({ }; FormikControl.propTypes = { - name: PropTypes.element.isRequired, + name: PropTypes.string.isRequired, label: PropTypes.element, help: PropTypes.element, className: PropTypes.string, diff --git a/src/generic/internet-connection-alert/index.jsx b/src/generic/internet-connection-alert/index.jsx index fbf8be5484..6795f13874 100644 --- a/src/generic/internet-connection-alert/index.jsx +++ b/src/generic/internet-connection-alert/index.jsx @@ -30,7 +30,10 @@ const InternetConnectionAlert = ({ useEffect(() => { if (isQueryPending) { - onQueryProcessing(); + if (onQueryProcessing) { + onQueryProcessing(); + } + setShowAlert(!isOnline); if (!isOnline) { @@ -63,13 +66,13 @@ const InternetConnectionAlert = ({ InternetConnectionAlert.defaultProps = { isQueryPending: false, - + onQueryProcessing: null, }; InternetConnectionAlert.propTypes = { isFailed: PropTypes.bool.isRequired, isQueryPending: PropTypes.bool, - onQueryProcessing: PropTypes.func.isRequired, + onQueryProcessing: PropTypes.func, onInternetConnectionFailed: PropTypes.func.isRequired, }; diff --git a/src/generic/sub-header/SubHeader.jsx b/src/generic/sub-header/SubHeader.jsx index f6a7608a6b..121326be5f 100644 --- a/src/generic/sub-header/SubHeader.jsx +++ b/src/generic/sub-header/SubHeader.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; const SubHeader = ({ - title, subtitle, contentTitle, description, instruction, + title, subtitle, contentTitle, description, instruction, headerActions, }) => ( <>
@@ -10,6 +10,11 @@ const SubHeader = ({ {subtitle} {title} + {headerActions && ( +
+ {headerActions} +
+ )}

{contentTitle}

@@ -20,12 +25,11 @@ const SubHeader = ({ )} ); - SubHeader.defaultProps = { instruction: '', description: '', + headerActions: null, }; - SubHeader.propTypes = { title: PropTypes.string.isRequired, subtitle: PropTypes.string.isRequired, @@ -35,6 +39,6 @@ SubHeader.propTypes = { PropTypes.element, PropTypes.string, ]), + headerActions: PropTypes.node, }; - export default SubHeader; diff --git a/src/generic/sub-header/SubHeader.scss b/src/generic/sub-header/SubHeader.scss index 3a52f6768e..f1c5ff267f 100644 --- a/src/generic/sub-header/SubHeader.scss +++ b/src/generic/sub-header/SubHeader.scss @@ -1,3 +1,12 @@ +.sub-header { + display: flex; + + .sub-header-actions { + margin-bottom: 1.75rem; + align-self: flex-end; + } +} + .sub-header-title { font: normal $font-weight-bold 2rem/2.25rem $font-family-base; margin-bottom: 1.75rem; diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 0c0fbc5c53..d16a0cea99 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -640,5 +640,77 @@ "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}" } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 064cd48550..435a0a8187 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -640,5 +640,77 @@ "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}" } diff --git a/src/i18n/messages/de_DE.json b/src/i18n/messages/de_DE.json index 21731f205c..f5e78c3360 100644 --- a/src/i18n/messages/de_DE.json +++ b/src/i18n/messages/de_DE.json @@ -640,5 +640,77 @@ "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}" } diff --git a/src/i18n/messages/es_419.json b/src/i18n/messages/es_419.json index 6f9d298b90..85458e3896 100644 --- a/src/i18n/messages/es_419.json +++ b/src/i18n/messages/es_419.json @@ -640,5 +640,77 @@ "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}" } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 52118e6d55..84a01ce154 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -640,5 +640,77 @@ "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}" } diff --git a/src/i18n/messages/fr_CA.json b/src/i18n/messages/fr_CA.json index f56ed3319a..4f52c1b31c 100644 --- a/src/i18n/messages/fr_CA.json +++ b/src/i18n/messages/fr_CA.json @@ -640,5 +640,77 @@ "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}" } diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 064cd48550..435a0a8187 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -640,5 +640,77 @@ "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}" } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 064cd48550..435a0a8187 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -640,5 +640,77 @@ "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}" } diff --git a/src/i18n/messages/it_IT.json b/src/i18n/messages/it_IT.json index 4c05f14075..4a00f3c66d 100644 --- a/src/i18n/messages/it_IT.json +++ b/src/i18n/messages/it_IT.json @@ -640,5 +640,77 @@ "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}" } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 064cd48550..435a0a8187 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -640,5 +640,77 @@ "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}" } diff --git a/src/i18n/messages/pt_PT.json b/src/i18n/messages/pt_PT.json index 8c9d0a6960..d9456a708a 100644 --- a/src/i18n/messages/pt_PT.json +++ b/src/i18n/messages/pt_PT.json @@ -640,5 +640,77 @@ "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}" } diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 064cd48550..b072f4611d 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -606,6 +606,78 @@ "course-authoring.schedule-section.license.creative-commons.option.SA.label": "Share alike", "course-authoring.schedule-section.license.creative-commons.option.SA.description": "Allow others to distribute derivative works only under a license identical to the license that governs your work. This option is incompatible with 'No Derivatives'.", "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/i18n/messages/uk.json b/src/i18n/messages/uk.json index 064cd48550..435a0a8187 100644 --- a/src/i18n/messages/uk.json +++ b/src/i18n/messages/uk.json @@ -640,5 +640,77 @@ "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}" } diff --git a/src/i18n/messages/zh_CN.json b/src/i18n/messages/zh_CN.json index 064cd48550..435a0a8187 100644 --- a/src/i18n/messages/zh_CN.json +++ b/src/i18n/messages/zh_CN.json @@ -640,5 +640,77 @@ "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", + "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", + "course-authoring.schedule.alert.button.saving": "Saving", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-team.headingTitle": "Course team", + "course-authoring.course-team.subTitle": "Settings", + "course-authoring.course-team.button.new-team-member": "New team member", + "course-authoring.course-team.sidebar.title": "Course team roles", + "course-authoring.course-team.sidebar.about-1": "Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.", + "course-authoring.course-team.sidebar.about-2": "Admins are course team members who can add and remove other course team members.", + "course-authoring.course-team.sidebar.about-3": "All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.", + "course-authoring.course-team.sidebar.ownership.title": "Transferring ownership", + "course-authoring.course-team.sidebar.ownership.description": "Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.", + "course-authoring.course-team.sidebar.ownership.addAdminAccess": "Add admin access", + "course-authoring.course-team.form.title": "Add a user to your course's team", + "course-authoring.course-team.form.label": "User's email address", + "course-authoring.course-team.form.placeholder": "example: {email}", + "course-authoring.course-team.form.helperText": "Provide the email address of the user you want to add as Staff", + "course-authoring.course-team.form.button.addUser": "Add user", + "course-authoring.course-team.form.button.cancel": "Cancel", + "course-authoring.course-team.add-team-member.title": "Add team members to this course", + "course-authoring.course-team.add-team-member.description": "Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.", + "course-authoring.course-team.add-team-member.button": "Add a new team member", + "course-authoring.course-team.member.role.admin": "Admin", + "course-authoring.course-team.member.role.staff": "Staff", + "course-authoring.course-team.member.role.you": "You!", + "course-authoring.course-team.member.hint": "Promote another member to Admin to remove your admin rights", + "course-authoring.course-team.member.button.add": "Add admin access", + "course-authoring.course-team.member.button.remove": "Remove admin access", + "course-authoring.course-team.delete-modal.title": "Are you sure?", + "course-authoring.course-team.delete-modal.message": "Are you sure you want to delete {email} from the course team for “{courseName}”?", + "course-authoring.course-team.delete-modal.button.delete": "Delete", + "course-authoring.course-team.delete-modal.button.cancel": "Cancel", + "course-authoring.course-team.error-modal.title": "Error adding user", + "course-authoring.course-team.error-modal.button.ok": "Ok", + "course-authoring.course-team.warning-modal.title": "Already a course team member", + "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", + "course-authoring.course-team.warning-modal.button.return": "Return to team listing", + "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", + "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", + "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", + "course-authoring.grading-settings.deadline.label": "Grace period on deadline:", + "course-authoring.grading-settings.deadline.description": "Leeway on due dates", + "course-authoring.grading-settings.alert.button.saving": "Saving", + "course-authoring.grading-settings.add-new-assignment-type.btn": "New assignment type", + "course-authoring.grading-settings.assignment-type.description": "Categories and labels for any exercises that are gradable", + "course-authoring.grading-settings.assignment-type.title": "Assignment types", + "course-authoring.grading-settings.grading-rules-policies.description": "Deadlines, requirements, and logistics around grading student work", + "course-authoring.grading-settings.grading-rules-policies.title": "Grading rules & policies", + "course-authoring.grading-settings.credit-eligibility.description": "Settings for course credit eligibility", + "course-authoring.grading-settings.credit-eligibility.title": "Credit eligibility", + "course-authoring.grading-settings.assignment.type-name.title": "Assignment type name", + "course-authoring.grading-settings.assignment.type-name.description": "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.", + "course-authoring.grading-settings.assignment.type-name.error.message-1": "The assignment type must have a name.", + "course-authoring.grading-settings.assignment.type-name.error.message-3": "There's already another assignment type with this name.", + "course-authoring.grading-settings.assignment.abbreviation.title": "Abbreviation", + "course-authoring.grading-settings.assignment.abbreviation.description": "This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.title": "Weight of total grade", + "course-authoring.grading-settings.assignment.weight-of-total-grade.description": "The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.", + "course-authoring.grading-settings.assignment.weight-of-total-grade.error.message": "Please enter an integer between 0 and 100.", + "course-authoring.grading-settings.assignment.total-number.title": "Total number", + "course-authoring.grading-settings.assignment.total-number.description": "The number of subsections in the course that contain problems of this assignment type.", + "course-authoring.grading-settings.assignment.total-number.error.message": "Please enter an integer greater than 0.", + "course-authoring.grading-settings.assignment.number-of-droppable.title": "Number of droppable", + "course-authoring.grading-settings.assignment.number-of-droppable.description": "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.", + "course-authoring.grading-settings.assignment.number-of-droppable.error.message": "Please enter non-negative integer.", + "course-authoring.grading-settings.assignment.alert.warning.description": "There are no assignments of this type in the course.", + "course-authoring.grading-settings.assignment.delete.button": "Delete", + "course-authoring.grading-settings.assignment.number-of-droppable.second.error.message": "Cannot drop more {type} assignments than are assigned.", + "course-authoring.grading-settings.assignment.alert.warning.usage.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.warning.title": "Warning: The number of {type} assignments defined here does not match the current number of {type} assignments in the course:", + "course-authoring.grading-settings.assignment.alert.success.title": "The number of {type} assignments in the course matches the number defined here.", "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}" } diff --git a/src/index.scss b/src/index.scss index 83c5dac165..91ee8d0ce2 100755 --- a/src/index.scss +++ b/src/index.scss @@ -14,3 +14,4 @@ @import "generic/styles"; @import "schedule-and-details/ScheduleAndDetails"; @import "pages-and-resources/PagesAndResources"; +@import "course-team/CourseTeam"; diff --git a/src/store.js b/src/store.js index f99f6b255f..26b6434e95 100644 --- a/src/store.js +++ b/src/store.js @@ -10,6 +10,7 @@ import { reducer as gradingSettingsReducer } from './grading-settings/data/slice import { reducer as scheduleAndDetailsReducer } from './schedule-and-details/data/slice'; import { reducer as liveReducer } from './pages-and-resources/live/data/slice'; import { reducer as filesReducer } from './files-and-uploads/data/slice'; +import { reducer as courseTeamReducer } from './course-team/data/slice'; export default function initializeStore(preloadedState = undefined) { return configureStore({ @@ -24,6 +25,7 @@ export default function initializeStore(preloadedState = undefined) { gradingSettings: gradingSettingsReducer, models: modelsReducer, live: liveReducer, + courseTeam: courseTeamReducer, }, preloadedState, }); From d7a4b5b45b6a33ea650d80e8cb5ab4d0fbc04599 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Wed, 23 Aug 2023 15:17:26 -0400 Subject: [PATCH 07/79] fix: add word break style for long words (#574) --- src/files-and-uploads/FileInfo.jsx | 22 +++++++++++++------ src/files-and-uploads/FilesAndUploads.jsx | 2 +- .../table-components/GalleryCard.jsx | 8 ++++--- .../table-components/ListCard.jsx | 8 ++++--- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/files-and-uploads/FileInfo.jsx b/src/files-and-uploads/FileInfo.jsx index 59f3a11ce7..a07c40851b 100644 --- a/src/files-and-uploads/FileInfo.jsx +++ b/src/files-and-uploads/FileInfo.jsx @@ -48,7 +48,11 @@ const FileInfo = ({ > - {asset.displayName} +
+ + {asset.displayName} + +
@@ -83,9 +87,11 @@ const FileInfo = ({ - - {asset.portableUrl} - +
+ + {asset.portableUrl} + +
- - {asset.externalUrl} - +
+ + {asset.externalUrl} + +
) : ( -
+
{ currentView === 'card' && } { currentView === 'list' && } diff --git a/src/files-and-uploads/table-components/GalleryCard.jsx b/src/files-and-uploads/table-components/GalleryCard.jsx index dde7c2f86c..57cd1c277c 100644 --- a/src/files-and-uploads/table-components/GalleryCard.jsx +++ b/src/files-and-uploads/table-components/GalleryCard.jsx @@ -61,9 +61,11 @@ const GalleryCard = ({
)}
- - {original.displayName} - +
+ + {original.displayName} + +
diff --git a/src/files-and-uploads/table-components/ListCard.jsx b/src/files-and-uploads/table-components/ListCard.jsx index 5f987f76d7..495741a2a7 100644 --- a/src/files-and-uploads/table-components/ListCard.jsx +++ b/src/files-and-uploads/table-components/ListCard.jsx @@ -50,9 +50,11 @@ const ListCard = ({ - - {original.displayName} - +
+ + {original.displayName} + +
{original.wrapperType} From 1d95af5a313c853bbb5a5642088c91a35905d090 Mon Sep 17 00:00:00 2001 From: Jhon Vente <134975835+johnvente@users.noreply.github.com> Date: Thu, 24 Aug 2023 08:16:27 -0500 Subject: [PATCH 08/79] [DOCS] Readme updated according OEP-55 (#526) --- README.rst | 160 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 137 insertions(+), 23 deletions(-) diff --git a/README.rst b/README.rst index 0b2fcbc708..7b0dfcec30 100644 --- a/README.rst +++ b/README.rst @@ -1,19 +1,65 @@ -|Build Status| |Codecov| |license| - -############################# frontend-app-course-authoring ############################# -Please tag `@edx/teaching-and-learning `_ on any PRs or issues. Thanks. +|license-badge| |status-badge| |codecov-badge| -************ -Introduction -************ + +Purpose +******* This is the Course Authoring micro-frontend, currently under development by `2U `_. Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below. + +************ +Getting Started +************ + +Prerequisites +============= + +The `devstack`_ is currently recommended as a development environment for your +new MFE. If you start it with ``make dev.up.lms`` that should give you +everything you need as a companion to this frontend. + +Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer +to the `relevant tutor-mfe documentation`_ to get started using it. + +.. _Devstack: https://github.com/openedx/devstack + +.. _Tutor: https://github.com/overhangio/tutor + +.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development + +Cloning and Startup +=================== + + +1. Clone the repo: + + ``git clone https://github.com/openedx/frontend-app-course-authoring.git`` + +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 use`_. + +3. Install npm dependencies: + + ``cd frontend-app-course-authoring && npm install`` + + +4. Start the dev server: + + ``npm start`` + + +The dev server is running at `http://localhost:2001 `_. +or whatever port you setup. + ******** Features ******** @@ -151,22 +197,6 @@ Developing `Devstack `_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend. -Installation and Startup -======================== - -1. Clone the repo: - - ``git clone https://github.com/openedx/frontend-app-course-authoring.git`` - -2. Install npm dependencies: - - ``cd frontend-app-course-authoring && npm install`` - -3. Start the dev server: - - ``npm start`` - -The dev server is running at `http://localhost:2001 `_. If your devstack includes the default Demo course, you can visit the following URLs to see content: @@ -197,3 +227,87 @@ The production build is created with ``npm run build``. :target: https://codecov.io/gh/edx/frontend-app-course-authoring .. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg :target: @edx/frontend-app-course-authoring + +Internationalization +==================== + +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 + +Getting Help +************ + +If you're having trouble, we have discussion forums at +https://discuss.openedx.org where you can connect with others in the community. + +Our real-time conversations are on Slack. You can request a `Slack +invitation`_, then join our `community Slack workspace`_. Because this is a +frontend repository, the best place to discuss it would be in the `#wg-frontend +channel`_. + +For anything non-trivial, the best path is to open an issue in this repository +with as many details about the issue you are facing as you can provide. + +https://github.com/openedx/frontend-app-course-authoring/issues + +For more information about these options, see the `Getting Help`_ page. + +.. _Slack invitation: https://openedx.org/slack +.. _community Slack workspace: https://openedx.slack.com/ +.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6 +.. _Getting Help: https://openedx.org/community/connect + +License +******* + +The code in this repository is licensed under the AGPLv3 unless otherwise +noted. + +Please see `LICENSE `_ for details. + +Contributing +************ + +Contributions are very welcome. Please read `How To Contribute`_ for details. + +.. _How To Contribute: https://openedx.org/r/how-to-contribute + +This project is currently accepting all types of contributions, bug fixes, +security fixes, maintenance work, or new features. However, please make sure +to have a discussion about your new feature idea with the maintainers prior to +beginning development to maximize the chances of your change being accepted. +You can start a conversation by creating a new issue on this repo summarizing +your idea. + +The Open edX Code of Conduct +**************************** + +All community members are expected to follow the `Open edX Code of Conduct`_. + +.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/ + +People +****** + +The assigned maintainers for this component and other project details may be +found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml`` +file in this repo. + +.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-course-authoring + +Reporting Security Issues +************************* + +Please do not report security issues in public, and email security@openedx.org instead. + +.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-course-authoring.svg + :target: https://github.com/openedx/frontend-app-course-authoring/blob/master/LICENSE + :alt: License + +.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen + +.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-course-authoring/coverage.svg?branch=master + :target: https://codecov.io/github/openedx/frontend-app-course-authoring?branch=master + :alt: Codecov \ No newline at end of file From 181f9c7a5f4522f5a437d2c642d964cdb0039de6 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Fri, 25 Aug 2023 10:08:15 -0400 Subject: [PATCH 09/79] feat: add sort function and modal (#577) * feat: add sort modal and function * fix: dateAdded typo * chore: update mock api data --- src/files-and-uploads/FileInfo.jsx | 5 +- src/files-and-uploads/FilesAndUploads.jsx | 25 ++- .../FilesAndUploads.test.jsx | 28 +++ src/files-and-uploads/data/thunks.js | 18 +- src/files-and-uploads/data/utils.js | 69 +++++-- .../factories/mockApiResponses.jsx | 22 ++- src/files-and-uploads/messages.js | 38 +++- .../table-components/TableActions.jsx | 179 ++++++++++++++---- 8 files changed, 315 insertions(+), 69 deletions(-) diff --git a/src/files-and-uploads/FileInfo.jsx b/src/files-and-uploads/FileInfo.jsx index a07c40851b..171958cbba 100644 --- a/src/files-and-uploads/FileInfo.jsx +++ b/src/files-and-uploads/FileInfo.jsx @@ -17,7 +17,6 @@ import { CheckboxControl, } from '@edx/paragon'; import { ContentCopy, InfoOutline } from '@edx/paragon/icons'; -import { getUtcDateTime } from './data/utils'; import AssetThumbnail from './FileThumbnail'; import messages from './messages'; @@ -36,8 +35,6 @@ const FileInfo = ({ setLockedState(locked); handleLockedAsset(asset.id, locked); }; - const dateAdded = getUtcDateTime(asset.dateAdded); - return ( { try { const file = fileData.get('file'); @@ -82,6 +83,11 @@ const FilesAndUploads = ({ } }; + const handleSort = (sortType) => { + const newAssetIdOrder = sortFiles(assets, sortType); + dispatch(updateAssetOrder(courseId, newAssetIdOrder, sortType)); + }; + const handleBulkDelete = () => { closeDeleteConfirmation(); setDeleteOpen(); @@ -117,6 +123,7 @@ const FilesAndUploads = ({ {...{ selectedFlatRows, fileInputControl, + handleSort, handleBulkDownload, handleOpenDeleteConfirmation, }} @@ -229,6 +236,22 @@ const FilesAndUploads = ({ }, ], }, + { + Header: 'Locked', + accessor: 'locked', + // Filter: CheckboxFilter, + // filter: 'exactText', + // filterChoices: [ + // { + // name: 'Locked', + // value: true, + // }, + // { + // name: 'Unlocked', + // value: false, + // }, + // ], + }, ]} itemCount={totalCount} pageCount={Math.ceil(totalCount / 50)} diff --git a/src/files-and-uploads/FilesAndUploads.test.jsx b/src/files-and-uploads/FilesAndUploads.test.jsx index dbd76a5f74..4e52e6b688 100644 --- a/src/files-and-uploads/FilesAndUploads.test.jsx +++ b/src/files-and-uploads/FilesAndUploads.test.jsx @@ -205,6 +205,34 @@ describe('FilesAndUploads', () => { expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL); expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull(); }); + it('sort button should be enabled and sort files by name', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage); + expect(sortsButton).toBeVisible(); + await waitFor(() => { + fireEvent.click(sortsButton); + expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible(); + }); + const sortNameAscendingButton = screen.getByText(messages.sortByNameAscending.defaultMessage); + fireEvent.click(sortNameAscendingButton); + fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage)); + expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull(); + }); + it('sort button should be enabled and sort files by file size', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage); + expect(sortsButton).toBeVisible(); + await waitFor(() => { + fireEvent.click(sortsButton); + expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible(); + }); + const sortBySizeDescendingButton = screen.getByText(messages.sortBySizeDescending.defaultMessage); + fireEvent.click(sortBySizeDescendingButton); + fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage)); + expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull(); + }); }); describe('card menu actions', () => { it('should open asset info', async () => { diff --git a/src/files-and-uploads/data/thunks.js b/src/files-and-uploads/data/thunks.js index 21622e6a54..6ef78b32ed 100644 --- a/src/files-and-uploads/data/thunks.js +++ b/src/files-and-uploads/data/thunks.js @@ -23,7 +23,7 @@ import { updateErrors, } from './slice'; -import { getWrapperType } from './utils'; +import { updateFileValues } from './utils'; export function fetchAssets(courseId) { return async (dispatch) => { @@ -32,8 +32,8 @@ export function fetchAssets(courseId) { try { const { totalCount } = await getAssets(courseId); const { assets } = await getAssets(courseId, totalCount); - const assetsWithWraperType = getWrapperType(assets); - dispatch(addModels({ modelType: 'assets', models: assetsWithWraperType })); + const parsedAssests = updateFileValues(assets); + dispatch(addModels({ modelType: 'assets', models: parsedAssests })); dispatch(setAssetIds({ assetIds: assets.map(asset => asset.id), })); @@ -49,6 +49,14 @@ export function fetchAssets(courseId) { }; } +export function updateAssetOrder(courseId, assetIds) { + return async (dispatch) => { + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS })); + dispatch(setAssetIds({ assetIds })); + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL })); + }; +} + export function deleteAssetFile(courseId, id, totalCount) { return async (dispatch) => { dispatch(updateDeletingStatus({ status: RequestStatus.IN_PROGRESS })); @@ -72,10 +80,10 @@ export function addAssetFile(courseId, file, totalCount) { try { const { asset } = await addAsset(courseId, file); - const [assetsWithWraperType] = getWrapperType([asset]); + const [parsedAssest] = updateFileValues([asset]); dispatch(addModel({ modelType: 'assets', - model: { ...assetsWithWraperType }, + model: { ...parsedAssest }, })); dispatch(addAssetSuccess({ assetId: asset.id, diff --git a/src/files-and-uploads/data/utils.js b/src/files-and-uploads/data/utils.js index 1416fdfdfb..823a81f6cb 100644 --- a/src/files-and-uploads/data/utils.js +++ b/src/files-and-uploads/data/utils.js @@ -6,22 +6,28 @@ ensureConfig([ 'STUDIO_BASE_URL', ], 'Course Apps API service'); -export const getWrapperType = (assets) => { - const assetsWithWraperType = []; - assets.forEach(asset => { - if (FILES_AND_UPLOAD_TYPE_FILTERS.images.includes(asset.contentType)) { - assetsWithWraperType.push({ wrapperType: 'image', ...asset }); - } else if (FILES_AND_UPLOAD_TYPE_FILTERS.documents.includes(asset.contentType)) { - assetsWithWraperType.push({ wrapperType: 'document', ...asset }); - } else if (FILES_AND_UPLOAD_TYPE_FILTERS.code.includes(asset.contentType)) { - assetsWithWraperType.push({ wrapperType: 'code', ...asset }); - } else if (FILES_AND_UPLOAD_TYPE_FILTERS.audio.includes(asset.contentType)) { - assetsWithWraperType.push({ wrapperType: 'audio', ...asset }); - } else { - assetsWithWraperType.push({ wrapperType: 'other', ...asset }); +export const updateFileValues = (files) => { + const updatedFiles = []; + files.forEach(file => { + let wrapperType = 'other'; + if (FILES_AND_UPLOAD_TYPE_FILTERS.images.includes(file.contentType)) { + wrapperType = 'image'; + } else if (FILES_AND_UPLOAD_TYPE_FILTERS.documents.includes(file.contentType)) { + wrapperType = 'document'; + } else if (FILES_AND_UPLOAD_TYPE_FILTERS.code.includes(file.contentType)) { + wrapperType = 'code'; + } else if (FILES_AND_UPLOAD_TYPE_FILTERS.audio.includes(file.contentType)) { + wrapperType = 'audio'; } + + const { dateAdded } = file; + const utcDateString = dateAdded.replace(/\bat\b/g, ''); + const utcDateTime = new Date(utcDateString).toString(); + + updatedFiles.push({ ...file, wrapperType, dateAdded: utcDateTime }); }); - return assetsWithWraperType; + + return updatedFiles; }; export const getSrc = ({ thumbnail, wrapperType, externalUrl }) => { @@ -40,8 +46,35 @@ export const getSrc = ({ thumbnail, wrapperType, externalUrl }) => { } }; -export const getUtcDateTime = (date) => { - const utcDateString = date.replace(/\bat\b/g, ''); - const utcDateTime = new Date(utcDateString); - return utcDateTime; +export const sortFiles = (files, sortType) => { + const [sort, direction] = sortType.split(','); + let sortedFiles; + if (sort === 'displayName') { + sortedFiles = files.sort((f1, f2) => { + const lowerCaseF1 = f1[sort].toLowerCase(); + const lowerCaseF2 = f2[sort].toLowerCase(); + if (lowerCaseF1 < lowerCaseF2) { + return 1; + } + if (lowerCaseF1 > lowerCaseF2) { + return -1; + } + return 0; + }); + } else { + sortedFiles = files.sort((f1, f2) => { + if (f1[sort] < f2[sort]) { + return 1; + } + if (f1[sort] > f2[sort]) { + return -1; + } + return 0; + }); + } + const sortedIds = sortedFiles.map(file => file.id); + if (direction === 'asc') { + return sortedIds.reverse(); + } + return sortedIds; }; diff --git a/src/files-and-uploads/factories/mockApiResponses.jsx b/src/files-and-uploads/factories/mockApiResponses.jsx index a71b60eeb0..d3ceb27209 100644 --- a/src/files-and-uploads/factories/mockApiResponses.jsx +++ b/src/files-and-uploads/factories/mockApiResponses.jsx @@ -48,6 +48,16 @@ export const generateFetchAssetApiResponse = () => ({ dateAdded: '', thumbnail: '/asset', }, + { + id: 'mOckID5', + displayName: 'mOckID5', + locked: false, + externalUrl: 'static_tab_1', + portableUrl: '', + contentType: 'application/json', + dateAdded: 'Aug 13, 2023 at 22:08 UTC', + thumbnail: null, + }, { id: 'mOckID3', displayName: 'mOckID3', @@ -55,7 +65,7 @@ export const generateFetchAssetApiResponse = () => ({ externalUrl: 'static_tab_1', portableUrl: '', contentType: 'application/pdf', - dateAdded: '', + dateAdded: 'Aug 17, 2023 at 22:08 UTC', thumbnail: null, }, { @@ -69,21 +79,21 @@ export const generateFetchAssetApiResponse = () => ({ thumbnail: null, }, { - id: 'mOckID5', - displayName: 'mOckID5', + id: 'mOckID6', + displayName: 'mOckID6', locked: false, externalUrl: 'static_tab_1', portableUrl: '', - contentType: 'application/json', + contentType: 'application/octet-stream', dateAdded: '', thumbnail: null, }, { - id: 'mOckID6', + id: 'mOckID6-2', displayName: 'mOckID6', locked: false, externalUrl: 'static_tab_1', - portableUrl: '', + portableUrl: 'May 17, 2023 at 22:08 UTC', contentType: 'application/octet-stream', dateAdded: '', thumbnail: null, diff --git a/src/files-and-uploads/messages.js b/src/files-and-uploads/messages.js index 21cd6196e9..4af9b26f2b 100644 --- a/src/files-and-uploads/messages.js +++ b/src/files-and-uploads/messages.js @@ -114,9 +114,45 @@ const messages = defineMessages({ defaultMessage: 'Delete', }, cancelButtonLabel: { - id: 'course-authoring.files-and-uploads.deleteConfirmation.cancelButton.label', + id: 'course-authoring.files-and-uploads.cancelButton.label', defaultMessage: 'Cancel', }, + sortButtonLabel: { + id: 'course-authoring.files-and-uploads.sortButton.label', + defaultMessage: 'Sort', + }, + sortModalTitleLabel: { + id: 'course-authoring.files-and-uploads.sortModal.title', + defaultMessage: 'Sort by', + }, + sortByNameAscending: { + id: 'course-authoring.files-and-uploads.sortByNameAscendingButton.label', + defaultMessage: 'Name (A-Z)', + }, + sortByNewest: { + id: 'course-authoring.files-and-uploads.sortByNewestButton.label', + defaultMessage: 'Newest', + }, + sortBySizeDescending: { + id: 'course-authoring.files-and-uploads.sortBySizeDescendingButton.label', + defaultMessage: 'File size (High to low)', + }, + sortByNameDescending: { + id: 'course-authoring.files-and-uploads.sortByNameDescendingButton.label', + defaultMessage: 'Name (Z-A)', + }, + sortByOldest: { + id: 'course-authoring.files-and-uploads.sortByOldestButton.label', + defaultMessage: 'Oldest', + }, + sortBySizeAscending: { + id: 'course-authoring.files-and-uploads.sortBySizeAscendingButton.label', + defaultMessage: 'File size(Low to high)', + }, + applySortButton: { + id: 'course-authoring.files-and-uploads.applyySortButton.label', + defaultMessage: 'Apply', + }, }); export default messages; diff --git a/src/files-and-uploads/table-components/TableActions.jsx b/src/files-and-uploads/table-components/TableActions.jsx index 76d6ae4180..e395ca301e 100644 --- a/src/files-and-uploads/table-components/TableActions.jsx +++ b/src/files-and-uploads/table-components/TableActions.jsx @@ -1,48 +1,156 @@ -import React from 'react'; +import React, { useState } from 'react'; import _ from 'lodash'; import { PropTypes } from 'prop-types'; -import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Button, Dropdown } from '@edx/paragon'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Button, + Dropdown, + ModalDialog, + SelectableBox, + useToggle, +} from '@edx/paragon'; import { Add } from '@edx/paragon/icons'; import messages from '../messages'; const TableActions = ({ selectedFlatRows, fileInputControl, + handleSort, handleBulkDownload, handleOpenDeleteConfirmation, -}) => ( - <> - - - - - - handleBulkDownload(selectedFlatRows)} - disabled={_.isEmpty(selectedFlatRows)} - > - - - - handleOpenDeleteConfirmation(selectedFlatRows)} - disabled={_.isEmpty(selectedFlatRows)} + // injected + intl, +}) => { + const [isSortOpen, openSort, closeSort] = useToggle(false); + const [sortBy, setSortBy] = useState('dateAdded,desc'); + const handleChange = (e) => { + setSortBy(e.target.value); + }; + return ( + <> + + + - - - - - - -); + + + + handleBulkDownload(selectedFlatRows)} + disabled={_.isEmpty(selectedFlatRows)} + > + + + + handleOpenDeleteConfirmation(selectedFlatRows)} + disabled={_.isEmpty(selectedFlatRows)} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; TableActions.defaultProps = { selectedFlatRows: null, @@ -66,6 +174,9 @@ TableActions.propTypes = { }).isRequired, handleOpenDeleteConfirmation: PropTypes.func.isRequired, handleBulkDownload: PropTypes.func.isRequired, + handleSort: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, }; export default injectIntl(TableActions); From ffae3bd8682d2d1ccb38c738e8f95499ecf93fb6 Mon Sep 17 00:00:00 2001 From: vladislavkeblysh <138868841+vladislavkeblysh@users.noreply.github.com> Date: Thu, 31 Aug 2023 17:56:45 +0300 Subject: [PATCH 10/79] feat: Created Course updates page (#581) --- .env | 1 - .env.development | 1 - .env.test | 1 - src/CourseAuthoringRoutes.jsx | 6 +- src/assets/scss/_animations.scss | 9 + src/constants.js | 8 +- src/course-updates/CourseUpdates.jsx | 163 +++++++++++++ src/course-updates/CourseUpdates.scss | 20 ++ src/course-updates/CourseUpdates.test.jsx | 220 ++++++++++++++++++ .../__mocks__/courseHandouts.js | 83 +++++++ src/course-updates/__mocks__/courseUpdates.js | 5 + src/course-updates/__mocks__/index.js | 2 + src/course-updates/constants.js | 7 + .../course-handouts/CourseHandouts.jsx | 41 ++++ .../course-handouts/CourseHandouts.scss | 16 ++ .../course-handouts/CourseHandouts.test.jsx | 45 ++++ .../course-handouts/messages.js | 14 ++ .../course-update/CourseUpdate.jsx | 64 +++++ .../course-update/CourseUpdate.scss | 36 +++ .../course-update/CourseUpdate.test.jsx | 72 ++++++ src/course-updates/course-update/messages.js | 18 ++ src/course-updates/course-update/utils.js | 17 ++ src/course-updates/data/api.js | 84 +++++++ src/course-updates/data/selectors.js | 4 + src/course-updates/data/slice.js | 72 ++++++ src/course-updates/data/thunk.js | 111 +++++++++ .../delete-modal/DeleteModal.jsx | 48 ++++ .../delete-modal/DeleteModal.test.jsx | 47 ++++ src/course-updates/delete-modal/messages.js | 22 ++ src/course-updates/hooks.jsx | 114 +++++++++ src/course-updates/index.js | 2 + src/course-updates/messages.js | 22 ++ src/course-updates/update-form/UpdateForm.jsx | 141 +++++++++++ .../update-form/UpdateForm.scss | 63 +++++ .../update-form/UpdateForm.test.jsx | 140 +++++++++++ src/course-updates/update-form/messages.js | 46 ++++ src/course-updates/update-form/utils.js | 56 +++++ src/course-updates/utils.js | 2 + src/generic/WysiwygEditor.scss | 5 + .../ProccessingNotification.scss | 25 ++ .../ProcessingNotification.test.jsx | 17 ++ .../processing-notification/data/selectors.js | 5 + .../processing-notification/data/slice.js | 28 +++ src/generic/processing-notification/index.jsx | 30 +++ src/generic/styles.scss | 2 + src/i18n/messages/ar.json | 22 ++ src/i18n/messages/de.json | 22 ++ src/i18n/messages/de_DE.json | 22 ++ src/i18n/messages/es_419.json | 22 ++ src/i18n/messages/fr.json | 22 ++ src/i18n/messages/fr_CA.json | 22 ++ src/i18n/messages/hi.json | 22 ++ src/i18n/messages/it.json | 22 ++ src/i18n/messages/it_IT.json | 22 ++ src/i18n/messages/pt.json | 22 ++ src/i18n/messages/pt_PT.json | 22 ++ src/i18n/messages/ru.json | 22 ++ src/i18n/messages/uk.json | 22 ++ src/i18n/messages/zh_CN.json | 22 ++ src/index.scss | 2 + src/store.js | 4 + 61 files changed, 2241 insertions(+), 8 deletions(-) create mode 100644 src/assets/scss/_animations.scss create mode 100644 src/course-updates/CourseUpdates.jsx create mode 100644 src/course-updates/CourseUpdates.scss create mode 100644 src/course-updates/CourseUpdates.test.jsx create mode 100644 src/course-updates/__mocks__/courseHandouts.js create mode 100644 src/course-updates/__mocks__/courseUpdates.js create mode 100644 src/course-updates/__mocks__/index.js create mode 100644 src/course-updates/constants.js create mode 100644 src/course-updates/course-handouts/CourseHandouts.jsx create mode 100644 src/course-updates/course-handouts/CourseHandouts.scss create mode 100644 src/course-updates/course-handouts/CourseHandouts.test.jsx create mode 100644 src/course-updates/course-handouts/messages.js create mode 100644 src/course-updates/course-update/CourseUpdate.jsx create mode 100644 src/course-updates/course-update/CourseUpdate.scss create mode 100644 src/course-updates/course-update/CourseUpdate.test.jsx create mode 100644 src/course-updates/course-update/messages.js create mode 100644 src/course-updates/course-update/utils.js create mode 100644 src/course-updates/data/api.js create mode 100644 src/course-updates/data/selectors.js create mode 100644 src/course-updates/data/slice.js create mode 100644 src/course-updates/data/thunk.js create mode 100644 src/course-updates/delete-modal/DeleteModal.jsx create mode 100644 src/course-updates/delete-modal/DeleteModal.test.jsx create mode 100644 src/course-updates/delete-modal/messages.js create mode 100644 src/course-updates/hooks.jsx create mode 100644 src/course-updates/index.js create mode 100644 src/course-updates/messages.js create mode 100644 src/course-updates/update-form/UpdateForm.jsx create mode 100644 src/course-updates/update-form/UpdateForm.scss create mode 100644 src/course-updates/update-form/UpdateForm.test.jsx create mode 100644 src/course-updates/update-form/messages.js create mode 100644 src/course-updates/update-form/utils.js create mode 100644 src/course-updates/utils.js create mode 100644 src/generic/WysiwygEditor.scss create mode 100644 src/generic/processing-notification/ProccessingNotification.scss create mode 100644 src/generic/processing-notification/ProcessingNotification.test.jsx create mode 100644 src/generic/processing-notification/data/selectors.js create mode 100644 src/generic/processing-notification/data/slice.js create mode 100644 src/generic/processing-notification/index.jsx diff --git a/.env b/.env index 2874b4a976..452df8210f 100644 --- a/.env +++ b/.env @@ -32,7 +32,6 @@ ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true ENABLE_NEW_HOME_PAGE = false ENABLE_NEW_COURSE_OUTLINE_PAGE = false -ENABLE_NEW_UPDATES_PAGE = false ENABLE_NEW_VIDEO_UPLOAD_PAGE = false ENABLE_NEW_GRADING_PAGE = false ENABLE_NEW_COURSE_TEAM_PAGE = false diff --git a/.env.development b/.env.development index b17611fad3..184972f459 100644 --- a/.env.development +++ b/.env.development @@ -34,7 +34,6 @@ ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true ENABLE_NEW_HOME_PAGE = false ENABLE_NEW_COURSE_OUTLINE_PAGE = false -ENABLE_NEW_UPDATES_PAGE = false ENABLE_NEW_VIDEO_UPLOAD_PAGE = false ENABLE_NEW_GRADING_PAGE = false ENABLE_NEW_COURSE_TEAM_PAGE = false diff --git a/.env.test b/.env.test index 8a57403943..f97e6c8932 100644 --- a/.env.test +++ b/.env.test @@ -30,7 +30,6 @@ ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true ENABLE_NEW_HOME_PAGE = false ENABLE_NEW_COURSE_OUTLINE_PAGE = true -ENABLE_NEW_UPDATES_PAGE = true ENABLE_NEW_VIDEO_UPLOAD_PAGE = true ENABLE_NEW_GRADING_PAGE = true ENABLE_NEW_COURSE_TEAM_PAGE = true diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 854c0ee08c..1d886b0f86 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -14,6 +14,7 @@ import { AdvancedSettings } from './advanced-settings'; import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; import CourseTeam from './course-team/CourseTeam'; +import { CourseUpdates } from './course-updates'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -43,10 +44,7 @@ const CourseAuthoringRoutes = ({ courseId }) => { )} - {process.env.ENABLE_NEW_UPDATES_PAGE === 'true' - && ( - - )} + diff --git a/src/assets/scss/_animations.scss b/src/assets/scss/_animations.scss new file mode 100644 index 0000000000..2100b62a41 --- /dev/null +++ b/src/assets/scss/_animations.scss @@ -0,0 +1,9 @@ +@keyframes rotate { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} diff --git a/src/constants.js b/src/constants.js index e0d0481bbd..a8c3742e1a 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,7 +1,7 @@ export const DATE_FORMAT = 'MM/dd/yyyy'; export const TIME_FORMAT = 'HH:mm'; export const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss\\Z'; -export const FORMATTED_DATE_FORMAT = 'MMMM D, YYYY'; +export const COMMA_SEPARATED_DATE_FORMAT = 'MMMM D, YYYY'; export const DEFAULT_EMPTY_WYSIWYG_VALUE = '

 

'; export const STATEFUL_BUTTON_STATES = { pending: 'pending', @@ -17,3 +17,9 @@ export const BADGE_STATES = { danger: 'danger', secondary: 'secondary', }; + +export const NOTIFICATION_MESSAGES = { + saving: 'Saving', + duplicating: 'Duplicating', + deleting: 'Deleting', +}; diff --git a/src/course-updates/CourseUpdates.jsx b/src/course-updates/CourseUpdates.jsx new file mode 100644 index 0000000000..cfcdfea960 --- /dev/null +++ b/src/course-updates/CourseUpdates.jsx @@ -0,0 +1,163 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, + Container, + Layout, +} from '@edx/paragon'; +import { Add as AddIcon } from '@edx/paragon/icons'; +import { useSelector } from 'react-redux'; + +import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; +import ProcessingNotification from '../generic/processing-notification'; +import SubHeader from '../generic/sub-header/SubHeader'; +import InternetConnectionAlert from '../generic/internet-connection-alert'; +import { RequestStatus } from '../data/constants'; +import CourseHandouts from './course-handouts/CourseHandouts'; +import CourseUpdate from './course-update/CourseUpdate'; +import DeleteModal from './delete-modal/DeleteModal'; +import UpdateForm from './update-form/UpdateForm'; +import { REQUEST_TYPES } from './constants'; +import messages from './messages'; +import { useCourseUpdates } from './hooks'; +import { getLoadingStatuses, getSavingStatuses } from './data/selectors'; +import { matchesAnyStatus } from './utils'; + +const CourseUpdates = ({ courseId }) => { + const intl = useIntl(); + + const { + requestType, + courseUpdates, + courseHandouts, + courseUpdatesInitialValues, + isMainFormOpen, + isInnerFormOpen, + isUpdateFormOpen, + isDeleteModalOpen, + closeUpdateForm, + closeDeleteModal, + handleUpdatesSubmit, + handleOpenUpdateForm, + handleOpenDeleteForm, + handleDeleteUpdateSubmit, + } = useCourseUpdates({ courseId }); + + const { + isShow: isShowProcessingNotification, + title: processingNotificationTitle, + } = useSelector(getProcessingNotification); + + const loadingStatuses = useSelector(getLoadingStatuses); + const savingStatuses = useSelector(getSavingStatuses); + + const anyStatusFailed = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.FAILED); + const anyStatusInProgress = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.IN_PROGRESS); + const anyStatusPending = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.PENDING); + + return ( + <> + +
+ + +
+
+ handleOpenUpdateForm(REQUEST_TYPES.add_new_update)} + disabled={isUpdateFormOpen} + > + {intl.formatMessage(messages.newUpdateButton)} + + )} + /> +
+ {isMainFormOpen && ( + + )} +
+
+ {courseUpdates.length ? courseUpdates.map((courseUpdate, index) => ( + isInnerFormOpen(courseUpdate.id) ? ( + + ) : ( + handleOpenUpdateForm(REQUEST_TYPES.edit_update, courseUpdate)} + onDelete={() => handleOpenDeleteForm(courseUpdate)} + isDisabledButtons={isUpdateFormOpen} + /> + ))) : null} +
+
+ handleOpenUpdateForm(REQUEST_TYPES.edit_handouts)} + isDisabledButtons={isUpdateFormOpen} + /> +
+ + {isShowProcessingNotification && ( + + )} +
+
+
+
+
+
+
+
+
+ null} + /> +
+ + ); +}; + +CourseUpdates.propTypes = { + courseId: PropTypes.string.isRequired, +}; + +export default CourseUpdates; diff --git a/src/course-updates/CourseUpdates.scss b/src/course-updates/CourseUpdates.scss new file mode 100644 index 0000000000..72a9ff7ab6 --- /dev/null +++ b/src/course-updates/CourseUpdates.scss @@ -0,0 +1,20 @@ +@import "./course-handouts/CourseHandouts"; +@import "./course-update/CourseUpdate"; +@import "./update-form/UpdateForm"; + +.updates-container { + @include pgn-box-shadow(1, "centered"); + + display: grid; + grid-template-columns: 65% 35%; + border: .0625rem solid $gray-200; + border-radius: .375rem; + background: $white; + overflow: hidden; +} + +.updates-handouts-container { + border-left: .0625rem solid $gray-200; + padding: 1.875rem; + background: $white; +} diff --git a/src/course-updates/CourseUpdates.test.jsx b/src/course-updates/CourseUpdates.test.jsx new file mode 100644 index 0000000000..46bdff9aa8 --- /dev/null +++ b/src/course-updates/CourseUpdates.test.jsx @@ -0,0 +1,220 @@ +import React from 'react'; +import { render, waitFor, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { + getCourseUpdatesApiUrl, + getCourseHandoutApiUrl, + updateCourseUpdatesApiUrl, +} from './data/api'; +import { + createCourseUpdateQuery, + deleteCourseUpdateQuery, + editCourseHandoutsQuery, + editCourseUpdateQuery, +} from './data/thunk'; +import initializeStore from '../store'; +import { executeThunk } from '../utils'; +import { courseUpdatesMock, courseHandoutsMock } from './__mocks__'; +import CourseUpdates from './CourseUpdates'; +import messages from './messages'; + +let axiosMock; +let store; +const mockPathname = '/foo-bar'; +const courseId = '123'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + pathname: mockPathname, + }), +})); + +jest.mock('@tinymce/tinymce-react', () => { + const originalModule = jest.requireActual('@tinymce/tinymce-react'); + return { + __esModule: true, + ...originalModule, + Editor: () => 'foo bar', + }; +}); + +jest.mock('@edx/frontend-lib-content-components', () => ({ + TinyMceWidget: () =>
Widget
, + prepareEditorRef: jest.fn(() => ({ + refReady: true, + setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'), + })), +})); + +const RootWrapper = () => ( + + + + + +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getCourseUpdatesApiUrl(courseId)) + .reply(200, courseUpdatesMock); + axiosMock + .onGet(getCourseHandoutApiUrl(courseId)) + .reply(200, courseHandoutsMock); + }); + + it('render CourseUpdates component correctly', async () => { + const { + getByText, getAllByTestId, getByTestId, getByRole, + } = render(); + + await waitFor(() => { + expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.sectionInfo.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeInTheDocument(); + expect(getAllByTestId('course-update')).toHaveLength(3); + expect(getByTestId('course-handouts')).toBeInTheDocument(); + }); + }); + + it('should create course update', async () => { + const { getByText } = render(); + + const data = { + content: '

Some text

', + date: 'August 29, 2023', + }; + + axiosMock + .onPost(getCourseUpdatesApiUrl(courseId)) + .reply(200, data); + + await executeThunk(createCourseUpdateQuery(courseId, data), store.dispatch); + expect(getByText('Some text')).toBeInTheDocument(); + expect(getByText(data.date)).toBeInTheDocument(); + }); + + it('should edit course update', async () => { + const { getByText, queryByText } = render(); + + const data = { + id: courseUpdatesMock[0].id, + content: '

Some text

', + date: 'August 29, 2023', + }; + + axiosMock + .onPut(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id)) + .reply(200, data); + + await executeThunk(editCourseUpdateQuery(courseId, data), store.dispatch); + expect(getByText('Some text')).toBeInTheDocument(); + expect(getByText(data.date)).toBeInTheDocument(); + expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument(); + expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument(); + }); + + it('should delete course update', async () => { + const { queryByText } = render(); + + axiosMock + .onDelete(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id)) + .reply(200); + + await executeThunk(deleteCourseUpdateQuery(courseId, courseUpdatesMock[0].id), store.dispatch); + expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument(); + expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument(); + }); + + it('should edit course handouts', async () => { + const { getByText, queryByText } = render(); + + const data = { + ...courseHandoutsMock, + data: '

Some handouts 1

', + }; + + axiosMock + .onPut(getCourseHandoutApiUrl(courseId)) + .reply(200, data); + + await executeThunk(editCourseHandoutsQuery(courseId, data), store.dispatch); + expect(getByText('Some handouts 1')).toBeInTheDocument(); + expect(queryByText(courseHandoutsMock.data)).not.toBeInTheDocument(); + }); + + it('Add new update form is visible after clicking "New update" button', async () => { + const { getByText, getByRole, getAllByRole } = render(); + + await waitFor(() => { + const editButtons = getAllByRole('button', { name: 'Edit' }); + const deleteButtons = getAllByRole('button', { name: 'Delete' }); + const newUpdateButton = getByRole('button', { name: messages.newUpdateButton.defaultMessage }); + + fireEvent.click(newUpdateButton); + + expect(newUpdateButton).toBeDisabled(); + editButtons.forEach((button) => expect(button).toBeDisabled()); + deleteButtons.forEach((button) => expect(button).toBeDisabled()); + expect(getByText('Add new update')).toBeInTheDocument(); + }); + }); + + it('Edit handouts form is visible after clicking "Edit" button', async () => { + const { + getByText, getByRole, getByTestId, getAllByRole, + } = render(); + + await waitFor(() => { + const editHandoutsButton = getByTestId('course-handouts-edit-button'); + const editButtons = getAllByRole('button', { name: 'Edit' }); + const deleteButtons = getAllByRole('button', { name: 'Delete' }); + + fireEvent.click(editHandoutsButton); + + expect(editHandoutsButton).toBeDisabled(); + expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled(); + editButtons.forEach((button) => expect(button).toBeDisabled()); + deleteButtons.forEach((button) => expect(button).toBeDisabled()); + expect(getByText('Edit handouts')).toBeInTheDocument(); + }); + }); + + it('Edit update form is visible after clicking "Edit" button', async () => { + const { + getByText, getByRole, getAllByTestId, getAllByRole, queryByText, + } = render(); + + await waitFor(() => { + const editUpdateFirstButton = getAllByTestId('course-update-edit-button')[0]; + const editButtons = getAllByRole('button', { name: 'Edit' }); + const deleteButtons = getAllByRole('button', { name: 'Delete' }); + + fireEvent.click(editUpdateFirstButton); + expect(getByText('Edit update')).toBeInTheDocument(); + expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled(); + editButtons.forEach((button) => expect(button).toBeDisabled()); + deleteButtons.forEach((button) => expect(button).toBeDisabled()); + expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/course-updates/__mocks__/courseHandouts.js b/src/course-updates/__mocks__/courseHandouts.js new file mode 100644 index 0000000000..55a6b25794 --- /dev/null +++ b/src/course-updates/__mocks__/courseHandouts.js @@ -0,0 +1,83 @@ +module.exports = { + id: 'block-v1:edX+DemoX+Demo_Course+type@course_info+block@handouts', + display_name: 'Text', + category: 'course_info', + has_children: false, + edited_on: 'Jul 12, 2023 at 17:52 UTC', + published: true, + published_on: 'Jul 12, 2023 at 17:52 UTC', + studio_url: null, + released_to_students: false, + release_date: null, + visibility_state: 'unscheduled', + has_explicit_staff_lock: false, + start: '2030-01-01T00:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: null, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + data: 'Some handouts', + metadata: {}, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, +}; diff --git a/src/course-updates/__mocks__/courseUpdates.js b/src/course-updates/__mocks__/courseUpdates.js new file mode 100644 index 0000000000..dca2909eb0 --- /dev/null +++ b/src/course-updates/__mocks__/courseUpdates.js @@ -0,0 +1,5 @@ +module.exports = [ + { id: 1, date: 'July 11, 2023', content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.' }, + { id: 2, date: 'August 20, 2023', content: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.' }, + { id: 3, date: 'January 30, 2023', content: 'But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself' }, +]; diff --git a/src/course-updates/__mocks__/index.js b/src/course-updates/__mocks__/index.js new file mode 100644 index 0000000000..bb4cba111d --- /dev/null +++ b/src/course-updates/__mocks__/index.js @@ -0,0 +1,2 @@ +export { default as courseUpdatesMock } from './courseUpdates'; +export { default as courseHandoutsMock } from './courseHandouts'; diff --git a/src/course-updates/constants.js b/src/course-updates/constants.js new file mode 100644 index 0000000000..fd082b7d5d --- /dev/null +++ b/src/course-updates/constants.js @@ -0,0 +1,7 @@ +// eslint-disable-next-line import/prefer-default-export +export const REQUEST_TYPES = { + add_new_update: 'add_new_update', + edit_update: 'edit_update', + edit_handouts: 'edit_handouts', + delete_update: 'delete_update', +}; diff --git a/src/course-updates/course-handouts/CourseHandouts.jsx b/src/course-updates/course-handouts/CourseHandouts.jsx new file mode 100644 index 0000000000..ef374a01e3 --- /dev/null +++ b/src/course-updates/course-handouts/CourseHandouts.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button } from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +const CourseHandouts = ({ contentForHandouts, onEdit, isDisabledButtons }) => { + const intl = useIntl(); + + return ( +
+
+

{intl.formatMessage(messages.handoutsTitle)}

+ +
+
+
+ ); +}; + +CourseHandouts.propTypes = { + contentForHandouts: PropTypes.string.isRequired, + onEdit: PropTypes.func.isRequired, + isDisabledButtons: PropTypes.bool.isRequired, +}; + +export default CourseHandouts; diff --git a/src/course-updates/course-handouts/CourseHandouts.scss b/src/course-updates/course-handouts/CourseHandouts.scss new file mode 100644 index 0000000000..731ddae5d9 --- /dev/null +++ b/src/course-updates/course-handouts/CourseHandouts.scss @@ -0,0 +1,16 @@ +.course-handouts { + .course-handouts-header { + display: flex; + justify-content: space-between; + margin-bottom: $spacer; + + .course-handouts-header__title { + font-weight: 300; + color: $gray-800; + } + + .course-handouts-header__btn { + align-self: flex-start; + } + } +} diff --git a/src/course-updates/course-handouts/CourseHandouts.test.jsx b/src/course-updates/course-handouts/CourseHandouts.test.jsx new file mode 100644 index 0000000000..6732442278 --- /dev/null +++ b/src/course-updates/course-handouts/CourseHandouts.test.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import CourseHandouts from './CourseHandouts'; +import messages from './messages'; + +const onEditMock = jest.fn(); +const handoutsContentMock = 'Handouts Content'; + +const renderComponent = (props) => render( + + + , +); + +describe('', () => { + it('render CourseHandouts component correctly', () => { + const { getByText, getByRole } = renderComponent(); + + expect(getByText(messages.handoutsTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(handoutsContentMock)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument(); + }); + + it('calls the onEdit function when the edit button is clicked', () => { + const { getByRole } = renderComponent(); + + const editButton = getByRole('button', { name: messages.editButton.defaultMessage }); + fireEvent.click(editButton); + expect(onEditMock).toHaveBeenCalledTimes(1); + }); + + it('"Edit" button is disabled when isDisabledButtons is true', () => { + const { getByRole } = renderComponent({ isDisabledButtons: true }); + + const editButton = getByRole('button', { name: messages.editButton.defaultMessage }); + expect(editButton).toBeDisabled(); + }); +}); diff --git a/src/course-updates/course-handouts/messages.js b/src/course-updates/course-handouts/messages.js new file mode 100644 index 0000000000..ea412c6a42 --- /dev/null +++ b/src/course-updates/course-handouts/messages.js @@ -0,0 +1,14 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + handoutsTitle: { + id: 'course-authoring.course-updates.handouts.title', + defaultMessage: 'Course handouts', + }, + editButton: { + id: 'course-authoring.course-updates.actions.edit', + defaultMessage: 'Edit', + }, +}); + +export default messages; diff --git a/src/course-updates/course-update/CourseUpdate.jsx b/src/course-updates/course-update/CourseUpdate.jsx new file mode 100644 index 0000000000..dffc3d583e --- /dev/null +++ b/src/course-updates/course-update/CourseUpdate.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button, Icon } from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Error as ErrorIcon } from '@edx/paragon/icons/es5'; + +import { isDateForUpdateValid } from './utils'; +import messages from './messages'; + +const CourseUpdate = ({ + dateForUpdate, + contentForUpdate, + onEdit, + onDelete, + isDisabledButtons, +}) => { + const intl = useIntl(); + + return ( +
+
+ {dateForUpdate} + {!isDateForUpdateValid(dateForUpdate) && ( +
+ +

{intl.formatMessage(messages.errorMessage)}

+
+ )} +
+ + +
+
+ {Boolean(contentForUpdate) && ( +
+ )} +
+ ); +}; + +CourseUpdate.propTypes = { + dateForUpdate: PropTypes.string.isRequired, + contentForUpdate: PropTypes.string.isRequired, + onEdit: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + isDisabledButtons: PropTypes.bool.isRequired, +}; + +export default CourseUpdate; diff --git a/src/course-updates/course-update/CourseUpdate.scss b/src/course-updates/course-update/CourseUpdate.scss new file mode 100644 index 0000000000..43f98bdfa1 --- /dev/null +++ b/src/course-updates/course-update/CourseUpdate.scss @@ -0,0 +1,36 @@ +.course-update { + &:not(:first-child) { + padding-top: 1.875rem; + margin-top: 1.875rem; + border-top: 1px solid $light-400; + } + + .course-update-header { + display: flex; + align-items: center; + margin-bottom: 1.125rem; + gap: .5rem; + + .course-update-header__date { + line-height: 1.875rem; + letter-spacing: 1px; + } + + .course-update-header__error { + display: flex; + align-items: center; + gap: .25rem; + + svg { + color: $warning-300; + } + } + + .course-update-header__action { + display: flex; + width: auto; + margin-left: auto; + gap: .5rem; + } + } +} diff --git a/src/course-updates/course-update/CourseUpdate.test.jsx b/src/course-updates/course-update/CourseUpdate.test.jsx new file mode 100644 index 0000000000..32e4447296 --- /dev/null +++ b/src/course-updates/course-update/CourseUpdate.test.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import CourseUpdate from './CourseUpdate'; +import messages from './messages'; + +const onEditMock = jest.fn(); +const onDeleteMock = jest.fn(); +const dateForUpdateMock = 'May 1, 2023'; +const contentForUpdateMock = 'Update Content'; + +const renderComponent = (props) => render( + + + , +); + +describe('', () => { + it('render CourseUpdate component correctly', () => { + const { getByText, getByRole } = renderComponent(); + + expect(getByText(dateForUpdateMock)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument(); + }); + + it('render CourseUpdate component without content correctly', () => { + const { getByText, queryByTestId, getByRole } = renderComponent({ contentForUpdate: '' }); + + expect(getByText(dateForUpdateMock)).toBeInTheDocument(); + expect(queryByTestId('course-update-content')).not.toBeInTheDocument(); + expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument(); + }); + + it('render error message when dateForUpdate is inValid', () => { + const { getByText } = renderComponent({ dateForUpdate: 'Welcome' }); + + expect(getByText(messages.errorMessage.defaultMessage)).toBeInTheDocument(); + }); + + it('calls the onEdit function when the "Edit" button is clicked', () => { + const { getByRole } = renderComponent(); + + const editButton = getByRole('button', { name: messages.editButton.defaultMessage }); + fireEvent.click(editButton); + expect(onEditMock).toHaveBeenCalledTimes(1); + }); + + it('calls the onDelete function when the "Delete" button is clicked', () => { + const { getByRole } = renderComponent(); + + const deleteButton = getByRole('button', { name: messages.deleteButton.defaultMessage }); + fireEvent.click(deleteButton); + expect(onDeleteMock).toHaveBeenCalledTimes(1); + }); + + it('"Edit" and "Delete" buttons is disabled when isDisabledButtons is true', () => { + const { getByRole } = renderComponent({ isDisabledButtons: true }); + + expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeDisabled(); + expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeDisabled(); + }); +}); diff --git a/src/course-updates/course-update/messages.js b/src/course-updates/course-update/messages.js new file mode 100644 index 0000000000..0814df91d2 --- /dev/null +++ b/src/course-updates/course-update/messages.js @@ -0,0 +1,18 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + editButton: { + id: 'course-authoring.course-updates.button.edit', + defaultMessage: 'Edit', + }, + deleteButton: { + id: 'course-authoring.course-updates.button.delete', + defaultMessage: 'Delete', + }, + errorMessage: { + id: 'course-authoring.course-updates.date-invalid', + defaultMessage: 'Action required: Enter a valid date.', + }, +}); + +export default messages; diff --git a/src/course-updates/course-update/utils.js b/src/course-updates/course-update/utils.js new file mode 100644 index 0000000000..a063c1962f --- /dev/null +++ b/src/course-updates/course-update/utils.js @@ -0,0 +1,17 @@ +import moment from 'moment'; + +import { COMMA_SEPARATED_DATE_FORMAT } from '../../constants'; + +/** + * Check is valid date format in course update + * @param {string} date - date for update + * @returns {boolean} - is valid date format + */ +const isDateForUpdateValid = (date) => { + const parsedDate = moment(date, COMMA_SEPARATED_DATE_FORMAT, true); + + return parsedDate.isValid() && parsedDate.format(COMMA_SEPARATED_DATE_FORMAT) === date; +}; + +// eslint-disable-next-line import/prefer-default-export +export { isDateForUpdateValid }; diff --git a/src/course-updates/data/api.js b/src/course-updates/data/api.js new file mode 100644 index 0000000000..818ccd1ed1 --- /dev/null +++ b/src/course-updates/data/api.js @@ -0,0 +1,84 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +export const getCourseUpdatesApiUrl = (courseId) => `${getApiBaseUrl()}/course_info_update/${courseId}/`; +export const updateCourseUpdatesApiUrl = (courseId, updateId) => `${getApiBaseUrl()}/course_info_update/${courseId}/${updateId}`; +export const getCourseHandoutApiUrl = (courseId) => { + const formattedCourseId = courseId.split('course-v1:')[1]; + return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@course_info+block@handouts`; +}; + +/** + * Get course updates. + * @param {string} courseId + * @returns {Promise} + */ +export async function getCourseUpdates(courseId) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseUpdatesApiUrl(courseId)); + + return data; +} + +/** + * Create new course update. + * @param {string} courseId + * @param {object} courseUpdate + * @returns {Promise} + */ +export async function createUpdate(courseId, courseUpdate) { + const { data } = await getAuthenticatedHttpClient() + .post(getCourseUpdatesApiUrl(courseId), courseUpdate); + + return data; +} + +/** + * Edit course update. + * @param {string} courseId + * @param {object} courseUpdate + * @returns {Promise} + */ +export async function editUpdate(courseId, courseUpdate) { + const { data } = await getAuthenticatedHttpClient() + .put(updateCourseUpdatesApiUrl(courseId, courseUpdate.id), courseUpdate); + + return data; +} + +/** + * Delete course update. + * @param {string} courseId + * @param {number} updateId +1 */ +export async function deleteUpdate(courseId, updateId) { + const { data } = await getAuthenticatedHttpClient() + .delete(updateCourseUpdatesApiUrl(courseId, updateId)); + + return data; +} + +/** + * Get course handouts. + * @param {string} courseId + * @returns {Promise} + */ +export async function getCourseHandouts(courseId) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseHandoutApiUrl(courseId)); + return data; +} + +/** + * Edit course handouts. + * @param {string} courseId + * @param {object} courseHandouts + * @returns {Promise} + */ +export async function editHandouts(courseId, courseHandouts) { + const { data } = await getAuthenticatedHttpClient() + .put(getCourseHandoutApiUrl(courseId), courseHandouts); + + return data; +} diff --git a/src/course-updates/data/selectors.js b/src/course-updates/data/selectors.js new file mode 100644 index 0000000000..947ad0f8ab --- /dev/null +++ b/src/course-updates/data/selectors.js @@ -0,0 +1,4 @@ +export const getCourseUpdates = (state) => state.courseUpdates.courseUpdates; +export const getCourseHandouts = (state) => state.courseUpdates.courseHandouts; +export const getSavingStatuses = (state) => state.courseUpdates.savingStatuses; +export const getLoadingStatuses = (state) => state.courseUpdates.loadingStatuses; diff --git a/src/course-updates/data/slice.js b/src/course-updates/data/slice.js new file mode 100644 index 0000000000..18cd86a1a6 --- /dev/null +++ b/src/course-updates/data/slice.js @@ -0,0 +1,72 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; +import { sortBy } from 'lodash'; + +const initialState = { + courseUpdates: [], + courseHandouts: {}, + savingStatuses: { + createCourseUpdateQuery: '', + editCourseUpdateQuery: '', + deleteCourseUpdateQuery: '', + editCourseHandoutsQuery: '', + }, + loadingStatuses: { + fetchCourseUpdatesQuery: '', + fetchCourseHandoutsQuery: '', + }, +}; + +const slice = createSlice({ + name: 'courseUpdates', + initialState, + reducers: { + fetchCourseUpdatesSuccess: (state, { payload }) => { + state.courseUpdates = payload; + }, + createCourseUpdate: (state, { payload }) => { + state.courseUpdates = [payload, ...state.courseUpdates]; + }, + editCourseUpdate: (state, { payload }) => { + state.courseUpdates = state.courseUpdates.map((courseUpdate) => { + if (courseUpdate.id === payload.id) { + return payload; + } + return courseUpdate; + }); + }, + deleteCourseUpdate: (state, { payload }) => { + state.courseUpdates = sortBy(payload, 'id').reverse(); + }, + fetchCourseHandoutsSuccess: (state, { payload }) => { + state.courseHandouts = payload; + }, + editCourseHandouts: (state, { payload }) => { + state.courseHandouts = { + ...state.courseHandouts, + ...payload, + }; + }, + updateSavingStatuses: (state, { payload }) => { + state.savingStatuses = { ...state.savingStatuses, ...payload }; + }, + updateLoadingStatuses: (state, { payload }) => { + state.loadingStatuses = { ...state.loadingStatuses, ...payload }; + }, + }, +}); + +export const { + fetchCourseUpdatesSuccess, + createCourseUpdate, + editCourseUpdate, + deleteCourseUpdate, + fetchCourseHandoutsSuccess, + editCourseHandouts, + updateSavingStatuses, + updateLoadingStatuses, +} = slice.actions; + +export const { + reducer, +} = slice; diff --git a/src/course-updates/data/thunk.js b/src/course-updates/data/thunk.js new file mode 100644 index 0000000000..8713b808c4 --- /dev/null +++ b/src/course-updates/data/thunk.js @@ -0,0 +1,111 @@ +import { NOTIFICATION_MESSAGES } from '../../constants'; +import { RequestStatus } from '../../data/constants'; +import { hideProcessingNotification, showProcessingNotification } from '../../generic/processing-notification/data/slice'; +import { + getCourseUpdates, + getCourseHandouts, + createUpdate, + editUpdate, + deleteUpdate, + editHandouts, +} from './api'; +import { + fetchCourseUpdatesSuccess, + createCourseUpdate, + editCourseUpdate, + deleteCourseUpdate, + fetchCourseHandoutsSuccess, + editCourseHandouts, + updateLoadingStatuses, + updateSavingStatuses, +} from './slice'; + +export function fetchCourseUpdatesQuery(courseId) { + return async (dispatch) => { + try { + dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.IN_PROGRESS })); + const courseUpdates = await getCourseUpdates(courseId); + dispatch(fetchCourseUpdatesSuccess(courseUpdates)); + dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.FAILED })); + } + }; +} + +export function createCourseUpdateQuery(courseId, data) { + return async (dispatch) => { + try { + dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + const courseUpdate = await createUpdate(courseId, data); + dispatch(createCourseUpdate(courseUpdate)); + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.FAILED })); + } + }; +} + +export function editCourseUpdateQuery(courseId, data) { + return async (dispatch) => { + try { + dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + const courseUpdate = await editUpdate(courseId, data); + dispatch(editCourseUpdate(courseUpdate)); + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.FAILED })); + } + }; +} + +export function deleteCourseUpdateQuery(courseId, updateId) { + return async (dispatch) => { + try { + dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + const courseUpdates = await deleteUpdate(courseId, updateId); + dispatch(deleteCourseUpdate(courseUpdates)); + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.FAILED })); + } + }; +} + +export function fetchCourseHandoutsQuery(courseId) { + return async (dispatch) => { + try { + dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.IN_PROGRESS })); + const courseHandouts = await getCourseHandouts(courseId); + dispatch(fetchCourseHandoutsSuccess(courseHandouts)); + dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.FAILED })); + } + }; +} + +export function editCourseHandoutsQuery(courseId, data) { + return async (dispatch) => { + try { + dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + const courseHandouts = await editHandouts(courseId, data); + dispatch(editCourseHandouts(courseHandouts)); + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.FAILED })); + } + }; +} diff --git a/src/course-updates/delete-modal/DeleteModal.jsx b/src/course-updates/delete-modal/DeleteModal.jsx new file mode 100644 index 0000000000..f3ea1608ca --- /dev/null +++ b/src/course-updates/delete-modal/DeleteModal.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + ActionRow, + Button, + AlertModal, +} from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +const DeleteModal = ({ isOpen, close, onDeleteSubmit }) => { + const intl = useIntl(); + + return ( + + + + + )} + > +

{intl.formatMessage(messages.deleteModalDescription)}

+
+ ); +}; + +DeleteModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, + onDeleteSubmit: PropTypes.func.isRequired, +}; + +export default DeleteModal; diff --git a/src/course-updates/delete-modal/DeleteModal.test.jsx b/src/course-updates/delete-modal/DeleteModal.test.jsx new file mode 100644 index 0000000000..bfd54e0bb0 --- /dev/null +++ b/src/course-updates/delete-modal/DeleteModal.test.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import DeleteModal from './DeleteModal'; +import messages from './messages'; + +const onDeleteSubmitMock = jest.fn(); +const closeMock = jest.fn(); + +const renderComponent = (props) => render( + + + , +); + +describe('', () => { + it('render DeleteModal component correctly', () => { + const { getByText, getByRole } = renderComponent(); + + expect(getByText(messages.deleteModalTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.deleteModalDescription.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.okButton.defaultMessage })).toBeInTheDocument(); + }); + + it('calls onDeleteSubmit function when the "Ok" button is clicked', () => { + const { getByRole } = renderComponent(); + + const okButton = getByRole('button', { name: messages.okButton.defaultMessage }); + fireEvent.click(okButton); + expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1); + }); + + it('calls the close function when the "Cancel" button is clicked', () => { + const { getByRole } = renderComponent(); + + const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage }); + fireEvent.click(cancelButton); + expect(closeMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/course-updates/delete-modal/messages.js b/src/course-updates/delete-modal/messages.js new file mode 100644 index 0000000000..c8100d05be --- /dev/null +++ b/src/course-updates/delete-modal/messages.js @@ -0,0 +1,22 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + deleteModalTitle: { + id: 'course-authoring.course-updates.delete-modal.title', + defaultMessage: 'Are you sure you want to delete this update?', + }, + deleteModalDescription: { + id: 'course-authoring.course-updates.delete-modal.description', + defaultMessage: 'This action cannot be undone.', + }, + cancelButton: { + id: 'course-authoring.course-updates.actions.cancel', + defaultMessage: 'Cancel', + }, + okButton: { + id: 'course-authoring.course-updates.button.ok', + defaultMessage: 'Ok', + }, +}); + +export default messages; diff --git a/src/course-updates/hooks.jsx b/src/course-updates/hooks.jsx new file mode 100644 index 0000000000..20c9c29475 --- /dev/null +++ b/src/course-updates/hooks.jsx @@ -0,0 +1,114 @@ +import { useDispatch, useSelector } from 'react-redux'; +import moment from 'moment/moment'; +import { useEffect, useState } from 'react'; +import { useToggle } from '@edx/paragon'; + +import { COMMA_SEPARATED_DATE_FORMAT } from '../constants'; +import { getCourseHandouts, getCourseUpdates } from './data/selectors'; +import { REQUEST_TYPES } from './constants'; +import { + createCourseUpdateQuery, + deleteCourseUpdateQuery, + editCourseHandoutsQuery, + editCourseUpdateQuery, + fetchCourseHandoutsQuery, + fetchCourseUpdatesQuery, +} from './data/thunk'; + +const useCourseUpdates = ({ courseId }) => { + const dispatch = useDispatch(); + const initialUpdate = { id: 0, date: moment().utc().toDate(), content: '' }; + + const [requestType, setRequestType] = useState(''); + const [isUpdateFormOpen, openUpdateForm, closeUpdateForm] = useToggle(false); + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const [currentUpdate, setCurrentUpdate] = useState(initialUpdate); + + const courseUpdates = useSelector(getCourseUpdates); + const courseHandouts = useSelector(getCourseHandouts); + + const courseUpdatesInitialValues = requestType === REQUEST_TYPES.edit_handouts + ? courseHandouts + : currentUpdate; + + const handleOpenUpdateForm = (type, courseUpdate) => { + setRequestType(type); + + switch (type) { + case REQUEST_TYPES.add_new_update: + setCurrentUpdate(initialUpdate); + break; + case REQUEST_TYPES.edit_update: + setCurrentUpdate(courseUpdate); + break; + default: + window.scrollTo(0, 0); + } + + openUpdateForm(); + }; + + const handleOpenDeleteForm = (courseUpdate) => { + setRequestType(REQUEST_TYPES.delete_update); + setCurrentUpdate(courseUpdate); + openDeleteModal(); + }; + + const handleUpdatesSubmit = (data) => { + const dataToSend = { + ...data, + date: moment(data.date).format(COMMA_SEPARATED_DATE_FORMAT), + }; + const { id, date, content } = dataToSend; + + const handleQuerySubmit = (handler) => { + closeUpdateForm(); + setCurrentUpdate(initialUpdate); + return handler(); + }; + + switch (requestType) { + case REQUEST_TYPES.add_new_update: + return handleQuerySubmit(dispatch(createCourseUpdateQuery(courseId, { date, content }))); + case REQUEST_TYPES.edit_update: + return handleQuerySubmit(dispatch(editCourseUpdateQuery(courseId, { id, date, content }))); + case REQUEST_TYPES.edit_handouts: + return handleQuerySubmit(dispatch(editCourseHandoutsQuery(courseId, { ...data, data: data?.data || '' }))); + default: + return true; + } + }; + + const handleDeleteUpdateSubmit = () => { + const { id } = currentUpdate; + + dispatch(deleteCourseUpdateQuery(courseId, id)); + setCurrentUpdate(initialUpdate); + closeDeleteModal(); + }; + + useEffect(() => { + dispatch(fetchCourseUpdatesQuery(courseId)); + dispatch(fetchCourseHandoutsQuery(courseId)); + }, [courseId]); + + return { + requestType, + courseUpdates, + courseHandouts, + courseUpdatesInitialValues, + isMainFormOpen: isUpdateFormOpen && requestType !== REQUEST_TYPES.edit_update, + isInnerFormOpen: (id) => isUpdateFormOpen && currentUpdate.id === id && requestType === REQUEST_TYPES.edit_update, + isUpdateFormOpen, + isDeleteModalOpen, + closeUpdateForm, + closeDeleteModal, + handleUpdatesSubmit, + handleOpenUpdateForm, + handleDeleteUpdateSubmit, + handleOpenDeleteForm, + }; +}; + +// eslint-disable-next-line import/prefer-default-export +export { useCourseUpdates }; diff --git a/src/course-updates/index.js b/src/course-updates/index.js new file mode 100644 index 0000000000..886e19e97f --- /dev/null +++ b/src/course-updates/index.js @@ -0,0 +1,2 @@ +/* eslint-disable import/prefer-default-export */ +export { default as CourseUpdates } from './CourseUpdates'; diff --git a/src/course-updates/messages.js b/src/course-updates/messages.js new file mode 100644 index 0000000000..b00a9d647c --- /dev/null +++ b/src/course-updates/messages.js @@ -0,0 +1,22 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + headingTitle: { + id: 'course-authoring.course-updates.header.title', + defaultMessage: 'Course updates', + }, + headingSubtitle: { + id: 'course-authoring.course-updates.header.subtitle', + defaultMessage: 'Content', + }, + sectionInfo: { + id: 'course-authoring.course-updates.section-info', + defaultMessage: 'Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.', + }, + newUpdateButton: { + id: 'course-authoring.course-updates.actions.new-update', + defaultMessage: 'New update', + }, +}); + +export default messages; diff --git a/src/course-updates/update-form/UpdateForm.jsx b/src/course-updates/update-form/UpdateForm.jsx new file mode 100644 index 0000000000..dec0913e06 --- /dev/null +++ b/src/course-updates/update-form/UpdateForm.jsx @@ -0,0 +1,141 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + ActionRow, + Button, + Form, + Icon, +} from '@edx/paragon'; +import classNames from 'classnames'; +import DatePicker from 'react-datepicker/dist'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Calendar as CalendarIcon, Error as ErrorIcon } from '@edx/paragon/icons'; +import { Formik } from 'formik'; + +import { + convertToStringFromDate, + convertToDateFromString, + isValidDate, +} from '../../utils'; +import { DATE_FORMAT, DEFAULT_EMPTY_WYSIWYG_VALUE } from '../../constants'; +import { WysiwygEditor } from '../../generic/WysiwygEditor'; +import { REQUEST_TYPES } from '../constants'; +import { geUpdateFormSettings } from './utils'; +import messages from './messages'; + +const UpdateForm = ({ + close, + requestType, + onSubmit, + courseUpdatesInitialValues, + isInnerForm, + isFirstUpdate, +}) => { + const intl = useIntl(); + + const { + currentContent, + formTitle, + validationSchema, + contentFieldName, + submitButtonText, + } = geUpdateFormSettings(requestType, courseUpdatesInitialValues, intl); + + return ( +
+ + {({ + values, handleSubmit, isValid, setFieldValue, + }) => ( + <> +

{formTitle}

+ {(requestType !== REQUEST_TYPES.edit_handouts) && ( + + + {intl.formatMessage(messages.updateFormDate)} + +
+ + { + if (!isValidDate(value)) { + return; + } + setFieldValue('date', convertToStringFromDate(value)); + }} + /> +
+ {!isValid && ( +
+ + {intl.formatMessage(messages.updateFormInValid)} +
+ )} +
+ )} + + { + setFieldValue(contentFieldName, value || DEFAULT_EMPTY_WYSIWYG_VALUE); + }} + /> + + + + + + + )} +
+
+ ); +}; + +UpdateForm.defaultProps = { + isInnerForm: false, + isFirstUpdate: false, +}; + +UpdateForm.propTypes = { + // eslint-disable-next-line react/forbid-prop-types + courseUpdatesInitialValues: PropTypes.object.isRequired, + close: PropTypes.func.isRequired, + requestType: PropTypes.string.isRequired, + onSubmit: PropTypes.func.isRequired, + isInnerForm: PropTypes.bool, + isFirstUpdate: PropTypes.bool, +}; + +export default UpdateForm; diff --git a/src/course-updates/update-form/UpdateForm.scss b/src/course-updates/update-form/UpdateForm.scss new file mode 100644 index 0000000000..82aed3feb7 --- /dev/null +++ b/src/course-updates/update-form/UpdateForm.scss @@ -0,0 +1,63 @@ +.update-form { + @include pgn-box-shadow(1, "centered"); + + border: .0625rem solid $gray-200; + border-radius: .375rem; + background: $white; + margin-bottom: map-get($spacers, 4); + padding: $spacer 1.875rem; + + .update-form-title { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: $spacer; + } + + .datepicker-field { + display: flex; + align-items: center; + gap: .5rem; + position: relative; + + .datepicker-float-labels { + position: absolute; + padding: 0 .1875rem; + top: -.625rem; + left: .3125rem; + z-index: 9; + background-color: $white; + } + + .datepicker-field-error { + display: flex; + align-items: center; + gap: .25rem; + + svg { + color: $warning-300; + } + } + + .react-datepicker-popper { + z-index: $zindex-dropdown; + } + } +} + +.update-form__inner { + margin-bottom: 0; + margin-top: 1.875rem; + padding: map-get($spacers, 4) 0 0; + border-top: .0625rem solid $light-400; + border-bottom: none; + border-left: none; + border-right: none; + border-radius: 0; + box-shadow: none; +} + +.update-form__inner-first { + border-top: none; + padding-top: 0; + margin-top: 0; +} diff --git a/src/course-updates/update-form/UpdateForm.test.jsx b/src/course-updates/update-form/UpdateForm.test.jsx new file mode 100644 index 0000000000..569efb7252 --- /dev/null +++ b/src/course-updates/update-form/UpdateForm.test.jsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { + render, + fireEvent, + waitFor, + act, +} from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import moment from 'moment/moment'; + +import { REQUEST_TYPES } from '../constants'; +import { courseHandoutsMock, courseUpdatesMock } from '../__mocks__'; +import UpdateForm from './UpdateForm'; +import messages from './messages'; + +const closeMock = jest.fn(); +const onSubmitMock = jest.fn(); +const addNewUpdateMock = { id: 0, date: moment().utc().toDate(), content: 'Some content' }; +const formattedDateMock = '07/11/2023'; +const contentMock = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'; + +jest.mock('@tinymce/tinymce-react', () => { + const originalModule = jest.requireActual('@tinymce/tinymce-react'); + return { + __esModule: true, + ...originalModule, + Editor: () => 'foo bar', + }; +}); + +jest.mock('@edx/frontend-lib-content-components', () => ({ + TinyMceWidget: () =>
Widget
, + prepareEditorRef: jest.fn(() => ({ + refReady: true, + setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'), + })), +})); + +const courseUpdatesInitialValues = (requestType) => { + switch (requestType) { + case REQUEST_TYPES.add_new_update: + return addNewUpdateMock; + case REQUEST_TYPES.edit_update: + return courseUpdatesMock[0]; + default: + return courseHandoutsMock; + } +}; + +const renderComponent = ({ requestType }) => render( + + + , +); + +describe('', () => { + it('render Add new update form correctly', async () => { + const { getByText, getByDisplayValue, getByRole } = renderComponent({ requestType: REQUEST_TYPES.add_new_update }); + const { date } = courseUpdatesInitialValues(REQUEST_TYPES.add_new_update); + const formattedDate = moment(date).utc().format('MM/DD/yyyy'); + + expect(getByText(messages.addNewUpdateTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.updateFormDate.defaultMessage)).toBeInTheDocument(); + expect(getByDisplayValue(formattedDate)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.cancelButton.defaultMessage })); + expect(getByRole('button', { name: messages.postButton.defaultMessage })); + }); + + it('render Edit update form correctly', async () => { + const { getByText, getByDisplayValue, getByRole } = renderComponent({ requestType: REQUEST_TYPES.edit_update }); + + expect(getByText(messages.editUpdateTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.updateFormDate.defaultMessage)).toBeInTheDocument(); + expect(getByDisplayValue(formattedDateMock)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.cancelButton.defaultMessage })); + expect(getByRole('button', { name: messages.postButton.defaultMessage })); + }); + + it('render Edit handouts form correctly', async () => { + const { + getByText, getByRole, queryByTestId, queryByText, + } = renderComponent({ requestType: REQUEST_TYPES.edit_handouts }); + + expect(getByText(messages.editHandoutsTitle.defaultMessage)).toBeInTheDocument(); + expect(queryByText(messages.updateFormDate.defaultMessage)).not.toBeInTheDocument(); + expect(queryByTestId('course-updates-datepicker')).not.toBeInTheDocument(); + expect(getByRole('button', { name: messages.cancelButton.defaultMessage })); + expect(getByRole('button', { name: messages.saveButton.defaultMessage })); + }); + + it('calls closeMock when clicking cancel button', () => { + const { getByRole } = renderComponent({ requestType: REQUEST_TYPES.add_new_update }); + + const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage }); + fireEvent.click(cancelButton); + expect(closeMock).toHaveBeenCalledTimes(1); + }); + + it('calls onSubmitMock with correct values when clicking post button', async () => { + const { getByDisplayValue, getByRole } = renderComponent({ requestType: REQUEST_TYPES.edit_update }); + const datePicker = getByDisplayValue(formattedDateMock); + const postButton = getByRole('button', { name: messages.postButton.defaultMessage }); + + fireEvent.change(datePicker, { target: { value: formattedDateMock } }); + + await act(async () => { + fireEvent.click(postButton); + }); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalledTimes(1); + expect(onSubmitMock).toHaveBeenCalledWith( + { + id: 1, + date: 'July 11, 2023', + content: contentMock, + }, + expect.objectContaining({ submitForm: expect.any(Function) }), + ); + }); + }); + + it('render error message when date is inValid', async () => { + const { getByDisplayValue, getByText, getByRole } = renderComponent({ requestType: REQUEST_TYPES.edit_update }); + const datePicker = getByDisplayValue(formattedDateMock); + + fireEvent.change(datePicker, { target: { value: '' } }); + + await waitFor(() => { + expect(getByText(messages.updateFormInValid.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.postButton.defaultMessage })).toBeDisabled(); + }); + }); +}); diff --git a/src/course-updates/update-form/messages.js b/src/course-updates/update-form/messages.js new file mode 100644 index 0000000000..8f36a9ff05 --- /dev/null +++ b/src/course-updates/update-form/messages.js @@ -0,0 +1,46 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + updateFormDate: { + id: 'course-authoring.course-updates.update-form.date', + defaultMessage: 'Date', + }, + updateFormInValid: { + id: 'course-authoring.course-updates.update-form.inValid', + defaultMessage: 'Action required: Enter a valid date.', + }, + updateFormCalendarAltText: { + id: 'course-authoring.course-updates.update-form.calendar-alt-text', + defaultMessage: 'Calendar for datepicker input', + }, + updateFormErrorAltText: { + id: 'course-authoring.course-updates.update-form.error-alt-text', + defaultMessage: 'Error icon', + }, + addNewUpdateTitle: { + id: 'course-authoring.course-updates.update-form.new-update-title', + defaultMessage: 'Add new update', + }, + editUpdateTitle: { + id: 'course-authoring.course-updates.update-form.edit-update-title', + defaultMessage: 'Edit update', + }, + editHandoutsTitle: { + id: 'course-authoring.course-updates.update-form.edit-handouts-title', + defaultMessage: 'Edit handouts', + }, + saveButton: { + id: 'course-authoring.course-updates.actions.save', + defaultMessage: 'Save', + }, + postButton: { + id: 'course-authoring.course-updates.actions.post', + defaultMessage: 'Post', + }, + cancelButton: { + id: 'course-authoring.course-updates.actions.cancel', + defaultMessage: 'Cancel', + }, +}); + +export default messages; diff --git a/src/course-updates/update-form/utils.js b/src/course-updates/update-form/utils.js new file mode 100644 index 0000000000..7f6ce9d053 --- /dev/null +++ b/src/course-updates/update-form/utils.js @@ -0,0 +1,56 @@ +import * as Yup from 'yup'; + +import { REQUEST_TYPES } from '../constants'; +import messages from './messages'; + +/** + * Get Update form settings depending on requestType + * @param {typeof REQUEST_TYPES} requestType - one of REQUEST_TYPES + * @param {object} courseUpdatesInitialValues - form initial values depending on requestType + * @returns {{ + * currentContent: string, + * validationSchema: object, + * formTitle: string, + * submitButtonText: string, + * contentFieldName: string + * }} + */ +const geUpdateFormSettings = (requestType, courseUpdatesInitialValues, intl) => { + const updatesValidationSchema = Yup.object().shape({ + id: Yup.number().required(), + date: Yup.date().required(), + content: Yup.string(), + }); + + switch (requestType) { + case REQUEST_TYPES.edit_handouts: + return { + currentContent: courseUpdatesInitialValues.data, + formTitle: intl.formatMessage(messages.editHandoutsTitle), + validationSchema: Yup.object().shape(), + contentFieldName: 'data', + submitButtonText: intl.formatMessage(messages.saveButton), + }; + case REQUEST_TYPES.add_new_update: + return { + currentContent: courseUpdatesInitialValues.content, + formTitle: intl.formatMessage(messages.addNewUpdateTitle), + validationSchema: updatesValidationSchema, + contentFieldName: 'content', + submitButtonText: intl.formatMessage(messages.postButton), + }; + case REQUEST_TYPES.edit_update: + return { + currentContent: courseUpdatesInitialValues.content, + formTitle: intl.formatMessage(messages.editUpdateTitle), + validationSchema: updatesValidationSchema, + contentFieldName: 'content', + submitButtonText: intl.formatMessage(messages.postButton), + }; + default: + return ''; + } +}; + +// eslint-disable-next-line import/prefer-default-export +export { geUpdateFormSettings }; diff --git a/src/course-updates/utils.js b/src/course-updates/utils.js new file mode 100644 index 0000000000..f1fc95c465 --- /dev/null +++ b/src/course-updates/utils.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const matchesAnyStatus = (statuses, status) => Object.values(statuses).some(s => s === status); diff --git a/src/generic/WysiwygEditor.scss b/src/generic/WysiwygEditor.scss new file mode 100644 index 0000000000..06a1226583 --- /dev/null +++ b/src/generic/WysiwygEditor.scss @@ -0,0 +1,5 @@ +.tox-dialog-wrap__backdrop { + background-color: $black !important; + opacity: .5; + z-index: $zindex-modal-backdrop; +} diff --git a/src/generic/processing-notification/ProccessingNotification.scss b/src/generic/processing-notification/ProccessingNotification.scss new file mode 100644 index 0000000000..257cbd2afc --- /dev/null +++ b/src/generic/processing-notification/ProccessingNotification.scss @@ -0,0 +1,25 @@ +.processing-notification { + display: flex; + position: fixed; + bottom: -13rem; + transition: bottom 1s; + right: 1.25rem; + padding: .625rem 1.25rem; + z-index: $zindex-popover; + + &.is-show { + bottom: .625rem; + } + + .processing-notification-icon { + margin-right: .625rem; + animation: rotate 1s linear infinite; + } + + .processing-notification-title { + font-size: 1rem; + line-height: 1.5rem; + color: $white; + margin-bottom: 0; + } +} diff --git a/src/generic/processing-notification/ProcessingNotification.test.jsx b/src/generic/processing-notification/ProcessingNotification.test.jsx new file mode 100644 index 0000000000..16b86401ea --- /dev/null +++ b/src/generic/processing-notification/ProcessingNotification.test.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { capitalize } from 'lodash'; +import { NOTIFICATION_MESSAGES } from '../../constants'; +import ProcessingNotification from '.'; + +const props = { + title: NOTIFICATION_MESSAGES.saving, + isShow: true, +}; + +describe('', () => { + it('renders successfully', () => { + const { getByText } = render(); + expect(getByText(capitalize(props.title))).toBeInTheDocument(); + }); +}); diff --git a/src/generic/processing-notification/data/selectors.js b/src/generic/processing-notification/data/selectors.js new file mode 100644 index 0000000000..f34ed9df55 --- /dev/null +++ b/src/generic/processing-notification/data/selectors.js @@ -0,0 +1,5 @@ +// eslint-disable-next-line import/prefer-default-export +export const getProcessingNotification = (state) => ({ + isShow: state.processingNotification.isShow, + title: state.processingNotification.title, +}); diff --git a/src/generic/processing-notification/data/slice.js b/src/generic/processing-notification/data/slice.js new file mode 100644 index 0000000000..4090524a9d --- /dev/null +++ b/src/generic/processing-notification/data/slice.js @@ -0,0 +1,28 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + isShow: false, + title: '', +}; + +const slice = createSlice({ + name: 'processingNotification', + initialState, + reducers: { + showProcessingNotification: (state, { payload }) => { + state.isShow = true; + state.title = payload; + }, + hideProcessingNotification: () => initialState, + }, +}); + +export const { + showProcessingNotification, + hideProcessingNotification, +} = slice.actions; + +export const { + reducer, +} = slice; diff --git a/src/generic/processing-notification/index.jsx b/src/generic/processing-notification/index.jsx new file mode 100644 index 0000000000..c58053f457 --- /dev/null +++ b/src/generic/processing-notification/index.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { Badge, Icon } from '@edx/paragon'; +import { Settings as IconSettings } from '@edx/paragon/icons'; +import { capitalize } from 'lodash'; + +import { NOTIFICATION_MESSAGES } from '../../constants'; + +const ProcessingNotification = ({ isShow, title }) => ( + + +

+ {capitalize(title)} +

+
+); + +ProcessingNotification.propTypes = { + isShow: PropTypes.bool.isRequired, + title: PropTypes.oneOf(Object.values(NOTIFICATION_MESSAGES)).isRequired, +}; + +export default ProcessingNotification; diff --git a/src/generic/styles.scss b/src/generic/styles.scss index e5dca7bba5..4eceeddce2 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -2,3 +2,5 @@ @import "./course-upload-image/CourseUploadImage"; @import "./sub-header/SubHeader"; @import "./section-sub-header/SectionSubHeader"; +@import "./processing-notification/ProccessingNotification"; +@import "./WysiwygEditor"; diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index d16a0cea99..2b817efded 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -678,6 +678,28 @@ "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", "course-authoring.course-team.warning-modal.button.return": "Return to team listing", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-modal.new-update-title": "Add new update", + "course-authoring.course-updates.update-modal.edit-update-title": "Edit update", + "course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-modal.date": "Date", + "course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-modal.error-alt-text": "Error icon", + "course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 435a0a8187..77f0fb751a 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -678,6 +678,28 @@ "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", "course-authoring.course-team.warning-modal.button.return": "Return to team listing", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-modal.new-update-title": "Add new update", + "course-authoring.course-updates.update-modal.edit-update-title": "Edit update", + "course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-modal.date": "Date", + "course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-modal.error-alt-text": "Error icon", + "course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/i18n/messages/de_DE.json b/src/i18n/messages/de_DE.json index f5e78c3360..bf1703b9bc 100644 --- a/src/i18n/messages/de_DE.json +++ b/src/i18n/messages/de_DE.json @@ -678,6 +678,28 @@ "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", "course-authoring.course-team.warning-modal.button.return": "Return to team listing", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-modal.new-update-title": "Add new update", + "course-authoring.course-updates.update-modal.edit-update-title": "Edit update", + "course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-modal.date": "Date", + "course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-modal.error-alt-text": "Error icon", + "course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/i18n/messages/es_419.json b/src/i18n/messages/es_419.json index 85458e3896..89b2cd2cfc 100644 --- a/src/i18n/messages/es_419.json +++ b/src/i18n/messages/es_419.json @@ -678,6 +678,28 @@ "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", "course-authoring.course-team.warning-modal.button.return": "Return to team listing", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-modal.new-update-title": "Add new update", + "course-authoring.course-updates.update-modal.edit-update-title": "Edit update", + "course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-modal.date": "Date", + "course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-modal.error-alt-text": "Error icon", + "course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 84a01ce154..64a333ca3b 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -678,6 +678,28 @@ "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", "course-authoring.course-team.warning-modal.button.return": "Return to team listing", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-modal.new-update-title": "Add new update", + "course-authoring.course-updates.update-modal.edit-update-title": "Edit update", + "course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-modal.date": "Date", + "course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-modal.error-alt-text": "Error icon", + "course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/i18n/messages/fr_CA.json b/src/i18n/messages/fr_CA.json index 4f52c1b31c..0106fb8fb1 100644 --- a/src/i18n/messages/fr_CA.json +++ b/src/i18n/messages/fr_CA.json @@ -678,6 +678,28 @@ "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", "course-authoring.course-team.warning-modal.button.return": "Return to team listing", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-modal.new-update-title": "Add new update", + "course-authoring.course-updates.update-modal.edit-update-title": "Edit update", + "course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-modal.date": "Date", + "course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-modal.error-alt-text": "Error icon", + "course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 435a0a8187..77f0fb751a 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -678,6 +678,28 @@ "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", "course-authoring.course-team.warning-modal.button.return": "Return to team listing", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-modal.new-update-title": "Add new update", + "course-authoring.course-updates.update-modal.edit-update-title": "Edit update", + "course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-modal.date": "Date", + "course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-modal.error-alt-text": "Error icon", + "course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 435a0a8187..77f0fb751a 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -678,6 +678,28 @@ "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", "course-authoring.course-team.warning-modal.button.return": "Return to team listing", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-modal.new-update-title": "Add new update", + "course-authoring.course-updates.update-modal.edit-update-title": "Edit update", + "course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-modal.date": "Date", + "course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-modal.error-alt-text": "Error icon", + "course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/i18n/messages/it_IT.json b/src/i18n/messages/it_IT.json index 4a00f3c66d..fd53f7ac00 100644 --- a/src/i18n/messages/it_IT.json +++ b/src/i18n/messages/it_IT.json @@ -678,6 +678,28 @@ "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", "course-authoring.course-team.warning-modal.button.return": "Return to team listing", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-modal.new-update-title": "Add new update", + "course-authoring.course-updates.update-modal.edit-update-title": "Edit update", + "course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-modal.date": "Date", + "course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-modal.error-alt-text": "Error icon", + "course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 435a0a8187..77f0fb751a 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -678,6 +678,28 @@ "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", "course-authoring.course-team.warning-modal.button.return": "Return to team listing", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-modal.new-update-title": "Add new update", + "course-authoring.course-updates.update-modal.edit-update-title": "Edit update", + "course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-modal.date": "Date", + "course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-modal.error-alt-text": "Error icon", + "course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/i18n/messages/pt_PT.json b/src/i18n/messages/pt_PT.json index d9456a708a..ea886df5cf 100644 --- a/src/i18n/messages/pt_PT.json +++ b/src/i18n/messages/pt_PT.json @@ -678,6 +678,28 @@ "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", "course-authoring.course-team.warning-modal.button.return": "Return to team listing", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-modal.new-update-title": "Add new update", + "course-authoring.course-updates.update-modal.edit-update-title": "Edit update", + "course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-modal.date": "Date", + "course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-modal.error-alt-text": "Error icon", + "course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index b072f4611d..1d3fa2d556 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -642,6 +642,28 @@ "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", "course-authoring.course-team.warning-modal.button.return": "Return to team listing", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-modal.new-update-title": "Add new update", + "course-authoring.course-updates.update-modal.edit-update-title": "Edit update", + "course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-modal.date": "Date", + "course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-modal.error-alt-text": "Error icon", + "course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/i18n/messages/uk.json b/src/i18n/messages/uk.json index 435a0a8187..e8ab912b0e 100644 --- a/src/i18n/messages/uk.json +++ b/src/i18n/messages/uk.json @@ -643,6 +643,28 @@ "course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}", "course-authoring.schedule.alert.button.saving": "Saving", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-form.new-update-title": "Add new update", + "course-authoring.course-updates.update-form.edit-update-title": "Edit update", + "course-authoring.course-updates.update-form.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-form.date": "Date", + "course-authoring.course-updates.update-form.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-form.error-alt-text": "Error icon", + "course-authoring.course-updates.update-form.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.course-team.headingTitle": "Course team", "course-authoring.course-team.subTitle": "Settings", "course-authoring.course-team.button.new-team-member": "New team member", diff --git a/src/i18n/messages/zh_CN.json b/src/i18n/messages/zh_CN.json index 435a0a8187..77f0fb751a 100644 --- a/src/i18n/messages/zh_CN.json +++ b/src/i18n/messages/zh_CN.json @@ -678,6 +678,28 @@ "course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.", "course-authoring.course-team.warning-modal.button.return": "Return to team listing", "course-authoring.advanced-settings.alert.button.saving": "Saving", + "course-authoring.course-updates.header.title": "Course updates", + "course-authoring.course-updates.header.subtitle": "Content", + "course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.", + "course-authoring.course-updates.handouts.title": "Course handouts", + "course-authoring.course-updates.button.new-update": "New update", + "course-authoring.course-updates.button.edit": "Edit", + "course-authoring.course-updates.button.delete": "Delete", + "course-authoring.course-updates.button.save": "Save", + "course-authoring.course-updates.button.cancel": "Cancel", + "course-authoring.course-updates.button.post": "Post", + "course-authoring.course-updates.button.ok": "Ok", + "course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.", + "course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?", + "course-authoring.course-updates.delete-modal.description": "This action cannot be undone.", + "course-authoring.course-updates.update-modal.new-update-title": "Add new update", + "course-authoring.course-updates.update-modal.edit-update-title": "Edit update", + "course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts", + "course-authoring.course-updates.update-modal.date": "Date", + "course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.", + "course-authoring.course-updates.update-modal.error-alt-text": "Error icon", + "course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input", + "course-authoring.advanced-settings.alert.button.saving": "Saving", "course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:", "course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade", "course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:", diff --git a/src/index.scss b/src/index.scss index 91ee8d0ce2..6af8bf24d9 100755 --- a/src/index.scss +++ b/src/index.scss @@ -7,6 +7,7 @@ @import "assets/scss/variables"; @import "assets/scss/form"; @import "assets/scss/utilities"; +@import "assets/scss/animations"; @import "proctored-exam-settings/proctoredExamSettings"; @import "pages-and-resources/discussions/app-list/AppList"; @import "advanced-settings/scss/AdvancedSettings"; @@ -15,3 +16,4 @@ @import "schedule-and-details/ScheduleAndDetails"; @import "pages-and-resources/PagesAndResources"; @import "course-team/CourseTeam"; +@import "course-updates/CourseUpdates"; diff --git a/src/store.js b/src/store.js index 26b6434e95..515751cbf1 100644 --- a/src/store.js +++ b/src/store.js @@ -11,6 +11,8 @@ import { reducer as scheduleAndDetailsReducer } from './schedule-and-details/dat import { reducer as liveReducer } from './pages-and-resources/live/data/slice'; import { reducer as filesReducer } from './files-and-uploads/data/slice'; import { reducer as courseTeamReducer } from './course-team/data/slice'; +import { reducer as CourseUpdatesReducer } from './course-updates/data/slice'; +import { reducer as processingNotificationReducer } from './generic/processing-notification/data/slice'; export default function initializeStore(preloadedState = undefined) { return configureStore({ @@ -26,6 +28,8 @@ export default function initializeStore(preloadedState = undefined) { models: modelsReducer, live: liveReducer, courseTeam: courseTeamReducer, + courseUpdates: CourseUpdatesReducer, + processingNotification: processingNotificationReducer, }, preloadedState, }); From e50b8c74074c52038c59ca27a153114ffbb23924 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Thu, 31 Aug 2023 12:21:37 -0400 Subject: [PATCH 11/79] feat: add file size and usage metrics (#573) --- src/files-and-uploads/FileInfo.jsx | 50 ++++++---- src/files-and-uploads/FilesAndUploads.jsx | 75 ++++++++++----- .../FilesAndUploads.test.jsx | 52 ++++++++-- src/files-and-uploads/UsageMetricsMessage.jsx | 60 ++++++++++++ src/files-and-uploads/data/api.js | 5 + src/files-and-uploads/data/slice.js | 14 ++- src/files-and-uploads/data/thunks.js | 25 ++++- src/files-and-uploads/data/utils.js | 24 ++++- src/files-and-uploads/data/utils.test.js | 21 ++++ .../factories/mockApiResponses.jsx | 9 +- src/files-and-uploads/messages.js | 22 +++-- .../table-components/GalleryCard.jsx | 89 ++++++++--------- .../table-components/ListCard.jsx | 95 +++++++++---------- 13 files changed, 369 insertions(+), 172 deletions(-) create mode 100644 src/files-and-uploads/UsageMetricsMessage.jsx create mode 100644 src/files-and-uploads/data/utils.test.js diff --git a/src/files-and-uploads/FileInfo.jsx b/src/files-and-uploads/FileInfo.jsx index 171958cbba..30f0afe80b 100644 --- a/src/files-and-uploads/FileInfo.jsx +++ b/src/files-and-uploads/FileInfo.jsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; + import { injectIntl, FormattedMessage, @@ -17,37 +18,44 @@ import { CheckboxControl, } from '@edx/paragon'; import { ContentCopy, InfoOutline } from '@edx/paragon/icons'; -import AssetThumbnail from './FileThumbnail'; +import { getFileSizeToClosestByte } from './data/utils'; +import AssetThumbnail from './FileThumbnail'; import messages from './messages'; +import UsageMetricsMessages from './UsageMetricsMessage'; const FileInfo = ({ asset, isOpen, onClose, handleLockedAsset, + usagePathStatus, + error, // injected intl, }) => { - const [lockedState, setLockedState] = useState(asset.locked); + const [lockedState, setLockedState] = useState(asset?.locked); const handleLock = (e) => { const locked = e.target.checked; setLockedState(locked); - handleLockedAsset(asset.id, locked); + handleLockedAsset(asset?.id, locked); }; + const fileSize = getFileSizeToClosestByte(asset?.fileSize); + return (
- {asset.displayName} + {asset?.displayName}
@@ -57,10 +65,10 @@ const FileInfo = ({
@@ -68,7 +76,7 @@ const FileInfo = ({
- {/* {asset.fileSize} */} -
-
+ {fileSize} +
- {asset.portableUrl} + {asset?.portableUrl}
@@ -94,7 +101,7 @@ const FileInfo = ({ src={ContentCopy} iconAs={Icon} alt={messages.copyStudioUrlTitle.defaultMessage} - onClick={() => navigator.clipboard.writeText(asset.portableUrl)} + onClick={() => navigator.clipboard.writeText(asset?.portableUrl)} />
@@ -103,7 +110,7 @@ const FileInfo = ({
- {asset.externalUrl} + {asset?.externalUrl}
@@ -111,11 +118,10 @@ const FileInfo = ({ src={ContentCopy} iconAs={Icon} alt={messages.copyWebUrlTitle.defaultMessage} - onClick={() => navigator.clipboard.writeText(asset.externalUrl)} + onClick={() => navigator.clipboard.writeText(asset?.externalUrl)} />
-
- +
@@ -140,11 +146,11 @@ const FileInfo = ({
+ ); }; - FileInfo.propTypes = { asset: PropTypes.shape({ displayName: PropTypes.string.isRequired, @@ -155,10 +161,14 @@ FileInfo.propTypes = { id: PropTypes.string.isRequired, portableUrl: PropTypes.string.isRequired, dateAdded: PropTypes.string.isRequired, + fileSize: PropTypes.number.isRequired, + usageLocations: PropTypes.arrayOf(PropTypes.string), }).isRequired, onClose: PropTypes.func.isRequired, isOpen: PropTypes.bool.isRequired, handleLockedAsset: PropTypes.func.isRequired, + usagePathStatus: PropTypes.string.isRequired, + error: PropTypes.arrayOf(PropTypes.string).isRequired, // injected intl: intlShape.isRequired, }; diff --git a/src/files-and-uploads/FilesAndUploads.jsx b/src/files-and-uploads/FilesAndUploads.jsx index 4b52c3953e..12a56b8f8b 100644 --- a/src/files-and-uploads/FilesAndUploads.jsx +++ b/src/files-and-uploads/FilesAndUploads.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; -import _ from 'lodash'; +import isEmpty from 'lodash/isEmpty'; import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; import { DataTable, @@ -22,12 +22,14 @@ import { addAssetFile, deleteAssetFile, fetchAssets, + getUsagePaths, updateAssetLock, updateAssetOrder, } from './data/thunks'; import { sortFiles } from './data/utils'; import messages from './messages'; +import FileInfo from './FileInfo'; import FileInput, { fileInput } from './FileInput'; import FilesAndUploadsProvider from './FilesAndUploadsProvider'; import { @@ -52,6 +54,7 @@ const FilesAndUploads = ({ }; const [currentView, setCurrentView] = useState(defaultVal); const [isDeleteOpen, setDeleteOpen, setDeleteClose] = useToggle(false); + const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false); const [isAddOpen, setAddOpen, setAddClose] = useToggle(false); const [selectedRows, setSelectedRows] = useState([]); const [isDeleteConfirmationOpen, openDeleteConfirmation, closeDeleteConfirmation] = useToggle(false); @@ -65,9 +68,10 @@ const FilesAndUploads = ({ loadingStatus, addingStatus: addAssetStatus, deletingStatus: deleteAssetStatus, - savingStatus: saveAssetStatus, + updatingStatus: updateAssetStatus, + usageStatus: usagePathStatus, + errors: errorMessages, } = useSelector(state => state.assets); - const errorMessages = useSelector(state => state.assets.errors); const fileInputControl = fileInput({ onAddFile: (file) => dispatch(addAssetFile(courseId, file, totalCount)), setSelectedRows, @@ -118,6 +122,12 @@ const FilesAndUploads = ({ openDeleteConfirmation(); }; + const handleOpenAssetInfo = (original) => { + setSelectedRows([{ original }]); + dispatch(getUsagePaths({ asset: original, courseId, setSelectedRows })); + openAssetInfo(); + }; + const headerActions = ({ selectedFlatRows }) => ( ); } - return (
@@ -171,19 +182,38 @@ const FilesAndUploads = ({ hideHeading={false} isError={addAssetStatus === RequestStatus.FAILED} > - {intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.upload })} +
    + {errorMessages.upload.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} +
- {intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.delete })} +
    + {errorMessages.delete.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} +
- {intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.lock })} +
    + {errorMessages.lock.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} +
+
@@ -236,28 +266,12 @@ const FilesAndUploads = ({ }, ], }, - { - Header: 'Locked', - accessor: 'locked', - // Filter: CheckboxFilter, - // filter: 'exactText', - // filterChoices: [ - // { - // name: 'Locked', - // value: true, - // }, - // { - // name: 'Unlocked', - // value: false, - // }, - // ], - }, ]} itemCount={totalCount} pageCount={Math.ceil(totalCount / 50)} data={assets} > - {_.isEmpty(assets) && loadingStatus !== RequestStatus.IN_PROGRESS ? ( + {isEmpty(assets) && loadingStatus !== RequestStatus.IN_PROGRESS ? ( - + {!isEmpty(selectedRows) && ( + + )} { expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); expect(assetMenuButton).toBeVisible(); + axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: ['subsection - unit / block'] }); await waitFor(() => { fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); fireEvent.click(screen.getByText('Info')); + executeThunk(getUsagePaths({ + courseId, + asset: { id: 'mOckID1', displayName: 'mOckID1' }, + setSelectedRows: jest.fn(), + }), store.dispatch); expect(screen.getAllByLabelText('mOckID1')[0]).toBeVisible(); }); + const { usageStatus } = store.getState().assets; + expect(usageStatus).toEqual(RequestStatus.SUCCESSFUL); + expect(screen.getByText('subsection - unit / block')).toBeVisible(); }); it('should open asset info and handle lock checkbox', async () => { renderComponent(); @@ -254,9 +264,15 @@ describe('FilesAndUploads', () => { const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); expect(assetMenuButton).toBeVisible(); axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false }); + axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: [] }); await waitFor(() => { fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); fireEvent.click(screen.getByText('Info')); + executeThunk(getUsagePaths({ + courseId, + asset: { id: 'mOckID1', displayName: 'mOckID1' }, + setSelectedRows: jest.fn(), + }), store.dispatch); expect(screen.getAllByLabelText('mOckID1')[0]).toBeVisible(); fireEvent.click(screen.getByLabelText('Checkbox')); executeThunk(updateAssetLock({ @@ -265,8 +281,9 @@ describe('FilesAndUploads', () => { locked: false, }), store.dispatch); }); - const saveStatus = store.getState().assets.savingStatus; - expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL); + expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); + const updateStatus = store.getState().assets.updatingStatus; + expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL); }); it('should unlock asset', async () => { renderComponent(); @@ -284,8 +301,8 @@ describe('FilesAndUploads', () => { locked: false, }), store.dispatch); }); - const saveStatus = store.getState().assets.savingStatus; - expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL); + const updateStatus = store.getState().assets.updatingStatus; + expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL); }); it('should lock asset', async () => { renderComponent(); @@ -303,8 +320,8 @@ describe('FilesAndUploads', () => { locked: true, }), store.dispatch); }); - const saveStatus = store.getState().assets.savingStatus; - expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL); + const updateStatus = store.getState().assets.updatingStatus; + expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL); }); it('delete button should delete file', async () => { renderComponent(); @@ -375,6 +392,25 @@ describe('FilesAndUploads', () => { expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); expect(screen.getByText('Error')).toBeVisible(); }); + it('404 usage path fetch should show error', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible(); + const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3'); + expect(assetMenuButton).toBeVisible(); + axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID3/usage`).reply(404); + await waitFor(() => { + fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + executeThunk(getUsagePaths({ + courseId, + asset: { id: 'mOckID3', displayName: 'mOckID3' }, + setSelectedRows: jest.fn(), + }), store.dispatch); + }); + const { usageStatus } = store.getState().assets; + expect(usageStatus).toEqual(RequestStatus.FAILED); + }); it('404 lock update should show error', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); @@ -391,8 +427,8 @@ describe('FilesAndUploads', () => { locked: true, }), store.dispatch); }); - const saveStatus = store.getState().assets.savingStatus; - expect(saveStatus).toEqual(RequestStatus.FAILED); + const updateStatus = store.getState().assets.updatingStatus; + expect(updateStatus).toEqual(RequestStatus.FAILED); expect(screen.getByText('Error')).toBeVisible(); }); }); diff --git a/src/files-and-uploads/UsageMetricsMessage.jsx b/src/files-and-uploads/UsageMetricsMessage.jsx new file mode 100644 index 0000000000..cefb91ccde --- /dev/null +++ b/src/files-and-uploads/UsageMetricsMessage.jsx @@ -0,0 +1,60 @@ +import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; +import { Icon, Row, Spinner } from '@edx/paragon'; +import { ErrorOutline } from '@edx/paragon/icons'; +import isEmpty from 'lodash/isEmpty'; +import { RequestStatus } from '../data/constants'; +import messages from './messages'; + +const UsageMetricsMessage = ({ + usagePathStatus, + usageLocations, + error, + // injected + intl, +}) => { + let usageMessage; + if (usagePathStatus === RequestStatus.SUCCESSFUL) { + usageMessage = isEmpty(usageLocations) ? ( + + ) : ( +
    + {usageLocations.map((location) => (
  • {location}
  • ))} +
+ ); + } else if (usagePathStatus === RequestStatus.FAILED) { + usageMessage = ( + + + {intl.formatMessage(messages.errorAlertMessage, { message: error })} + + ); + } else { + usageMessage = ( + <> + + + + ); + } + return usageMessage; +}; + +UsageMetricsMessage.propTypes = { + usagePathStatus: PropTypes.string.isRequired, + usageLocations: PropTypes.arrayOf(PropTypes.string).isRequired, + error: PropTypes.arrayOf(PropTypes.string).isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(UsageMetricsMessage); diff --git a/src/files-and-uploads/data/api.js b/src/files-and-uploads/data/api.js index d3601bbf2e..06009545de 100644 --- a/src/files-and-uploads/data/api.js +++ b/src/files-and-uploads/data/api.js @@ -20,6 +20,11 @@ export async function getAssets(courseId, totalCount) { .get(`${getAssetsUrl(courseId)}?page_size=${pageCount}`); return camelCaseObject(data); } +export async function getAssetUsagePaths({ courseId, assetId }) { + const { data } = await getAuthenticatedHttpClient() + .get(`${getAssetsUrl(courseId)}${assetId}/usage`); + return camelCaseObject(data); +} /** * Delete custom page for provided block. diff --git a/src/files-and-uploads/data/slice.js b/src/files-and-uploads/data/slice.js index 00d470b419..5f61d33564 100644 --- a/src/files-and-uploads/data/slice.js +++ b/src/files-and-uploads/data/slice.js @@ -8,13 +8,15 @@ const slice = createSlice({ initialState: { assetIds: [], loadingStatus: RequestStatus.IN_PROGRESS, - savingStatus: '', + updatingStatus: '', addingStatus: '', deletingStatus: '', + usageStatus: '', errors: { upload: [], delete: [], lock: [], + usageMetrics: [], }, totalCount: 0, }, @@ -28,8 +30,8 @@ const slice = createSlice({ updateLoadingStatus: (state, { payload }) => { state.loadingStatus = payload.status; }, - updateSavingStatus: (state, { payload }) => { - state.savingStatus = payload.status; + updateUpdatingStatus: (state, { payload }) => { + state.updatingStatus = payload.status; }, updateAddingStatus: (state, { payload }) => { state.addingStatus = payload.status; @@ -43,6 +45,9 @@ const slice = createSlice({ addAssetSuccess: (state, { payload }) => { state.assetIds = [payload.assetId, ...state.assetIds]; }, + updateUsageStatus: (state, { payload }) => { + state.usageStatus = payload.status; + }, updateErrors: (state, { payload }) => { const { error, message } = payload; const currentErrorState = state.errors[error]; @@ -55,11 +60,12 @@ export const { setAssetIds, setTotalCount, updateLoadingStatus, - updateSavingStatus, + updateUpdatingStatus, deleteAssetSuccess, updateDeletingStatus, addAssetSuccess, updateAddingStatus, + updateUsageStatus, updateErrors, } = slice.actions; diff --git a/src/files-and-uploads/data/thunks.js b/src/files-and-uploads/data/thunks.js index 6ef78b32ed..9039b8626f 100644 --- a/src/files-and-uploads/data/thunks.js +++ b/src/files-and-uploads/data/thunks.js @@ -7,6 +7,7 @@ import { } from '../../generic/model-store'; import { getAssets, + getAssetUsagePaths, addAsset, deleteAsset, updateLockStatus, @@ -15,12 +16,13 @@ import { setAssetIds, setTotalCount, updateLoadingStatus, - updateSavingStatus, + updateUpdatingStatus, deleteAssetSuccess, updateDeletingStatus, addAssetSuccess, updateAddingStatus, updateErrors, + updateUsageStatus, } from './slice'; import { updateFileValues } from './utils'; @@ -104,7 +106,7 @@ export function addAssetFile(courseId, file, totalCount) { export function updateAssetLock({ assetId, courseId, locked }) { return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + dispatch(updateUpdatingStatus({ status: RequestStatus.IN_PROGRESS })); try { await updateLockStatus({ assetId, courseId, locked }); @@ -115,11 +117,26 @@ export function updateAssetLock({ assetId, courseId, locked }) { locked, }, })); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(updateUpdatingStatus({ status: RequestStatus.SUCCESSFUL })); } catch (error) { const lockStatus = locked ? 'lock' : 'unlock'; dispatch(updateErrors({ error: 'lock', message: `Failed to ${lockStatus} file id ${assetId}.` })); - dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + dispatch(updateUpdatingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function getUsagePaths({ asset, courseId, setSelectedRows }) { + return async (dispatch) => { + dispatch(updateUsageStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + const { usageLocations } = await getAssetUsagePaths({ assetId: asset.id, courseId }); + setSelectedRows([{ original: { ...asset, usageLocations } }]); + dispatch(updateUsageStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateErrors({ error: 'usageMetrics', message: `Failed to get usage metrics for ${asset.displayName}.` })); + dispatch(updateUsageStatus({ status: RequestStatus.FAILED })); } }; } diff --git a/src/files-and-uploads/data/utils.js b/src/files-and-uploads/data/utils.js index 823a81f6cb..75ace4caff 100644 --- a/src/files-and-uploads/data/utils.js +++ b/src/files-and-uploads/data/utils.js @@ -24,7 +24,12 @@ export const updateFileValues = (files) => { const utcDateString = dateAdded.replace(/\bat\b/g, ''); const utcDateTime = new Date(utcDateString).toString(); - updatedFiles.push({ ...file, wrapperType, dateAdded: utcDateTime }); + updatedFiles.push({ + ...file, + wrapperType, + dateAdded: utcDateTime, + usageLocations: [], + }); }); return updatedFiles; @@ -46,6 +51,23 @@ export const getSrc = ({ thumbnail, wrapperType, externalUrl }) => { } }; +export const getFileSizeToClosestByte = (fileSize, numberOfDivides = 0) => { + if (fileSize > 1000) { + const updatedSize = fileSize / 1000; + const incrementNumberOfDivides = numberOfDivides + 1; + return getFileSizeToClosestByte(updatedSize, incrementNumberOfDivides); + } + const fileSizeFixedDecimal = Number.parseFloat(fileSize).toFixed(2); + switch (numberOfDivides) { + case 1: + return `${fileSizeFixedDecimal} KB`; + case 2: + return `${fileSizeFixedDecimal} MB`; + default: + return `${fileSizeFixedDecimal} B`; + } +}; + export const sortFiles = (files, sortType) => { const [sort, direction] = sortType.split(','); let sortedFiles; diff --git a/src/files-and-uploads/data/utils.test.js b/src/files-and-uploads/data/utils.test.js new file mode 100644 index 0000000000..ebe9a38ebb --- /dev/null +++ b/src/files-and-uploads/data/utils.test.js @@ -0,0 +1,21 @@ +import { getFileSizeToClosestByte } from './utils'; + +describe('FilesAndUploads utils', () => { + describe('getFileSizeToClosestByte', () => { + it('should return file size with B for bytes', () => { + const expectedSize = '219.00 B'; + const actualSize = getFileSizeToClosestByte(219); + expect(expectedSize).toEqual(actualSize); + }); + it('should return file size with KB for kilobytes', () => { + const expectedSize = '21.90 KB'; + const actualSize = getFileSizeToClosestByte(21900); + expect(expectedSize).toEqual(actualSize); + }); + it('should return file size with MB for megabytes', () => { + const expectedSize = '2.19 MB'; + const actualSize = getFileSizeToClosestByte(2190000); + expect(expectedSize).toEqual(actualSize); + }); + }); +}); diff --git a/src/files-and-uploads/factories/mockApiResponses.jsx b/src/files-and-uploads/factories/mockApiResponses.jsx index d3ceb27209..3437fd0fcc 100644 --- a/src/files-and-uploads/factories/mockApiResponses.jsx +++ b/src/files-and-uploads/factories/mockApiResponses.jsx @@ -10,13 +10,15 @@ export const initialState = { assets: { assetIds: ['mOckID1'], loadingStatus: 'successful', - savingStatus: '', + updatingStatus: '', deletingStatus: '', addingStatus: '', + usageStatus: '', errors: { upload: [], delete: [], lock: [], + usageMetrics: [], }, }, models: { @@ -31,6 +33,7 @@ export const initialState = { wrapperType: 'document', dateAdded: '', thumbnail: null, + fileSize: 1234567, }, }, }, @@ -47,6 +50,7 @@ export const generateFetchAssetApiResponse = () => ({ contentType: 'image/png', dateAdded: '', thumbnail: '/asset', + fileSize: 123, }, { id: 'mOckID5', @@ -67,6 +71,7 @@ export const generateFetchAssetApiResponse = () => ({ contentType: 'application/pdf', dateAdded: 'Aug 17, 2023 at 22:08 UTC', thumbnail: null, + fileSize: 1234, }, { id: 'mOckID4', @@ -87,6 +92,7 @@ export const generateFetchAssetApiResponse = () => ({ contentType: 'application/octet-stream', dateAdded: '', thumbnail: null, + fileSize: 0, }, { id: 'mOckID6-2', @@ -118,6 +124,7 @@ export const generateNewAssetApiResponse = () => ({ thumbnail: '/download.png', locked: false, id: 'mOckID2', + fileSize: 1234, }, }); diff --git a/src/files-and-uploads/messages.js b/src/files-and-uploads/messages.js index 4af9b26f2b..7eed7d54a8 100644 --- a/src/files-and-uploads/messages.js +++ b/src/files-and-uploads/messages.js @@ -42,27 +42,27 @@ const messages = defineMessages({ defaultMessage: '{message}', }, dateAddedTitle: { - id: 'course-authoring.files-and-uploads.dateAdded.title', + id: 'course-authoring.files-and-uploads.file-info.dateAdded.title', defaultMessage: 'Date added', }, fileSizeTitle: { - id: 'course-authoring.files-and-uploads.fileSize.title', + id: 'course-authoring.files-and-uploads.file-info.fileSize.title', defaultMessage: 'File size', }, studioUrlTitle: { - id: 'course-authoring.files-and-uploads.studioUrl.title', + id: 'course-authoring.files-and-uploads.file-info.studioUrl.title', defaultMessage: 'Studio URL', }, webUrlTitle: { - id: 'course-authoring.files-and-uploads.webUrl.title', + id: 'course-authoring.files-and-uploads.file-info.webUrl.title', defaultMessage: 'Web URL', }, lockFileTitle: { - id: 'course-authoring.files-and-uploads.lockFile.title', + id: 'course-authoring.files-and-uploads.file-info.lockFile.title', defaultMessage: 'Lock file', }, lockFileTooltipContent: { - id: 'course-authoring.files-and-uploads.lockFile.tooltip.content', + id: 'course-authoring.files-and-uploads.file-info.lockFile.tooltip.content', defaultMessage: `By default, anyone can access a file you upload if they know the web URL, even if they are not enrolled in your course. You can prevent outside access to a file by locking the file. When @@ -70,9 +70,17 @@ const messages = defineMessages({ in your course and signed in to access the file.`, }, usageTitle: { - id: 'course-authoring.files-and-uploads.usage.title', + id: 'course-authoring.files-and-uploads.file-info.usage.title', defaultMessage: 'Usage', }, + usageLoadingMessage: { + id: 'course-authoring.files-and-uploads.file-info.usage.loading.message', + defaultMessage: 'Loading', + }, + usageNotInUseMessage: { + id: 'course-authoring.files-and-uploads.file-info.usage.notInUse.message', + defaultMessage: 'Currently not in use', + }, copyStudioUrlTitle: { id: 'course-authoring.files-and-uploads.cardMenu.copyStudioUrlTitle', defaultMessage: 'Copy Studio Url', diff --git a/src/files-and-uploads/table-components/GalleryCard.jsx b/src/files-and-uploads/table-components/GalleryCard.jsx index 57cd1c277c..dce4183812 100644 --- a/src/files-and-uploads/table-components/GalleryCard.jsx +++ b/src/files-and-uploads/table-components/GalleryCard.jsx @@ -4,7 +4,6 @@ import { ActionRow, Icon, Card, - useToggle, Chip, Truncate, Image, @@ -13,7 +12,6 @@ import { MoreVert, } from '@edx/paragon/icons'; import FileMenu from '../FileMenu'; -import FileInfo from '../FileInfo'; import { getSrc } from '../data/utils'; const GalleryCard = ({ @@ -21,8 +19,8 @@ const GalleryCard = ({ original, handleLockedAsset, handleOpenDeleteConfirmation, + handleOpenAssetInfo, }) => { - const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false); const lockAsset = () => { const { locked } = original; handleLockedAsset(original.id, !locked); @@ -33,53 +31,45 @@ const GalleryCard = ({ }); return ( - <> - - - handleOpenDeleteConfirmation([{ original }])} - /> - - )} - /> - -
- {original.thumbnail ? ( - - ) : ( -
- -
- )} -
-
- - {original.displayName} - -
-
- - - {original.wrapperType} - - -
- + + handleOpenAssetInfo(original)} + portableUrl={original.portableUrl} + iconSrc={MoreVert} + id={original.id} + openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])} + /> + + )} /> - + +
+ {original.thumbnail ? ( + + ) : ( +
+ +
+ )} +
+
+ + {original.displayName} + +
+
+ + + {original.wrapperType} + + + ); }; @@ -99,6 +89,7 @@ GalleryCard.propTypes = { }).isRequired, handleLockedAsset: PropTypes.func.isRequired, handleOpenDeleteConfirmation: PropTypes.func.isRequired, + handleOpenAssetInfo: PropTypes.func.isRequired, }; export default GalleryCard; diff --git a/src/files-and-uploads/table-components/ListCard.jsx b/src/files-and-uploads/table-components/ListCard.jsx index 495741a2a7..639061c946 100644 --- a/src/files-and-uploads/table-components/ListCard.jsx +++ b/src/files-and-uploads/table-components/ListCard.jsx @@ -4,7 +4,6 @@ import { ActionRow, Icon, Card, - useToggle, Chip, Truncate, Image, @@ -13,7 +12,6 @@ import { MoreVert, } from '@edx/paragon/icons'; import FileMenu from '../FileMenu'; -import FileInfo from '../FileInfo'; import { getSrc } from '../data/utils'; const ListCard = ({ @@ -21,8 +19,8 @@ const ListCard = ({ original, handleLockedAsset, handleOpenDeleteConfirmation, + handleOpenAssetInfo, }) => { - const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false); const lockAsset = () => { const { locked } = original; handleLockedAsset(original.id, !locked); @@ -33,55 +31,47 @@ const ListCard = ({ }); return ( - <> - -
- {original.thumbnail ? ( - - ) : ( -
- -
- )} -
- - -
- - {original.displayName} - -
- - {original.wrapperType} - -
-
- - - handleOpenDeleteConfirmation([{ original }])} - /> - - -
- - + +
+ {original.thumbnail ? ( + + ) : ( +
+ +
+ )} +
+ + +
+ + {original.displayName} + +
+ + {original.wrapperType} + +
+
+ + + handleOpenAssetInfo(original)} + portableUrl={original.portableUrl} + iconSrc={MoreVert} + id={original.id} + openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])} + /> + + +
); }; @@ -101,6 +91,7 @@ ListCard.propTypes = { }).isRequired, handleLockedAsset: PropTypes.func.isRequired, handleOpenDeleteConfirmation: PropTypes.func.isRequired, + handleOpenAssetInfo: PropTypes.func.isRequired, }; export default ListCard; From ed2eed5110f4b5f1e8b4021476005b2119d22775 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Tue, 5 Sep 2023 10:23:47 -0400 Subject: [PATCH 12/79] feat: add file zip on download (#580) --- package-lock.json | 6 + package.json | 1 + src/data/constants.js | 1 + src/files-and-uploads/FileInput.jsx | 3 +- src/files-and-uploads/FileMenu.jsx | 4 +- src/files-and-uploads/FilesAndUploads.jsx | 45 ++--- .../FilesAndUploads.test.jsx | 160 +++++++++++++++++- src/files-and-uploads/UsageMetricsMessage.jsx | 6 +- src/files-and-uploads/data/api.js | 63 ++++++- src/files-and-uploads/data/api.test.js | 57 +++++++ src/files-and-uploads/data/slice.js | 45 +++-- src/files-and-uploads/data/thunks.js | 55 ++++-- .../factories/mockApiResponses.jsx | 3 +- .../table-components/GalleryCard.jsx | 5 + .../table-components/ListCard.jsx | 5 + 15 files changed, 396 insertions(+), 63 deletions(-) create mode 100644 src/files-and-uploads/data/api.test.js diff --git a/package-lock.json b/package-lock.json index bfe7cf8d03..db756f68b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "classnames": "2.2.6", "core-js": "3.8.1", "email-validator": "2.0.4", + "file-saver": "^2.0.5", "formik": "2.2.6", "jszip": "^3.10.1", "lodash": "4.17.21", @@ -9885,6 +9886,11 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "node_modules/file-selector": { "version": "0.6.0", "license": "MIT", diff --git a/package.json b/package.json index 3524e700d5..e0d4688fec 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "classnames": "2.2.6", "core-js": "3.8.1", "email-validator": "2.0.4", + "file-saver": "^2.0.5", "formik": "2.2.6", "jszip": "^3.10.1", "lodash": "4.17.21", diff --git a/src/data/constants.js b/src/data/constants.js index 42e1562536..d91b6bfb5d 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -11,6 +11,7 @@ export const RequestStatus = { FAILED: 'failed', DENIED: 'denied', PENDING: 'pending', + CLEAR: 'clear', }; /** diff --git a/src/files-and-uploads/FileInput.jsx b/src/files-and-uploads/FileInput.jsx index b7be691f11..a094cdd15a 100644 --- a/src/files-and-uploads/FileInput.jsx +++ b/src/files-and-uploads/FileInput.jsx @@ -1,12 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; -export const fileInput = ({ +export const useFileInput = ({ onAddFile, setSelectedRows, setAddOpen, }) => { - // eslint-disable-next-line react-hooks/rules-of-hooks const ref = React.useRef(); const click = () => ref.current.click(); const addFile = (e) => { diff --git a/src/files-and-uploads/FileMenu.jsx b/src/files-and-uploads/FileMenu.jsx index 46b9defe10..7d9ac47cdc 100644 --- a/src/files-and-uploads/FileMenu.jsx +++ b/src/files-and-uploads/FileMenu.jsx @@ -12,6 +12,7 @@ const FileMenu = ({ externalUrl, handleLock, locked, + onDownload, openAssetInfo, openDeleteConfirmation, portableUrl, @@ -40,7 +41,7 @@ const FileMenu = ({ > {intl.formatMessage(messages.copyWebUrlTitle)} - + {intl.formatMessage(messages.downloadTitle)} @@ -64,6 +65,7 @@ FileMenu.propTypes = { externalUrl: PropTypes.string.isRequired, handleLock: PropTypes.func.isRequired, locked: PropTypes.bool.isRequired, + onDownload: PropTypes.func.isRequired, openAssetInfo: PropTypes.func.isRequired, openDeleteConfirmation: PropTypes.func.isRequired, portableUrl: PropTypes.string.isRequired, diff --git a/src/files-and-uploads/FilesAndUploads.jsx b/src/files-and-uploads/FilesAndUploads.jsx index 12a56b8f8b..98070e151e 100644 --- a/src/files-and-uploads/FilesAndUploads.jsx +++ b/src/files-and-uploads/FilesAndUploads.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import isEmpty from 'lodash/isEmpty'; @@ -22,15 +22,17 @@ import { addAssetFile, deleteAssetFile, fetchAssets, + resetErrors, getUsagePaths, updateAssetLock, updateAssetOrder, + fetchAssetDownload, } from './data/thunks'; import { sortFiles } from './data/utils'; import messages from './messages'; import FileInfo from './FileInfo'; -import FileInput, { fileInput } from './FileInput'; +import FileInput, { useFileInput } from './FileInput'; import FilesAndUploadsProvider from './FilesAndUploadsProvider'; import { GalleryCard, @@ -38,6 +40,7 @@ import { TableActions, } from './table-components'; import ApiStatusToast from './ApiStatusToast'; +import { clearErrors } from './data/slice'; const FilesAndUploads = ({ courseId, @@ -72,7 +75,7 @@ const FilesAndUploads = ({ usageStatus: usagePathStatus, errors: errorMessages, } = useSelector(state => state.assets); - const fileInputControl = fileInput({ + const fileInputControl = useFileInput({ onAddFile: (file) => dispatch(addAssetFile(courseId, file, totalCount)), setSelectedRows, setAddOpen, @@ -95,25 +98,18 @@ const FilesAndUploads = ({ const handleBulkDelete = () => { closeDeleteConfirmation(); setDeleteOpen(); + dispatch(resetErrors({ errorType: 'delete' })); const assetIdsToDelete = selectedRows.map(row => row.original.id); assetIdsToDelete.forEach(id => dispatch(deleteAssetFile(courseId, id, totalCount))); }; - const handleBulkDownload = (selectedFlatRows) => { - selectedFlatRows.forEach(row => { - const { externalUrl } = row.original; - const link = document.createElement('a'); - link.target = '_blank'; - link.download = true; - link.href = externalUrl; - link.click(); - }); - /* ********** TODO *********** - * implement a zip file function when there are multiple files - */ - }; + const handleBulkDownload = useCallback(async (selectedFlatRows) => { + dispatch(resetErrors({ errorType: 'download' })); + dispatch(fetchAssetDownload({ selectedRows: selectedFlatRows, courseId })); + }, []); const handleLockedAsset = (assetId, locked) => { + dispatch(clearErrors({ errorType: 'lock' })); dispatch(updateAssetLock({ courseId, assetId, locked })); }; @@ -123,6 +119,7 @@ const FilesAndUploads = ({ }; const handleOpenAssetInfo = (original) => { + dispatch(resetErrors({ errorType: 'usageMetrics' })); setSelectedRows([{ original }]); dispatch(getUsagePaths({ asset: original, courseId, setSelectedRows })); openAssetInfo(); @@ -146,6 +143,7 @@ const FilesAndUploads = ({
    - {errorMessages.upload.map(message => ( -
  • + {errorMessages.add.map(message => ( +
  • {intl.formatMessage(messages.errorAlertMessage, { message })}
  • ))} @@ -196,7 +195,7 @@ const FilesAndUploads = ({ >
      {errorMessages.delete.map(message => ( -
    • +
    • {intl.formatMessage(messages.errorAlertMessage, { message })}
    • ))} @@ -208,12 +207,16 @@ const FilesAndUploads = ({ >
        {errorMessages.lock.map(message => ( -
      • +
      • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
      • + ))} + {errorMessages.download.map(message => ( +
      • {intl.formatMessage(messages.errorAlertMessage, { message })}
      • ))}
      -
      diff --git a/src/files-and-uploads/FilesAndUploads.test.jsx b/src/files-and-uploads/FilesAndUploads.test.jsx index 38de84b8d5..b9ff8a0d81 100644 --- a/src/files-and-uploads/FilesAndUploads.test.jsx +++ b/src/files-and-uploads/FilesAndUploads.test.jsx @@ -8,6 +8,7 @@ import { } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import ReactDOM from 'react-dom'; +import { saveAs } from 'file-saver'; import { initializeMockApp } from '@edx/frontend-platform'; import MockAdapter from 'axios-mock-adapter'; @@ -42,6 +43,7 @@ let axiosMock; let store; let file; ReactDOM.createPortal = jest.fn(node => node); +jest.mock('file-saver'); const renderComponent = () => { render( @@ -89,22 +91,27 @@ describe('FilesAndUploads', () => { axiosMock = new MockAdapter(getAuthenticatedHttpClient()); file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' }); }); + it('should return placeholder component', async () => { renderComponent(); await mockStore(RequestStatus.DENIED); expect(screen.getByTestId('under-construction-placeholder')).toBeVisible(); }); + it('should have Files and uploads title', async () => { renderComponent(); await emptyMockStore(RequestStatus.SUCCESSFUL); expect(screen.getByText('Files and uploads')).toBeVisible(); }); + it('should render dropzone', async () => { renderComponent(); await emptyMockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('files-dropzone')).toBeVisible(); + expect(screen.queryByTestId('files-data-table')).toBeNull(); }); + it('should upload a single file', async () => { renderComponent(); await emptyMockStore(RequestStatus.SUCCESSFUL); @@ -119,10 +126,13 @@ describe('FilesAndUploads', () => { }); const addStatus = store.getState().assets.addingStatus; expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); + expect(screen.queryByTestId('files-dropzone')).toBeNull(); + expect(screen.getByTestId('files-data-table')).toBeVisible(); }); }); + describe('valid assets', () => { beforeEach(async () => { initializeMockApp({ @@ -137,27 +147,39 @@ describe('FilesAndUploads', () => { axiosMock = new MockAdapter(getAuthenticatedHttpClient()); file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' }); }); + + afterEach(() => { + saveAs.mockClear(); + }); + describe('table view', () => { it('should render table with gallery card', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('files-data-table')).toBeVisible(); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); }); + it('should switch table to list view', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('files-data-table')).toBeVisible(); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + expect(screen.queryByTestId('list-card-mOckID1')).toBeNull(); + const listButton = screen.getByLabelText('List'); await act(async () => { fireEvent.click(listButton); }); expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull(); + expect(screen.getByTestId('list-card-mOckID1')).toBeVisible(); }); }); + describe('table actions', () => { it('should upload a single file', async () => { renderComponent(); @@ -171,17 +193,21 @@ describe('FilesAndUploads', () => { const addStatus = store.getState().assets.addingStatus; expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); }); + it('should have disabled action buttons', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); expect(actionsButton).toBeVisible(); + await waitFor(() => { fireEvent.click(actionsButton); }); expect(screen.getByText(messages.downloadTitle.defaultMessage).closest('a')).toHaveClass('disabled'); + expect(screen.getByText(messages.deleteTitle.defaultMessage).closest('a')).toHaveClass('disabled'); }); + it('delete button should be enabled and delete selected file', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); @@ -189,59 +215,113 @@ describe('FilesAndUploads', () => { fireEvent.click(selectCardButton); const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); expect(actionsButton).toBeVisible(); + await waitFor(() => { fireEvent.click(actionsButton); }); const deleteButton = screen.getByText(messages.deleteTitle.defaultMessage).closest('a'); expect(deleteButton).not.toHaveClass('disabled'); + axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(204); await waitFor(() => { fireEvent.click(deleteButton); expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible(); + fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage)); expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull(); + executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch); }); const deleteStatus = store.getState().assets.deletingStatus; expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL); + expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull(); }); + + it('download button should be enabled and download single selected file', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const selectCardButton = screen.getAllByTestId('datatable-select-column-checkbox-cell')[0]; + fireEvent.click(selectCardButton); + const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); + expect(actionsButton).toBeVisible(); + + await waitFor(() => { + fireEvent.click(actionsButton); + }); + const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a'); + expect(downloadButton).not.toHaveClass('disabled'); + + fireEvent.click(downloadButton); + expect(saveAs).toHaveBeenCalled(); + }); + + it('download button should be enabled and download multiple selected files', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell'); + fireEvent.click(selectCardButtons[0]); + fireEvent.click(selectCardButtons[1]); + const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); + expect(actionsButton).toBeVisible(); + + await waitFor(() => { + fireEvent.click(actionsButton); + }); + const mockResponseData = { ok: true, blob: () => 'Data' }; + const mockFetchResponse = Promise.resolve(mockResponseData); + const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a'); + expect(downloadButton).not.toHaveClass('disabled'); + + global.fetch = jest.fn().mockImplementation(() => mockFetchResponse); + fireEvent.click(downloadButton); + expect(fetch).toHaveBeenCalledTimes(2); + }); + it('sort button should be enabled and sort files by name', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage); expect(sortsButton).toBeVisible(); + await waitFor(() => { fireEvent.click(sortsButton); expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible(); }); + const sortNameAscendingButton = screen.getByText(messages.sortByNameAscending.defaultMessage); fireEvent.click(sortNameAscendingButton); fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage)); expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull(); }); + it('sort button should be enabled and sort files by file size', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage); expect(sortsButton).toBeVisible(); + await waitFor(() => { fireEvent.click(sortsButton); expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible(); }); + const sortBySizeDescendingButton = screen.getByText(messages.sortBySizeDescending.defaultMessage); fireEvent.click(sortBySizeDescendingButton); fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage)); expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull(); }); }); + describe('card menu actions', () => { it('should open asset info', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); expect(assetMenuButton).toBeVisible(); + axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: ['subsection - unit / block'] }); await waitFor(() => { fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); @@ -253,16 +333,19 @@ describe('FilesAndUploads', () => { }), store.dispatch); expect(screen.getAllByLabelText('mOckID1')[0]).toBeVisible(); }); + const { usageStatus } = store.getState().assets; expect(usageStatus).toEqual(RequestStatus.SUCCESSFUL); expect(screen.getByText('subsection - unit / block')).toBeVisible(); }); + it('should open asset info and handle lock checkbox', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); expect(assetMenuButton).toBeVisible(); + axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false }); axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: [] }); await waitFor(() => { @@ -274,6 +357,7 @@ describe('FilesAndUploads', () => { setSelectedRows: jest.fn(), }), store.dispatch); expect(screen.getAllByLabelText('mOckID1')[0]).toBeVisible(); + fireEvent.click(screen.getByLabelText('Checkbox')); executeThunk(updateAssetLock({ courseId, @@ -282,15 +366,19 @@ describe('FilesAndUploads', () => { }), store.dispatch); }); expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); + const updateStatus = store.getState().assets.updatingStatus; expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL); }); + it('should unlock asset', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); expect(assetMenuButton).toBeVisible(); + await waitFor(() => { axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false }); fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); @@ -304,12 +392,15 @@ describe('FilesAndUploads', () => { const updateStatus = store.getState().assets.updatingStatus; expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL); }); + it('should lock asset', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible(); + const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3'); expect(assetMenuButton).toBeVisible(); + await waitFor(() => { axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID3`).reply(201, { locked: true }); fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); @@ -323,26 +414,48 @@ describe('FilesAndUploads', () => { const updateStatus = store.getState().assets.updatingStatus; expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL); }); + + it('download button should download file', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + + const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); + expect(assetMenuButton).toBeVisible(); + + await waitFor(() => { + fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); + fireEvent.click(screen.getByText('Download')); + }); + expect(saveAs).toHaveBeenCalled(); + }); + it('delete button should delete file', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); expect(assetMenuButton).toBeVisible(); + await waitFor(() => { axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(204); fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible(); + fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage)); expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull(); + executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch); }); const deleteStatus = store.getState().assets.deletingStatus; expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL); + expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull(); }); }); + describe('api errors', () => { it('invalid file size should show error', async () => { const errorMessage = 'File download.png exceeds maximum size of 20 MB.'; @@ -356,8 +469,10 @@ describe('FilesAndUploads', () => { }); const addStatus = store.getState().assets.addingStatus; expect(addStatus).toEqual(RequestStatus.FAILED); + expect(screen.getByText('Error')).toBeVisible(); }); + it('404 upload should show error', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); @@ -369,35 +484,45 @@ describe('FilesAndUploads', () => { }); const addStatus = store.getState().assets.addingStatus; expect(addStatus).toEqual(RequestStatus.FAILED); + expect(screen.getByText('Error')).toBeVisible(); }); + it('404 delete should show error', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); expect(assetMenuButton).toBeVisible(); + await waitFor(() => { axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(404); fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible(); + fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage)); expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull(); + executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch); }); const deleteStatus = store.getState().assets.deletingStatus; expect(deleteStatus).toEqual(RequestStatus.FAILED); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + expect(screen.getByText('Error')).toBeVisible(); }); + it('404 usage path fetch should show error', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible(); + const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3'); expect(assetMenuButton).toBeVisible(); + axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID3/usage`).reply(404); await waitFor(() => { fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); @@ -411,12 +536,15 @@ describe('FilesAndUploads', () => { const { usageStatus } = store.getState().assets; expect(usageStatus).toEqual(RequestStatus.FAILED); }); + it('404 lock update should show error', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible(); + const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3'); expect(assetMenuButton).toBeVisible(); + await waitFor(() => { axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID3`).reply(404); fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); @@ -429,6 +557,36 @@ describe('FilesAndUploads', () => { }); const updateStatus = store.getState().assets.updatingStatus; expect(updateStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Error')).toBeVisible(); + }); + + it('multiple asset file fetch failure should show error', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell'); + fireEvent.click(selectCardButtons[0]); + fireEvent.click(selectCardButtons[1]); + const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); + expect(actionsButton).toBeVisible(); + + await waitFor(() => { + fireEvent.click(actionsButton); + }); + const mockResponseData = { ok: false }; + const mockFetchResponse = Promise.resolve(mockResponseData); + const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a'); + expect(downloadButton).not.toHaveClass('disabled'); + + global.fetch = jest.fn().mockImplementation(() => mockFetchResponse); + await waitFor(() => { + fireEvent.click(downloadButton); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + const updateStatus = store.getState().assets.updatingStatus; + expect(updateStatus).toEqual(RequestStatus.FAILED); + expect(screen.getByText('Error')).toBeVisible(); }); }); diff --git a/src/files-and-uploads/UsageMetricsMessage.jsx b/src/files-and-uploads/UsageMetricsMessage.jsx index cefb91ccde..fe1d5c97d2 100644 --- a/src/files-and-uploads/UsageMetricsMessage.jsx +++ b/src/files-and-uploads/UsageMetricsMessage.jsx @@ -19,7 +19,11 @@ const UsageMetricsMessage = ({ ) : (
        - {usageLocations.map((location) => (
      • {location}
      • ))} + {usageLocations.map(location => ( +
      • + {location} +
      • + ))}
      ); } else if (usagePathStatus === RequestStatus.FAILED) { diff --git a/src/files-and-uploads/data/api.js b/src/files-and-uploads/data/api.js index 06009545de..357e3ff2cc 100644 --- a/src/files-and-uploads/data/api.js +++ b/src/files-and-uploads/data/api.js @@ -2,6 +2,9 @@ import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import JSZip from 'jszip'; +import saveAs from 'file-saver'; + ensureConfig([ 'STUDIO_BASE_URL', ], 'Course Apps API service'); @@ -20,6 +23,62 @@ export async function getAssets(courseId, totalCount) { .get(`${getAssetsUrl(courseId)}?page_size=${pageCount}`); return camelCaseObject(data); } + +/** + * Fetch asset file. + * @param {blockId} courseId Course ID for the course to operate on + + */ +export async function getDownload(selectedRows, courseId) { + const downloadErrors = []; + if (selectedRows?.length > 1) { + const zip = new JSZip(); + const date = new Date().toString(); + const folder = zip.folder(`${courseId}-assets-${date}`); + const assetNames = []; + const assetFetcher = await Promise.allSettled( + selectedRows.map(async (row) => { + const asset = row?.original; + try { + assetNames.push(asset.displayName); + const res = await fetch(`${getApiBaseUrl()}/${asset.id}`); + if (!res.ok) { + throw new Error(); + } + return res.blob(); + } catch (error) { + downloadErrors.push(`Failed to download ${asset?.displayName}.`); + return null; + } + }), + ); + const definedAssets = assetFetcher.filter(asset => asset.value !== null); + if (definedAssets.length > 0) { + definedAssets.forEach((assetBlob, index) => { + folder.file(assetNames[index], assetBlob.value, { blob: true }); + }); + zip.generateAsync({ type: 'blob' }).then(content => { + saveAs(content, `${courseId}-assets-${date}.zip`); + }); + } + } else if (selectedRows?.length === 1) { + const asset = selectedRows[0].original; + try { + saveAs(`${getApiBaseUrl()}/${asset.id}`, asset.displayName); + } catch (error) { + downloadErrors.push(`Failed to download ${asset?.displayName}.`); + } + } else { + downloadErrors.push('No files were selected to download'); + } + return downloadErrors; +} + +/** + * Fetch where asset is used in a course. + * @param {blockId} courseId Course ID for the course to operate on + + */ export async function getAssetUsagePaths({ courseId, assetId }) { const { data } = await getAuthenticatedHttpClient() .get(`${getAssetsUrl(courseId)}${assetId}/usage`); @@ -27,7 +86,7 @@ export async function getAssetUsagePaths({ courseId, assetId }) { } /** - * Delete custom page for provided block. + * Delete asset to course. * @param {blockId} courseId Course ID for the course to operate on */ @@ -37,7 +96,7 @@ export async function deleteAsset(courseId, assetId) { } /** - * Add custom page for provided block. + * Add asset to course. * @param {blockId} courseId Course ID for the course to operate on */ diff --git a/src/files-and-uploads/data/api.test.js b/src/files-and-uploads/data/api.test.js new file mode 100644 index 0000000000..e852bc4ec6 --- /dev/null +++ b/src/files-and-uploads/data/api.test.js @@ -0,0 +1,57 @@ +import { getDownload } from './api'; +import 'file-saver'; + +jest.mock('file-saver'); + +describe('api.js', () => { + describe('getDownload', () => { + describe('selectedRows length is undefined or less than zero', () => { + it('should return with no files selected error if selectedRows is empty', async () => { + const expected = ['No files were selected to download']; + const actual = await getDownload([], 'courseId'); + expect(actual).toEqual(expected); + }); + it('should return with no files selected error if selectedRows is null', async () => { + const expected = ['No files were selected to download']; + const actual = await getDownload(null, 'courseId'); + expect(actual).toEqual(expected); + }); + }); + describe('selectedRows length is greater than one', () => { + it('should not throw error when blob returns null', async () => { + const mockResponseData = { ok: true, blob: () => null }; + const mockFetchResponse = Promise.resolve(mockResponseData); + global.fetch = jest.fn().mockImplementation(() => mockFetchResponse); + const expected = []; + const actual = await getDownload([ + { original: { displayName: 'test1' } }, + { original: { displayName: 'test2', id: '2' } }, + ]); + expect(actual).toEqual(expected); + }); + it('should return error if row does not contain .original ancestor', async () => { + const mockResponseData = { ok: true, blob: () => 'data' }; + const mockFetchResponse = Promise.resolve(mockResponseData); + global.fetch = jest.fn().mockImplementation(() => mockFetchResponse); + const expected = ['Failed to download undefined.']; + const actual = await getDownload([ + { asset: { displayName: 'test1', id: '1' } }, + { original: { displayName: 'test2', id: '2' } }, + ]); + expect(actual).toEqual(expected); + }); + }); + describe('selectedRows length equals one', () => { + it('should return error if row does not contain .original ancestor', async () => { + const mockResponseData = { ok: true, blob: () => 'data' }; + const mockFetchResponse = Promise.resolve(mockResponseData); + global.fetch = jest.fn().mockImplementation(() => mockFetchResponse); + const expected = ['Failed to download undefined.']; + const actual = await getDownload([ + { asset: { displayName: 'test1', id: '1' } }, + ]); + expect(actual).toEqual(expected); + }); + }); + }); +}); diff --git a/src/files-and-uploads/data/slice.js b/src/files-and-uploads/data/slice.js index 5f61d33564..a9e8121c60 100644 --- a/src/files-and-uploads/data/slice.js +++ b/src/files-and-uploads/data/slice.js @@ -13,9 +13,10 @@ const slice = createSlice({ deletingStatus: '', usageStatus: '', errors: { - upload: [], + add: [], delete: [], lock: [], + download: [], usageMetrics: [], }, totalCount: 0, @@ -30,14 +31,27 @@ const slice = createSlice({ updateLoadingStatus: (state, { payload }) => { state.loadingStatus = payload.status; }, - updateUpdatingStatus: (state, { payload }) => { - state.updatingStatus = payload.status; - }, - updateAddingStatus: (state, { payload }) => { - state.addingStatus = payload.status; - }, - updateDeletingStatus: (state, { payload }) => { - state.deletingStatus = payload.status; + updateEditStatus: (state, { payload }) => { + const { editType, status } = payload; + switch (editType) { + case 'delete': + state.deletingStatus = status; + break; + case 'add': + state.addingStatus = status; + break; + case 'lock': + state.updatingStatus = status; + break; + case 'download': + state.updatingStatus = status; + break; + case 'usageMetrics': + state.usageStatus = status; + break; + default: + break; + } }, deleteAssetSuccess: (state, { payload }) => { state.assetIds = state.assetIds.filter(id => id !== payload.assetId); @@ -45,14 +59,15 @@ const slice = createSlice({ addAssetSuccess: (state, { payload }) => { state.assetIds = [payload.assetId, ...state.assetIds]; }, - updateUsageStatus: (state, { payload }) => { - state.usageStatus = payload.status; - }, updateErrors: (state, { payload }) => { const { error, message } = payload; const currentErrorState = state.errors[error]; state.errors[error] = [...currentErrorState, message]; }, + clearErrors: (state, { payload }) => { + const { error } = payload; + state.errors[error] = []; + }, }, }); @@ -60,13 +75,11 @@ export const { setAssetIds, setTotalCount, updateLoadingStatus, - updateUpdatingStatus, deleteAssetSuccess, - updateDeletingStatus, addAssetSuccess, - updateAddingStatus, - updateUsageStatus, updateErrors, + clearErrors, + updateEditStatus, } = slice.actions; export const { diff --git a/src/files-and-uploads/data/thunks.js b/src/files-and-uploads/data/thunks.js index 9039b8626f..53af42cf05 100644 --- a/src/files-and-uploads/data/thunks.js +++ b/src/files-and-uploads/data/thunks.js @@ -1,3 +1,4 @@ +import { isEmpty } from 'lodash'; import { RequestStatus } from '../../data/constants'; import { addModel, @@ -11,18 +12,17 @@ import { addAsset, deleteAsset, updateLockStatus, + getDownload, } from './api'; import { setAssetIds, setTotalCount, updateLoadingStatus, - updateUpdatingStatus, deleteAssetSuccess, - updateDeletingStatus, addAssetSuccess, - updateAddingStatus, updateErrors, - updateUsageStatus, + clearErrors, + updateEditStatus, } from './slice'; import { updateFileValues } from './utils'; @@ -61,24 +61,24 @@ export function updateAssetOrder(courseId, assetIds) { export function deleteAssetFile(courseId, id, totalCount) { return async (dispatch) => { - dispatch(updateDeletingStatus({ status: RequestStatus.IN_PROGRESS })); + dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.IN_PROGRESS })); try { await deleteAsset(courseId, id); dispatch(deleteAssetSuccess({ assetId: id })); dispatch(removeModel({ modelType: 'assets', id })); dispatch(setTotalCount({ totalCount: totalCount - 1 })); - dispatch(updateDeletingStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.SUCCESSFUL })); } catch (error) { dispatch(updateErrors({ error: 'delete', message: `Failed to delete file id ${id}.` })); - dispatch(updateDeletingStatus({ status: RequestStatus.FAILED })); + dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.FAILED })); } }; } export function addAssetFile(courseId, file, totalCount) { return async (dispatch) => { - dispatch(updateAddingStatus({ status: RequestStatus.IN_PROGRESS })); + dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS })); try { const { asset } = await addAsset(courseId, file); @@ -91,22 +91,22 @@ export function addAssetFile(courseId, file, totalCount) { assetId: asset.id, })); dispatch(setTotalCount({ totalCount: totalCount + 1 })); - dispatch(updateAddingStatus({ courseId, status: RequestStatus.SUCCESSFUL })); + dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL })); } catch (error) { if (error.response && error.response.status === 413) { const message = error.response.data.error; - dispatch(updateErrors({ error: 'upload', message })); + dispatch(updateErrors({ error: 'add', message })); } else { - dispatch(updateErrors({ error: 'upload', message: `Failed to add ${file.name}.` })); + dispatch(updateErrors({ error: 'add', message: `Failed to add ${file.name}.` })); } - dispatch(updateAddingStatus({ status: RequestStatus.FAILED })); + dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED })); } }; } export function updateAssetLock({ assetId, courseId, locked }) { return async (dispatch) => { - dispatch(updateUpdatingStatus({ status: RequestStatus.IN_PROGRESS })); + dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.IN_PROGRESS })); try { await updateLockStatus({ assetId, courseId, locked }); @@ -117,26 +117,45 @@ export function updateAssetLock({ assetId, courseId, locked }) { locked, }, })); - dispatch(updateUpdatingStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.SUCCESSFUL })); } catch (error) { const lockStatus = locked ? 'lock' : 'unlock'; dispatch(updateErrors({ error: 'lock', message: `Failed to ${lockStatus} file id ${assetId}.` })); - dispatch(updateUpdatingStatus({ status: RequestStatus.FAILED })); + dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.FAILED })); } }; } +export function resetErrors({ errorType }) { + return (dispatch) => { dispatch(clearErrors({ error: errorType })); }; +} + export function getUsagePaths({ asset, courseId, setSelectedRows }) { return async (dispatch) => { - dispatch(updateUsageStatus({ status: RequestStatus.IN_PROGRESS })); + dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.IN_PROGRESS })); try { const { usageLocations } = await getAssetUsagePaths({ assetId: asset.id, courseId }); setSelectedRows([{ original: { ...asset, usageLocations } }]); - dispatch(updateUsageStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL })); } catch (error) { dispatch(updateErrors({ error: 'usageMetrics', message: `Failed to get usage metrics for ${asset.displayName}.` })); - dispatch(updateUsageStatus({ status: RequestStatus.FAILED })); + dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.FAILED })); + } + }; +} + +export function fetchAssetDownload({ selectedRows, courseId }) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.IN_PROGRESS })); + const errors = await getDownload(selectedRows, courseId); + if (isEmpty(errors)) { + dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.SUCCESSFUL })); + } else { + errors.forEach(error => { + dispatch(updateErrors({ error: 'download', message: error })); + }); + dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.FAILED })); } }; } diff --git a/src/files-and-uploads/factories/mockApiResponses.jsx b/src/files-and-uploads/factories/mockApiResponses.jsx index 3437fd0fcc..6b99e18d62 100644 --- a/src/files-and-uploads/factories/mockApiResponses.jsx +++ b/src/files-and-uploads/factories/mockApiResponses.jsx @@ -15,9 +15,10 @@ export const initialState = { addingStatus: '', usageStatus: '', errors: { - upload: [], + add: [], delete: [], lock: [], + download: [], usageMetrics: [], }, }, diff --git a/src/files-and-uploads/table-components/GalleryCard.jsx b/src/files-and-uploads/table-components/GalleryCard.jsx index dce4183812..5728f2b3d6 100644 --- a/src/files-and-uploads/table-components/GalleryCard.jsx +++ b/src/files-and-uploads/table-components/GalleryCard.jsx @@ -17,6 +17,7 @@ import { getSrc } from '../data/utils'; const GalleryCard = ({ className, original, + handleBulkDownload, handleLockedAsset, handleOpenDeleteConfirmation, handleOpenAssetInfo, @@ -43,6 +44,9 @@ const GalleryCard = ({ portableUrl={original.portableUrl} iconSrc={MoreVert} id={original.id} + onDownload={() => handleBulkDownload( + [{ original: { id: original.id, displayName: original.displayName } }], + )} openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])} /> @@ -87,6 +91,7 @@ GalleryCard.propTypes = { id: PropTypes.string.isRequired, portableUrl: PropTypes.string.isRequired, }).isRequired, + handleBulkDownload: PropTypes.func.isRequired, handleLockedAsset: PropTypes.func.isRequired, handleOpenDeleteConfirmation: PropTypes.func.isRequired, handleOpenAssetInfo: PropTypes.func.isRequired, diff --git a/src/files-and-uploads/table-components/ListCard.jsx b/src/files-and-uploads/table-components/ListCard.jsx index 639061c946..9a1d38b1bb 100644 --- a/src/files-and-uploads/table-components/ListCard.jsx +++ b/src/files-and-uploads/table-components/ListCard.jsx @@ -17,6 +17,7 @@ import { getSrc } from '../data/utils'; const ListCard = ({ className, original, + handleBulkDownload, handleLockedAsset, handleOpenDeleteConfirmation, handleOpenAssetInfo, @@ -67,6 +68,9 @@ const ListCard = ({ portableUrl={original.portableUrl} iconSrc={MoreVert} id={original.id} + onDownload={() => handleBulkDownload( + [{ original: { id: original.id, displayName: original.displayName } }], + )} openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])} /> @@ -89,6 +93,7 @@ ListCard.propTypes = { id: PropTypes.string.isRequired, portableUrl: PropTypes.string.isRequired, }).isRequired, + handleBulkDownload: PropTypes.func.isRequired, handleLockedAsset: PropTypes.func.isRequired, handleOpenDeleteConfirmation: PropTypes.func.isRequired, handleOpenAssetInfo: PropTypes.func.isRequired, From a1793efcc01dc0874652c456cf2b3e03307fb5bf Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Tue, 5 Sep 2023 21:17:39 +0300 Subject: [PATCH 13/79] feat: add help-urls (#585) --- src/help-urls/data/api.js | 9 +++++++++ src/help-urls/data/selectors.js | 13 +++++++++++++ src/help-urls/data/slice.js | 27 +++++++++++++++++++++++++++ src/help-urls/data/thunks.js | 24 ++++++++++++++++++++++++ src/help-urls/hooks.jsx | 22 ++++++++++++++++++++++ src/store.js | 2 ++ 6 files changed, 97 insertions(+) create mode 100644 src/help-urls/data/api.js create mode 100644 src/help-urls/data/selectors.js create mode 100644 src/help-urls/data/slice.js create mode 100644 src/help-urls/data/thunks.js create mode 100644 src/help-urls/hooks.jsx diff --git a/src/help-urls/data/api.js b/src/help-urls/data/api.js new file mode 100644 index 0000000000..3243f5be33 --- /dev/null +++ b/src/help-urls/data/api.js @@ -0,0 +1,9 @@ +/* eslint-disable import/prefer-default-export */ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +export async function getHelpUrls() { + const { data } = await getAuthenticatedHttpClient() + .get(`${getConfig().STUDIO_BASE_URL}/api/contentstore/v1/help_urls`); + return camelCaseObject(data); +} diff --git a/src/help-urls/data/selectors.js b/src/help-urls/data/selectors.js new file mode 100644 index 0000000000..cd88d4e72c --- /dev/null +++ b/src/help-urls/data/selectors.js @@ -0,0 +1,13 @@ +export const selectHelpUrlsByNames = (names) => (state) => { + const urlsDictionary = {}; + + names.forEach(name => { + urlsDictionary[name] = state.helpUrls.pages[name] || null; + }); + + return urlsDictionary; +}; + +export const getPages = (state) => state.helpUrls.pages; + +export const getLoadingHelpUrlsStatus = (state) => state.helpUrls.loadingHelpUrlsStatus; diff --git a/src/help-urls/data/slice.js b/src/help-urls/data/slice.js new file mode 100644 index 0000000000..01c8821e55 --- /dev/null +++ b/src/help-urls/data/slice.js @@ -0,0 +1,27 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + loadingHelpUrlsStatus: '', + pages: {}, +}; + +const slice = createSlice({ + name: 'helpUrls', + initialState, + reducers: { + updatePages: (state, { payload }) => { + state.pages = payload; + }, + updateLoadingHelpUrlsStatus: (state, { payload }) => { + state.loadingHelpUrlsStatus = payload.status; + }, + }, +}); + +export const { + updatePages, + updateLoadingHelpUrlsStatus, +} = slice.actions; + +export const { reducer } = slice; diff --git a/src/help-urls/data/thunks.js b/src/help-urls/data/thunks.js new file mode 100644 index 0000000000..439c1cd1de --- /dev/null +++ b/src/help-urls/data/thunks.js @@ -0,0 +1,24 @@ +import { RequestStatus } from '../../data/constants'; + +import { getHelpUrls } from './api'; +import { updateLoadingHelpUrlsStatus, updatePages } from './slice'; + +/* eslint-disable import/prefer-default-export */ +export function fetchHelpUrls() { + return async (dispatch) => { + dispatch(updateLoadingHelpUrlsStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + const urls = await getHelpUrls(); + + dispatch(updatePages(urls)); + + dispatch(updateLoadingHelpUrlsStatus({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + dispatch(updateLoadingHelpUrlsStatus({ status: RequestStatus.FAILED })); + + return false; + } + }; +} diff --git a/src/help-urls/hooks.jsx b/src/help-urls/hooks.jsx new file mode 100644 index 0000000000..666f2b8771 --- /dev/null +++ b/src/help-urls/hooks.jsx @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { isEmpty } from 'lodash'; + +import { fetchHelpUrls } from './data/thunks'; +import { getPages, selectHelpUrlsByNames } from './data/selectors'; + +const useHelpUrls = (tokenNames) => { + const dispatch = useDispatch(); + const helpTokens = useSelector(selectHelpUrlsByNames(tokenNames)); + const pages = useSelector(getPages); + + useEffect(() => { + if (isEmpty(pages)) { + dispatch(fetchHelpUrls()); + } + }, []); + + return helpTokens; +}; +/* eslint-disable-next-line import/prefer-default-export */ +export { useHelpUrls }; diff --git a/src/store.js b/src/store.js index 515751cbf1..5b538134a5 100644 --- a/src/store.js +++ b/src/store.js @@ -13,6 +13,7 @@ import { reducer as filesReducer } from './files-and-uploads/data/slice'; import { reducer as courseTeamReducer } from './course-team/data/slice'; import { reducer as CourseUpdatesReducer } from './course-updates/data/slice'; import { reducer as processingNotificationReducer } from './generic/processing-notification/data/slice'; +import { reducer as helpUrlsReducer } from './help-urls/data/slice'; export default function initializeStore(preloadedState = undefined) { return configureStore({ @@ -30,6 +31,7 @@ export default function initializeStore(preloadedState = undefined) { courseTeam: courseTeamReducer, courseUpdates: CourseUpdatesReducer, processingNotification: processingNotificationReducer, + helpUrls: helpUrlsReducer, }, preloadedState, }); From 2bd8037d7b76cc118e476178d06dd9b9e110b7a1 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Wed, 6 Sep 2023 11:02:16 -0400 Subject: [PATCH 14/79] feat: change head title depending on page (#582) --- src/advanced-settings/AdvancedSettings.jsx | 5 +++++ src/course-team/CourseTeam.jsx | 5 +++++ src/course-updates/CourseUpdates.jsx | 5 +++++ src/custom-pages/CustomPages.jsx | 6 +++++- src/files-and-uploads/FilesAndUploads.jsx | 6 +++++- src/generic/utils.js | 10 ++++++++++ src/generic/utils.test.js | 16 ++++++++++++++++ src/grading-settings/GradingSettings.jsx | 5 +++++ src/pages-and-resources/PagesAndResources.jsx | 4 ++++ .../ProctoredExamSettings.jsx | 7 ++++++- src/schedule-and-details/index.jsx | 5 +++++ 11 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 src/generic/utils.js create mode 100644 src/generic/utils.test.js diff --git a/src/advanced-settings/AdvancedSettings.jsx b/src/advanced-settings/AdvancedSettings.jsx index 9b9f8b720a..ea6f0e9a8f 100644 --- a/src/advanced-settings/AdvancedSettings.jsx +++ b/src/advanced-settings/AdvancedSettings.jsx @@ -9,6 +9,7 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/ import Placeholder from '@edx/frontend-lib-content-components'; import AlertProctoringError from '../generic/AlertProctoringError'; +import { useModel } from '../generic/model-store'; import InternetConnectionAlert from '../generic/internet-connection-alert'; import { parseArrayOrObjectValues } from '../utils'; import { RequestStatus } from '../data/constants'; @@ -23,6 +24,7 @@ import SettingsSidebar from './settings-sidebar/SettingsSidebar'; import validateAdvancedSettingsData from './utils'; import messages from './messages'; import ModalError from './modal-error/ModalError'; +import getPageHeadTitle from '../generic/utils'; const AdvancedSettings = ({ intl, courseId }) => { const dispatch = useDispatch(); @@ -36,6 +38,9 @@ const AdvancedSettings = ({ intl, courseId }) => { const [isEditableState, setIsEditableState] = useState(false); const [hasInternetConnectionError, setInternetConnectionError] = useState(false); + const courseDetails = useModel('courseDetails', courseId); + document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle)); + useEffect(() => { dispatch(fetchCourseAppSettings(courseId)); dispatch(fetchProctoringExamErrors(courseId)); diff --git a/src/course-team/CourseTeam.jsx b/src/course-team/CourseTeam.jsx index d0a2ba5739..e5a3c1b84c 100644 --- a/src/course-team/CourseTeam.jsx +++ b/src/course-team/CourseTeam.jsx @@ -9,6 +9,7 @@ import { import { Add as IconAdd } from '@edx/paragon/icons'; import InternetConnectionAlert from '../generic/internet-connection-alert'; +import { useModel } from '../generic/model-store'; import SubHeader from '../generic/sub-header/SubHeader'; import { USER_ROLES } from '../constants'; import messages from './messages'; @@ -18,10 +19,14 @@ import AddTeamMember from './add-team-member/AddTeamMember'; import CourseTeamMember from './course-team-member/CourseTeamMember'; import InfoModal from './info-modal/InfoModal'; import { useCourseTeam } from './hooks'; +import getPageHeadTitle from '../generic/utils'; const CourseTeam = ({ courseId }) => { const intl = useIntl(); + const courseDetails = useModel('courseDetails', courseId); + document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle)); + const { modalType, errorMessage, diff --git a/src/course-updates/CourseUpdates.jsx b/src/course-updates/CourseUpdates.jsx index cfcdfea960..4167785260 100644 --- a/src/course-updates/CourseUpdates.jsx +++ b/src/course-updates/CourseUpdates.jsx @@ -9,6 +9,7 @@ import { import { Add as AddIcon } from '@edx/paragon/icons'; import { useSelector } from 'react-redux'; +import { useModel } from '../generic/model-store'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; import ProcessingNotification from '../generic/processing-notification'; import SubHeader from '../generic/sub-header/SubHeader'; @@ -23,10 +24,14 @@ import messages from './messages'; import { useCourseUpdates } from './hooks'; import { getLoadingStatuses, getSavingStatuses } from './data/selectors'; import { matchesAnyStatus } from './utils'; +import getPageHeadTitle from '../generic/utils'; const CourseUpdates = ({ courseId }) => { const intl = useIntl(); + const courseDetails = useModel('courseDetails', courseId); + document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle)); + const { requestType, courseUpdates, diff --git a/src/custom-pages/CustomPages.jsx b/src/custom-pages/CustomPages.jsx index b7019ba10e..1f2955ffc4 100644 --- a/src/custom-pages/CustomPages.jsx +++ b/src/custom-pages/CustomPages.jsx @@ -26,7 +26,7 @@ import Placeholder, { } from '@edx/frontend-lib-content-components'; import { RequestStatus } from '../data/constants'; -import { useModels } from '../generic/model-store'; +import { useModels, useModel } from '../generic/model-store'; import { getLoadingStatus, getSavingStatus } from './data/selectors'; import { addSingleCustomPage, @@ -40,6 +40,7 @@ import CustomPageCard from './CustomPageCard'; import messages from './messages'; import CustomPagesProvider from './CustomPagesProvider'; import EditModal from './EditModal'; +import getPageHeadTitle from '../generic/utils'; const CustomPages = ({ courseId, @@ -52,6 +53,9 @@ const CustomPages = ({ const [isOpen, open, close] = useToggle(false); const [isEditModalOpen, openEditModal, closeEditModal] = useToggle(false); + const courseDetails = useModel('courseDetails', courseId); + document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading)); + const { config } = useContext(AppContext); const { path, url } = useRouteMatch(); const learningCourseURL = `${config.LEARNING_BASE_URL}/course/${courseId}`; diff --git a/src/files-and-uploads/FilesAndUploads.jsx b/src/files-and-uploads/FilesAndUploads.jsx index 98070e151e..d2a203de0a 100644 --- a/src/files-and-uploads/FilesAndUploads.jsx +++ b/src/files-and-uploads/FilesAndUploads.jsx @@ -17,7 +17,7 @@ import { import Placeholder, { ErrorAlert } from '@edx/frontend-lib-content-components'; import { RequestStatus } from '../data/constants'; -import { useModels } from '../generic/model-store'; +import { useModels, useModel } from '../generic/model-store'; import { addAssetFile, deleteAssetFile, @@ -41,6 +41,7 @@ import { } from './table-components'; import ApiStatusToast from './ApiStatusToast'; import { clearErrors } from './data/slice'; +import getPageHeadTitle from '../generic/utils'; const FilesAndUploads = ({ courseId, @@ -62,6 +63,9 @@ const FilesAndUploads = ({ const [selectedRows, setSelectedRows] = useState([]); const [isDeleteConfirmationOpen, openDeleteConfirmation, closeDeleteConfirmation] = useToggle(false); + const courseDetails = useModel('courseDetails', courseId); + document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading)); + useEffect(() => { dispatch(fetchAssets(courseId)); }, [courseId]); diff --git a/src/generic/utils.js b/src/generic/utils.js new file mode 100644 index 0000000000..27236e8aa8 --- /dev/null +++ b/src/generic/utils.js @@ -0,0 +1,10 @@ +import { isEmpty } from 'lodash'; + +const getPageHeadTitle = (courseName, pageName) => { + if (isEmpty(courseName)) { + return `${pageName} | ${process.env.SITE_NAME}`; + } + return `${pageName} | ${courseName} | ${process.env.SITE_NAME}`; +}; + +export default getPageHeadTitle; diff --git a/src/generic/utils.test.js b/src/generic/utils.test.js new file mode 100644 index 0000000000..587164d9d6 --- /dev/null +++ b/src/generic/utils.test.js @@ -0,0 +1,16 @@ +import getPageHeadTitle from './utils'; + +describe('utils', () => { + describe('getPageHeader', () => { + it('should return with page name and site name', () => { + const expected = 'pageName | edX'; + const actual = getPageHeadTitle(null, 'pageName'); + expect(expected).toEqual(actual); + }); + it('should return with page name, course name, and site name', () => { + const expected = 'pageName | courseName | edX'; + const actual = getPageHeadTitle('courseName', 'pageName'); + expect(expected).toEqual(actual); + }); + }); +}); diff --git a/src/grading-settings/GradingSettings.jsx b/src/grading-settings/GradingSettings.jsx index 9df43dc299..ede74d4ff5 100644 --- a/src/grading-settings/GradingSettings.jsx +++ b/src/grading-settings/GradingSettings.jsx @@ -7,6 +7,7 @@ import { } from '@edx/paragon'; import { CheckCircle, Warning, Add as IconAdd } from '@edx/paragon/icons'; +import { useModel } from '../generic/model-store'; import AlertMessage from '../generic/alert-message'; import { RequestStatus } from '../data/constants'; import InternetConnectionAlert from '../generic/internet-connection-alert'; @@ -28,6 +29,7 @@ import AssignmentSection from './assignment-section'; import CreditSection from './credit-section'; import DeadlineSection from './deadline-section'; import { useConvertGradeCutoffs, useUpdateGradingData } from './hooks'; +import getPageHeadTitle from '../generic/utils'; const GradingSettings = ({ intl, courseId }) => { const gradingSettingsData = useSelector(getGradingSettings); @@ -42,6 +44,9 @@ const GradingSettings = ({ intl, courseId }) => { const [showOverrideInternetConnectionAlert, setOverrideInternetConnectionAlert] = useState(false); const [eligibleGrade, setEligibleGrade] = useState(null); + const courseDetails = useModel('courseDetails', courseId); + document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle)); + const { graders, resetDataRef, diff --git a/src/pages-and-resources/PagesAndResources.jsx b/src/pages-and-resources/PagesAndResources.jsx index 3147741cc5..d91cbefae0 100644 --- a/src/pages-and-resources/PagesAndResources.jsx +++ b/src/pages-and-resources/PagesAndResources.jsx @@ -23,10 +23,14 @@ import { getCourseAppsApiStatus, getLoadingStatus } from './data/selectors'; import PagesAndResourcesProvider from './PagesAndResourcesProvider'; import { RequestStatus } from '../data/constants'; import PermissionDeniedAlert from '../generic/PermissionDeniedAlert'; +import getPageHeadTitle from '../generic/utils'; const PagesAndResources = ({ courseId, intl }) => { const { path, url } = useRouteMatch(); + const courseDetails = useModel('courseDetails', courseId); + document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading)); + const dispatch = useDispatch(); useEffect(() => { dispatch(fetchCourseApps(courseId)); diff --git a/src/proctored-exam-settings/ProctoredExamSettings.jsx b/src/proctored-exam-settings/ProctoredExamSettings.jsx index 7a4757507f..577f32d15f 100644 --- a/src/proctored-exam-settings/ProctoredExamSettings.jsx +++ b/src/proctored-exam-settings/ProctoredExamSettings.jsx @@ -12,8 +12,9 @@ import { intlShape, FormattedMessage, } from '@edx/frontend-platform/i18n'; - import { getConfig } from '@edx/frontend-platform'; + +import { useModel } from '../generic/model-store'; import messages from './ProctoredExamSettings.messages'; import ExamsApiService from '../data/services/ExamsApiService'; import StudioApiService from '../data/services/StudioApiService'; @@ -25,6 +26,7 @@ import { fetchExamSettingsPending, fetchExamSettingsSuccess, } from './data/thunks'; +import getPageHeadTitle from '../generic/utils'; const ProctoredExamSettings = ({ courseId, intl }) => { const dispatch = useDispatch(); @@ -51,6 +53,9 @@ const ProctoredExamSettings = ({ courseId, intl }) => { errors: {}, }); + const courseDetails = useModel('courseDetails', courseId); + document.title = getPageHeadTitle(courseDetails?.name, 'Proctored Exam Settings'); + const alertRef = React.createRef(); const saveStatusAlertRef = React.createRef(); const proctoringEscalationEmailInputRef = useRef(null); diff --git a/src/schedule-and-details/index.jsx b/src/schedule-and-details/index.jsx index 11d1102306..1533442566 100644 --- a/src/schedule-and-details/index.jsx +++ b/src/schedule-and-details/index.jsx @@ -12,6 +12,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import Placeholder from '@edx/frontend-lib-content-components'; import { RequestStatus } from '../data/constants'; +import { useModel } from '../generic/model-store'; import AlertMessage from '../generic/alert-message'; import InternetConnectionAlert from '../generic/internet-connection-alert'; import { STATEFUL_BUTTON_STATES } from '../constants'; @@ -39,6 +40,7 @@ import LicenseSection from './license-section'; import ScheduleSidebar from './schedule-sidebar'; import messages from './messages'; import { useSaveValuesPrompt } from './hooks'; +import getPageHeadTitle from '../generic/utils'; const ScheduleAndDetails = ({ intl, courseId }) => { const courseSettings = useSelector(getCourseSettings); @@ -48,6 +50,9 @@ const ScheduleAndDetails = ({ intl, courseId }) => { const isLoading = loadingDetailsStatus === RequestStatus.IN_PROGRESS || loadingSettingsStatus === RequestStatus.IN_PROGRESS; + const course = useModel('courseDetails', courseId); + document.title = getPageHeadTitle(course?.name, intl.formatMessage(messages.headingTitle)); + const { errorFields, savingStatus, From 62cde57556d06d1736abae9b7cb7dd65dbdb61db Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Wed, 6 Sep 2023 11:20:12 -0400 Subject: [PATCH 15/79] fix: grading page UI bugs (#591) --- src/grading-settings/GradingSettings.jsx | 16 ++++++++++------ .../assignments/AssignmentItem.jsx | 4 ++++ .../assignment-section/index.jsx | 3 ++- .../grading-scale/GradingScale.jsx | 6 ++++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/grading-settings/GradingSettings.jsx b/src/grading-settings/GradingSettings.jsx index ede74d4ff5..5aeaf240a4 100644 --- a/src/grading-settings/GradingSettings.jsx +++ b/src/grading-settings/GradingSettings.jsx @@ -115,7 +115,7 @@ const GradingSettings = ({ intl, courseId }) => { return ( <> - +
      { />
      - +
      +

      + {intl.formatMessage(messages.assignmentTypeSectionTitle)} +

      + + {intl.formatMessage(messages.assignmentTypeSectionDescription)} + +
      { setShowSuccessAlert={setShowSuccessAlert} />