diff --git a/app/controllers/course/duplications_controller.rb b/app/controllers/course/duplications_controller.rb index 434d2379600..491b5caf93d 100644 --- a/app/controllers/course/duplications_controller.rb +++ b/app/controllers/course/duplications_controller.rb @@ -17,11 +17,8 @@ def create def authorize_duplication authorize!(:duplicate_from, current_course) - return if instance_params == current_tenant.id destination_tenant = Instance.find(instance_params) - - authorize!(:duplicate_across_instances, current_tenant) authorize!(:duplicate_across_instances, destination_tenant) end diff --git a/app/controllers/course/object_duplications_controller.rb b/app/controllers/course/object_duplications_controller.rb index 44013bba1c7..40da235cd77 100644 --- a/app/controllers/course/object_duplications_controller.rb +++ b/app/controllers/course/object_duplications_controller.rb @@ -69,15 +69,13 @@ def load_videos_component_data def load_destination_instances_data @destination_instances = if current_user.administrator? Instance.all - elsif can?(:duplicate_across_instances, current_tenant) + else instance_ids = InstanceUser.unscope(where: :instance_id). where(user_id: current_user.id, role: [InstanceUser.roles[:instructor], InstanceUser.roles[:administrator]]). pluck(:instance_id) Instance.where(id: instance_ids) - else - Instance.where(id: current_tenant.id) end end diff --git a/app/views/course/object_duplications/new.json.jbuilder b/app/views/course/object_duplications/new.json.jbuilder index 98036754a9d..92c86e8e876 100644 --- a/app/views/course/object_duplications/new.json.jbuilder +++ b/app/views/course/object_duplications/new.json.jbuilder @@ -24,8 +24,8 @@ json.destinationInstances sorted_destination_instances do |instance| end json.metadata do - json.canDuplicateToAnotherInstance can?(:duplicate_across_instances, current_tenant) json.currentInstanceId current_tenant.id + json.currentInstanceHost current_tenant.host end json.partial! 'course_duplication_data' diff --git a/client/app/bundles/course/courses/pages/CoursesIndex/index.tsx b/client/app/bundles/course/courses/pages/CoursesIndex/index.tsx index 0ea3e55dbb2..8b45eae21c7 100644 --- a/client/app/bundles/course/courses/pages/CoursesIndex/index.tsx +++ b/client/app/bundles/course/courses/pages/CoursesIndex/index.tsx @@ -49,7 +49,6 @@ const CoursesIndex: FC = () => { const [params] = useSearchParams(); const [isLoading, setIsLoading] = useState(true); - const [isRoleRequestDialogOpen, setRoleRequestDialogOpen] = useState(false); const courses = useAppSelector(getAllCourseMiniEntities); const coursesPermissions = useAppSelector(getCoursePermissions); @@ -67,6 +66,13 @@ const CoursesIndex: FC = () => { shouldOpenNewCourseDialog, ); + const shouldOpenRoleRequestDialog = + Boolean(params.get('request_instructor')) && !coursesPermissions?.canCreate; + + const [isRoleRequestDialogOpen, setRoleRequestDialogOpen] = useState( + shouldOpenRoleRequestDialog, + ); + useEffect(() => { dispatch(fetchCourses()) .finally(() => setIsLoading(false)) @@ -77,6 +83,10 @@ const CoursesIndex: FC = () => { setIsNewCourseDialogOpen(shouldOpenNewCourseDialog); }, [shouldOpenNewCourseDialog]); + useEffect(() => { + setRoleRequestDialogOpen(shouldOpenRoleRequestDialog); + }, [shouldOpenRoleRequestDialog]); + // Adding appropriate button to the header const headerToolbars: ReactElement[] = []; if (coursesPermissions?.canCreate) { diff --git a/client/app/bundles/course/duplication/components/IndentedCheckbox.jsx b/client/app/bundles/course/duplication/components/IndentedCheckbox.jsx deleted file mode 100644 index 69516e73abe..00000000000 --- a/client/app/bundles/course/duplication/components/IndentedCheckbox.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Checkbox, FormControlLabel } from '@mui/material'; -import PropTypes from 'prop-types'; - -const styles = { - tabSize: 15, - row: { - display: 'flex', - alignItems: 'center', - }, - label: { - width: 'auto', - margin: '5px 0', - }, -}; - -const IndentedCheckbox = ({ indentLevel, children, label, ...props }) => { - const checkboxStyle = { - marginLeft: indentLevel * styles.tabSize, - padding: '0 12px', - }; - if (children) { - checkboxStyle.width = 'auto'; - } - - return ( -
- } - label={{label}} - style={styles.label} - /> - {children} -
- ); -}; - -IndentedCheckbox.propTypes = { - indentLevel: PropTypes.number, - children: PropTypes.node, - label: PropTypes.node, -}; - -IndentedCheckbox.defaultProps = { - indentLevel: 0, -}; - -export default IndentedCheckbox; diff --git a/client/app/bundles/course/duplication/components/IndentedCheckbox.tsx b/client/app/bundles/course/duplication/components/IndentedCheckbox.tsx new file mode 100644 index 00000000000..1753341246e --- /dev/null +++ b/client/app/bundles/course/duplication/components/IndentedCheckbox.tsx @@ -0,0 +1,41 @@ +import { CSSProperties, FC, ReactNode } from 'react'; +import { Checkbox, CheckboxProps, FormControlLabel } from '@mui/material'; + +const styles = { + tabSize: 15, +}; + +interface IndentedCheckboxProps extends CheckboxProps { + indentLevel?: number; + children?: ReactNode; + label: JSX.Element | string; +} + +const IndentedCheckbox: FC = ({ + indentLevel = 0, + children = [], + label, + ...props +}) => { + const checkboxStyle: CSSProperties = { + marginLeft: indentLevel * styles.tabSize, + }; + if (children && Array.isArray(children) && children.length > 0) { + checkboxStyle.width = 'auto'; + } + + return ( +
+ + } + label={{label}} + /> + {children} +
+ ); +}; + +export default IndentedCheckbox; diff --git a/client/app/bundles/course/duplication/components/TypeBadge/index.jsx b/client/app/bundles/course/duplication/components/TypeBadge/index.jsx deleted file mode 100644 index 0966ab77157..00000000000 --- a/client/app/bundles/course/duplication/components/TypeBadge/index.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import { defineMessages, FormattedMessage } from 'react-intl'; -import PropTypes from 'prop-types'; - -import { duplicableItemTypes } from 'course/duplication/constants'; - -const styles = { - badge: { - padding: '2px 5px', - marginRight: 10, - borderStyle: 'solid', - borderRadius: 5, - borderWidth: 1, - fontSize: 12, - }, -}; - -const translations = defineMessages({ - [duplicableItemTypes.ASSESSMENT]: { - id: 'course.duplication.TypeBadge.assessment', - defaultMessage: 'Assessment', - }, - [duplicableItemTypes.CATEGORY]: { - id: 'course.duplication.TypeBadge.category', - defaultMessage: 'Category', - }, - [duplicableItemTypes.TAB]: { - id: 'course.duplication.TypeBadge.tab', - defaultMessage: 'Tab', - }, - [duplicableItemTypes.SURVEY]: { - id: 'course.duplication.TypeBadge.survey', - defaultMessage: 'Survey', - }, - [duplicableItemTypes.ACHIEVEMENT]: { - id: 'course.duplication.TypeBadge.achievement', - defaultMessage: 'Achievement', - }, - [duplicableItemTypes.FOLDER]: { - id: 'course.duplication.TypeBadge.folder', - defaultMessage: 'Folder', - }, - [duplicableItemTypes.MATERIAL]: { - id: 'course.duplication.TypeBadge.material', - defaultMessage: 'Material', - }, - [duplicableItemTypes.VIDEO]: { - id: 'course.duplication.TypeBadge.video', - defaultMessage: 'Video', - }, - [duplicableItemTypes.VIDEO_TAB]: { - id: 'course.duplication.TypeBadge.video_tab', - defaultMessage: 'Tab', - }, -}); - -const TypeBadge = ({ text, itemType }) => ( - - {text || } - -); - -TypeBadge.propTypes = { - text: PropTypes.string, - itemType: PropTypes.string, -}; - -export default TypeBadge; diff --git a/client/app/bundles/course/duplication/components/TypeBadge/index.tsx b/client/app/bundles/course/duplication/components/TypeBadge/index.tsx new file mode 100644 index 00000000000..1f4add68ec4 --- /dev/null +++ b/client/app/bundles/course/duplication/components/TypeBadge/index.tsx @@ -0,0 +1,65 @@ +import { FC } from 'react'; +import { defineMessages, MessageDescriptor } from 'react-intl'; +import { Typography } from '@mui/material'; + +import { DuplicableItemType } from 'course/duplication/types'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations: Record = + defineMessages({ + ASSESSMENT: { + id: 'course.duplication.TypeBadge.assessment', + defaultMessage: 'Assessment', + }, + CATEGORY: { + id: 'course.duplication.TypeBadge.category', + defaultMessage: 'Category', + }, + TAB: { + id: 'course.duplication.TypeBadge.tab', + defaultMessage: 'Tab', + }, + SURVEY: { + id: 'course.duplication.TypeBadge.survey', + defaultMessage: 'Survey', + }, + ACHIEVEMENT: { + id: 'course.duplication.TypeBadge.achievement', + defaultMessage: 'Achievement', + }, + FOLDER: { + id: 'course.duplication.TypeBadge.folder', + defaultMessage: 'Folder', + }, + MATERIAL: { + id: 'course.duplication.TypeBadge.material', + defaultMessage: 'Material', + }, + VIDEO: { + id: 'course.duplication.TypeBadge.video', + defaultMessage: 'Video', + }, + VIDEO_TAB: { + id: 'course.duplication.TypeBadge.video_tab', + defaultMessage: 'Tab', + }, + }); + +const TypeBadge: FC<{ text?: string; itemType: DuplicableItemType }> = ({ + text, + itemType, +}) => { + const { t } = useTranslation(); + + return ( + + {text || t(translations[itemType])} + + ); +}; + +export default TypeBadge; diff --git a/client/app/bundles/course/duplication/constants.js b/client/app/bundles/course/duplication/constants.js index 63a926c68e7..5d0f2d54be7 100644 --- a/client/app/bundles/course/duplication/constants.js +++ b/client/app/bundles/course/duplication/constants.js @@ -1,31 +1,5 @@ import mirrorCreator from 'mirror-creator'; -export const duplicationModes = mirrorCreator(['OBJECT', 'COURSE']); - -// These are mirrored in app/helpers/course/object_duplications_helper.rb -export const duplicableItemTypes = mirrorCreator([ - 'ASSESSMENT', - 'TAB', - 'CATEGORY', - 'SURVEY', - 'ACHIEVEMENT', - 'FOLDER', - 'MATERIAL', - 'VIDEO', - 'VIDEO_TAB', -]); - -// These are mirrored in app/helpers/course/object_duplications_helper.rb -export const itemSelectorPanels = mirrorCreator([ - 'ASSESSMENTS', - 'SURVEYS', - 'ACHIEVEMENTS', - 'MATERIALS', - 'VIDEOS', -]); - -export const formNames = mirrorCreator(['NEW_COURSE']); - const actionTypes = mirrorCreator([ 'LOAD_OBJECTS_LIST_REQUEST', 'LOAD_OBJECTS_LIST_SUCCESS', diff --git a/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/InstanceDropdown.tsx b/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/InstanceDropdown.tsx index df9a5e1a06d..eebc06c9489 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/InstanceDropdown.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/InstanceDropdown.tsx @@ -9,10 +9,7 @@ import { defineMessages, FormattedMessage } from 'react-intl'; import MyLocation from '@mui/icons-material/MyLocation'; import { Autocomplete, Box, IconButton, Tooltip } from '@mui/material'; -import { - selectDestinationInstances, - selectMetadata, -} from 'course/duplication/selectors/destinationInstance'; +import { selectDestinationInstances } from 'course/duplication/selectors'; import TextField from 'lib/components/core/fields/TextField'; import { formatErrorMessage } from 'lib/components/form/fields/utils/mapError'; import { useAppSelector } from 'lib/hooks/store'; @@ -40,7 +37,6 @@ const translations = defineMessages({ const InstanceDropdown: FC = (props) => { const { currentInstanceId, disabled, field, fieldState, setValue } = props; const instances = useAppSelector(selectDestinationInstances); - const metadata = useAppSelector(selectMetadata); const instanceIds = useMemo( () => Object.keys(instances).toSorted( @@ -54,7 +50,7 @@ const InstanceDropdown: FC = (props) => {
instances[instanceId]?.name ?? '' @@ -85,7 +81,7 @@ const InstanceDropdown: FC = (props) => {
setValue('destination_instance_id', currentInstanceId) } diff --git a/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/index.jsx b/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/index.jsx index a3ea5d0649b..cbf633df6fa 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/index.jsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/index.jsx @@ -4,7 +4,6 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import CourseDropdownMenu from 'course/duplication/components/CourseDropdownMenu'; -import { duplicationModes } from 'course/duplication/constants'; import { duplicateCourse } from 'course/duplication/operations'; import { courseShape, sourceCourseShape } from 'course/duplication/propTypes'; import { actions } from 'course/duplication/store'; @@ -116,7 +115,7 @@ class DestinationCourseSelector extends Component { render() { const { duplicationMode } = this.props; - return duplicationMode === duplicationModes.COURSE + return duplicationMode === 'COURSE' ? this.renderNewCourseForm() : this.renderExistingCourseForm(); } @@ -144,7 +143,9 @@ export default connect(({ duplication }) => ({ destinationCourseId: duplication.destinationCourseId, duplicationMode: duplication.duplicationMode, sourceCourse: duplication.sourceCourse, - currentInstanceId: duplication.metadata.currentInstanceId, + currentInstanceId: + duplication.destinationInstances[duplication.metadata.currentInstanceId] + ?.id, destinationInstances: duplication.destinationInstances, isDuplicating: duplication.isDuplicating, }))(injectIntl(DestinationCourseSelector)); diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateAllButton.jsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateAllButton.jsx deleted file mode 100644 index eebe596caaf..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateAllButton.jsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { Button, CircularProgress } from '@mui/material'; -import PropTypes from 'prop-types'; - -import { duplicationModes } from 'course/duplication/constants'; -import ConfirmationDialog from 'lib/components/core/dialogs/ConfirmationDialog'; - -const translations = defineMessages({ - duplicateCourse: { - id: 'course.duplication.Duplication.DuplicateAllButton.duplicateCourse', - defaultMessage: 'Duplicate Course', - }, - info: { - id: 'course.duplication.Duplication.DuplicateAllButton.info', - defaultMessage: - 'Duplication usually takes some time to complete. \ - You may close the window while duplication is in progress.\ - You will receive an email with a link to the new course when it becomes available.', - }, - confirmationMessage: { - id: 'course.duplication.Duplication.DuplicateAllButton.confirmationMessage', - defaultMessage: 'Proceed with course duplication?', - }, -}); - -const styles = { - spinner: { - position: 'absolute', - marginLeft: 8, - }, -}; - -class DuplicateAllButton extends Component { - constructor(props) { - super(props); - this.state = { confirmationOpen: false }; - } - - render() { - const { duplicationMode, disabled, isDuplicating, isDuplicationSuccess } = - this.props; - if (duplicationMode !== duplicationModes.COURSE) { - return null; - } - - return ( - <> -
- - {(isDuplicating || isDuplicationSuccess) && ( - - )} -
- - -
-
- - - } - onCancel={() => this.setState({ confirmationOpen: false })} - onConfirm={() => { - this.setState({ confirmationOpen: false }); - }} - open={this.state.confirmationOpen} - /> - - ); - } -} - -DuplicateAllButton.propTypes = { - duplicationMode: PropTypes.string.isRequired, - isDuplicating: PropTypes.bool.isRequired, - isDuplicationSuccess: PropTypes.bool.isRequired, - disabled: PropTypes.bool.isRequired, - - dispatch: PropTypes.func.isRequired, -}; - -export default connect(({ duplication }) => ({ - duplicationMode: duplication.duplicationMode, - isDuplicating: duplication.isDuplicating, - isDuplicationSuccess: duplication.isDuplicationSuccess, - disabled: - duplication.isDuplicating || - duplication.isChangingCourse || - duplication.isDuplicationSuccess, -}))(DuplicateAllButton); diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateAllButton.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateAllButton.tsx new file mode 100644 index 00000000000..4c4e82fdfdc --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateAllButton.tsx @@ -0,0 +1,78 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Button, CircularProgress, Typography } from '@mui/material'; + +import { selectDuplicationStore } from 'course/duplication/selectors'; +import ConfirmationDialog from 'lib/components/core/dialogs/ConfirmationDialog'; +import { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + duplicateCourse: { + id: 'course.duplication.Duplication.DuplicateAllButton.duplicateCourse', + defaultMessage: 'Duplicate Course', + }, + info: { + id: 'course.duplication.Duplication.DuplicateAllButton.info', + defaultMessage: + 'Duplication usually takes some time to complete. \ + You may close the window while duplication is in progress.\ + You will receive an email with a link to the new course when it becomes available.', + }, + confirmationMessage: { + id: 'course.duplication.Duplication.DuplicateAllButton.confirmationMessage', + defaultMessage: 'Proceed with course duplication?', + }, +}); + +const DuplicateAllButton: FC = () => { + const { t } = useTranslation(); + const { + duplicationMode, + isDuplicating, + isChangingCourse, + isDuplicationSuccess, + } = useAppSelector(selectDuplicationStore); + const [confirmationOpen, setConfirmationOpen] = useState(false); + const disabled = isDuplicating || isChangingCourse || isDuplicationSuccess; + + if (duplicationMode !== 'COURSE') { + return null; + } + + return ( + <> +
+ +
+ + {t(translations.info)} + + {t(translations.confirmationMessage)} + +
+ } + onCancel={() => setConfirmationOpen(false)} + onConfirm={() => setConfirmationOpen(false)} + open={confirmationOpen} + /> + + ); +}; + +export default DuplicateAllButton; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AchievementsListing.jsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AchievementsListing.jsx deleted file mode 100644 index 0ca46922721..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AchievementsListing.jsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Component } from 'react'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { - Card, - CardContent, - Checkbox, - FormControlLabel, - ListSubheader, -} from '@mui/material'; -import PropTypes from 'prop-types'; - -import TypeBadge from 'course/duplication/components/TypeBadge'; -import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; -import { duplicableItemTypes } from 'course/duplication/constants'; -import { achievementShape } from 'course/duplication/propTypes'; -import { getAchievementBadgeUrl } from 'course/helper/achievements'; -import componentTranslations from 'course/translations'; - -const styles = { - badge: { - maxHeight: 24, - maxWidth: 24, - marginRight: 5, - }, - row: { - alignItems: 'center', - display: 'flex', - width: 'auto', - }, -}; - -class AchievementsListing extends Component { - static renderRow(achievement) { - return ( - } - label={ - - - - {getAchievementBadgeUrl(achievement.url, - {achievement.title} - - } - style={styles.row} - /> - ); - } - - selectedAchievements() { - const { achievements, selectedItems } = this.props; - return achievements - ? achievements.filter( - (achievement) => - selectedItems[duplicableItemTypes.ACHIEVEMENT][achievement.id], - ) - : []; - } - - render() { - const selectedAchievements = this.selectedAchievements(); - if (selectedAchievements.length < 1) { - return null; - } - - return ( - <> - - - - - - {selectedAchievements.map(AchievementsListing.renderRow)} - - - - ); - } -} - -AchievementsListing.propTypes = { - achievements: PropTypes.arrayOf(achievementShape), - selectedItems: PropTypes.shape({}), -}; - -export default connect(({ duplication }) => ({ - achievements: duplication.achievementsComponent, - selectedItems: duplication.selectedItems, -}))(AchievementsListing); diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AchievementsListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AchievementsListing.tsx new file mode 100644 index 00000000000..5b975a8ef3f --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AchievementsListing.tsx @@ -0,0 +1,62 @@ +import { FC } from 'react'; +import { + Card, + CardContent, + Checkbox, + FormControlLabel, + ListSubheader, +} from '@mui/material'; + +import TypeBadge from 'course/duplication/components/TypeBadge'; +import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; +import { selectDuplicationStore } from 'course/duplication/selectors'; +import { DuplicationAchievementData } from 'course/duplication/types'; +import { getAchievementBadgeUrl } from 'course/helper/achievements'; +import componentTranslations from 'course/translations'; +import { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +const renderRow = (achievement: DuplicationAchievementData): JSX.Element => ( + } + label={ + + + + {achievement.title} + {achievement.title} + + } + /> +); + +const AchievementsListing: FC = () => { + const { achievementsComponent, selectedItems } = useAppSelector( + selectDuplicationStore, + ); + const { t } = useTranslation(); + + const selected = achievementsComponent.filter( + (a) => selectedItems.ACHIEVEMENT[a.id], + ); + if (selected.length < 1) return null; + + return ( + <> + + {t(componentTranslations.course_achievements_component)} + + + {selected.map(renderRow)} + + + ); +}; + +export default AchievementsListing; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.jsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.jsx deleted file mode 100644 index d649864dfa8..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.jsx +++ /dev/null @@ -1,205 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { Card, CardContent, ListSubheader } from '@mui/material'; -import PropTypes from 'prop-types'; - -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; -import TypeBadge from 'course/duplication/components/TypeBadge'; -import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; -import { duplicableItemTypes } from 'course/duplication/constants'; -import { categoryShape } from 'course/duplication/propTypes'; -import componentTranslations from 'course/translations'; - -const { TAB, ASSESSMENT, CATEGORY } = duplicableItemTypes; - -const translations = defineMessages({ - defaultCategory: { - id: 'course.duplication.Duplication.DuplicateItemsConfirmation.AssessmentsListing.defaultCategory', - defaultMessage: 'Default Category', - }, - defaultTab: { - id: 'course.duplication.Duplication.DuplicateItemsConfirmation.AssessmentsListing.defaultTab', - defaultMessage: 'Default Tab', - }, -}); - -class AssessmentsListing extends Component { - static renderAssessmentRow(assessment) { - return ( - - - - {assessment.title} - - } - /> - ); - } - - static renderCategoryCard(category, orphanTabs, orphanAssessments) { - const hasOrphanAssessments = - orphanAssessments && orphanAssessments.length > 0; - const hasOrphanTabs = orphanTabs && orphanTabs.length > 0; - const categoryRow = category - ? AssessmentsListing.renderCategoryRow(category) - : AssessmentsListing.renderDefaultCategoryRow(); - const tabsTrees = (tabs) => - tabs && - tabs.map((tab) => AssessmentsListing.renderTabTree(tab, tab.assessments)); - - return ( - - - {categoryRow} - {hasOrphanAssessments && - AssessmentsListing.renderTabTree(null, orphanAssessments)} - {hasOrphanTabs && tabsTrees(orphanTabs)} - {category && tabsTrees(category.tabs)} - - - ); - } - - static renderCategoryRow(category) { - return ( - - - {category.title} - - } - /> - ); - } - - static renderDefaultCategoryRow() { - return ( - } - /> - ); - } - - static renderDefaultTabRow() { - return ( - } - /> - ); - } - - static renderTabRow(tab) { - return ( - - - {tab.title} - - } - /> - ); - } - - static renderTabTree(tab, children) { - return ( -
- {tab - ? AssessmentsListing.renderTabRow(tab) - : AssessmentsListing.renderDefaultTabRow()} - {children && - children.length > 0 && - children.map(AssessmentsListing.renderAssessmentRow)} -
- ); - } - - // Identifies connected subtrees of selected categories, tabs and assessments. - selectedSubtrees() { - const { categories, selectedItems } = this.props; - const categoriesTrees = []; - const tabTrees = []; - const assessmentTrees = []; - - categories.forEach((category) => { - const selectedTabs = []; - category.tabs.forEach((tab) => { - const selectedAssessments = tab.assessments.filter( - (assessment) => selectedItems[ASSESSMENT][assessment.id], - ); - - if (selectedItems[TAB][tab.id]) { - selectedTabs.push({ ...tab, assessments: selectedAssessments }); - } else { - assessmentTrees.push(...selectedAssessments); - } - }); - - if (selectedItems[CATEGORY][category.id]) { - categoriesTrees.push({ ...category, tabs: selectedTabs }); - } else { - tabTrees.push(...selectedTabs); - } - }); - - return [categoriesTrees, tabTrees, assessmentTrees]; - } - - render() { - const [categoriesTrees, tabTrees, assessmentTrees] = - this.selectedSubtrees(); - const orphanTreesCount = tabTrees.length + assessmentTrees.length; - const totalTreesCount = orphanTreesCount + categoriesTrees.length; - if (totalTreesCount < 1) { - return null; - } - - return ( - <> - - - - {categoriesTrees.map((category) => - AssessmentsListing.renderCategoryCard(category, null, null), - )} - {orphanTreesCount > 0 && - AssessmentsListing.renderCategoryCard( - null, - tabTrees, - assessmentTrees, - )} - - ); - } -} - -AssessmentsListing.propTypes = { - categories: PropTypes.arrayOf(categoryShape), - selectedItems: PropTypes.shape({}), -}; - -export default connect(({ duplication }) => ({ - categories: duplication.assessmentsComponent, - selectedItems: duplication.selectedItems, -}))(AssessmentsListing); diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx new file mode 100644 index 00000000000..12f47b012e3 --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx @@ -0,0 +1,171 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Card, CardContent, ListSubheader } from '@mui/material'; + +import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; +import TypeBadge from 'course/duplication/components/TypeBadge'; +import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; +import { selectDuplicationStore } from 'course/duplication/selectors'; +import { + DuplicationAssessmentData, + DuplicationCategoryData, + DuplicationTabData, +} from 'course/duplication/types'; +import componentTranslations from 'course/translations'; +import { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + defaultCategory: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.AssessmentsListing.defaultCategory', + defaultMessage: 'Default Category', + }, + defaultTab: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.AssessmentsListing.defaultTab', + defaultMessage: 'Default Tab', + }, +}); + +const AssessmentsListing: FC = () => { + const { assessmentsComponent: categories, selectedItems } = useAppSelector( + selectDuplicationStore, + ); + const { t } = useTranslation(); + + const renderAssessmentRow = ( + assessment: DuplicationAssessmentData, + ): JSX.Element => ( + + + + {assessment.title} + + } + /> + ); + + const renderTabRow = (tab: DuplicationTabData): JSX.Element => ( + + + {tab.title} + + } + /> + ); + + const renderCategoryRow = ( + category: DuplicationCategoryData, + ): JSX.Element => ( + + + {category.title} + + } + /> + ); + + const renderTabTree = ( + tab: DuplicationTabData | null, + children: DuplicationAssessmentData[], + ): JSX.Element => ( +
+ {tab ? ( + renderTabRow(tab) + ) : ( + + )} + {children.length > 0 && children.map(renderAssessmentRow)} +
+ ); + + const renderCategoryCard = ( + category: DuplicationCategoryData | null, + orphanTabs: DuplicationTabData[], + orphanAssessments: DuplicationAssessmentData[], + ): JSX.Element => { + const tabsTrees = (tabs: DuplicationTabData[]): JSX.Element[] => + tabs.map((tab) => renderTabTree(tab, tab.assessments)); + + return ( + + + {category ? ( + renderCategoryRow(category) + ) : ( + + )} + {orphanAssessments.length > 0 && + renderTabTree(null, orphanAssessments)} + {orphanTabs.length > 0 && tabsTrees(orphanTabs)} + {category && tabsTrees(category.tabs)} + + + ); + }; + + // Identifies connected subtrees of selected categories, tabs and assessments. + const categoriesTrees: DuplicationCategoryData[] = []; + const tabTrees: DuplicationTabData[] = []; + const assessmentTrees: DuplicationAssessmentData[] = []; + + categories.forEach((category) => { + const selectedTabs: DuplicationTabData[] = []; + category.tabs.forEach((tab) => { + const selectedAssessments = tab.assessments.filter( + (a) => selectedItems.ASSESSMENT[a.id], + ); + if (selectedItems.TAB[tab.id]) { + selectedTabs.push({ ...tab, assessments: selectedAssessments }); + } else { + assessmentTrees.push(...selectedAssessments); + } + }); + + if (selectedItems.CATEGORY[category.id]) { + categoriesTrees.push({ ...category, tabs: selectedTabs }); + } else { + tabTrees.push(...selectedTabs); + } + }); + + const orphanTreesCount = tabTrees.length + assessmentTrees.length; + if (orphanTreesCount + categoriesTrees.length < 1) return null; + + return ( + <> + + {t(componentTranslations.course_assessments_component)} + + {categoriesTrees.map((category) => renderCategoryCard(category, [], []))} + {orphanTreesCount > 0 && + renderCategoryCard(null, tabTrees, assessmentTrees)} + + ); +}; + +export default AssessmentsListing; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.jsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.jsx deleted file mode 100644 index 557f29e94a2..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.jsx +++ /dev/null @@ -1,144 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { Card, CardContent, ListSubheader } from '@mui/material'; -import PropTypes from 'prop-types'; - -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; -import TypeBadge from 'course/duplication/components/TypeBadge'; -import { duplicableItemTypes } from 'course/duplication/constants'; -import { folderShape } from 'course/duplication/propTypes'; -import componentTranslations from 'course/translations'; - -const { FOLDER, MATERIAL } = duplicableItemTypes; -const ROOT_CHILDREN_LEVEL = 1; - -const flatten = (arr) => arr.reduce((a, b) => a.concat(b), []); - -const translations = defineMessages({ - root: { - id: 'course.duplication.Duplication.DuplicateItemsConfirmation.MaterialsListing.root', - defaultMessage: 'Root Folder', - }, - nameConflictWarning: { - id: 'course.duplication.Duplication.DuplicateItemsConfirmation.MaterialsListing.nameConflictWarning', - defaultMessage: - "Warning: Naming conflict exists. A serial number will be appended to the duplicated item's name.", - }, -}); - -class MaterialsListing extends Component { - static renderRootRow() { - return ( - } - /> - ); - } - - static renderRow(item, itemType, indentLevel, nameConflict) { - return ( - - - {item.name} - {nameConflict && ( -
- -
- )} - - } - /> - ); - } - - renderFolderTree(folder, indentLevel) { - const { selectedItems, targetRootFolder } = this.props; - const checked = !!selectedItems[FOLDER][folder.id]; - // Children will be duplicated under the target course root folder if current folder is not checked - const childrenIndentLevel = checked ? indentLevel + 1 : ROOT_CHILDREN_LEVEL; - const exisitingNames = targetRootFolder.subfolders - .concat(targetRootFolder.materials) - .map((name) => name.toLowerCase()); - const nameConflict = - indentLevel === ROOT_CHILDREN_LEVEL && - exisitingNames.includes(folder.name.toLowerCase()); - - const folderNode = checked - ? MaterialsListing.renderRow(folder, FOLDER, indentLevel, nameConflict) - : []; - const materialNodes = folder.materials - .filter((material) => !!selectedItems[MATERIAL][material.id]) - .map((material) => { - const materialNameConflict = - childrenIndentLevel === ROOT_CHILDREN_LEVEL && - exisitingNames.includes(material.name.toLowerCase()); - return MaterialsListing.renderRow( - material, - MATERIAL, - childrenIndentLevel, - materialNameConflict, - ); - }); - const subfolderNodes = flatten( - folder.subfolders.map((subfolder) => - this.renderFolderTree(subfolder, childrenIndentLevel), - ), - ); - return flatten([folderNode, materialNodes, subfolderNodes]); - } - - render() { - const { folders } = this.props; - const folderTrees = flatten( - folders.map((folder) => - this.renderFolderTree(folder, ROOT_CHILDREN_LEVEL), - ), - ); - if (folderTrees.length < 1) { - return null; - } - - return ( -
- - - - - - {MaterialsListing.renderRootRow()} - {folderTrees} - - -
- ); - } -} - -MaterialsListing.propTypes = { - folders: PropTypes.arrayOf(folderShape), - selectedItems: PropTypes.shape(), - targetRootFolder: PropTypes.shape({ - subfolders: PropTypes.arrayOf(PropTypes.string), - materials: PropTypes.arrayOf(PropTypes.string), - }), -}; - -export default connect(({ duplication }) => ({ - folders: duplication.materialsComponent, - selectedItems: duplication.selectedItems, - targetRootFolder: duplication.destinationCourses.find( - (course) => course.id === duplication.destinationCourseId, - ).rootFolder, -}))(MaterialsListing); diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx new file mode 100644 index 00000000000..f2beac495af --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx @@ -0,0 +1,125 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Card, CardContent, ListSubheader } from '@mui/material'; + +import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; +import TypeBadge from 'course/duplication/components/TypeBadge'; +import { selectDuplicationStore } from 'course/duplication/selectors'; +import { + DuplicationFolderData, + DuplicationMaterialData, +} from 'course/duplication/types'; +import componentTranslations from 'course/translations'; +import { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +const ROOT_CHILDREN_LEVEL = 1; + +const translations = defineMessages({ + root: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.MaterialsListing.root', + defaultMessage: 'Root Folder', + }, + nameConflictWarning: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.MaterialsListing.nameConflictWarning', + defaultMessage: + "Warning: Naming conflict exists. A serial number will be appended to the duplicated item's name.", + }, +}); + +const MaterialsListing: FC = () => { + const { + materialsComponent: folders, + selectedItems, + destinationCourses, + destinationCourseId, + } = useAppSelector(selectDuplicationStore); + const { t } = useTranslation(); + + const targetRootFolder = destinationCourses.find( + (c) => c.id === destinationCourseId, + )?.rootFolder; + + if (!targetRootFolder) return null; + + const existingNames = targetRootFolder.subfolders + .concat(targetRootFolder.materials) + .map((name) => name.toLowerCase()); + + const renderRow = ( + item: DuplicationFolderData | DuplicationMaterialData, + itemType: 'FOLDER' | 'MATERIAL', + indentLevel: number, + nameConflict: boolean, + ): JSX.Element => ( + + + {item.name} + {nameConflict && ( +
+ {t(translations.nameConflictWarning)} +
+ )} + + } + /> + ); + + const renderFolderTree = ( + folder: DuplicationFolderData, + indentLevel: number, + ): JSX.Element[] => { + const checked = !!selectedItems.FOLDER[folder.id]; + const childrenIndentLevel = checked ? indentLevel + 1 : ROOT_CHILDREN_LEVEL; + const nameConflict = + indentLevel === ROOT_CHILDREN_LEVEL && + existingNames.includes(folder.name.toLowerCase()); + + const folderNode = checked + ? [renderRow(folder, 'FOLDER', indentLevel, nameConflict)] + : []; + const materialNodes = folder.materials + .filter((m) => !!selectedItems.MATERIAL[m.id]) + .map((material) => { + const materialNameConflict = + childrenIndentLevel === ROOT_CHILDREN_LEVEL && + existingNames.includes(material.name.toLowerCase()); + return renderRow( + material, + 'MATERIAL', + childrenIndentLevel, + materialNameConflict, + ); + }); + const subfolderNodes = folder.subfolders.flatMap((subfolder) => + renderFolderTree(subfolder, childrenIndentLevel), + ); + return [...folderNode, ...materialNodes, ...subfolderNodes]; + }; + + const folderTrees = folders.flatMap((folder) => + renderFolderTree(folder, ROOT_CHILDREN_LEVEL), + ); + if (folderTrees.length < 1) return null; + + return ( +
+ + {t(componentTranslations.course_materials_component)} + + + + + {folderTrees} + + +
+ ); +}; + +export default MaterialsListing; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/SurveyListing.jsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/SurveyListing.jsx deleted file mode 100644 index d41acd77c26..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/SurveyListing.jsx +++ /dev/null @@ -1,85 +0,0 @@ -import { Component } from 'react'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { - Card, - CardContent, - Checkbox, - FormControlLabel, - ListSubheader, -} from '@mui/material'; -import PropTypes from 'prop-types'; - -import TypeBadge from 'course/duplication/components/TypeBadge'; -import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; -import { duplicableItemTypes } from 'course/duplication/constants'; -import { surveyShape } from 'course/duplication/propTypes'; -import componentTranslations from 'course/translations'; - -const styles = { - row: { - alignItems: 'center', - display: 'flex', - width: 'auto', - }, -}; - -class SurveyListing extends Component { - static renderRow(survey) { - return ( - } - label={ - - - - {survey.title} - - } - style={styles.row} - /> - ); - } - - selectedSurveys() { - const { surveys, selectedItems } = this.props; - return surveys - ? surveys.filter( - (survey) => selectedItems[duplicableItemTypes.SURVEY][survey.id], - ) - : []; - } - - render() { - const selectedSurveys = this.selectedSurveys(); - if (selectedSurveys.length < 1) { - return null; - } - - return ( - <> - - - - - - {selectedSurveys.map(SurveyListing.renderRow)} - - - - ); - } -} - -SurveyListing.propTypes = { - surveys: PropTypes.arrayOf(surveyShape), - selectedItems: PropTypes.shape({}), -}; - -export default connect(({ duplication }) => ({ - surveys: duplication.surveyComponent, - selectedItems: duplication.selectedItems, -}))(SurveyListing); diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/SurveyListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/SurveyListing.tsx new file mode 100644 index 00000000000..c868cd9e2f0 --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/SurveyListing.tsx @@ -0,0 +1,54 @@ +import { FC } from 'react'; +import { + Card, + CardContent, + Checkbox, + FormControlLabel, + ListSubheader, +} from '@mui/material'; + +import TypeBadge from 'course/duplication/components/TypeBadge'; +import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; +import { selectDuplicationStore } from 'course/duplication/selectors'; +import { DuplicationSurveyData } from 'course/duplication/types'; +import componentTranslations from 'course/translations'; +import { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +const renderRow = (survey: DuplicationSurveyData): JSX.Element => ( + } + label={ + + + + {survey.title} + + } + /> +); + +const DuplicationSurveyDataListing: FC = () => { + const { surveyComponent, selectedItems } = useAppSelector( + selectDuplicationStore, + ); + const { t } = useTranslation(); + + const selected = surveyComponent.filter((s) => selectedItems.SURVEY[s.id]); + if (selected.length < 1) return null; + + return ( + <> + + {t(componentTranslations.course_survey_component)} + + + {selected.map(renderRow)} + + + ); +}; + +export default DuplicationSurveyDataListing; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.jsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.jsx deleted file mode 100644 index 5963a9a04ee..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.jsx +++ /dev/null @@ -1,130 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { Card, CardContent, ListSubheader } from '@mui/material'; -import PropTypes from 'prop-types'; - -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; -import TypeBadge from 'course/duplication/components/TypeBadge'; -import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; -import { duplicableItemTypes } from 'course/duplication/constants'; -import { videoTabShape } from 'course/duplication/propTypes'; -import componentTranslations from 'course/translations'; - -const { VIDEO_TAB, VIDEO } = duplicableItemTypes; - -const translations = defineMessages({ - defaultTab: { - id: 'course.duplication.Duplication.DuplicateItemsConfirmation.VideoListing.defaultTab', - defaultMessage: 'Default Tab', - }, -}); - -class VideoListing extends Component { - static renderDefaultTabRow() { - return ( - } - /> - ); - } - - static renderTab(tab) { - return ( -
- {VideoListing.renderTabRow(tab)} - {tab.videos.map(VideoListing.renderVideoRow)} -
- ); - } - - static renderTabRow(tab) { - return ( - - - {tab.title} - - } - /> - ); - } - - static renderVideoRow(video) { - return ( - - - - {video.title} - - } - /> - ); - } - - selectedSubtrees() { - const { tabs, selectedItems } = this.props; - const tabTrees = []; - const orphanedVideos = []; - - tabs.forEach((tab) => { - const selectedVideos = tab.videos.filter( - (video) => selectedItems[VIDEO][video.id], - ); - - if (selectedItems[VIDEO_TAB][tab.id]) { - tabTrees.push({ ...tab, videos: selectedVideos }); - } else { - orphanedVideos.push(...selectedVideos); - } - }); - - return [tabTrees, orphanedVideos]; - } - - render() { - const [tabTrees, orphanedVideos] = this.selectedSubtrees(); - if (tabTrees.length + orphanedVideos.length < 1) { - return null; - } - - return ( - <> - - - - - - {tabTrees.map(VideoListing.renderTab)} -
- {orphanedVideos.length > 0 && VideoListing.renderDefaultTabRow()} - {orphanedVideos.map(VideoListing.renderVideoRow)} -
-
-
- - ); - } -} - -VideoListing.propTypes = { - tabs: PropTypes.arrayOf(videoTabShape), - selectedItems: PropTypes.shape({}), -}; - -export default connect(({ duplication }) => ({ - tabs: duplication.videosComponent, - selectedItems: duplication.selectedItems, -}))(VideoListing); diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx new file mode 100644 index 00000000000..55610147699 --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx @@ -0,0 +1,107 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Card, CardContent, ListSubheader } from '@mui/material'; + +import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; +import TypeBadge from 'course/duplication/components/TypeBadge'; +import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; +import { selectDuplicationStore } from 'course/duplication/selectors'; +import { + DuplicationVideoData, + DuplicationVideoTabData, +} from 'course/duplication/types'; +import componentTranslations from 'course/translations'; +import { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + defaultTab: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.VideoListing.defaultTab', + defaultMessage: 'Default Tab', + }, +}); + +const renderDuplicationVideoDataRow = ( + video: DuplicationVideoData, +): JSX.Element => ( + + + + {video.title} + + } + /> +); + +const renderTabRow = (tab: DuplicationVideoTabData): JSX.Element => ( + + + {tab.title} + + } + /> +); + +const renderTab = (tab: DuplicationVideoTabData): JSX.Element => ( +
+ {renderTabRow(tab)} + {tab.videos.map(renderDuplicationVideoDataRow)} +
+); + +const DuplicationVideoDatasListing: FC = () => { + const { videosComponent: tabs, selectedItems } = useAppSelector( + selectDuplicationStore, + ); + const { t } = useTranslation(); + + const tabTrees: DuplicationVideoTabData[] = []; + const orphanedDuplicationVideoDatas: DuplicationVideoData[] = []; + + tabs.forEach((tab) => { + const selectedDuplicationVideoDatas = tab.videos.filter( + (v) => selectedItems.VIDEO[v.id], + ); + if (selectedItems.VIDEO_TAB[tab.id]) { + tabTrees.push({ ...tab, videos: selectedDuplicationVideoDatas }); + } else { + orphanedDuplicationVideoDatas.push(...selectedDuplicationVideoDatas); + } + }); + + if (tabTrees.length + orphanedDuplicationVideoDatas.length < 1) return null; + + return ( + <> + + {t(componentTranslations.course_videos_component)} + + + + {tabTrees.map(renderTab)} +
+ {orphanedDuplicationVideoDatas.length > 0 && ( + + )} + {orphanedDuplicationVideoDatas.map(renderDuplicationVideoDataRow)} +
+
+
+ + ); +}; + +export default DuplicationVideoDatasListing; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.jsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.jsx deleted file mode 100644 index 977e1394dc0..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.jsx +++ /dev/null @@ -1,140 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { ListSubheader, Typography } from '@mui/material'; -import PropTypes from 'prop-types'; - -import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; -import TypeBadge from 'course/duplication/components/TypeBadge'; -import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; -import { duplicableItemTypes } from 'course/duplication/constants'; -import { achievementShape } from 'course/duplication/propTypes'; -import { actions } from 'course/duplication/store'; -import { getAchievementBadgeUrl } from 'course/helper/achievements'; -import componentTranslations from 'course/translations'; -import Thumbnail from 'lib/components/core/Thumbnail'; - -const translations = defineMessages({ - noItems: { - id: 'course.duplication.Duplication.ItemsSelector.AchievementsSelector.noItems', - defaultMessage: 'There are no achievements to duplicate.', - }, -}); - -const styles = { - badge: { - maxHeight: 24, - maxWidth: 24, - }, - badgeContainer: { - zIndex: 3, - position: 'relative', - display: 'inline-block', - marginRight: 5, - }, -}; - -class AchievementsSelector extends Component { - setAllAchievementsSelection = (value) => { - const { dispatch, achievements } = this.props; - - achievements.forEach((achievement) => { - dispatch( - actions.setItemSelectedBoolean( - duplicableItemTypes.ACHIEVEMENT, - achievement.id, - value, - ), - ); - }); - }; - - renderBody() { - const { achievements } = this.props; - - if (achievements.length < 1) { - return ( - - - - ); - } - - return ( - <> - {achievements.length > 1 ? ( - - ) : null} - {achievements.map((achievement) => this.renderRow(achievement))} - - ); - } - - renderRow(achievement) { - const { dispatch, selectedItems } = this.props; - const checked = - !!selectedItems[duplicableItemTypes.ACHIEVEMENT][achievement.id]; - const label = ( - - - {achievement.published || } - - {achievement.title} - - ); - return ( - - dispatch( - actions.setItemSelectedBoolean( - duplicableItemTypes.ACHIEVEMENT, - achievement.id, - value, - ), - ) - } - /> - ); - } - - render() { - const { achievements } = this.props; - if (!achievements) { - return null; - } - - return ( - <> - - - - {this.renderBody()} - - ); - } -} - -AchievementsSelector.propTypes = { - achievements: PropTypes.arrayOf(achievementShape), - selectedItems: PropTypes.shape({}), - - dispatch: PropTypes.func.isRequired, -}; - -export default connect(({ duplication }) => ({ - achievements: duplication.achievementsComponent, - selectedItems: duplication.selectedItems, -}))(AchievementsSelector); diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx new file mode 100644 index 00000000000..03a1b32fb3a --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx @@ -0,0 +1,107 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { ListSubheader, Typography } from '@mui/material'; + +import BulkSelectors from 'course/duplication/components/BulkSelectors'; +import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; +import TypeBadge from 'course/duplication/components/TypeBadge'; +import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; +import { selectDuplicationStore } from 'course/duplication/selectors'; +import { actions } from 'course/duplication/store'; +import { DuplicationAchievementData } from 'course/duplication/types'; +import { getAchievementBadgeUrl } from 'course/helper/achievements'; +import componentTranslations from 'course/translations'; +import Thumbnail from 'lib/components/core/Thumbnail'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + noItems: { + id: 'course.duplication.Duplication.ItemsSelector.AchievementsSelector.noItems', + defaultMessage: 'There are no achievements to duplicate.', + }, +}); + +const AchievementsSelector: FC = () => { + const { achievementsComponent: achievements, selectedItems } = useAppSelector( + selectDuplicationStore, + ); + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + + if (!achievements) return null; + + const setAllAchievementsSelection = (value: boolean): void => { + achievements.forEach((achievement) => { + dispatch( + actions.setItemSelectedBoolean('ACHIEVEMENT', achievement.id, value), + ); + }); + }; + + const renderRow = (achievement: DuplicationAchievementData): JSX.Element => { + const checked = !!selectedItems.ACHIEVEMENT[achievement.id]; + return ( + + + {achievement.published || } + + {achievement.title} + + } + onChange={(_, value) => + dispatch( + actions.setItemSelectedBoolean( + 'ACHIEVEMENT', + achievement.id, + value, + ), + ) + } + /> + ); + }; + + const renderBody = (): JSX.Element => { + if (achievements.length < 1) { + return ( + {t(translations.noItems)} + ); + } + return ( + <> + {achievements.length > 1 && ( + + )} + {achievements.map(renderRow)} + + ); + }; + + return ( + <> + + {t(componentTranslations.course_achievements_component)} + + {renderBody()} + + ); +}; + +export default AchievementsSelector; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.jsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.jsx deleted file mode 100644 index fb5c7afed50..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.jsx +++ /dev/null @@ -1,177 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { ListSubheader, Typography } from '@mui/material'; -import PropTypes from 'prop-types'; - -import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; -import TypeBadge from 'course/duplication/components/TypeBadge'; -import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; -import { duplicableItemTypes } from 'course/duplication/constants'; -import { categoryShape } from 'course/duplication/propTypes'; -import destinationCourseSelector from 'course/duplication/selectors/destinationCourse'; -import { actions } from 'course/duplication/store'; -import componentTranslations from 'course/translations'; - -const { TAB, ASSESSMENT, CATEGORY } = duplicableItemTypes; - -const translations = defineMessages({ - noItems: { - id: 'course.duplication.Duplication.ItemsSelector.AssessmentsSelector.noItems', - defaultMessage: 'There are no assessment items to duplicate.', - }, -}); - -class AssessmentsSelector extends Component { - categorySetAll = (category) => (value) => { - const { dispatch, categoryDisabled } = this.props; - if (!categoryDisabled) { - dispatch(actions.setItemSelectedBoolean(CATEGORY, category.id, value)); - } - category.tabs.forEach((tab) => this.tabSetAll(tab)(value)); - }; - - tabSetAll = (tab) => (value) => { - const { dispatch, tabDisabled } = this.props; - if (!tabDisabled) { - dispatch(actions.setItemSelectedBoolean(TAB, tab.id, value)); - } - tab.assessments.forEach((assessment) => { - dispatch( - actions.setItemSelectedBoolean(ASSESSMENT, assessment.id, value), - ); - }); - }; - - renderAssessmentTree(assessment) { - const { dispatch, selectedItems } = this.props; - const { id, title, published } = assessment; - const checked = !!selectedItems[ASSESSMENT][id]; - const label = ( - - - {published || } - {title} - - ); - - return ( - - dispatch(actions.setItemSelectedBoolean(ASSESSMENT, id, value)) - } - /> - ); - } - - renderCategoryTree(category) { - const { dispatch, selectedItems, categoryDisabled } = this.props; - const { id, title, tabs } = category; - const checked = !!selectedItems[CATEGORY][id]; - - return ( -
- - - {title} - - } - onChange={(e, value) => - dispatch(actions.setItemSelectedBoolean(CATEGORY, id, value)) - } - > - - - {tabs.map((tab) => this.renderTabTree(tab))} -
- ); - } - - renderTabTree(tab) { - const { dispatch, selectedItems, tabDisabled } = this.props; - const { id, title, assessments } = tab; - const checked = !!selectedItems[TAB][id]; - - return ( -
- - - {title} - - } - onChange={(e, value) => - dispatch(actions.setItemSelectedBoolean(TAB, id, value)) - } - > - - - {assessments.map((assessment) => this.renderAssessmentTree(assessment))} -
- ); - } - - render() { - const { categories } = this.props; - if (!categories) { - return null; - } - - return ( - <> - - - - {categories.length > 0 ? ( - categories.map((category) => this.renderCategoryTree(category)) - ) : ( - - - - )} - - ); - } -} - -AssessmentsSelector.propTypes = { - categories: PropTypes.arrayOf(categoryShape), - selectedItems: PropTypes.shape({}), - tabDisabled: PropTypes.bool, - categoryDisabled: PropTypes.bool, - - dispatch: PropTypes.func.isRequired, -}; - -const mapStateToProps = (state) => { - const destinationCourse = destinationCourseSelector(state); - const duplication = state.duplication; - - return { - categories: duplication.assessmentsComponent, - selectedItems: duplication.selectedItems, - tabDisabled: - duplication.sourceCourse.unduplicableObjectTypes.includes(TAB) || - destinationCourse.unduplicableObjectTypes.includes(TAB), - categoryDisabled: - duplication.sourceCourse.unduplicableObjectTypes.includes(CATEGORY) || - destinationCourse.unduplicableObjectTypes.includes(CATEGORY), - }; -}; - -export default connect(mapStateToProps)(AssessmentsSelector); diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx new file mode 100644 index 00000000000..3b4817e9590 --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx @@ -0,0 +1,164 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { ListSubheader, Typography } from '@mui/material'; + +import BulkSelectors from 'course/duplication/components/BulkSelectors'; +import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; +import TypeBadge from 'course/duplication/components/TypeBadge'; +import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; +import { + selectDestinationCourse, + selectDuplicationStore, +} from 'course/duplication/selectors'; +import { actions } from 'course/duplication/store'; +import { + DuplicationAssessmentData, + DuplicationCategoryData, + DuplicationTabData, +} from 'course/duplication/types'; +import componentTranslations from 'course/translations'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + noItems: { + id: 'course.duplication.Duplication.ItemsSelector.AssessmentsSelector.noItems', + defaultMessage: 'There are no assessment items to duplicate.', + }, +}); + +const AssessmentsSelector: FC = () => { + const { + assessmentsComponent: categories, + selectedItems, + sourceCourse, + } = useAppSelector(selectDuplicationStore); + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const destinationCourse = useAppSelector(selectDestinationCourse); + + if (!categories) return null; + + const tabDisabled = + sourceCourse.unduplicableObjectTypes.includes('TAB') || + !!destinationCourse?.unduplicableObjectTypes.includes('TAB'); + const categoryDisabled = + sourceCourse.unduplicableObjectTypes.includes('CATEGORY') || + !!destinationCourse?.unduplicableObjectTypes.includes('CATEGORY'); + + const tabSetAll = + (tab: DuplicationTabData) => + (value: boolean): void => { + if (!tabDisabled) { + dispatch(actions.setItemSelectedBoolean('TAB', tab.id, value)); + } + tab.assessments.forEach((assessment) => { + dispatch( + actions.setItemSelectedBoolean('ASSESSMENT', assessment.id, value), + ); + }); + }; + + const categorySetAll = + (category: DuplicationCategoryData) => + (value: boolean): void => { + if (!categoryDisabled) { + dispatch( + actions.setItemSelectedBoolean('CATEGORY', category.id, value), + ); + } + category.tabs.forEach((tab) => tabSetAll(tab)(value)); + }; + + const renderAssessmentTree = ( + assessment: DuplicationAssessmentData, + ): JSX.Element => { + const { id, title, published } = assessment; + const checked = !!selectedItems.ASSESSMENT[id]; + return ( + + + {published || } + {title} + + } + onChange={(_, value) => + dispatch(actions.setItemSelectedBoolean('ASSESSMENT', id, value)) + } + /> + ); + }; + + const renderTabTree = (tab: DuplicationTabData): JSX.Element => { + const { id, title, assessments } = tab; + const checked = !!selectedItems.TAB[id]; + return ( +
+ + + {title} + + } + onChange={(_, value) => + dispatch(actions.setItemSelectedBoolean('TAB', id, value)) + } + > + + + {assessments.map(renderAssessmentTree)} +
+ ); + }; + + const renderCategoryTree = ( + category: DuplicationCategoryData, + ): JSX.Element => { + const { id, title, tabs } = category; + const checked = !!selectedItems.CATEGORY[id]; + return ( +
+ + + {title} + + } + onChange={(_, value) => + dispatch(actions.setItemSelectedBoolean('CATEGORY', id, value)) + } + > + + + {tabs.map(renderTabTree)} +
+ ); + }; + + return ( + <> + + {t(componentTranslations.course_assessments_component)} + + {categories.length > 0 ? ( + categories.map(renderCategoryTree) + ) : ( + {t(translations.noItems)} + )} + + ); +}; + +export default AssessmentsSelector; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.jsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.jsx deleted file mode 100644 index 227a946ca80..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.jsx +++ /dev/null @@ -1,129 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { ListSubheader, Typography } from '@mui/material'; -import PropTypes from 'prop-types'; - -import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; -import TypeBadge from 'course/duplication/components/TypeBadge'; -import { duplicableItemTypes } from 'course/duplication/constants'; -import { folderShape } from 'course/duplication/propTypes'; -import { actions } from 'course/duplication/store'; -import componentTranslations from 'course/translations'; - -const { FOLDER, MATERIAL } = duplicableItemTypes; - -const translations = defineMessages({ - noItems: { - id: 'course.duplication.Duplication.ItemsSelector.MaterialsSelector.noItems', - defaultMessage: 'There are no materials to duplicate.', - }, -}); - -class MaterialsSelector extends Component { - folderSetAll = (folder) => (value) => { - this.props.dispatch( - actions.setItemSelectedBoolean(FOLDER, folder.id, value), - ); - folder.subfolders.forEach((subfolder) => - this.folderSetAll(subfolder)(value), - ); - folder.materials.forEach((material) => { - this.props.dispatch( - actions.setItemSelectedBoolean(MATERIAL, material.id, value), - ); - }); - }; - - renderFolder(folder, indentLevel) { - const { dispatch, selectedItems } = this.props; - const { id, name, materials, subfolders } = folder; - const checked = !!selectedItems[FOLDER][folder.id]; - const hasChildren = materials.length + subfolders.length > 0; - - return ( -
- - - {name} - - } - onChange={(e, value) => - dispatch(actions.setItemSelectedBoolean(FOLDER, id, value)) - } - {...{ checked, indentLevel }} - > - {hasChildren ? ( - - ) : null} - - {materials.map((material) => - this.renderMaterial(material, indentLevel + 1), - )} - {subfolders.map((subfolder) => - this.renderFolder(subfolder, indentLevel + 1), - )} -
- ); - } - - renderMaterial(material, indentLevel) { - const { dispatch, selectedItems } = this.props; - const checked = !!selectedItems[MATERIAL][material.id]; - - return ( - - - {material.name} - - } - onChange={(e, value) => - dispatch(actions.setItemSelectedBoolean(MATERIAL, material.id, value)) - } - {...{ checked, indentLevel }} - /> - ); - } - - render() { - const { folders } = this.props; - if (!folders) { - return null; - } - - return ( - <> - - - - {folders.length > 0 ? ( - folders.map((rootFolder) => this.renderFolder(rootFolder, 0)) - ) : ( - - - - )} - - ); - } -} - -MaterialsSelector.propTypes = { - folders: PropTypes.arrayOf(folderShape), - selectedItems: PropTypes.shape(), - - dispatch: PropTypes.func.isRequired, -}; - -export default connect(({ duplication }) => ({ - folders: duplication.materialsComponent, - selectedItems: duplication.selectedItems, -}))(MaterialsSelector); diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx new file mode 100644 index 00000000000..9d6c22a01ad --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx @@ -0,0 +1,120 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { ListSubheader, Typography } from '@mui/material'; + +import BulkSelectors from 'course/duplication/components/BulkSelectors'; +import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; +import TypeBadge from 'course/duplication/components/TypeBadge'; +import { selectDuplicationStore } from 'course/duplication/selectors'; +import { actions } from 'course/duplication/store'; +import { + DuplicationFolderData, + DuplicationMaterialData, +} from 'course/duplication/types'; +import componentTranslations from 'course/translations'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + noItems: { + id: 'course.duplication.Duplication.ItemsSelector.MaterialsSelector.noItems', + defaultMessage: 'There are no materials to duplicate.', + }, +}); + +const MaterialsSelector: FC = () => { + const { materialsComponent: folders, selectedItems } = useAppSelector( + selectDuplicationStore, + ); + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + + if (!folders) return null; + + const folderSetAll = + (folder: DuplicationFolderData) => + (value: boolean): void => { + dispatch(actions.setItemSelectedBoolean('FOLDER', folder.id, value)); + folder.subfolders.forEach((subfolder) => folderSetAll(subfolder)(value)); + folder.materials.forEach((material) => { + dispatch( + actions.setItemSelectedBoolean('MATERIAL', material.id, value), + ); + }); + }; + + const renderMaterial = ( + material: DuplicationMaterialData, + indentLevel: number, + ): JSX.Element => { + const checked = !!selectedItems.MATERIAL[material.id]; + return ( + + + {material.name} + + } + onChange={(_, value) => + dispatch( + actions.setItemSelectedBoolean('MATERIAL', material.id, value), + ) + } + /> + ); + }; + + const renderFolder = ( + folder: DuplicationFolderData, + indentLevel: number, + ): JSX.Element => { + const { id, name, materials, subfolders } = folder; + const checked = !!selectedItems.FOLDER[id]; + const hasChildren = materials.length + subfolders.length > 0; + + return ( +
+ + + {name} + + } + onChange={(_, value) => + dispatch(actions.setItemSelectedBoolean('FOLDER', id, value)) + } + > + {hasChildren ? ( + + ) : null} + + {materials.map((material) => renderMaterial(material, indentLevel + 1))} + {subfolders.map((subfolder) => + renderFolder(subfolder, indentLevel + 1), + )} +
+ ); + }; + + return ( + <> + + {t(componentTranslations.course_materials_component)} + + {folders.length > 0 ? ( + folders.map((rootFolder) => renderFolder(rootFolder, 0)) + ) : ( + {t(translations.noItems)} + )} + + ); +}; + +export default MaterialsSelector; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.jsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.jsx deleted file mode 100644 index 0127124c92d..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.jsx +++ /dev/null @@ -1,120 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { ListSubheader, Typography } from '@mui/material'; -import PropTypes from 'prop-types'; - -import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; -import TypeBadge from 'course/duplication/components/TypeBadge'; -import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; -import { duplicableItemTypes } from 'course/duplication/constants'; -import { surveyShape } from 'course/duplication/propTypes'; -import { actions } from 'course/duplication/store'; -import componentTranslations from 'course/translations'; - -const translations = defineMessages({ - noItems: { - id: 'course.duplication.Duplication.ItemsSelector.SurveysSelector.noItems', - defaultMessage: 'There are no surveys to duplicate.', - }, -}); - -class SurveysSelector extends Component { - setAllSurveysSelection = (value) => { - const { dispatch, surveys } = this.props; - - surveys.forEach((survey) => { - dispatch( - actions.setItemSelectedBoolean( - duplicableItemTypes.SURVEY, - survey.id, - value, - ), - ); - }); - }; - - renderBody() { - const { surveys } = this.props; - - if (surveys.length < 1) { - return ( - - - - ); - } - - return ( - <> - {surveys.length > 1 ? ( - - ) : null} - {surveys.map((survey) => this.renderRow(survey))} - - ); - } - - renderRow(survey) { - const { dispatch, selectedItems } = this.props; - const checked = !!selectedItems[duplicableItemTypes.SURVEY][survey.id]; - const label = ( - - - {survey.published || } - {survey.title} - - ); - - return ( - - dispatch( - actions.setItemSelectedBoolean( - duplicableItemTypes.SURVEY, - survey.id, - value, - ), - ) - } - /> - ); - } - - render() { - const { surveys } = this.props; - if (!surveys) { - return null; - } - - return ( - <> - - - - {this.renderBody()} - - ); - } -} - -SurveysSelector.propTypes = { - surveys: PropTypes.arrayOf(surveyShape), - selectedItems: PropTypes.shape({}), - - dispatch: PropTypes.func.isRequired, -}; - -export default connect(({ duplication }) => ({ - surveys: duplication.surveyComponent, - selectedItems: duplication.selectedItems, -}))(SurveysSelector); diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx new file mode 100644 index 00000000000..1c1a6a77068 --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx @@ -0,0 +1,87 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { ListSubheader, Typography } from '@mui/material'; + +import BulkSelectors from 'course/duplication/components/BulkSelectors'; +import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; +import TypeBadge from 'course/duplication/components/TypeBadge'; +import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; +import { selectDuplicationStore } from 'course/duplication/selectors'; +import { actions } from 'course/duplication/store'; +import { DuplicationSurveyData } from 'course/duplication/types'; +import componentTranslations from 'course/translations'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + noItems: { + id: 'course.duplication.Duplication.ItemsSelector.SurveysSelector.noItems', + defaultMessage: 'There are no surveys to duplicate.', + }, +}); + +const SurveysSelector: FC = () => { + const { surveyComponent: surveys, selectedItems } = useAppSelector( + selectDuplicationStore, + ); + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + + if (!surveys) return null; + + const setAllDuplicationSurveyDatasSelection = (value: boolean): void => { + surveys.forEach((survey) => { + dispatch(actions.setItemSelectedBoolean('SURVEY', survey.id, value)); + }); + }; + + const renderRow = (survey: DuplicationSurveyData): JSX.Element => { + const checked = !!selectedItems.SURVEY[survey.id]; + return ( + + + {survey.published || } + {survey.title} + + } + onChange={(_, value) => + dispatch(actions.setItemSelectedBoolean('SURVEY', survey.id, value)) + } + /> + ); + }; + + const renderBody = (): JSX.Element => { + if (surveys.length < 1) { + return ( + {t(translations.noItems)} + ); + } + return ( + <> + {surveys.length > 1 && ( + + )} + {surveys.map(renderRow)} + + ); + }; + + return ( + <> + + {t(componentTranslations.course_survey_component)} + + {renderBody()} + + ); +}; + +export default SurveysSelector; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.jsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.jsx deleted file mode 100644 index c18fdc34637..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.jsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { ListSubheader, Typography } from '@mui/material'; -import PropTypes from 'prop-types'; - -import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; -import TypeBadge from 'course/duplication/components/TypeBadge'; -import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; -import { duplicableItemTypes } from 'course/duplication/constants'; -import { videoTabShape } from 'course/duplication/propTypes'; -import { actions } from 'course/duplication/store'; -import componentTranslations from 'course/translations'; - -const { VIDEO_TAB, VIDEO } = duplicableItemTypes; - -const translations = defineMessages({ - noItems: { - id: 'course.duplication.Duplication.ItemsSelector.VideosSelector.noItems', - defaultMessage: 'There are no videos to duplicate.', - }, -}); - -class VideosSelector extends Component { - setAllInTab = (tab) => (value) => { - const { dispatch } = this.props; - dispatch(actions.setItemSelectedBoolean(VIDEO_TAB, tab.id, value)); - tab.videos.forEach((video) => { - dispatch(actions.setItemSelectedBoolean(VIDEO, video.id, value)); - }); - }; - - setEverything = (value) => { - const { tabs } = this.props; - tabs.forEach((tab) => this.setAllInTab(tab)(value)); - }; - - renderBody() { - const { tabs } = this.props; - - if (tabs.length < 1) { - return ( - - - - ); - } - - return ( - <> - {tabs.length > 1 ? ( - - ) : null} - {tabs.map((tab) => this.renderTabTree(tab))} - - ); - } - - renderTabTree(tab) { - const { dispatch, selectedItems } = this.props; - const { id, title, videos } = tab; - const checked = !!selectedItems[VIDEO_TAB][id]; - - return ( -
- - - {title} - - } - onChange={(e, value) => - dispatch(actions.setItemSelectedBoolean(VIDEO_TAB, id, value)) - } - > - - - {videos.map((video) => this.renderVideo(video))} -
- ); - } - - renderVideo(video) { - const { dispatch, selectedItems } = this.props; - const checked = !!selectedItems[VIDEO][video.id]; - - return ( - - - {video.published || } - {video.title} - - } - onChange={(e, value) => - dispatch(actions.setItemSelectedBoolean(VIDEO, video.id, value)) - } - /> - ); - } - - render() { - const { tabs } = this.props; - if (!tabs) { - return null; - } - - return ( - <> - - - - {this.renderBody()} - - ); - } -} - -VideosSelector.propTypes = { - tabs: PropTypes.arrayOf(videoTabShape), - selectedItems: PropTypes.shape({}), - - dispatch: PropTypes.func.isRequired, -}; - -export default connect(({ duplication }) => ({ - tabs: duplication.videosComponent, - selectedItems: duplication.selectedItems, -}))(VideosSelector); diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx new file mode 100644 index 00000000000..25ab8fa9505 --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx @@ -0,0 +1,123 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { ListSubheader, Typography } from '@mui/material'; + +import BulkSelectors from 'course/duplication/components/BulkSelectors'; +import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; +import TypeBadge from 'course/duplication/components/TypeBadge'; +import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; +import { selectDuplicationStore } from 'course/duplication/selectors'; +import { actions } from 'course/duplication/store'; +import { + DuplicationVideoData, + DuplicationVideoTabData, +} from 'course/duplication/types'; +import componentTranslations from 'course/translations'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + noItems: { + id: 'course.duplication.Duplication.ItemsSelector.VideosSelector.noItems', + defaultMessage: 'There are no videos to duplicate.', + }, +}); + +const VideosSelector: FC = () => { + const { videosComponent: tabs, selectedItems } = useAppSelector( + selectDuplicationStore, + ); + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + + if (!tabs) return null; + + const setAllInTab = + (tab: DuplicationVideoTabData) => + (value: boolean): void => { + dispatch(actions.setItemSelectedBoolean('VIDEO_TAB', tab.id, value)); + tab.videos.forEach((video) => { + dispatch(actions.setItemSelectedBoolean('VIDEO', video.id, value)); + }); + }; + + const setEverything = (value: boolean): void => { + tabs.forEach((tab) => setAllInTab(tab)(value)); + }; + + const renderVideo = (video: DuplicationVideoData): JSX.Element => { + const checked = !!selectedItems.VIDEO[video.id]; + return ( + + + {video.published || } + {video.title} + + } + onChange={(_, value) => + dispatch(actions.setItemSelectedBoolean('VIDEO', video.id, value)) + } + /> + ); + }; + + const renderTabTree = (tab: DuplicationVideoTabData): JSX.Element => { + const { id, title, videos } = tab; + const checked = !!selectedItems.VIDEO_TAB[id]; + return ( +
+ + + {title} + + } + onChange={(_, value) => + dispatch(actions.setItemSelectedBoolean('VIDEO_TAB', id, value)) + } + > + + + {videos.map(renderVideo)} +
+ ); + }; + + const renderBody = (): JSX.Element => { + if (tabs.length < 1) { + return ( + {t(translations.noItems)} + ); + } + return ( + <> + {tabs.length > 1 && ( + + )} + {tabs.map(renderTabTree)} + + ); + }; + + return ( + <> + + {t(componentTranslations.course_videos_component)} + + {renderBody()} + + ); +}; + +export default VideosSelector; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/index.jsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/index.jsx deleted file mode 100644 index a155c65e8e2..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/index.jsx +++ /dev/null @@ -1,66 +0,0 @@ -import { defineMessages, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { Typography } from '@mui/material'; -import PropTypes from 'prop-types'; - -import { itemSelectorPanels } from 'course/duplication/constants'; -import { courseShape } from 'course/duplication/propTypes'; -import destinationCourseSelector from 'course/duplication/selectors/destinationCourse'; - -import AchievementsSelector from './AchievementsSelector'; -import AssessmentsSelector from './AssessmentsSelector'; -import MaterialsSelector from './MaterialsSelector'; -import SurveysSelector from './SurveysSelector'; -import VideosSelector from './VideosSelector'; - -const translations = defineMessages({ - pleaseSelectItems: { - id: 'course.duplication.Duplication.ItemsSelector.pleaseSelectItems', - defaultMessage: 'Please select items to duplicate via the sidebar.', - }, - componentDisabled: { - id: 'course.duplication.Duplication.ItemsSelector.componentDisabled', - defaultMessage: 'This component is not enabled for the destination course.', - }, -}); - -const ItemsSelector = (props) => { - const { currentPanel, destinationCourse } = props; - - if (!currentPanel) { - return ( - - - - ); - } - - if (!destinationCourse.enabledComponents.includes(currentPanel)) { - return ( - - - - ); - } - - const CurrentPanel = ItemsSelector.panelComponentMap[currentPanel]; - return ; -}; - -ItemsSelector.panelComponentMap = { - [itemSelectorPanels.ASSESSMENTS]: AssessmentsSelector, - [itemSelectorPanels.SURVEYS]: SurveysSelector, - [itemSelectorPanels.ACHIEVEMENTS]: AchievementsSelector, - [itemSelectorPanels.MATERIALS]: MaterialsSelector, - [itemSelectorPanels.VIDEOS]: VideosSelector, -}; - -ItemsSelector.propTypes = { - currentPanel: PropTypes.string, - destinationCourse: courseShape, -}; - -export default connect((state) => ({ - currentPanel: state.duplication.currentItemSelectorPanel, - destinationCourse: destinationCourseSelector(state), -}))(ItemsSelector); diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/index.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/index.tsx new file mode 100644 index 00000000000..25bc38dfb94 --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/index.tsx @@ -0,0 +1,68 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Typography } from '@mui/material'; + +import { + selectDestinationCourse, + selectDuplicationStore, +} from 'course/duplication/selectors'; +import { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import AchievementsSelector from './AchievementsSelector'; +import AssessmentsSelector from './AssessmentsSelector'; +import MaterialsSelector from './MaterialsSelector'; +import SurveysSelector from './SurveysSelector'; +import VideosSelector from './VideosSelector'; + +const translations = defineMessages({ + pleaseSelectItems: { + id: 'course.duplication.Duplication.ItemsSelector.pleaseSelectItems', + defaultMessage: 'Please select items to duplicate via the sidebar.', + }, + componentDisabled: { + id: 'course.duplication.Duplication.ItemsSelector.componentDisabled', + defaultMessage: 'This component is not enabled for the destination course.', + }, +}); + +const PanelComponentMapper = { + ASSESSMENTS: AssessmentsSelector, + SURVEYS: SurveysSelector, + ACHIEVEMENTS: AchievementsSelector, + MATERIALS: MaterialsSelector, + VIDEOS: VideosSelector, +}; + +const ItemsSelector: FC = () => { + const { currentItemSelectorPanel: currentPanel } = useAppSelector( + selectDuplicationStore, + ); + const destinationCourse = useAppSelector(selectDestinationCourse); + const { t } = useTranslation(); + + if (!destinationCourse) { + return null; + } + + if (!currentPanel) { + return ( + + {t(translations.pleaseSelectItems)} + + ); + } + + if (!destinationCourse.enabledComponents.includes(currentPanel)) { + return ( + + {t(translations.componentDisabled)} + + ); + } + + const CurrentPanel = PanelComponentMapper[currentPanel]; + return ; +}; + +export default ItemsSelector; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelectorMenu/index.jsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelectorMenu/index.jsx deleted file mode 100644 index a91172df79c..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelectorMenu/index.jsx +++ /dev/null @@ -1,164 +0,0 @@ -import { Component } from 'react'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { - Avatar, - List, - ListItem, - ListItemAvatar, - ListItemText, -} from '@mui/material'; -import { cyan } from '@mui/material/colors'; -import PropTypes from 'prop-types'; - -import { - duplicableItemTypes, - itemSelectorPanels as panels, -} from 'course/duplication/constants'; -import { courseShape } from 'course/duplication/propTypes'; -import { actions } from 'course/duplication/store'; -import componentTranslations from 'course/translations'; - -import DuplicateButton from '../DuplicateButton'; - -const { - TAB, - ASSESSMENT, - CATEGORY, - SURVEY, - ACHIEVEMENT, - FOLDER, - MATERIAL, - VIDEO_TAB, - VIDEO, -} = duplicableItemTypes; - -const styles = { - countAvatar: { - height: '30px', - width: '30px', - margin: 5, - }, - duplicateButton: { - display: 'flex', - justifyContent: 'center', - }, -}; - -class ItemsSelectorMenu extends Component { - renderSidebarItem(panelKey, titleKey, count, className) { - const { dispatch, enabledComponents } = this.props; - if (!enabledComponents.includes(panelKey)) { - return null; - } - if (enabledComponents.length === 1) { - dispatch(actions.setItemSelectorPanel(panelKey)); - } - - return ( - dispatch(actions.setItemSelectorPanel(panelKey))} - > - - 0 ? cyan[500] : null, - }} - > - {count} - - - - - - - ); - } - - render() { - const { selectedItems, courses, destinationCourseId } = this.props; - // Disabled models for cherry pick duplication as defined in `disabled_cherrypickable_types`. - const unduplicableObjectTypes = courses.find( - (course) => course.id === destinationCourseId, - ).unduplicableObjectTypes; - - const counts = {}; - Object.keys(selectedItems).forEach((key) => { - const idsHash = selectedItems[key]; - counts[key] = Object.keys(idsHash).reduce( - (count, id) => (idsHash[id] ? count + 1 : count), - 0, - ); - }); - - const assessmentsComponentCount = - counts[TAB] + counts[ASSESSMENT] + counts[CATEGORY]; - const videosComponentCount = counts[VIDEO] + counts[VIDEO_TAB]; - - return ( - - {unduplicableObjectTypes.includes('ASSESSMENT') - ? null - : this.renderSidebarItem( - panels.ASSESSMENTS, - 'course_assessments_component', - assessmentsComponentCount, - 'items-selector-menu-assessment', - )} - {unduplicableObjectTypes.includes('SURVEY') - ? null - : this.renderSidebarItem( - panels.SURVEYS, - 'course_survey_component', - counts[SURVEY], - 'items-selector-menu-survey', - )} - {unduplicableObjectTypes.includes('ACHIEVEMENT') - ? null - : this.renderSidebarItem( - panels.ACHIEVEMENTS, - 'course_achievements_component', - counts[ACHIEVEMENT], - 'items-selector-menu-achievement', - )} - {unduplicableObjectTypes.includes('MATERIAL') - ? null - : this.renderSidebarItem( - panels.MATERIALS, - 'course_materials_component', - counts[FOLDER] + counts[MATERIAL], - 'items-selector-menu-material', - )} - {unduplicableObjectTypes.includes('VIDEO') - ? null - : this.renderSidebarItem( - panels.VIDEOS, - 'course_videos_component', - videosComponentCount, - 'items-selector-menu-video', - )} - - - - - ); - } -} - -ItemsSelectorMenu.propTypes = { - selectedItems: PropTypes.shape({}), - enabledComponents: PropTypes.arrayOf(PropTypes.string), - destinationCourseId: PropTypes.number, - courses: PropTypes.arrayOf(courseShape), - dispatch: PropTypes.func.isRequired, -}; - -export default connect(({ duplication }) => ({ - selectedItems: duplication.selectedItems, - enabledComponents: duplication.sourceCourse.enabledComponents, - destinationCourseId: duplication.destinationCourseId, - courses: duplication.destinationCourses, -}))(ItemsSelectorMenu); diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelectorMenu/index.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelectorMenu/index.tsx new file mode 100644 index 00000000000..a5a62b71bc6 --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelectorMenu/index.tsx @@ -0,0 +1,140 @@ +import { FC } from 'react'; +import { + Avatar, + List, + ListItem, + ListItemAvatar, + ListItemButton, + ListItemText, +} from '@mui/material'; + +import { selectDuplicationStore } from 'course/duplication/selectors'; +import { actions } from 'course/duplication/store'; +import { + DUPLICABLE_ITEM_TYPES, + DuplicableItemType, + ItemSelectorPanel, +} from 'course/duplication/types'; +import componentTranslations from 'course/translations'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import DuplicateButton from '../DuplicateButton'; + +interface ItemsSelectorSidebarItemProps { + panelKey: ItemSelectorPanel; + titleKey: string; + count: number; + className: string; +} + +const ItemsSelectorSidebarItem: FC = ({ + panelKey, + titleKey, + count, + className, +}) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + + return ( + dispatch(actions.setItemSelectorPanel(panelKey))} + > + + 0 ? 'bg-cyan-500' : ''}`}> + {count} + + + {t(componentTranslations[titleKey])} + + ); +}; + +const ItemsSelectorMenu: FC = () => { + const { + selectedItems, + destinationCourses, + destinationCourseId, + sourceCourse: { enabledComponents }, + } = useAppSelector(selectDuplicationStore); + + // Disabled models for cherry pick duplication as defined in `disabled_cherrypickable_types`. + const unduplicableObjectTypes = + destinationCourses.find((course) => course.id === destinationCourseId) + ?.unduplicableObjectTypes ?? DUPLICABLE_ITEM_TYPES; + + const counts: Record = {} as Record< + DuplicableItemType, + number + >; + DUPLICABLE_ITEM_TYPES.forEach((itemType) => { + const idsHash = selectedItems[itemType]; + counts[itemType] = Object.keys(idsHash).reduce( + (count, id) => (idsHash[id] ? count + 1 : count), + 0, + ); + }); + + const assessmentsComponentCount = + counts.TAB + counts.ASSESSMENT + counts.CATEGORY; + const videosComponentCount = counts.VIDEO + counts.VIDEO_TAB; + + const shouldRenderSidebarItem = ( + panelKey: ItemSelectorPanel, + objectKey: DuplicableItemType, + ): boolean => + !unduplicableObjectTypes.includes(objectKey) && + enabledComponents.includes(panelKey); + + return ( + + {shouldRenderSidebarItem('ASSESSMENTS', 'ASSESSMENT') && ( + + )} + {shouldRenderSidebarItem('SURVEYS', 'SURVEY') && ( + + )} + {shouldRenderSidebarItem('ACHIEVEMENTS', 'ACHIEVEMENT') && ( + + )} + {shouldRenderSidebarItem('MATERIALS', 'MATERIAL') && ( + + )} + {shouldRenderSidebarItem('VIDEOS', 'VIDEO') && ( + + )} + + + + + ); +}; + +export default ItemsSelectorMenu; diff --git a/client/app/bundles/course/duplication/pages/Duplication/__test__/DestinationCourseSelector.test.tsx b/client/app/bundles/course/duplication/pages/Duplication/__test__/DestinationCourseSelector.test.tsx new file mode 100644 index 00000000000..1cb1d705540 --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/__test__/DestinationCourseSelector.test.tsx @@ -0,0 +1,135 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { store } from 'store'; +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; +import { loadObjectsList } from 'course/duplication/store'; + +import ObjectDuplication from '../index'; + +const client = CourseAPI.duplication.client; +const mock = createMockAdapter(client); +const url = `/courses/${global.courseId}/object_duplication/new`; +const defaultInstance = { + id: 1, + name: 'Default', + host: 'example.org', +}; + +const baseData = { + sourceCourse: { + id: 5, + title: 'Source Course', + start_at: '2024-01-01', + duplicationModesAllowed: ['COURSE', 'OBJECT'], + enabledComponents: ['ASSESSMENTS'], + unduplicableObjectTypes: [], + }, + metadata: { currentInstanceId: 1, currentInstanceHost: 'coursemology.org' }, + destinationInstances: [defaultInstance], + destinationCourses: [], + materialsComponent: [], + assessmentsComponent: [], + surveyComponent: [], + achievementsComponent: [], + videosComponent: [], +}; + +const waitForCombobox = (): Promise => + screen.findByRole('combobox', { name: /destination instance/i }); + +beforeEach(() => { + mock.reset(); + store.dispatch(loadObjectsList(baseData)); +}); + +describe(' instance dropdown', () => { + it('pre-fills instance dropdown with current instance when it is a valid destination', async () => { + const testData = { + ...baseData, + metadata: { + currentInstanceId: 1, + currentInstanceHost: 'coursemology.org', + }, + destinationInstances: [defaultInstance], + }; + store.dispatch(loadObjectsList(testData)); + mock.onGet(url).reply(200, testData); + + render(); + const combobox = await waitForCombobox(); + + expect(combobox).toHaveValue('Default'); + }); + + it('does not pre-fill instance dropdown when current instance is not a valid destination', async () => { + const testData = { + ...baseData, + metadata: { + currentInstanceId: 99, + currentInstanceHost: 'other.coursemology.org', + }, + destinationInstances: [defaultInstance], + }; + store.dispatch(loadObjectsList(testData)); + mock.onGet(url).reply(200, testData); + + render(); + const combobox = await waitForCombobox(); + + expect(combobox).toHaveValue(''); + }); + + it('MyLocation button sets the Autocomplete to the current instance', async () => { + const testData = { + ...baseData, + metadata: { + currentInstanceId: 1, + currentInstanceHost: 'coursemology.org', + }, + destinationInstances: [ + defaultInstance, + { id: 2, name: 'Other', host: 'other.org' }, + ], + }; + store.dispatch(loadObjectsList(testData)); + mock.onGet(url).reply(200, testData); + + render(); + const combobox = await waitForCombobox(); + await waitFor(() => expect(combobox).toHaveValue('Default')); + + // Click MyLocation to confirm it sets the current instance + fireEvent.click( + screen.getByRole('button', { name: /select current instance/i }), + ); + + // Combobox should show the current instance name + await waitFor(() => expect(combobox).toHaveValue('Default')); + }); + + /** + * The MyLocation IconButton is currently only disabled when `isDuplicating` is true, + * but it should also be disabled when the current instance is not a valid destination. + */ + it('disables MyLocation button when current instance is not a valid destination', async () => { + const testData = { + ...baseData, + metadata: { + currentInstanceId: 99, + currentInstanceHost: 'other.coursemology.org', + }, + destinationInstances: [defaultInstance], + }; + store.dispatch(loadObjectsList(testData)); + mock.onGet(url).reply(200, testData); + + render(); + await waitForCombobox(); + + const locationButton = screen.getByRole('button', { + name: /select current instance/i, + }); + expect(locationButton).toBeDisabled(); + }); +}); diff --git a/client/app/bundles/course/duplication/pages/Duplication/__test__/DuplicateButton.test.tsx b/client/app/bundles/course/duplication/pages/Duplication/__test__/DuplicateButton.test.tsx index 7c49e3a4e81..7fb5c86e368 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/__test__/DuplicateButton.test.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/__test__/DuplicateButton.test.tsx @@ -1,15 +1,14 @@ import { store } from 'store'; -import { fireEvent, render } from 'test-utils'; +import { fireEvent, render, screen, within } from 'test-utils'; import CourseAPI from 'api/course'; -import { duplicableItemTypes } from 'course/duplication/constants'; import { loadObjectsList } from 'course/duplication/store'; import DuplicateButton from '../DuplicateButton'; const data = { sourceCourse: { id: 37 }, - metadata: { canDuplicateToAnotherInstance: false, currentInstanceId: 0 }, + metadata: { currentInstanceId: 0 }, destinationCourseId: 9, destinationCourses: [ { @@ -21,9 +20,15 @@ const data = { ], destinationInstances: [{ id: 0, name: 'default', host: 'example.org' }], selectedItems: { - [duplicableItemTypes.TAB]: { 3: true, 4: true, 5: false }, - [duplicableItemTypes.CATEGORY]: { 6: false }, - [duplicableItemTypes.ASSESSMENT]: { 7: true }, + ASSESSMENT: { 7: true }, + TAB: { 3: true, 4: true, 5: false }, + CATEGORY: { 6: false }, + SURVEY: { 10: true }, + ACHIEVEMENT: { 11: true, 12: false }, + FOLDER: { 13: true }, + MATERIAL: { 14: true }, + VIDEO: { 15: true }, + VIDEO_TAB: { 16: true }, }, materialsComponent: [], assessmentsComponent: [ @@ -54,7 +59,19 @@ const data = { ], }, ], - videosComponent: [], + surveyComponent: [{ id: 10, title: 'Survey 10', published: true }], + achievementsComponent: [ + { id: 11, title: 'Achievement 11', published: true, url: '/badges/11' }, + { id: 12, title: 'Achievement 12', published: true, url: '/badges/12' }, + ], + videosComponent: [ + { + id: 16, + title: 'Video Tab 16', + parent_id: null, + videos: [{ id: 15, title: 'Video 15', published: true }], + }, + ], }; const expectedPayload = { @@ -62,6 +79,12 @@ const expectedPayload = { items: { ASSESSMENT: ['7'], TAB: ['3', '4'], + SURVEY: ['10'], + ACHIEVEMENT: ['11'], + FOLDER: ['13'], + MATERIAL: ['14'], + VIDEO: ['15'], + VIDEO_TAB: ['16'], }, destination_course_id: 9, }, @@ -79,4 +102,43 @@ describe('', () => { expect(spy).toHaveBeenCalledWith(data.sourceCourse.id, expectedPayload); }); + + it('shows only selected items in the confirmation dialog and sends the correct request', async () => { + const spy = jest.spyOn(CourseAPI.duplication, 'duplicateItems'); + + store.dispatch(loadObjectsList(data)); + const page = render(); + + fireEvent.click( + await page.findByRole('button', { name: /duplicate items/i }), + ); + + const dialog = await screen.findByRole('dialog'); + const d = within(dialog); + + // Destination course card + expect(d.getByText('destination')).toBeInTheDocument(); + + // Selected assessments and tabs appear; unselected ones do not + expect(d.getByText('Assessment 7')).toBeInTheDocument(); + expect(d.getByText('Tab 3')).toBeInTheDocument(); + expect(d.getByText('Tab 4')).toBeInTheDocument(); + expect(d.queryByText('Tab 5')).not.toBeInTheDocument(); + expect(d.queryByText('Category 6')).not.toBeInTheDocument(); + + // Selected survey appears + expect(d.getByText('Survey 10')).toBeInTheDocument(); + + // Selected achievement appears; unselected one does not + expect(d.getByText('Achievement 11')).toBeInTheDocument(); + expect(d.queryByText('Achievement 12')).not.toBeInTheDocument(); + + // Selected video and video tab appear + expect(d.getByText('Video Tab 16')).toBeInTheDocument(); + expect(d.getByText('Video 15')).toBeInTheDocument(); + + // Clicking Duplicate sends the correct API request + fireEvent.click(d.getByRole('button', { name: 'Duplicate' })); + expect(spy).toHaveBeenCalledWith(data.sourceCourse.id, expectedPayload); + }); }); diff --git a/client/app/bundles/course/duplication/pages/Duplication/__test__/ObjectDuplication.test.jsx b/client/app/bundles/course/duplication/pages/Duplication/__test__/ObjectDuplication.test.jsx deleted file mode 100644 index e0afa3e9f43..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/__test__/ObjectDuplication.test.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import { createMockAdapter } from 'mocks/axiosMock'; -import { store } from 'store'; -import { render, waitFor } from 'test-utils'; - -import CourseAPI from 'api/course'; - -import ObjectDuplication from '../index'; - -const client = CourseAPI.duplication.client; -const mock = createMockAdapter(client); - -const responseData = { - sourceCourse: { id: 5 }, - metadata: { canDuplicateToAnotherInstance: false, currentInstanceId: 0 }, - destinationInstances: [{ id: 0, name: 'default', host: 'example.org' }], - destinationCourses: [ - { id: 54, title: 'Course B', path: '/courses/54' }, - { id: 55, title: 'Course A', path: '/courses/55' }, - { id: 56, title: 'Course C', path: '/courses/56' }, - ], - materialsComponent: [ - { id: 91, parent_id: 93, name: 'L2' }, - { id: 92, parent_id: null, name: 'Root' }, - { id: 93, parent_id: 92, name: 'L1' }, - ], -}; - -beforeEach(() => { - mock.reset(); -}); - -describe('', () => { - it('fetches and receives sorted data', async () => { - const spy = jest.spyOn(CourseAPI.duplication, 'fetch'); - const url = `/courses/${global.courseId}/object_duplication/new`; - mock.onGet(url).reply(200, responseData); - - render(); - - await waitFor(() => expect(spy).toHaveBeenCalled()); - - const data = store.getState().duplication; - const courseTitles = data.destinationCourses.map((course) => course.title); - const rootFolder = data.materialsComponent[0]; - - expect(courseTitles).toEqual(['Course A', 'Course B', 'Course C']); - expect(data.materialsComponent).toHaveLength(1); - expect(rootFolder.name).toBe('Root'); - expect(rootFolder.subfolders[0].name).toBe('L1'); - expect(rootFolder.subfolders[0].subfolders[0].name).toBe('L2'); - }); -}); diff --git a/client/app/bundles/course/duplication/pages/Duplication/__test__/ObjectDuplication.test.tsx b/client/app/bundles/course/duplication/pages/Duplication/__test__/ObjectDuplication.test.tsx new file mode 100644 index 00000000000..cf66a84f3c5 --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/__test__/ObjectDuplication.test.tsx @@ -0,0 +1,176 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { store } from 'store'; +import { render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; +import { loadObjectsList } from 'course/duplication/store'; +import { DuplicationData } from 'course/duplication/types'; + +import ObjectDuplication from '../index'; + +const client = CourseAPI.duplication.client; +const mock = createMockAdapter(client); +const url = `/courses/${global.courseId}/object_duplication/new`; + +const defaultInstance = { + id: 1, + name: 'Default', + host: 'example.org', +}; + +const baseData = { + sourceCourse: { + id: 5, + title: 'Source Course', + start_at: '2024-01-01', + duplicationModesAllowed: ['COURSE', 'OBJECT'], + enabledComponents: ['ASSESSMENTS'], + unduplicableObjectTypes: [], + }, + metadata: { currentInstanceId: 2, currentInstanceHost: 'coursemology.org' }, + destinationInstances: [defaultInstance], + destinationCourses: [], + materialsComponent: [], + assessmentsComponent: [], + surveyComponent: [], + achievementsComponent: [], + videosComponent: [], +}; + +beforeEach(() => { + mock.reset(); + store.dispatch(loadObjectsList(baseData)); +}); + +describe('', () => { + it('fetches and receives sorted data', async () => { + const spy = jest.spyOn(CourseAPI.duplication, 'fetch'); + mock.onGet(url).reply(200, { + sourceCourse: { id: 5 }, + metadata: { currentInstanceId: 0 }, + destinationInstances: [defaultInstance], + destinationCourses: [ + { id: 54, title: 'Course B', path: '/courses/54' }, + { id: 55, title: 'Course A', path: '/courses/55' }, + { id: 56, title: 'Course C', path: '/courses/56' }, + ], + materialsComponent: [ + { id: 91, parent_id: 93, name: 'L2' }, + { id: 92, parent_id: null, name: 'Root' }, + { id: 93, parent_id: 92, name: 'L1' }, + ], + }); + + render(); + + await waitFor(() => expect(spy).toHaveBeenCalled()); + + const data = store.getState().duplication as DuplicationData; + const courseTitles = data.destinationCourses.map((course) => course.title); + const rootFolder = data.materialsComponent[0]; + + expect(courseTitles).toEqual(['Course A', 'Course B', 'Course C']); + expect(data.materialsComponent).toHaveLength(1); + expect(rootFolder.name).toBe('Root'); + expect(rootFolder.subfolders[0].name).toBe('L1'); + expect(rootFolder.subfolders[0].subfolders[0].name).toBe('L2'); + }); + + it('hides alert when current instance is a valid destination', async () => { + mock.onGet(url).reply(200, { + ...baseData, + metadata: { + currentInstanceId: 1, + currentInstanceHost: 'coursemology.org', + }, + destinationInstances: [defaultInstance], + }); + + render(); + await screen.findByRole('radio', { name: /new course/i }); + + expect( + screen.queryByText(/cannot duplicate to a new course/i), + ).not.toBeInTheDocument(); + }); + + it('shows alert when current instance is not a valid destination', async () => { + mock.onGet(url).reply(200, { + ...baseData, + metadata: { + currentInstanceId: 99, + currentInstanceHost: 'other.coursemology.org', + }, + destinationInstances: [defaultInstance], + }); + + render(); + + await screen.findByText(/cannot duplicate to a new course/i); + }); + + it('disables New Course radio when there are no destination instances', async () => { + mock.onGet(url).reply(200, { + ...baseData, + destinationInstances: [], + }); + + render(); + await screen.findByRole('radio', { name: /new course/i }); + + expect(screen.getByRole('radio', { name: /new course/i })).toBeDisabled(); + expect( + screen.getByRole('radio', { name: /existing course/i }), + ).not.toBeDisabled(); + }); + + it('enables both mode radios when destination instances exist', async () => { + mock.onGet(url).reply(200, { + ...baseData, + destinationInstances: [defaultInstance], + }); + + render(); + await screen.findByRole('radio', { name: /new course/i }); + + expect( + screen.getByRole('radio', { name: /new course/i }), + ).not.toBeDisabled(); + expect( + screen.getByRole('radio', { name: /existing course/i }), + ).not.toBeDisabled(); + }); + + it('shows loading indicator while data is being fetched', async () => { + mock.onGet(url).reply(() => new Promise(() => {})); + + render(); + await screen.findByTestId('CircularProgress'); + + expect(screen.queryByRole('radio')).not.toBeInTheDocument(); + }); + + it('shows duplication disabled message when no modes are allowed', async () => { + mock.onGet(url).reply(200, { + ...baseData, + sourceCourse: { ...baseData.sourceCourse, duplicationModesAllowed: [] }, + }); + + render(); + + await screen.findByText(/duplication is disabled for this course/i); + }); + + it('shows no components message when all components are disabled', async () => { + mock.onGet(url).reply(200, { + ...baseData, + sourceCourse: { ...baseData.sourceCourse, enabledComponents: [] }, + }); + + render(); + + await screen.findByText( + /all components with duplicable items are disabled/i, + ); + }); +}); diff --git a/client/app/bundles/course/duplication/pages/Duplication/index.jsx b/client/app/bundles/course/duplication/pages/Duplication/index.jsx deleted file mode 100644 index a9d07ed9e92..00000000000 --- a/client/app/bundles/course/duplication/pages/Duplication/index.jsx +++ /dev/null @@ -1,253 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { - FormControlLabel, - ListSubheader, - Paper, - Radio, - RadioGroup, - Typography, -} from '@mui/material'; -import PropTypes from 'prop-types'; - -import { duplicationModes } from 'course/duplication/constants'; -import { fetchObjectsList } from 'course/duplication/operations'; -import { sourceCourseShape } from 'course/duplication/propTypes'; -import { actions } from 'course/duplication/store'; -import Page from 'lib/components/core/layouts/Page'; -import LoadingIndicator from 'lib/components/core/LoadingIndicator'; - -import DestinationCourseSelector from './DestinationCourseSelector'; -import DuplicateAllButton from './DuplicateAllButton'; -import ItemsSelector from './ItemsSelector'; -import ItemsSelectorMenu from './ItemsSelectorMenu'; - -const translations = defineMessages({ - duplicateData: { - id: 'course.duplication.Duplication.duplicateData', - defaultMessage: 'Duplicate Data', - }, - fromCourse: { - id: 'course.duplication.Duplication.fromCourse', - defaultMessage: 'Duplicate data from {courseTitle}', - }, - toCourse: { - id: 'course.duplication.Duplication.toCourse', - defaultMessage: 'To', - }, - items: { - id: 'course.duplication.Duplication.items', - defaultMessage: 'Selected Items', - }, - newCourse: { - id: 'course.duplication.Duplication.newCourse', - defaultMessage: 'New Course', - }, - existingCourse: { - id: 'course.duplication.Duplication.existingCourse', - defaultMessage: 'Existing Course', - }, - duplicationDisabled: { - id: 'course.duplication.Duplication.duplicationDisabled', - defaultMessage: 'Duplication is disabled for this course.', - }, - noComponentsEnabled: { - id: 'course.duplication.Duplication.noComponentsEnabled', - defaultMessage: - 'All components with duplicable items are disabled. \ - You may enable them under course settings.', - }, -}); - -const styles = { - bodyGrid: { - display: 'grid', - gridTemplateColumns: '210px auto', - gridTemplateRows: 'auto', - }, - itemsSidebarHeader: { - padding: '25px 20px 0px 20px', - }, - mainPanel: { - marginTop: 15, - padding: '5px 40px 20px 40px', - }, - radioButtonGroup: { - marginTop: 20, - }, - duplicateAllButton: { - marginTop: 30, - }, -}; - -class Duplication extends Component { - componentDidMount() { - this.props.dispatch(fetchObjectsList()); - } - - renderBody() { - const { - isLoading, - isCourseSelected, - duplicationMode, - modesAllowed, - enabledComponents, - } = this.props; - if (isLoading) { - return ; - } - - if (!modesAllowed || modesAllowed.length < 1) { - return ( - - - - ); - } - if (!enabledComponents || enabledComponents.length < 1) { - return ( - - - - ); - } - - return ( -
-
{this.renderToCourseSidebar()}
- - - - - - {this.renderItemsSelectorSidebar()} - - {duplicationMode === duplicationModes.OBJECT && isCourseSelected ? ( - - - - ) : ( -
- )} -
- ); - } - - renderItemsSelectorSidebar() { - const { duplicationMode, isCourseSelected } = this.props; - - if (duplicationMode === duplicationModes.COURSE) { - return ; - } - if (isCourseSelected) { - return ( -
- - - - -
- ); - } - return
; - } - - renderToCourseModeSelector() { - const { dispatch, duplicationMode } = this.props; - return ( - dispatch(actions.setDuplicationMode(mode))} - style={styles.radioButtonGroup} - value={duplicationMode} - > - } - label={} - value={duplicationModes.COURSE} - /> - } - label={} - value={duplicationModes.OBJECT} - /> - - ); - } - - renderToCourseSidebar() { - const { dispatch, modesAllowed } = this.props; - const header = ( - - - - ); - - const isSingleValidMode = - modesAllowed && - modesAllowed.length === 1 && - duplicationModes[modesAllowed[0]]; - if (isSingleValidMode) { - dispatch(actions.setDuplicationMode(modesAllowed[0])); - return header; - } - - return ( - <> - {header} - {this.renderToCourseModeSelector()} - - ); - } - - render() { - const { sourceCourse } = this.props; - return ( - - } - > - {this.renderBody()} - - ); - } -} - -Duplication.propTypes = { - isLoading: PropTypes.bool.isRequired, - isCourseSelected: PropTypes.bool.isRequired, - isChangingCourse: PropTypes.bool.isRequired, - duplicationMode: PropTypes.string.isRequired, - modesAllowed: PropTypes.arrayOf(PropTypes.string), - enabledComponents: PropTypes.arrayOf(PropTypes.string), - currentHost: PropTypes.string.isRequired, - currentCourseId: PropTypes.number, - sourceCourse: sourceCourseShape.isRequired, - - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object, -}; - -const handle = translations.duplicateData; - -export default Object.assign( - connect(({ duplication }) => ({ - isLoading: duplication.isLoading, - isChangingCourse: duplication.isChangingCourse, - isCourseSelected: !!duplication.destinationCourseId, - duplicationMode: duplication.duplicationMode, - modesAllowed: duplication.sourceCourse.duplicationModesAllowed, - enabledComponents: duplication.sourceCourse.enabledComponents, - currentHost: duplication.currentHost, - currentCourseId: duplication.currentCourseId, - sourceCourse: duplication.sourceCourse, - }))(injectIntl(Duplication)), - { handle }, -); diff --git a/client/app/bundles/course/duplication/pages/Duplication/index.tsx b/client/app/bundles/course/duplication/pages/Duplication/index.tsx new file mode 100644 index 00000000000..d28cc799830 --- /dev/null +++ b/client/app/bundles/course/duplication/pages/Duplication/index.tsx @@ -0,0 +1,255 @@ +import { FC, useEffect } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Alert, + FormControlLabel, + ListSubheader, + Paper, + Radio, + RadioGroup, + Typography, +} from '@mui/material'; + +import { fetchObjectsList } from 'course/duplication/operations'; +import { selectDuplicationStore } from 'course/duplication/selectors'; +import { actions } from 'course/duplication/store'; +import { DuplicationMode } from 'course/duplication/types'; +import Page from 'lib/components/core/layouts/Page'; +import Link from 'lib/components/core/Link'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import DestinationCourseSelector from './DestinationCourseSelector'; +import DuplicateAllButton from './DuplicateAllButton'; +import ItemsSelector from './ItemsSelector'; +import ItemsSelectorMenu from './ItemsSelectorMenu'; + +const translations = defineMessages({ + duplicateData: { + id: 'course.duplication.Duplication.duplicateData', + defaultMessage: 'Duplicate Data', + }, + fromCourse: { + id: 'course.duplication.Duplication.fromCourse', + defaultMessage: 'Duplicate data from {courseTitle}', + }, + toCourse: { + id: 'course.duplication.Duplication.toCourse', + defaultMessage: 'To', + }, + items: { + id: 'course.duplication.Duplication.items', + defaultMessage: 'Selected Items', + }, + newCourse: { + id: 'course.duplication.Duplication.newCourse', + defaultMessage: 'New Course', + }, + existingCourse: { + id: 'course.duplication.Duplication.existingCourse', + defaultMessage: 'Existing Course', + }, + duplicationDisabled: { + id: 'course.duplication.Duplication.duplicationDisabled', + defaultMessage: 'Duplication is disabled for this course.', + }, + noComponentsEnabled: { + id: 'course.duplication.Duplication.noComponentsEnabled', + defaultMessage: + 'All components with duplicable items are disabled. \ + You may enable them under course settings.', + }, + cannotDuplicateToNewCourseInThisInstance: { + id: 'course.duplication.Duplication.cannotDuplicateToNewCourseInThisInstance', + defaultMessage: + 'You cannot duplicate to a new course in {instanceHost} because you are not an instructor here. {duplicationLink}', + }, + requestInstanceInstructorRole: { + id: 'course.duplication.Duplication.requestInstanceInstructorRole', + defaultMessage: 'Request to be an instructor', + }, +}); + +const DuplicationModeSelector: FC<{ + duplicationMode: DuplicationMode; + modesAllowed: DuplicationMode[]; +}> = ({ duplicationMode, modesAllowed }) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + + const singleValidMode = + modesAllowed && modesAllowed.length === 1 && modesAllowed[0]; + + useEffect(() => { + if (singleValidMode) { + dispatch(actions.setDuplicationMode(singleValidMode)); + } + }, [dispatch, singleValidMode]); + + return ( + <> + + {t(translations.toCourse)} + + dispatch(actions.setDuplicationMode(mode))} + value={duplicationMode} + > + } + disabled={!modesAllowed.includes('COURSE')} + label={t(translations.newCourse)} + value="COURSE" + /> + } + disabled={!modesAllowed.includes('OBJECT')} + label={t(translations.existingCourse)} + value="OBJECT" + /> + + + ); +}; + +const ItemsSelectorSidebar: FC = () => { + const { duplicationMode, destinationCourseId } = useAppSelector( + selectDuplicationStore, + ); + const { t } = useTranslation(); + + const isCourseSelected = !!destinationCourseId; + + if (duplicationMode === 'OBJECT' && isCourseSelected) { + return ( +
+ + {t(translations.items)} + + +
+ ); + } + return null; +}; + +const DuplicationBody: FC = () => { + const { + isLoading, + sourceCourse: { duplicationModesAllowed, enabledComponents }, + destinationCourseId, + duplicationMode, + metadata: { currentInstanceId, currentInstanceHost }, + destinationInstances, + } = useAppSelector(selectDuplicationStore); + const { t } = useTranslation(); + + const isCourseSelected = !!destinationCourseId; + const modesAllowed = duplicationModesAllowed.filter( + (mode) => Object.keys(destinationInstances).length > 0 || mode === 'OBJECT', + ); + + if (isLoading) { + return ; + } + + if (!modesAllowed || modesAllowed.length < 1) { + return ( + + {t(translations.duplicationDisabled)} + + ); + } + + if (!enabledComponents || enabledComponents.length < 1) { + return ( + + {t(translations.noComponentsEnabled)} + + ); + } + + return ( + <> + {!(currentInstanceId in destinationInstances) && ( + + {t(translations.cannotDuplicateToNewCourseInThisInstance, { + instanceHost: currentInstanceHost, + duplicationLink: ( + <> +
+ + {t(translations.requestInstanceInstructorRole)} + + + ), + })} +
+ )} +
+
+ + {duplicationMode === 'COURSE' && ( +
+ +
+ )} +
+ +
+ + + +
+ +
+ +
+ +
+ {duplicationMode === 'OBJECT' && isCourseSelected && ( + + + + )} +
+
+ + ); +}; + +const DuplicationPage: FC = () => { + const { sourceCourse } = useAppSelector(selectDuplicationStore); + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + + useEffect(() => { + dispatch(fetchObjectsList()); + }, [dispatch]); + + return ( + + + + ); +}; + +const handle = translations.duplicateData; + +export default Object.assign(DuplicationPage, { handle }); diff --git a/client/app/bundles/course/duplication/selectors.ts b/client/app/bundles/course/duplication/selectors.ts new file mode 100644 index 00000000000..cb3a54869ab --- /dev/null +++ b/client/app/bundles/course/duplication/selectors.ts @@ -0,0 +1,29 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { AppState } from 'store'; + +import { DuplicationInstanceListData, DuplicationState } from './types'; + +export const selectDuplicationStore = (state: AppState): DuplicationState => + state.duplication as DuplicationState; + +export const selectDestinationInstances = createSelector( + selectDuplicationStore, + (duplicationStore) => + duplicationStore.destinationInstances as Record< + number, + DuplicationInstanceListData + >, +); + +export const selectDestinationCourse = createSelector( + selectDuplicationStore, + (duplicationStore) => { + const { destinationCourseId, destinationCourses } = duplicationStore; + if (destinationCourseId === null || !destinationCourses) { + return null; + } + return destinationCourses.find( + (course) => course.id === destinationCourseId, + ); + }, +); diff --git a/client/app/bundles/course/duplication/selectors/destinationCourse.js b/client/app/bundles/course/duplication/selectors/destinationCourse.js deleted file mode 100644 index 4d4e8028752..00000000000 --- a/client/app/bundles/course/duplication/selectors/destinationCourse.js +++ /dev/null @@ -1,19 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; - -const destinationCourseIdSelector = (state) => - state.duplication.destinationCourseId; -const destinationCoursesSelector = (state) => - state.duplication.destinationCourses; - -const destinationCourseSelector = createSelector( - destinationCourseIdSelector, - destinationCoursesSelector, - (id, courses) => { - if (id === null || !courses) { - return null; - } - return courses.find((course) => course.id === id); - }, -); - -export default destinationCourseSelector; diff --git a/client/app/bundles/course/duplication/selectors/destinationInstance.ts b/client/app/bundles/course/duplication/selectors/destinationInstance.ts deleted file mode 100644 index 8185d4702e0..00000000000 --- a/client/app/bundles/course/duplication/selectors/destinationInstance.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from 'store'; -import { DuplicationInstanceListData } from 'types/course/duplication'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const selectDuplicationStore = (state: AppState): any => state.duplication; - -export const selectDestinationInstances = createSelector( - selectDuplicationStore, - (duplicationStore) => - duplicationStore.destinationInstances as Record< - number, - DuplicationInstanceListData - >, -); - -export const selectMetadata = createSelector( - selectDuplicationStore, - (duplicationStore) => duplicationStore.metadata, -); diff --git a/client/app/bundles/course/duplication/store.js b/client/app/bundles/course/duplication/store.js index 02e9d1e69f8..821e6914eb7 100644 --- a/client/app/bundles/course/duplication/store.js +++ b/client/app/bundles/course/duplication/store.js @@ -1,6 +1,6 @@ import { produce } from 'immer'; -import actionTypes, { duplicationModes } from 'course/duplication/constants'; +import actionTypes from 'course/duplication/constants'; import { getEmptySelectedItems, nestFolders } from 'course/duplication/utils'; const initialState = { @@ -9,12 +9,12 @@ const initialState = { destinationCourseId: null, destinationCourses: [], destinationInstances: {}, - duplicationMode: duplicationModes.COURSE, + duplicationMode: 'COURSE', currentItemSelectorPanel: null, metadata: { - canDuplicateToAnotherInstance: false, currentInstanceId: 0, + currentInstanceHost: '', }, currentHost: '', diff --git a/client/app/bundles/course/duplication/types.ts b/client/app/bundles/course/duplication/types.ts new file mode 100644 index 00000000000..c6bd9e0a1a3 --- /dev/null +++ b/client/app/bundles/course/duplication/types.ts @@ -0,0 +1,142 @@ +// These are mirrored in app/helpers/course/object_duplications_helper.rb +export const DUPLICABLE_ITEM_TYPES = [ + 'ASSESSMENT', + 'TAB', + 'CATEGORY', + 'SURVEY', + 'ACHIEVEMENT', + 'FOLDER', + 'MATERIAL', + 'VIDEO', + 'VIDEO_TAB', +] as const; + +export type DuplicableItemType = (typeof DUPLICABLE_ITEM_TYPES)[number]; + +// These are mirrored in app/helpers/course/object_duplications_helper.rb +export const ITEM_SELECTOR_PANELS = [ + 'ASSESSMENTS', + 'SURVEYS', + 'ACHIEVEMENTS', + 'MATERIALS', + 'VIDEOS', +] as const; + +export type ItemSelectorPanel = (typeof ITEM_SELECTOR_PANELS)[number]; + +export const DUPLICATION_MODES = ['COURSE', 'OBJECT'] as const; + +export type DuplicationMode = (typeof DUPLICATION_MODES)[number]; + +export interface DuplicationAchievementData { + id: number; + title: string; + published: boolean; + url: string; +} + +export interface DuplicationCategoryData { + id: number; + title: string; + tabs: { + id: number; + title: string; + assessments: { + id: number; + title: string; + published: boolean; + }[]; + }[]; +} +export type DuplicationTabData = DuplicationCategoryData['tabs'][number]; +export type DuplicationAssessmentData = + DuplicationTabData['assessments'][number]; + +export interface DuplicationFolderData { + id: number; + name: string; + parent_id: number | null; + materials: { id: number; name: string }[]; + subfolders: DuplicationFolderData[]; +} +export type DuplicationMaterialData = + DuplicationFolderData['materials'][number]; + +export interface DuplicationSurveyData { + id: number; + title: string; + published: boolean; +} + +export interface DuplicationVideoTabData { + id: number; + title: string; + parent_id: number | null; + videos: { + id: number; + title: string; + published: boolean; + }[]; +} +export type DuplicationVideoData = DuplicationVideoTabData['videos'][number]; + +export interface CourseDuplicationData { + sourceCourse: { + id: number; + title: string; + start_at: string; + duplicationModesAllowed: DuplicationMode[]; + enabledComponents: ItemSelectorPanel[]; + unduplicableObjectTypes: DuplicableItemType[]; + }; + achievementsComponent: DuplicationAchievementData[]; + assessmentsComponent: DuplicationCategoryData[]; + materialsComponent: DuplicationFolderData[]; + surveyComponent: DuplicationSurveyData[]; + videosComponent: DuplicationVideoTabData[]; +} + +export interface DuplicationInstanceListData { + id: number; + name: string; + host: string; +} + +export interface DuplicationData extends CourseDuplicationData { + destinationCourses: { + id: number; + title: string; + path: string; + host: string; + rootFolder: { + subfolders: string[]; + materials: string[]; + }; + enabledComponents: ItemSelectorPanel[]; + unduplicableObjectTypes: DuplicableItemType[]; + }[]; + destinationInstances: DuplicationInstanceListData[]; + metadata: { + currentInstanceId: number; + currentInstanceHost: string; + }; +} + +export interface DuplicationState + extends Omit { + confirmationOpen: boolean; + selectedItems: Record>; + destinationCourseId: number | null; + destinationInstances: Record< + number, + DuplicationInstanceListData & { weight: number } + >; + duplicationMode: DuplicationMode; + currentHost: string; + currentCourseId: number | null; + currentItemSelectorPanel: ItemSelectorPanel | null; + isLoading: boolean; + isChangingCourse: boolean; + isDuplicating: boolean; + isDuplicationSuccess: boolean; +} diff --git a/client/app/bundles/course/duplication/utils.js b/client/app/bundles/course/duplication/utils.js deleted file mode 100644 index c30e8bf4481..00000000000 --- a/client/app/bundles/course/duplication/utils.js +++ /dev/null @@ -1,51 +0,0 @@ -/* eslint-disable no-param-reassign */ -import { duplicableItemTypes } from './constants'; - -export const nestFolders = (folders) => { - const rootFolders = []; - - const idFolderHash = folders.reduce((hash, folder) => { - folder.subfolders = []; - hash[folder.id] = folder; - return hash; - }, {}); - - folders.forEach((folder) => { - if (folder.parent_id === null) { - rootFolders.push(folder); - } else { - idFolderHash[folder.parent_id].subfolders.push(folder); - } - }); - - return rootFolders; -}; - -export const getEmptySelectedItems = () => - Object.keys(duplicableItemTypes).reduce((hash, type) => { - hash[type] = {}; - return hash; - }, {}); - -/** - * Prepares the payload containing ids and types of items selected for duplication. - * - * @param {object} selectedItemsHash Maps types to hashes that indicate which items have been selected, e.g. - * { TAB: { 3: true, 4: false }, SURVEY: { 9: true }, CATEGORY: { 10: false } } - * @return {object} Maps types to arrays with ids of items that have been selected, e.g. - * { TAB: [3], SURVEY: [9] } - */ -export const getItemsPayload = (selectedItemsHash) => - Object.keys(selectedItemsHash).reduce((hash, key) => { - const idsHash = selectedItemsHash[key]; - const idsArray = Object.keys(idsHash).reduce((selectedIds, id) => { - if (idsHash[id]) { - selectedIds.push(id); - } - return selectedIds; - }, []); - if (idsArray.length > 0) { - hash[key] = idsArray; - } - return hash; - }, {}); diff --git a/client/app/bundles/course/duplication/utils.ts b/client/app/bundles/course/duplication/utils.ts new file mode 100644 index 00000000000..a2877313746 --- /dev/null +++ b/client/app/bundles/course/duplication/utils.ts @@ -0,0 +1,66 @@ +import { + DUPLICABLE_ITEM_TYPES, + DuplicableItemType, + DuplicationFolderData, +} from './types'; + +export const nestFolders = ( + folders: DuplicationFolderData[], +): DuplicationFolderData[] => { + const rootFolders: DuplicationFolderData[] = []; + + const idFolderHash: Record = folders.reduce( + (hash, folder) => { + hash[folder.id] = { ...folder, subfolders: [] }; + return hash; + }, + {}, + ); + + Object.values(idFolderHash).forEach((folder) => { + if (folder.parent_id === null) { + rootFolders.push(folder); + } else { + idFolderHash[folder.parent_id].subfolders.push(folder); + } + }); + + return rootFolders; +}; + +export const getEmptySelectedItems: () => Record< + DuplicableItemType, + Record +> = () => + DUPLICABLE_ITEM_TYPES.reduce((hash, type) => { + hash[type] = {}; + return hash; + }, {}) as Record>; + +/** + * Prepares the payload containing ids and types of items selected for duplication. + * + * @param {object} selectedItemsHash Maps types to hashes that indicate which items have been selected, e.g. + * { TAB: { 3: true, 4: false }, SURVEY: { 9: true }, CATEGORY: { 10: false } } + * @return {object} Maps types to arrays with ids of items that have been selected, e.g. + * { TAB: [3], SURVEY: [9] } + */ +export const getItemsPayload = ( + selectedItemsHash: Record>, +): Record => + Object.keys(selectedItemsHash).reduce((hash, key) => { + const idsHash = selectedItemsHash[key]; + const idsArray = Object.keys(idsHash).reduce( + (selectedIds, id) => { + if (idsHash[id]) { + selectedIds.push(id); + } + return selectedIds; + }, + [], + ); + if (idsArray.length > 0) { + hash[key] = idsArray; + } + return hash; + }, {}) as Record; diff --git a/client/app/bundles/system/admin/instance/instance/components/forms/InstanceUserRoleRequestForm.tsx b/client/app/bundles/system/admin/instance/instance/components/forms/InstanceUserRoleRequestForm.tsx index f6e9b998f7a..5b131acc91e 100644 --- a/client/app/bundles/system/admin/instance/instance/components/forms/InstanceUserRoleRequestForm.tsx +++ b/client/app/bundles/system/admin/instance/instance/components/forms/InstanceUserRoleRequestForm.tsx @@ -8,11 +8,13 @@ import { import { actions } from 'bundles/course/courses/store'; import FormDialog from 'lib/components/form/dialog/FormDialog'; +import FormSelectField from 'lib/components/form/fields/SelectField'; import FormTextField from 'lib/components/form/fields/TextField'; import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper'; import { useAppDispatch } from 'lib/hooks/store'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; +import instanceRoleTranslations from 'lib/translations/instance/users/roles'; import tableTranslations from 'lib/translations/table'; import { createRoleRequest, updateRoleRequest } from '../../operations'; @@ -106,15 +108,16 @@ const InstanceUserRoleRequestForm: FC = (props) => { control={control} name="role" render={({ field, fieldState }): JSX.Element => ( - ({ + label: t(instanceRoleTranslations[option]), + value: option, + }))} + shrink variant="standard" /> )} diff --git a/client/app/types/course/duplication.ts b/client/app/types/course/duplication.ts deleted file mode 100644 index f36b1f4f2ee..00000000000 --- a/client/app/types/course/duplication.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface DuplicationInstanceListData { - id: number; - name: string; - host: string; -} diff --git a/client/locales/en.json b/client/locales/en.json index 857c8288ebf..66413747666 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -4530,6 +4530,9 @@ "course.duplication.CourseDropdownMenu.currentCourse": { "defaultMessage": "Select Current Course" }, + "course.duplication.Duplication.DestinationCourseSelector.InstanceDropdown.currentInstance": { + "defaultMessage": "Select current instance" + }, "course.duplication.Duplication.DestinationCourseSelector.InstanceDropdown.destinationInstance": { "defaultMessage": "Destination Instance" }, @@ -4629,6 +4632,9 @@ "course.duplication.Duplication.ItemsSelector.pleaseSelectItems": { "defaultMessage": "Please select items to duplicate via the sidebar." }, + "course.duplication.Duplication.cannotDuplicateToNewCourseInThisInstance": { + "defaultMessage": "You cannot duplicate to a new course in {instanceHost} because you are not an instructor here. {duplicationLink}" + }, "course.duplication.Duplication.duplicateData": { "defaultMessage": "Duplicate Data" }, @@ -4650,6 +4656,9 @@ "course.duplication.Duplication.noComponentsEnabled": { "defaultMessage": "All components with duplicable items are disabled. You may enable them under course settings." }, + "course.duplication.Duplication.requestInstanceInstructorRole": { + "defaultMessage": "Request to be an instructor" + }, "course.duplication.Duplication.toCourse": { "defaultMessage": "To" }, diff --git a/client/locales/ko.json b/client/locales/ko.json index e83fec9896e..2592efdaf4a 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -4511,6 +4511,9 @@ "course.duplication.CourseDropdownMenu.currentCourse": { "defaultMessage": "현재 과정 선택" }, + "course.duplication.Duplication.DestinationCourseSelector.InstanceDropdown.currentInstance": { + "defaultMessage": "현재 인스턴스 선택" + }, "course.duplication.Duplication.DestinationCourseSelector.InstanceDropdown.destinationInstance": { "defaultMessage": "대상 인스턴스" }, @@ -4610,6 +4613,9 @@ "course.duplication.Duplication.ItemsSelector.pleaseSelectItems": { "defaultMessage": "사이드바를 통해 복사할 항목을 선택하세요." }, + "course.duplication.Duplication.cannotDuplicateToNewCourseInThisInstance": { + "defaultMessage": "{instanceHost}에서 강사가 아니므로 새 과정으로 복사할 수 없습니다. {duplicationLink}" + }, "course.duplication.Duplication.duplicateData": { "defaultMessage": "데이터 복사" }, @@ -4631,6 +4637,9 @@ "course.duplication.Duplication.noComponentsEnabled": { "defaultMessage": "복사 가능한 항목이 있는 모든 구성 요소가 비활성화되었습니다. 과정 설정에서 활성화할 수 있습니다." }, + "course.duplication.Duplication.requestInstanceInstructorRole": { + "defaultMessage": "강사 역할 요청" + }, "course.duplication.Duplication.toCourse": { "defaultMessage": "대상" }, diff --git a/client/locales/zh.json b/client/locales/zh.json index d97404f2baa..93d955f80be 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -4505,6 +4505,9 @@ "course.duplication.CourseDropdownMenu.currentCourse": { "defaultMessage": "选择当前课程" }, + "course.duplication.Duplication.DestinationCourseSelector.InstanceDropdown.currentInstance": { + "defaultMessage": "选择当前实例" + }, "course.duplication.Duplication.DestinationCourseSelector.InstanceDropdown.destinationInstance": { "defaultMessage": "目标实例" }, @@ -4604,6 +4607,9 @@ "course.duplication.Duplication.ItemsSelector.pleaseSelectItems": { "defaultMessage": "请通过边栏选择要复制的项目。" }, + "course.duplication.Duplication.cannotDuplicateToNewCourseInThisInstance": { + "defaultMessage": "您无法在 {instanceHost} 中复制到新课程,因为您不是该实例的讲师。{duplicationLink}" + }, "course.duplication.Duplication.duplicateData": { "defaultMessage": "复制数据" }, @@ -4625,6 +4631,9 @@ "course.duplication.Duplication.noComponentsEnabled": { "defaultMessage": "所有包含重复项目的组件都被禁用。你可以在课程设置下启用它们。" }, + "course.duplication.Duplication.requestInstanceInstructorRole": { + "defaultMessage": "申请讲师角色" + }, "course.duplication.Duplication.toCourse": { "defaultMessage": "到" }, diff --git a/spec/controllers/course/duplications_controller_spec.rb b/spec/controllers/course/duplications_controller_spec.rb index b70aa0fb981..96bd36cb91b 100644 --- a/spec/controllers/course/duplications_controller_spec.rb +++ b/spec/controllers/course/duplications_controller_spec.rb @@ -35,6 +35,36 @@ expect(response).to be_successful end end + + context 'when a course manager (without instance instructor role) duplicates to the same instance' do + let(:course_manager_user) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, course_manager_user) } + + subject do + post :create, format: :json, params: { + course_id: course.id, duplication: { + destination_instance_id: instance.id, new_title: 'Copy', new_start_at: Time.now + } + } + end + + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + + context 'when a user is instructor in the destination instance but not the source instance' do + let(:dest_instructor_user) do + user = create(:user) + create(:course_manager, course: course, user: user) + ActsAsTenant.with_tenant(destination_instance) { create(:instance_user, :instructor, user: user) } + user + end + before { controller_sign_in(controller, dest_instructor_user) } + + it 'allows duplication to the destination instance' do + subject + expect(response).to be_successful + end + end end end end diff --git a/spec/controllers/course/object_duplication_controller_spec.rb b/spec/controllers/course/object_duplication_controller_spec.rb index 991b7addcf4..6d8b6bf2f4d 100644 --- a/spec/controllers/course/object_duplication_controller_spec.rb +++ b/spec/controllers/course/object_duplication_controller_spec.rb @@ -59,6 +59,52 @@ expect(instance_ids).to contain_exactly(instance.id) end end + + context 'when a course manager without any instance role fetches the destination data' do + let(:course_manager_user) { create(:course_manager, course: course).user } + before do + controller_sign_in(controller, course_manager_user) + subject + end + + it 'returns no destination instances' do + instance_ids = json_response['destinationInstances'].map { |inst| inst['id'] } + expect(instance_ids).to be_empty + end + end + + context 'when a user who is instructor only in another instance fetches the destination data' do + let(:other_instance_instructor_user) do + user = create(:user) + ActsAsTenant.with_tenant(other_instance) { create(:instance_user, :instructor, user: user) } + create(:course_manager, course: course, user: user) + user + end + before do + controller_sign_in(controller, other_instance_instructor_user) + subject + end + + it 'includes only the other instance where user is instructor' do + instance_ids = json_response['destinationInstances'].map { |inst| inst['id'] } + expect(instance_ids).to contain_exactly(other_instance.id) + end + end + + context 'when fetching the metadata' do + before do + controller_sign_in(controller, user) + subject + end + + it 'includes currentInstanceHost in metadata' do + expect(json_response['metadata']['currentInstanceHost']).to eq(instance.host) + end + + it 'does not include canDuplicateToAnotherInstance in metadata' do + expect(json_response['metadata']).not_to have_key('canDuplicateToAnotherInstance') + end + end end describe '#create' do diff --git a/spec/features/course/duplication_spec.rb b/spec/features/course/duplication_spec.rb index b94063baaa3..d1d091e8314 100644 --- a/spec/features/course/duplication_spec.rb +++ b/spec/features/course/duplication_spec.rb @@ -73,6 +73,26 @@ wait_for_job expect(course.assessments.where(title: assessment_title1).count).to be(1) end + end + + context 'As a Course Administrator and Instance Instructor' do + let(:user) do + user = create(:instance_user, :instructor).user + create(:course_manager, course: course, user: user).user + end + let(:source_course) { create(:course) } + let!(:course_user) { create(:course_manager, course: source_course, user: user) } + let(:assessment_title1) { SecureRandom.hex } + let(:assessment_title2) { SecureRandom.hex } + let(:new_course_title) { SecureRandom.hex } + let!(:assessment1) { create(:assessment, title: assessment_title1, tab: source_course.assessment_tabs.first) } + + let!(:assessment2) do + create(:assessment, + title: assessment_title2, + tab: source_course.assessment_tabs.first, + end_at: 2.days.from_now) + end scenario 'I can duplicate the whole course' do visit course_duplication_path(source_course)