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 (
+ <>
+
+
+ ) : null
+ }
+ onClick={() => setConfirmationOpen(true)}
+ variant="contained"
+ >
+ {t(translations.duplicateCourse)}
+
+
+
+ {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={
-
-
-
-
- {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}
+
+ }
+ />
+);
+
+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)