diff --git a/app/assets/javascripts/actions/training_modification_actions.js b/app/assets/javascripts/actions/training_modification_actions.js
new file mode 100644
index 0000000000..bcaa447d7a
--- /dev/null
+++ b/app/assets/javascripts/actions/training_modification_actions.js
@@ -0,0 +1,277 @@
+import { API_FAIL } from '../constants/api';
+import { addNotification } from '../actions/notification_actions';
+import logErrorMessage from '../utils/log_error_message';
+import request from '../utils/request';
+import { setInvalid } from './validation_actions';
+import { SET_TRAINING_MODE } from '../constants/training';
+
+// For Modifying Training Content
+const libraryValidationRules = [
+ { keyword: 'name', field: 'name' },
+ { keyword: 'slug', field: 'slug' },
+ { keyword: 'introduction', field: 'introduction' }
+];
+
+const categoryValidationRules = [
+ { keyword: 'title', field: 'title' },
+ { keyword: 'description', field: 'description' }
+];
+
+const moduleValidationRules = [
+ { keyword: 'name', field: 'name' },
+ { keyword: 'slug', field: 'slug' },
+ { keyword: 'description', field: 'description' }
+];
+
+const slideValidationRules = [
+ { keyword: 'title', field: 'title' },
+ { keyword: 'slug', field: 'slug' },
+ { keyword: 'wikipage', field: 'wiki_page' }
+];
+
+// For switching between edit and view mode
+export const getTrainingMode = () => (dispatch) => {
+ dispatch({
+ type: SET_TRAINING_MODE,
+ });
+};
+
+const updateTrainingModePromise = async (editMode, setUpdatingEditMode) => {
+ const body = {
+ edit_mode: editMode,
+ };
+ const response = await request('training_mode/update', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ });
+ setUpdatingEditMode(false);
+ if (!response.ok) {
+ logErrorMessage(response);
+ const data = await response.text();
+ response.responseText = data;
+ throw response;
+ }
+ return response.json();
+};
+
+export const updateTrainingMode = (editMode, setUpdatingEditMode) => (dispatch) => {
+ return updateTrainingModePromise(editMode, setUpdatingEditMode)
+ .then(() => window.location.reload())
+ .catch(data => dispatch({ type: API_FAIL, data }));
+};
+
+const performValidation = (error, dispatch, validationRules) => {
+ const errorMessages = error.responseText.errorMessages;
+ let apiFailDispatched = false;
+
+ for (let i = 0; i < errorMessages.length; i += 1) {
+ const message = errorMessages[i];
+ const lowercaseMessage = message.toLowerCase();
+ const matchedRule = validationRules.find(rule => lowercaseMessage.includes(rule.keyword));
+
+ if (matchedRule) {
+ dispatch(setInvalid(matchedRule.field, message));
+ } else {
+ if (!apiFailDispatched) {
+ dispatch({ type: API_FAIL, data: error });
+ apiFailDispatched = true;
+ }
+ return;
+ }
+ }
+};
+
+
+// For Creating New Library
+const createLibraryPromise = async (library, setSubmitting) => {
+ const response = await request('/training/create_library', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(library),
+ });
+ setSubmitting(false);
+ if (!response.ok) {
+ logErrorMessage(response);
+ const data = await response.json();
+ response.responseText = data;
+ throw response;
+ }
+ return response.json();
+};
+
+export const createLibrary = (library, setSubmitting, toggleModal) => (dispatch) => {
+ return createLibraryPromise(library, setSubmitting)
+ .then(() => {
+ toggleModal();
+ dispatch(addNotification({
+ type: 'success',
+ message: 'Library Created Successfully.',
+ closable: true
+ }));
+ window.location.reload();
+ })
+ .catch((error) => {
+ performValidation(error, dispatch, libraryValidationRules);
+ });
+};
+
+// For Creating New Category
+const createCategoryPromise = async (library_id, category, setSubmitting) => {
+ const response = await request(`/training/${library_id}/create_category`, {
+ method: 'POST',
+ body: JSON.stringify({ category }),
+ });
+ setSubmitting(false);
+ if (!response.ok) {
+ logErrorMessage(response);
+ const data = await response.json();
+ response.responseText = data;
+ throw response;
+ }
+ return response.json();
+};
+
+export const createCategory = (library_id, category, setSubmitting, toggleModal) => (dispatch) => {
+ return createCategoryPromise(library_id, category, setSubmitting)
+ .then(() => {
+ toggleModal();
+ dispatch(addNotification({
+ type: 'success',
+ message: 'Category Created Successfully.',
+ closable: true
+ }));
+ window.location.reload();
+ })
+ .catch((error) => {
+ performValidation(error, dispatch, categoryValidationRules);
+ });
+};
+
+// For Adding New Module
+const addModulePromise = async (library_id, category_id, module, setSubmitting) => {
+ const response = await request(`/training/${library_id}/${category_id}/add_module`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ module }),
+ });
+ setSubmitting(false);
+ if (!response.ok) {
+ logErrorMessage(response);
+ const data = await response.json();
+ response.responseText = data;
+ throw response;
+ }
+ return response.json();
+};
+
+export const addModule = (library_id, category_id, module, setSubmitting) => (dispatch) => {
+ return addModulePromise(library_id, category_id, module, setSubmitting)
+ .then(() => {
+ window.location.href = `/training/${library_id}`;
+ })
+ .catch((error) => {
+ performValidation(error, dispatch, moduleValidationRules);
+ });
+};
+
+// For Transferring Modules
+const transferModulesPromise = async (library_id, transferInfo, setSubmitting) => {
+ const response = await request(`/training/${library_id}/transfer_modules`, {
+ method: 'PUT',
+ body: JSON.stringify({ transferInfo }),
+ });
+ setSubmitting(false);
+ if (!response.ok) {
+ logErrorMessage(response);
+ const data = await response.json();
+ response.responseText = data;
+ throw response;
+ }
+ return response.json();
+};
+
+export const transferModules = (library_id, transferInfo, setSubmitting) => (dispatch) => {
+ return transferModulesPromise(library_id, transferInfo, setSubmitting)
+ .then(() => {
+ window.location.reload();
+ })
+ .catch(data => dispatch({ type: API_FAIL, data }));
+};
+
+// For Adding New Slide to Training Module
+const addSlidePromise = async (library_id, module_id, slide, setSubmitting) => {
+ const response = await request(`/training/${library_id}/${module_id}/add_slide`, {
+ method: 'POST',
+ body: JSON.stringify({ slide }),
+ });
+ setSubmitting(false);
+ if (!response.ok) {
+ logErrorMessage(response);
+ const data = await response.json();
+ response.responseText = data;
+ throw response;
+ }
+ return response.json();
+};
+
+export const addSlide = (library_id, module_id, slide, setSubmitting) => (dispatch) => {
+ // Transform wiki_page if it starts with the base URL
+ const wikiBaseUrl = 'https://meta.wikimedia.org/wiki/';
+ slide.wiki_page = slide.wiki_page.startsWith(wikiBaseUrl)
+ ? slide.wiki_page.replace(wikiBaseUrl, '')
+ : slide.wiki_page;
+ return addSlidePromise(library_id, module_id, slide, setSubmitting)
+ .then(() => window.location.reload())
+ .catch((error) => {
+ performValidation(error, dispatch, slideValidationRules);
+ });
+};
+
+// For Removing Slide from Training Module
+const removeSlidesPromise = async (module_id, slideSlugList) => {
+ const response = await request(`/training/${module_id}/remove_slide`, {
+ method: 'DELETE',
+ body: JSON.stringify({ slideSlugList }),
+ });
+ if (!response.ok) {
+ logErrorMessage(response);
+ const data = await response.json();
+ response.responseText = data;
+ throw response;
+ }
+ return response.json();
+};
+
+export const removeSlides = (module_id, slideSlugList) => (dispatch) => {
+ return removeSlidesPromise(module_id, slideSlugList)
+ .then(() => window.location.reload())
+ .catch(data => dispatch({ type: API_FAIL, data }));
+};
+
+// For Reordering Slides
+const reorderSlidesPromise = async (module_id, slides, setSubmitting) => {
+ // Only sending necessary data to server
+ const reorderedSlides = slides.map(slide => slide.slug);
+ const response = await request(`/training/${module_id}/reorder_slides`, {
+ method: 'PUT',
+ body: JSON.stringify({ reorderedSlides }),
+ });
+ setSubmitting(false);
+ if (!response.ok) {
+ logErrorMessage(response);
+ const data = await response.json();
+ response.responseText = data;
+ throw response;
+ }
+ return response.json();
+};
+
+export const reorderSlides = (module_id, slides, setSubmitting) => (dispatch) => {
+ return reorderSlidesPromise(module_id, slides, setSubmitting)
+ .then(() => window.location.reload())
+ .catch(data => dispatch({ type: API_FAIL, data }));
+};
diff --git a/app/assets/javascripts/components/common/text_area_input.jsx b/app/assets/javascripts/components/common/text_area_input.jsx
index d5e0e2a6a3..1dd9d88719 100644
--- a/app/assets/javascripts/components/common/text_area_input.jsx
+++ b/app/assets/javascripts/components/common/text_area_input.jsx
@@ -24,10 +24,13 @@ const TextAreaInput = ({
markdown,
className,
clearOnSubmit,
- invalid
+ invalid,
+ label,
+ spacer
}) => {
const [tinymceLoaded, setTinymceLoaded] = useState(false);
const [activeEditor, setActiveEditor] = useState(null);
+ const labelContent = label ? `${label}${spacer || ': '}` : undefined;
useEffect(() => {
if (wysiwyg) {
@@ -113,7 +116,14 @@ const TextAreaInput = ({
);
}
- return
{inputElement}
;
+ return (
+
+
+ {inputElement}
+
+ );
}
// ////////////
@@ -143,7 +153,9 @@ TextAreaInput.propTypes = {
wysiwyg: PropTypes.bool, // use rich text editor instead of plain text
markdown: PropTypes.bool, // render value as Markdown when in read mode
className: PropTypes.string,
- clearOnSubmit: PropTypes.bool
+ clearOnSubmit: PropTypes.bool,
+ label: PropTypes.string,
+ spacer: PropTypes.string,
};
export default InputHOC(TextAreaInput);
diff --git a/app/assets/javascripts/constants/training.js b/app/assets/javascripts/constants/training.js
index f984069ca5..dbde0c6aa9 100644
--- a/app/assets/javascripts/constants/training.js
+++ b/app/assets/javascripts/constants/training.js
@@ -5,3 +5,4 @@ export const SET_CURRENT_SLIDE = 'SET_CURRENT_SLIDE';
export const RECEIVE_ALL_TRAINING_MODULES = 'RECEIVE_ALL_TRAINING_MODULES';
export const SLIDE_COMPLETED = 'SLIDE_COMPLETED';
export const EXERCISE_COMPLETION_UPDATE = 'EXERCISE_COMPLETION_UPDATE';
+export const SET_TRAINING_MODE = 'SET_TRAINING_MODE';
diff --git a/app/assets/javascripts/reducers/training.js b/app/assets/javascripts/reducers/training.js
index 5bb3423e09..0a475d22c9 100644
--- a/app/assets/javascripts/reducers/training.js
+++ b/app/assets/javascripts/reducers/training.js
@@ -2,9 +2,11 @@ import { findIndex } from 'lodash-es';
import {
RECEIVE_TRAINING_MODULE, MENU_TOGGLE, REVIEW_ANSWER,
SET_CURRENT_SLIDE, RECEIVE_ALL_TRAINING_MODULES,
- SLIDE_COMPLETED
+ SLIDE_COMPLETED, SET_TRAINING_MODE
} from '../constants';
+const mainDiv = document.getElementById('main');
+
const reviewAnswer = function (state, answer) {
const answerId = parseInt(answer);
const temp = { ...state, currentSlide: { ...state.currentSlide, selectedAnswer: answerId } };
@@ -69,7 +71,8 @@ const initialState = {
enabledSlides: [],
loading: true,
completed: null,
- valid: false
+ valid: false,
+ editMode: false,
};
export default function training(state = initialState, action) {
@@ -100,6 +103,11 @@ export default function training(state = initialState, action) {
enabledSlides: [...state.enabledSlides, data.slide.id],
completed: data.completed
};
+ case SET_TRAINING_MODE:
+ return {
+ ...state,
+ editMode: JSON.parse(mainDiv.getAttribute('data-training_mode')).editMode
+ };
default:
return state;
}
diff --git a/app/assets/javascripts/training/components/draggableSlide.jsx b/app/assets/javascripts/training/components/draggableSlide.jsx
new file mode 100644
index 0000000000..413f110093
--- /dev/null
+++ b/app/assets/javascripts/training/components/draggableSlide.jsx
@@ -0,0 +1,48 @@
+import React, { useState } from 'react';
+import { useBlockDrag } from '../../hooks/useBlockDrag';
+
+const DraggableSlide = ({
+ slide,
+ heading,
+ description,
+ canDrag,
+ animating,
+ onDrag,
+ canSlideMoveUp,
+ canSlideMoveDown,
+ onSlideMoveUp,
+ onSlideMoveDown,
+ index
+ }) => {
+ const [isHoverArrowDown, setIsHoverArrowDown] = useState(false);
+ const [isHoverArrowUp, setIsHoverArrowUp] = useState(false);
+ const { ref, isDragging, handlerId } = useBlockDrag({
+ block: slide,
+ canDrag,
+ isAnimating: animating,
+ onBlockDragOver: onDrag
+ });
+
+ const opacity = isDragging ? 0.5 : 1;
+
+ return (
+
+
+
+
{heading}
+ {description?.split('\n').map((paragraph, i) => paragraph &&
{paragraph}
)}
+
+
+
+
+
+
+
+ );
+};
+
+export default DraggableSlide;
diff --git a/app/assets/javascripts/training/components/modals/add_module.jsx b/app/assets/javascripts/training/components/modals/add_module.jsx
new file mode 100644
index 0000000000..d53037994e
--- /dev/null
+++ b/app/assets/javascripts/training/components/modals/add_module.jsx
@@ -0,0 +1,136 @@
+import React, { useState, useEffect } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import { useNavigate, useLocation, useParams } from 'react-router-dom';
+import Modal from '../../../components/common/modal.jsx';
+import TextInput from '../../../components/common/text_input.jsx';
+import TextAreaInput from '../../../components/common/text_area_input.jsx';
+import { addModule } from '../../../actions/training_modification_actions.js';
+import { setValid, setInvalid, activateValidations, resetValidations } from '../../../actions/validation_actions.js';
+import { firstValidationErrorMessage } from '../../../selectors';
+
+const AddModule = (props) => {
+ const [submitting, setSubmitting] = useState(false);
+ const [module, setModule] = useState({
+ name: '',
+ slug: '',
+ description: ''
+ });
+ const firstErrorMessage = useSelector(state => firstValidationErrorMessage(state));
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { library_id, category_id } = useParams();
+ const libraryPath = location.pathname.split('/').slice(0, 3).join('/');
+
+ const handleInputChange = (key, value) => {
+ setModule(prevModule => ({
+ ...prevModule,
+ [key]: value
+ }));
+ };
+
+ useEffect(() => {
+ dispatch(resetValidations());
+ }, [props.editMode]);
+
+ const validateFields = () => {
+ let valid = true;
+ const message = I18n.t('training.validation_message');
+ if (!module.name.trim()) {
+ dispatch(setInvalid('name', message));
+ valid = false;
+ } else {
+ dispatch(setValid('name'));
+ }
+
+ if (!module.slug.trim()) {
+ dispatch(setInvalid('slug', message));
+ valid = false;
+ } else {
+ dispatch(setValid('slug'));
+ }
+
+ if (!module.description.trim()) {
+ dispatch(setInvalid('description', message));
+ valid = false;
+ } else {
+ dispatch(setValid('description'));
+ }
+
+ return valid;
+ };
+
+ const submitHandler = () => {
+ dispatch(activateValidations());
+
+ if (validateFields()) {
+ setSubmitting(true);
+ dispatch(addModule(library_id, category_id, module, setSubmitting));
+ }
+ };
+
+ const handleCancelClick = () => {
+ navigate(libraryPath);
+ };
+
+ let formStyle;
+ if (submitting) {
+ formStyle = { pointerEvents: 'none', opacity: '0.5' };
+ }
+
+ if (!props.editMode) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
{I18n.t('training.add_new_module')}
+
{I18n.t('training.add_module_msg')}
+
+
+
+
+ {firstErrorMessage || '\xa0'}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AddModule;
diff --git a/app/assets/javascripts/training/components/modals/add_slide.jsx b/app/assets/javascripts/training/components/modals/add_slide.jsx
new file mode 100644
index 0000000000..714cabea86
--- /dev/null
+++ b/app/assets/javascripts/training/components/modals/add_slide.jsx
@@ -0,0 +1,121 @@
+import React, { useState, useEffect } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import Modal from '../../../components/common/modal.jsx';
+import TextInput from '../../../components/common/text_input.jsx';
+import { addSlide } from '../../../actions/training_modification_actions.js';
+import { setValid, setInvalid, activateValidations, resetValidations } from '../../../actions/validation_actions.js';
+import { firstValidationErrorMessage } from '../../../selectors';
+
+const AddSlide = (props) => {
+ const [submitting, setSubmitting] = useState(false);
+ const [slide, setSlide] = useState({
+ title: '',
+ slug: '',
+ wiki_page: '',
+ });
+ const firstErrorMessage = useSelector(state => firstValidationErrorMessage(state));
+ const dispatch = useDispatch();
+
+ const handleInputChange = (key, value) => {
+ setSlide(prevSlide => ({
+ ...prevSlide,
+ [key]: value
+ }));
+ };
+
+ useEffect(() => {
+ dispatch(resetValidations());
+ }, []);
+
+ const validateFields = () => {
+ let valid = true;
+ const message = I18n.t('training.validation_message');
+ if (!slide.title.trim()) {
+ dispatch(setInvalid('title', message));
+ valid = false;
+ } else {
+ dispatch(setValid('title'));
+ }
+
+ if (!slide.slug.trim()) {
+ dispatch(setInvalid('slug', message));
+ valid = false;
+ } else {
+ dispatch(setValid('slug'));
+ }
+
+ if (!slide.wiki_page.trim()) {
+ dispatch(setInvalid('wiki_page', message));
+ valid = false;
+ } else {
+ dispatch(setValid('wiki_page'));
+ }
+
+ return valid;
+ };
+
+ const submitHandler = () => {
+ dispatch(activateValidations());
+
+ if (validateFields()) {
+ setSubmitting(true);
+ dispatch(addSlide(props.library_id, props.module_id, slide, setSubmitting));
+ }
+ };
+
+ let formStyle;
+ if (submitting) {
+ formStyle = { pointerEvents: 'none', opacity: '0.5' };
+ }
+
+ return (
+ <>
+
+
+
+
{I18n.t('training.add_slide')}
+
{I18n.t('training.add_slide_msg')}
+
+
+
+
+
+ {firstErrorMessage || '\xa0'}
+
+
+
+
+
+ >
+ );
+};
+
+export default AddSlide;
diff --git a/app/assets/javascripts/training/components/modals/create_category.jsx b/app/assets/javascripts/training/components/modals/create_category.jsx
new file mode 100644
index 0000000000..e006b6d637
--- /dev/null
+++ b/app/assets/javascripts/training/components/modals/create_category.jsx
@@ -0,0 +1,105 @@
+import React, { useState, useEffect } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import { useParams } from 'react-router-dom';
+import Modal from '../../../components/common/modal.jsx';
+import TextInput from '../../../components/common/text_input.jsx';
+import TextAreaInput from '../../../components/common/text_area_input.jsx';
+import { createCategory } from '../../../actions/training_modification_actions.js';
+import { setValid, setInvalid, activateValidations, resetValidations } from '../../../actions/validation_actions.js';
+import { firstValidationErrorMessage } from '../../../selectors';
+
+const CreateCategory = (props) => {
+ const [submitting, setSubmitting] = useState(false);
+ const [category, setCategory] = useState({
+ title: '',
+ description: ''
+ });
+ const firstErrorMessage = useSelector(state => firstValidationErrorMessage(state));
+ const { library_id } = useParams();
+ const dispatch = useDispatch();
+
+ const handleInputChange = (key, value) => {
+ setCategory(prevCategory => ({
+ ...prevCategory,
+ [key]: value
+ }));
+ };
+
+ useEffect(() => {
+ dispatch(resetValidations());
+ }, []);
+
+ const validateFields = () => {
+ let valid = true;
+ const message = I18n.t('training.validation_message');
+ if (!category.title.trim()) {
+ dispatch(setInvalid('title', message));
+ valid = false;
+ } else {
+ dispatch(setValid('title'));
+ }
+
+ if (!category.description.trim()) {
+ dispatch(setInvalid('description', message));
+ valid = false;
+ } else {
+ dispatch(setValid('description'));
+ }
+
+ return valid;
+ };
+
+ const submitHandler = () => {
+ dispatch(activateValidations());
+
+ if (validateFields()) {
+ setSubmitting(true);
+ dispatch(createCategory(library_id, category, setSubmitting, props.toggleModal));
+ }
+ };
+
+ let formStyle;
+ if (submitting) {
+ formStyle = { pointerEvents: 'none', opacity: '0.5' };
+ }
+
+ return (
+ <>
+
+
+
+
{I18n.t('training.create_category')}
+
{I18n.t('training.create_category_msg')}
+
+
+
+
+ {firstErrorMessage || '\xa0'}
+
+
+
+
+
+ >
+ );
+};
+
+export default CreateCategory;
diff --git a/app/assets/javascripts/training/components/modals/create_library.jsx b/app/assets/javascripts/training/components/modals/create_library.jsx
new file mode 100644
index 0000000000..a0d351978c
--- /dev/null
+++ b/app/assets/javascripts/training/components/modals/create_library.jsx
@@ -0,0 +1,123 @@
+import React, { useState, useEffect } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import Modal from '../../../components/common/modal.jsx';
+import TextInput from '../../../components/common/text_input.jsx';
+import TextAreaInput from '../../../components/common/text_area_input.jsx';
+import { createLibrary } from '../../../actions/training_modification_actions.js';
+import { setValid, setInvalid, activateValidations, resetValidations } from '../../../actions/validation_actions.js';
+import { firstValidationErrorMessage } from '../../../selectors';
+
+const CreateLibrary = (props) => {
+ const [submitting, setSubmitting] = useState(false);
+ const [library, setLibrary] = useState({
+ name: '',
+ slug: '',
+ introduction: ''
+ });
+ const firstErrorMessage = useSelector(state => firstValidationErrorMessage(state));
+ const dispatch = useDispatch();
+
+ const handleInputChange = (key, value) => {
+ setLibrary(prevLibrary => ({
+ ...prevLibrary,
+ [key]: value
+ }));
+ };
+
+ useEffect(() => {
+ dispatch(resetValidations());
+ }, []);
+
+ const validateFields = () => {
+ let valid = true;
+ const message = I18n.t('training.validation_message');
+ if (!library.name.trim()) {
+ dispatch(setInvalid('name', message));
+ valid = false;
+ } else {
+ dispatch(setValid('name'));
+ }
+
+ if (!library.slug.trim()) {
+ dispatch(setInvalid('slug', message));
+ valid = false;
+ } else {
+ dispatch(setValid('slug'));
+ }
+
+ if (!library.introduction.trim()) {
+ dispatch(setInvalid('introduction', message));
+ valid = false;
+ } else {
+ dispatch(setValid('introduction'));
+ }
+
+ return valid;
+ };
+
+ const submitHandler = () => {
+ dispatch(activateValidations());
+
+ if (validateFields()) {
+ setSubmitting(true);
+ dispatch(createLibrary(library, setSubmitting, props.toggleModal));
+ }
+ };
+
+ let formStyle;
+ if (submitting) {
+ formStyle = { pointerEvents: 'none', opacity: '0.5' };
+ }
+
+ return (
+ <>
+
+
+
+
{I18n.t('training.create_library')}
+
{I18n.t('training.create_library_msg')}
+
+
+
+
+ {firstErrorMessage || '\xa0'}
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default CreateLibrary;
diff --git a/app/assets/javascripts/training/components/modals/remove_slide.jsx b/app/assets/javascripts/training/components/modals/remove_slide.jsx
new file mode 100644
index 0000000000..4901f86c15
--- /dev/null
+++ b/app/assets/javascripts/training/components/modals/remove_slide.jsx
@@ -0,0 +1,57 @@
+import React, { useState } from 'react';
+import { useDispatch } from 'react-redux';
+import SelectableBox from '../../../components/common/selectable_box.jsx';
+import { removeSlides } from '../../../actions/training_modification_actions.js';
+import Modal from '../../../components/common/modal.jsx';
+
+// Choose slides to remove from training module
+const RemoveSlides = (props) => {
+ const [submitting, setSubmitting] = useState(false);
+ const [slideSlugList, setSlideSlugList] = useState([]);
+ const dispatch = useDispatch();
+
+ const handleSlideSelection = (selectedSlide) => {
+ setSlideSlugList((prev) => {
+ if (prev.includes(selectedSlide)) {
+ return prev.filter(slide => slide !== selectedSlide);
+ }
+ return [...prev, selectedSlide];
+ }
+ );
+ };
+
+ const submitHandler = () => {
+ setSubmitting(true);
+ dispatch(removeSlides(props.module_id, slideSlugList, setSubmitting));
+ };
+
+ const formClassName = submitting ? 'form-submitting' : '';
+
+ return (
+
+
+
+
{I18n.t('training.remove_slide')}
+
{I18n.t('training.remove_slide_msg')}
+
+ {props.slidesAry.map(slide => (
+ handleSlideSelection(slide.slug)}
+ heading={slide.title}
+ description={slide.wiki_page}
+ selected={slideSlugList.includes(slide.slug)}
+ />
+ ))}
+
+
+
+
+
+
+ );
+};
+
+export default RemoveSlides;
diff --git a/app/assets/javascripts/training/components/modals/reorder_slides.jsx b/app/assets/javascripts/training/components/modals/reorder_slides.jsx
new file mode 100644
index 0000000000..5eda73ad59
--- /dev/null
+++ b/app/assets/javascripts/training/components/modals/reorder_slides.jsx
@@ -0,0 +1,93 @@
+import React, { useState } from 'react';
+import { useDispatch } from 'react-redux';
+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
+import { Flipper } from 'react-flip-toolkit';
+import Modal from '../../../components/common/modal.jsx';
+import SpringSlide from '../springSlide.jsx';
+import { reorderSlides } from '../../../actions/training_modification_actions.js';
+
+// Choose slides to remove from training module
+const ReorderSlides = (props) => {
+ const [submitting, setSubmitting] = useState(false);
+ const dispatch = useDispatch();
+ const [slides, setSlides] = useState(
+ props.slidesAry.map(slide => ({
+ order: slide.order,
+ slug: slide.slug,
+ title: slide.title,
+ wiki_page: slide.wiki_page,
+ }))
+ );
+
+ const submitHandler = () => {
+ setSubmitting(true);
+ dispatch(reorderSlides(props.module_id, slides, setSubmitting));
+ };
+
+ const onSlideDragOver = (draggedItem, hoveredItem) => {
+ const dragIndex = draggedItem.order;
+ const hoverIndex = hoveredItem.order;
+ const updatedSlides = [...slides];
+ const [removed] = updatedSlides.splice(dragIndex, 1);
+ updatedSlides.splice(hoverIndex, 0, removed);
+
+ setSlides(updatedSlides);
+ };
+
+ const onSlideMoveUp = (slideIndex) => {
+ const updatedSlides = [...slides];
+ const [removed] = updatedSlides.splice(slideIndex, 1);
+ updatedSlides.splice(slideIndex - 1, 0, removed);
+ setSlides(updatedSlides);
+ };
+
+ const onSlideMoveDown = (slideIndex) => {
+ const updatedSlides = [...slides];
+ const [removed] = updatedSlides.splice(slideIndex, 1);
+ updatedSlides.splice(slideIndex + 1, 0, removed);
+ setSlides(updatedSlides);
+ };
+
+ const springSlides = slides.map((slide, index) => {
+ slide.order = index;
+ return ;
+ });
+
+ const formClassName = submitting ? 'form-submitting' : '';
+
+ return (
+
+
+
+
{I18n.t('training.change_order')}
+
{I18n.t('training.change_order_msg')}
+
+ slide.slug).join('')} spring="stiff">
+
+ {springSlides}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ReorderSlides;
diff --git a/app/assets/javascripts/training/components/modals/transfer_modules.jsx b/app/assets/javascripts/training/components/modals/transfer_modules.jsx
new file mode 100644
index 0000000000..191fe7bcff
--- /dev/null
+++ b/app/assets/javascripts/training/components/modals/transfer_modules.jsx
@@ -0,0 +1,77 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import Modal from '../../../components/common/modal.jsx';
+import TransferStep1 from './transfer_step1.jsx';
+import TransferStep2 from './transfer_step2.jsx';
+import TransferStep3 from './transfer_step3.jsx';
+
+const STEPS = {
+ SOURCE_CATEGORY: 1,
+ CHOOSE_MODULES: 2,
+ DESTINATION_CATEGORY: 3,
+};
+
+const getInstruction = (step) => {
+ switch (step) {
+ case STEPS.SOURCE_CATEGORY:
+ return I18n.t('training.source_category_msg');
+ case STEPS.CHOOSE_MODULES:
+ return I18n.t('training.choose_modules_msg');
+ case STEPS.DESTINATION_CATEGORY:
+ return I18n.t('training.destination_category_msg');
+ default:
+ return '';
+ }
+};
+
+const TransferModules = ({ toggleModal }) => {
+ const [submitting, setSubmitting] = useState(false);
+ const [transferInfo, setTransferInfo] = useState({});
+ const [step, setStep] = useState(STEPS.SOURCE_CATEGORY);
+ const reactRoot = document.getElementById('react_root');
+ const categories = JSON.parse(reactRoot.getAttribute('data-translated_categories'));
+
+ const formClassName = submitting ? 'form-submitting' : '';
+
+ return (
+
+
+
+
{I18n.t('training.transfer_module')}
+
{getInstruction(step)}
+
+
+
+
+
+
+
+
+ );
+};
+
+TransferModules.propTypes = {
+ toggleModal: PropTypes.func.isRequired,
+};
+
+export default TransferModules;
diff --git a/app/assets/javascripts/training/components/modals/transfer_step1.jsx b/app/assets/javascripts/training/components/modals/transfer_step1.jsx
new file mode 100644
index 0000000000..f5cb41527b
--- /dev/null
+++ b/app/assets/javascripts/training/components/modals/transfer_step1.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import SelectableBox from '../../../components/common/selectable_box.jsx';
+
+// Choose category from which modules were transferred
+const TransferStep1 = ({ categories, transferInfo, setTransferInfo, step, setStep, toggleModal }) => {
+ const handleCategorySelection = (selectedCategory) => {
+ setTransferInfo(prev => ({ ...prev, sourceCategory: selectedCategory }));
+ };
+ const nonEmptyCategories = categories.filter(cat => cat.modules.length > 0);
+
+ return (
+
+
+ {nonEmptyCategories.map(category => (
+ handleCategorySelection(category.title)}
+ heading={category.title}
+ description={category.description}
+ selected={transferInfo?.sourceCategory === category.title}
+ />
+ ))}
+
+
+
+
+ );
+};
+
+export default TransferStep1;
+
diff --git a/app/assets/javascripts/training/components/modals/transfer_step2.jsx b/app/assets/javascripts/training/components/modals/transfer_step2.jsx
new file mode 100644
index 0000000000..a939d89c48
--- /dev/null
+++ b/app/assets/javascripts/training/components/modals/transfer_step2.jsx
@@ -0,0 +1,47 @@
+import React, { useEffect } from 'react';
+import SelectableBox from '../../../components/common/selectable_box.jsx';
+
+// Choose modules to transfer
+const TransferStep2 = ({ categories, transferInfo, setTransferInfo, step, setStep }) => {
+ const setModules = () => {
+ const category = categories.find(cat => cat.title === transferInfo.sourceCategory);
+ if (category) {
+ return category.modules;
+ }
+ return [];
+ };
+ const allModules = setModules();
+
+ const handleModuleSelection = (moduleName) => {
+ setTransferInfo((prev) => {
+ const modules = prev.modules.includes(moduleName)
+ ? prev.modules.filter(module => module !== moduleName)
+ : [...prev.modules, moduleName];
+ return { ...prev, modules };
+ });
+ };
+
+ useEffect(() => {
+ setTransferInfo(prev => ({ ...prev, modules: [], destinationCategory: '' }));
+ }, [transferInfo.sourceCategory]);
+
+ return (
+
+
+ {allModules.map(module => (
+ handleModuleSelection(module.name)}
+ heading={module.name}
+ description={module.description}
+ selected={transferInfo?.modules.includes(module.name)}
+ />
+ ))}
+
+
+
+
+ );
+};
+
+export default TransferStep2;
diff --git a/app/assets/javascripts/training/components/modals/transfer_step3.jsx b/app/assets/javascripts/training/components/modals/transfer_step3.jsx
new file mode 100644
index 0000000000..06bd94f24b
--- /dev/null
+++ b/app/assets/javascripts/training/components/modals/transfer_step3.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { useDispatch } from 'react-redux';
+import { useParams } from 'react-router-dom';
+import SelectableBox from '../../../components/common/selectable_box.jsx';
+import { transferModules } from '../../../actions/training_modification_actions.js';
+
+// Choose category to which user wants to transfer module
+const TransferStep3 = ({ categories, transferInfo, setTransferInfo, step, setStep, setSubmitting }) => {
+ const { library_id } = useParams();
+ const remainingCategories = categories.filter(cat => cat.title !== transferInfo.sourceCategory);
+ const dispatch = useDispatch();
+
+ const handleCategorySelection = (selectedCategory) => {
+ setTransferInfo(prev => ({ ...prev, destinationCategory: selectedCategory }));
+ };
+
+ const submitHandler = () => {
+ setSubmitting(true);
+ dispatch(transferModules(library_id, transferInfo, setSubmitting));
+ };
+
+ return (
+
+
+ {remainingCategories.map(category => (
+ handleCategorySelection(category.title)}
+ heading={category.title}
+ description={category.description}
+ selected={transferInfo?.destinationCategory === category.title}
+ />
+ ))}
+
+
+
+
+ );
+};
+
+export default TransferStep3;
diff --git a/app/assets/javascripts/training/components/springSlide.jsx b/app/assets/javascripts/training/components/springSlide.jsx
new file mode 100644
index 0000000000..1c149c0cd5
--- /dev/null
+++ b/app/assets/javascripts/training/components/springSlide.jsx
@@ -0,0 +1,55 @@
+import { Flipped } from 'react-flip-toolkit';
+import React, { useState, useEffect } from 'react';
+import DraggableSlide from './draggableSlide';
+
+export default function SpringSlide({
+ slide,
+ index,
+ id,
+ heading,
+ description,
+ onSlideDrag,
+ onSlideMoveUp,
+ onSlideMoveDown,
+ totalSlides
+}) {
+ const [animating, setAnimating] = useState(false);
+ const [canSlideMoveUp, setCanSlideMoveUp] = useState(false);
+ const [canSlideMoveDown, setCanSlideMoveDown] = useState(false);
+
+ useEffect(() => {
+ setCanSlideMoveUp(index > 0);
+ setCanSlideMoveDown(index < totalSlides - 1);
+ }, [index, totalSlides]);
+
+ return (
+ {
+ setAnimating(false);
+ }}
+ onStartImmediate={() => {
+ setAnimating(true);
+ }}
+ >
+
+
+ );
+}
diff --git a/app/assets/javascripts/training/components/training_app.jsx b/app/assets/javascripts/training/components/training_app.jsx
index 8f7bf74000..d9e5fa5e3a 100644
--- a/app/assets/javascripts/training/components/training_app.jsx
+++ b/app/assets/javascripts/training/components/training_app.jsx
@@ -1,18 +1,37 @@
-import React from 'react';
+import React, { useEffect } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import { Route, Routes } from 'react-router-dom';
+import TrainingContentHandler from './training_content_handler.jsx';
+import TrainingLibraryHandler from './training_library_handler.jsx';
import TrainingModuleHandler from './training_module_handler.jsx';
import TrainingSlideHandler from './training_slide_handler.jsx';
+import { getCurrentUser } from '../../selectors/index.js';
+import { getTrainingMode } from '../../actions/training_modification_actions.js';
+import AddModule from './modals/add_module.jsx';
-const TrainingApp = () => (
-
-
- } />
- } />
-
-
-);
+const TrainingApp = () => {
+ const dispatch = useDispatch();
+ const editMode = useSelector(state => state.training.editMode);
+ const currentUser = useSelector(state => getCurrentUser(state));
+
+ useEffect(() => {
+ dispatch(getTrainingMode());
+ }, []);
+
+ return (
+
+
+ }/>
+ }/>
+ } />
+ } />
+ } />
+
+
+ );
+};
TrainingApp.propTypes = {
children: PropTypes.node
diff --git a/app/assets/javascripts/training/components/training_content_handler.jsx b/app/assets/javascripts/training/components/training_content_handler.jsx
new file mode 100644
index 0000000000..796d22be65
--- /dev/null
+++ b/app/assets/javascripts/training/components/training_content_handler.jsx
@@ -0,0 +1,55 @@
+import React, { useState } from 'react';
+import { useDispatch } from 'react-redux';
+import CreateLibrary from './modals/create_library.jsx';
+import Notifications from '../../components/common/notifications.jsx';
+import { updateTrainingMode } from '../../actions/training_modification_actions.js';
+
+const TrainingContentHandler = (props) => {
+ const [showModal, setShowModal] = useState(false);
+ const [updatingEditMode, setUpdatingEditMode] = useState(false);
+ const dispatch = useDispatch();
+
+ const toggleModal = () => {
+ setShowModal(!showModal);
+ };
+
+ const toggleEditMode = () => {
+ setUpdatingEditMode(true);
+ dispatch(updateTrainingMode(!props.editMode, setUpdatingEditMode));
+ };
+
+ let trainingMode;
+ if (props.editMode) {
+ trainingMode = I18n.t('training.switch_view');
+ } else {
+ trainingMode = I18n.t('training.switch_edit');
+ }
+
+ let buttonStyle;
+ if (updatingEditMode) {
+ buttonStyle = { pointerEvents: 'none', opacity: '0.5' };
+ }
+
+ return (
+
+
+
+ {showModal &&
}
+
+ {props.editMode && props.currentUser.isAdmin && (
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default TrainingContentHandler;
diff --git a/app/assets/javascripts/training/components/training_library_handler.jsx b/app/assets/javascripts/training/components/training_library_handler.jsx
new file mode 100644
index 0000000000..c2fb6ee0b0
--- /dev/null
+++ b/app/assets/javascripts/training/components/training_library_handler.jsx
@@ -0,0 +1,43 @@
+import React, { useState } from 'react';
+import Notifications from '../../components/common/notifications.jsx';
+import CreateCategory from './modals/create_category.jsx';
+import TransferModules from './modals/transfer_modules.jsx';
+
+const TrainingLibraryHandler = (props) => {
+ const [showCreateCategoryForm, setShowCreateCategoryForm] = useState(false);
+ const [transferModulesForm, setTransferModulesForm] = useState(false);
+
+ const toggleCreateCategoryModal = () => {
+ setShowCreateCategoryForm(!showCreateCategoryForm);
+ };
+
+ const toggleTransferModulesModal = () => {
+ setTransferModulesForm(!transferModulesForm);
+ };
+
+ if (!props.editMode) {
+ return null;
+ }
+
+ return (
+
+
+
+ {showCreateCategoryForm &&
}
+ {transferModulesForm &&
}
+
+
+
+
+
+
+
+ );
+};
+
+export default TrainingLibraryHandler;
diff --git a/app/assets/javascripts/training/components/training_module_handler.jsx b/app/assets/javascripts/training/components/training_module_handler.jsx
index 4ccabd7087..32ca88d862 100644
--- a/app/assets/javascripts/training/components/training_module_handler.jsx
+++ b/app/assets/javascripts/training/components/training_module_handler.jsx
@@ -1,8 +1,11 @@
-import React, { useEffect } from 'react';
+import React, { useState, useEffect } from 'react';
+import { useParams } from 'react-router-dom';
import { compact } from 'lodash-es';
import { connect } from 'react-redux';
import { fetchTrainingModule } from '../../actions/training_actions.js';
-
+import AddSlide from './modals/add_slide.jsx';
+import RemoveSlides from './modals/remove_slide.jsx';
+import ReorderSlides from './modals/reorder_slides.jsx';
const TrainingModuleHandler = (props) => {
useEffect(() => {
@@ -10,53 +13,85 @@ const TrainingModuleHandler = (props) => {
props.fetchTrainingModule({ module_id: moduleId });
}, []);
- const locale = I18n.locale;
- const slidesAry = compact(props.training.module.slides);
- const slides = slidesAry.map((slide, i) => {
- const disabled = !slide.enabled;
- const slideLink = `${props.training.module.slug}/${slide.slug}`;
- let liClassName;
- if (disabled) { liClassName = 'disabled'; }
- let summary;
- if (slide.summary) {
- summary = {slide.summary}
;
- }
- let slideTitle = slide.title;
- if (slide.translations && slide.translations[locale]) {
- slideTitle = slide.translations[locale].title;
- }
- return (
-
-
- {slideTitle}
- {summary}
-
-
- );
+ const toggleAddSlideModal = () => {
+ setShowAddSlideModal(!showAddSlideModal);
+ };
+
+ const toggleRemoveSlideModal = () => {
+ setShowRemoveSlideModal(!showRemoveSlideModal);
+ };
+
+ const toggleReorderSlideModal = () => {
+ setShowReorderSlideModal(!showReorderSlideModal);
+ };
+
+ const [showRemoveSlideModal, setShowRemoveSlideModal] = useState(false);
+ const [showAddSlideModal, setShowAddSlideModal] = useState(false);
+ const [showReorderSlideModal, setShowReorderSlideModal] = useState(false);
+ const { library_id, module_id } = useParams();
+ const locale = I18n.locale;
+ const slidesAry = compact(props.training.module.slides);
+ const slides = slidesAry.map((slide, i) => {
+ const disabled = !slide.enabled;
+ const slideLink = `${props.training.module.slug}/${slide.slug}`;
+ let liClassName;
+ if (disabled) { liClassName = 'disabled'; }
+ let summary;
+ if (slide.summary) {
+ summary = {slide.summary}
;
}
- );
- let moduleSource;
- if (props.training.module.wiki_page) {
- moduleSource = (
-
- );
+ let slideTitle = slide.title;
+ if (slide.translations && slide.translations[locale]) {
+ slideTitle = slide.translations[locale].title;
}
-
return (
+
+
+ {slideTitle}
+ {summary}
+
+
+ );
+ }
+ );
+ let moduleSource;
+ if (props.training.module.wiki_page) {
+ moduleSource = (
+
+ );
+ }
+ let slideModificationButtons;
+ if (props.editMode) {
+ slideModificationButtons = (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {showAddSlideModal &&
}
+ {showRemoveSlideModal &&
}
+ {showReorderSlideModal &&
}
{I18n.t('training.table_of_contents')} ({slidesAry.length})
-
+
{slides}
{moduleSource}
+ {slideModificationButtons}
+
+ );
+};
- );
- };
TrainingModuleHandler.displayName = 'TrainingModuleHandler';
const mapStateToProps = state => ({
training: state.training
diff --git a/app/assets/stylesheets/training.styl b/app/assets/stylesheets/training.styl
index a253ea505d..c3dea3ab8b 100644
--- a/app/assets/stylesheets/training.styl
+++ b/app/assets/stylesheets/training.styl
@@ -75,6 +75,8 @@ old-ie ?= false
@import "modules/_language_picker"
@import "modules/_tables"
@import "training_modules/*"
+@import "modules/_wizard"
+@import "modules/_forms"
@import "_base"
@import "_fonts"
@@ -101,3 +103,91 @@ old-ie ?= false
&:last-child
border-right 1px solid #ddd
+.training-modification
+ .notice
+ padding 4px
+
+ .notification
+ padding 4px
+
+ .validation-error
+ font-size 1rem
+ color #d95757
+
+ .red
+ color #d95757
+
+ .invalid
+ border 1px solid #d95757 !important
+
+ .form-submitting
+ pointer-events: none;
+ opacity: 0.5;
+
+ .wizard__panel
+ margin-bottom 100px
+ max-height calc(100vh - 200px)
+ .training_scrollable_container
+ max-height calc(100vh - 470px)
+ overflow-y auto
+ margin-bottom 20px
+
+ .wizared__panel .wide-container
+ width: 100% !important
+
+ .remove-slide-container
+ max-height calc(100vh - 470px)
+ overflow-y auto
+ margin-bottom 20px
+
+ .program-description
+ position relative
+ padding 0.5rem 0.5rem 0rem 0.7rem !important
+ margin-top 15px !important
+ cursor pointer
+ p
+ margin 0 0 0.5rem 0
+ h4
+ font-size 0.9em !important
+ letter-spacing 0.02em
+ margin 0 0 4px 0
+ font-weight 00
+ color rgb(114, 143, 186)
+ padding 0
+
+ &.selected
+ outline 2px solid #878dcd !important
+ border-color: #878dcd !important
+
+ .checkbox-image
+ border 1px solid mischka
+ border-width 0 0 1px 1px
+ height 30px
+ position absolute
+ right 0
+ top 0
+ transition all .2s ease-in-out
+ width 30px
+ background #878dcd
+
+ .reorder-slides
+ li
+ margin 0px !important
+ .program-description
+ width: calc(100% - 21px) !important
+ margin-right: 21px !important
+ margin-top: 5px !important
+
+ .program-description__header
+ width: calc(100% - 140px)
+
+ .draggable-slide-container
+ width: 100%
+ display: flex
+ flex-direction: row
+ justify-content: space-between
+ align-items: center
+
+.scrollable_slides_container
+ max-height calc(100vh - 480px) !important
+ overflow-y auto
\ No newline at end of file
diff --git a/app/assets/stylesheets/training_modules/_buttons.styl b/app/assets/stylesheets/training_modules/_buttons.styl
index e10fd6c15a..69d483bb1a 100644
--- a/app/assets/stylesheets/training_modules/_buttons.styl
+++ b/app/assets/stylesheets/training_modules/_buttons.styl
@@ -45,3 +45,105 @@ a[disabled]
.btn.btn-med
font-size $btn_font_size_med
+
+.training-modification
+ .button
+ border-radius 3px
+ cursor pointer
+ display inline-block
+ line-height 1.5
+ font-weight 600
+ text-decoration none
+ font-size 14px
+ padding 8px 20px
+ transition all 0.15s ease-out, color 0.15s ease-out
+ &:focus
+ outline none
+ &:disabled
+ opacity: 0.5;
+ pointer-events: none;
+ .button.border
+ background-color: transparent;
+ border-color: rgb(56, 61, 114);
+ color: rgb(114, 143, 186);
+ border: 1px solid #676eb4;
+ &:hover
+ background-color: rgb(62, 68, 125);
+ color: rgb(232, 230, 227);
+ .button.reordering_icons
+ padding-left: 2px;
+ margin-left: 10px;
+ line-height: 0.6;
+ border-radius: 2px;
+ overflow: hidden;
+ width: 30px;
+ height: 30px;
+ font-size: 25px;
+ .button.dark
+ background-color #676eb4
+ border-color #676eb4
+ color #fff
+ &:hover
+ background-color #595f9b
+ border-color #595f9b
+ .button.right
+ float right
+ margin-left 10px
+ .button.down
+ float down
+ margin-top 10px
+ .button.light
+ background-color: #e7e7e7;
+ border: 1px solid #e2e2e2;
+ color: #6a6a6a;
+ padding: 8px 20px 8px;
+ &:hover
+ background-color: #e2e2e2;
+ border: 1px solid #dcdcdc;
+ color: #6a6a6a;
+ .button.danger
+ background-color: #d95757;
+ border-color: #d95757;
+ color: #fff;
+ &:hover
+ background-color: #c54f4f;
+ border-color: #c54f4f;
+
+ .reorder-slide-buttons
+ display: flex
+ gap: 0.25rem
+ justify-content: space-between
+ align-items: center
+ margin-right: 10px
+
+.lib-container
+ position relative
+ .training_content_page_btn_container
+ position absolute
+ top 3rem
+ right 0
+ display flex
+ gap 1rem
+ .library-page-btn-container
+ position absolute
+ top 2rem
+ right 0
+ display flex
+ gap 1rem
+
+.training-modification-buttons
+ display: flex
+ flex-direction: row-reverse
+ justify-content: flex-start
+ gap: 2rem
+
+.training_slide_modification_buttons
+ display: flex
+ flex-wrap: wrap;
+ justify-content: space-between
+ margin-top: 60px
+ margin-bottom: 20px
+ color red
+ .button
+ flex: 1 1 auto;
+ margin: 5px;
\ No newline at end of file
diff --git a/app/assets/stylesheets/training_modules/_layout.styl b/app/assets/stylesheets/training_modules/_layout.styl
index a7bd06aed6..8bc9bc2aa1 100644
--- a/app/assets/stylesheets/training_modules/_layout.styl
+++ b/app/assets/stylesheets/training_modules/_layout.styl
@@ -29,7 +29,7 @@ main > .container
main
background-color transparent
border-width 0 1px
- padding-bottom 80px
+ padding-bottom 5px
header
padding 20px 0
diff --git a/app/assets/stylesheets/training_modules/_modal.styl b/app/assets/stylesheets/training_modules/_modal.styl
new file mode 100644
index 0000000000..299278fd1c
--- /dev/null
+++ b/app/assets/stylesheets/training_modules/_modal.styl
@@ -0,0 +1,40 @@
+.training_modal
+ font-family: 'Open Sans', arial, sans-serif
+ margin-top: 100px
+ input[type="text"], textarea
+ width: 100%
+ padding: 10px
+ margin-bottom: 16px
+ font-size: 15px
+ background-color: #fff
+ border: 1px solid #d9d9d9
+ border-radius: 0px
+ font-family: 'Open Sans', arial, sans-serif
+ color: #333 // Assuming text-dark is a shade of dark text
+ outline: none
+ transition: border-color 125ms ease
+ -webkit-font-smoothing: antialiased
+ max-width: 100%
+ textarea
+ height: 138px
+ min-height: 100px
+ resize: vertical
+ h3
+ font-weight: 300
+ font-size: 24px
+ margin-bottom: 16px
+ letter-spacing: 0.02em
+ color: #676eb4
+ p
+ font-size: 15px
+ margin-bottom: 16px
+
+.single_column.small-container
+ max-width: 550px !important
+ width: 50% !important
+ min-width: 350px !important
+
+.single_column.medium-container
+ max-width: 700px !important
+ width: 60% !important
+ min-width: 350px !important
diff --git a/app/assets/stylesheets/training_modules/_training.styl b/app/assets/stylesheets/training_modules/_training.styl
index 114c3a5020..b19fe4dd38 100644
--- a/app/assets/stylesheets/training_modules/_training.styl
+++ b/app/assets/stylesheets/training_modules/_training.styl
@@ -200,7 +200,3 @@
flex-direction column
.training-libraries__individual-library, .training-library-focus
width 100%
-
-@media only screen and (min-width: ($desktop))
- .container.training
- padding-left 20px
\ No newline at end of file
diff --git a/app/controllers/training_controller.rb b/app/controllers/training_controller.rb
index 569ca835ed..1524133d4f 100644
--- a/app/controllers/training_controller.rb
+++ b/app/controllers/training_controller.rb
@@ -7,6 +7,7 @@
class TrainingController < ApplicationController
layout 'training'
before_action :init_query_object, only: :index
+ before_action :fetch_current_training_mode, only: [:index, :show, :training_module, :slide_view]
def index
if @search
@@ -68,8 +69,22 @@ def find_slide
redirect_to "/training/#{training_library.slug}/#{training_module.slug}/#{training_slide.slug}"
end
+ def update_training_mode
+ edit_mode_value = params[:edit_mode]
+ store_training_mode_in_session(edit_mode_value)
+ render json: { message: 'Training Mode Updated Successfully.' }, status: :ok
+ end
+
private
+ def fetch_current_training_mode
+ @current_training_mode = session[:training_mode] || { 'editMode' => false }
+ end
+
+ def store_training_mode_in_session(edit_mode_value)
+ session[:training_mode] = { 'editMode' => ActiveModel::Type::Boolean.new.cast(edit_mode_value) }
+ end
+
def add_training_root_breadcrumb
add_breadcrumb I18n.t('training.training_library'), :training_path
end
diff --git a/app/controllers/training_library_controller.rb b/app/controllers/training_library_controller.rb
new file mode 100644
index 0000000000..fe6ce49739
--- /dev/null
+++ b/app/controllers/training_library_controller.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+class TrainingLibraryController < ApplicationController
+ before_action :find_library_by_slug, only: [:create_category]
+
+ def create_library
+ training_library = TrainingLibrary.new(training_library_params)
+ if training_library.save
+ render json: { status: 'success', data: training_library }, status: :created
+ else
+ render json: { status: 'error', errorMessages: training_library.errors.full_messages },
+ status: :unprocessable_entity
+ end
+ end
+
+ def create_category
+ if category_exists?(training_category_params[:title])
+ render json: { status: 'error', errorMessages: [I18n.t('training.validation.category')] },
+ status: :unprocessable_entity
+ return
+ end
+ if @library.add_category(training_category_params)
+ render json: { status: 'success', data: @library }, status: :created
+ else
+ render json: { status: 'error', errorMessages: @library.errors.full_messages },
+ status: :unprocessable_entity
+ end
+ end
+
+ def delete_category
+ find_library_by_slug
+ category_title = params[:category_id]
+
+ if @library.delete_category_by_title(category_title)
+ message = "#{I18n.t('training.category')} #{I18n.t('training.notice.deleted')}"
+ redirect_to training_library_path(@library.slug), notice: message
+ else
+ render json: { status: 'error',
+ errorMessages: [I18n.t('training.validation.category_not_found')] },
+ status: :not_found
+ end
+ end
+
+ private
+
+ def find_library_by_slug
+ @library = TrainingLibrary.find_by(slug: params[:library_id])
+ if @library.nil?
+ render json: { status: 'error',
+ errorMessages: [I18n.t('training.validation.lib_not_found')] },
+ status: :not_found
+ end
+ end
+
+ def category_exists?(title)
+ lowercase_title = title.downcase
+ @library.categories.any? { |category| category['title'].downcase == lowercase_title }
+ end
+
+ # Strong parameters method to permit necessary fields
+ def training_library_params
+ params.require(:training_library).permit(:name, :slug, :introduction)
+ end
+
+ def training_category_params
+ params.require(:category).permit(:title, :description)
+ end
+end
diff --git a/app/controllers/training_modules_controller.rb b/app/controllers/training_modules_controller.rb
index f7677658a7..89b405b959 100644
--- a/app/controllers/training_modules_controller.rb
+++ b/app/controllers/training_modules_controller.rb
@@ -14,8 +14,141 @@ def show
def find
training_module = TrainingModule.find(params[:module_id])
- # Use a specific training library for the module, or a default library if it is not found
training_library = training_module.find_or_default_library
redirect_to "/training/#{training_library.slug}/#{training_module.slug}"
end
+
+ def add_module
+ @library = find_library
+ return unless @library
+
+ category = find_category(@library)
+ return unless category
+
+ @module = create_module
+ return unless @module.persisted?
+
+ associate_module_with_category(category, @module)
+ save_library_with_response
+ end
+
+ def transfer_modules
+ @library = find_library
+ return unless @library
+
+ source_category = find_category(@library, transfer_info_params[:sourceCategory])
+ return unless source_category
+
+ destination_category = find_category(@library, transfer_info_params[:destinationCategory])
+ return unless destination_category
+
+ modules_to_move = find_modules_to_move(source_category, transfer_info_params[:modules])
+ move_modules(modules_to_move, source_category, destination_category)
+ save_library_with_response
+ end
+
+ def reorder_slides
+ @training_module = find_training_module
+ return unless @training_module
+
+ reordered_slides = reordered_slides_params
+ if @training_module.update(slide_slugs: reordered_slides)
+ render json: { status: 'success' }, status: :ok
+ else
+ render json: { status: 'error', errorMessages: @training_module.errors.full_messages },
+ status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ # Find the training library by slug
+ def find_library
+ library = TrainingLibrary.find_by(slug: params[:library_id])
+ unless library
+ render json: { status: 'error',
+ errorMessages: ["Training library with slug '#{params[:library_id]}' not found."] },
+ status: :not_found
+ end
+ library
+ end
+
+ def find_training_module
+ training_module = TrainingModule.find_by(slug: params[:module_id])
+ unless training_module
+ render json: { status: 'error', errorMessages: ['Training module not found.'] },
+ status: :not_found
+ end
+ training_module
+ end
+
+ # Find the category within the library by title
+ def find_category(library, category_title = params[:category_id])
+ category = library.categories.find { |cat| cat['title'] == category_title }
+ error_message = "Category '#{category_title}' not exist in library '#{library.slug}'."
+ unless category
+ render json: { status: 'error',
+ errorMessages: [error_message] },
+ status: :not_found
+ end
+ category
+ end
+
+ # Create a new training module
+ def create_module
+ training_module = TrainingModule.new(training_module_params)
+ unless training_module.save
+ render json: { status: 'error', errorMessages: training_module.errors.full_messages },
+ status: :unprocessable_entity
+ end
+ training_module
+ end
+
+ # Associate a module with a category
+ def associate_module_with_category(category, training_module)
+ category['modules'] ||= []
+ category['modules'] << {
+ 'name' => training_module.name,
+ 'slug' => training_module.slug,
+ 'description' => training_module.description
+ }
+ end
+
+ # Save the library and render response
+ def save_library_with_response
+ if @library.save
+ render json: { status: 'success', data: @module }, status: :created
+ else
+ render json: { status: 'error', errorMessages: @library.errors.full_messages },
+ status: :unprocessable_entity
+ end
+ end
+
+ # Find modules to be moved by names
+ def find_modules_to_move(category, module_names)
+ category['modules'].select { |mod| module_names.include?(mod['name']) }
+ end
+
+ # Move modules from source to destination category
+ def move_modules(modules, source_category, destination_category)
+ modules.each do |module_to_move|
+ destination_category['modules'] << module_to_move
+ end
+ source_category['modules'].reject! { |mod| modules.map { |m| m['name'] }.include?(mod['name']) }
+ end
+
+ # Strong parameters for training module creation
+ def training_module_params
+ params.require(:module).permit(:name, :slug, :description)
+ end
+
+ # Strong parameters for transfer info
+ def transfer_info_params
+ params.require(:transferInfo).permit(:sourceCategory, :destinationCategory, modules: [])
+ end
+
+ # Strong parameter for reorder slides
+ def reordered_slides_params
+ params.require(:reorderedSlides)
+ end
end
diff --git a/app/controllers/training_slides_controller.rb b/app/controllers/training_slides_controller.rb
new file mode 100644
index 0000000000..ac60eca2c2
--- /dev/null
+++ b/app/controllers/training_slides_controller.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+class TrainingSlidesController < ApplicationController
+ before_action :set_training_module, only: [:add_slide, :remove_slide]
+ respond_to :json
+
+ def add_slide
+ slide_params = params.require(:slide).permit(:title, :slug, :wiki_page)
+
+ existing_slide = TrainingSlide.find_by(slug: slide_params[:slug])
+
+ if existing_slide
+ handle_existing_slide(existing_slide, slide_params)
+ else
+ create_and_add_new_slide(slide_params)
+ end
+ end
+
+ def remove_slide
+ slide_slugs = params.require(:slideSlugList)
+
+ slide_slugs.each do |slug|
+ @training_module.slide_slugs.delete(slug)
+ end
+
+ if @training_module.save
+ render json: { status: 'success', message: 'Slides removed successfully' }, status: :ok
+ else
+ render json: { status: 'error', errorMessages: @training_module.errors.full_messages },
+ status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def set_training_module
+ @training_module = TrainingModule.find_by(slug: params[:module_id])
+ render json: { error: 'Training module not found' }, status: :not_found unless @training_module
+ end
+
+ def create_and_add_new_slide(slide_params)
+ @slide = TrainingSlide.new(slide_params)
+ return unless check_wiki_page_exist(@slide.wiki_page)
+
+ if @slide.save
+ parse_slide_content(fetch_wikitext(@slide.wiki_page))
+ @training_module.slide_slugs << @slide.slug
+ @training_module.save
+ render json: @slide, status: :created
+ else
+ render json: { status: 'error', errorMessages: @slide.errors.full_messages },
+ status: :unprocessable_entity
+ end
+ end
+
+ def check_wiki_page_exist(wiki_page)
+ wikitext = fetch_wikitext(wiki_page)
+ if wikitext.blank?
+ render json: { status: 'error',
+ errorMessages: [I18n.t('training.validation.wikipage_not_found')] },
+ status: :unprocessable_entity
+ return false
+ end
+ true
+ end
+
+ def fetch_wikitext(wiki_page)
+ WikiApi.new(MetaWiki.new).get_page_content(wiki_page)
+ end
+
+ def parse_slide_content(wikitext)
+ parser = WikiSlideParser.new(wikitext)
+ @slide.update(
+ title: parser.title,
+ content: parser.content,
+ assessment: parser.quiz
+ )
+ end
+
+ def handle_existing_slide(existing_slide, slide_params)
+ if existing_slide.wiki_page != slide_params[:wiki_page]
+ render json: { status: 'error',
+ errorMessages: [I18n.t('training.validation.slide_slug_already_exist')] },
+ status: :unprocessable_entity
+ return
+ end
+
+ if @training_module.slide_slugs.include?(existing_slide.slug)
+ render json: { status: 'error',
+ errorMessages: [I18n.t('training.validation.slide_already_exist')] },
+ status: :unprocessable_entity
+ else
+ @training_module.slide_slugs << existing_slide.slug
+ @training_module.save
+ render json: existing_slide, status: :ok
+ end
+ end
+end
diff --git a/app/models/training_library.rb b/app/models/training_library.rb
index 2cc1240039..2446148467 100644
--- a/app/models/training_library.rb
+++ b/app/models/training_library.rb
@@ -23,8 +23,9 @@ class TrainingLibrary < ApplicationRecord
serialize :translations, Hash
validates_uniqueness_of :slug, case_sensitive: false
+ validates_uniqueness_of :name, case_sensitive: false
- validates_presence_of [:id, :name, :slug, :introduction, :categories]
+ validates_presence_of [:name, :slug, :introduction]
def self.path_to_yaml
"#{base_path}/libraries/*.yml"
@@ -103,4 +104,29 @@ def training_module_slugs
cat['modules'].map { |mod| mod['slug'] }
end.flatten
end
+
+ ########################
+ # Modification methods #
+ ########################
+ def add_category(category_params)
+ category_hash = {
+ 'title' => category_params[:title],
+ 'description' => category_params[:description],
+ 'modules' => []
+ }
+ if categories.nil?
+ self.categories = [category_hash]
+ else
+ categories << category_hash
+ end
+ save
+ end
+
+ def delete_category_by_title(category_title)
+ category_index = categories.index { |category| category['title'] == category_title }
+ return false if category_index.nil?
+
+ categories.delete_at(category_index)
+ save
+ end
end
diff --git a/app/models/training_slide.rb b/app/models/training_slide.rb
index 42a59a05db..5f300fde10 100644
--- a/app/models/training_slide.rb
+++ b/app/models/training_slide.rb
@@ -21,7 +21,7 @@
#= Class representing an individual training slide
class TrainingSlide < ApplicationRecord
- validates_presence_of :id, :slug, :title
+ validates_presence_of :slug, :title
serialize :assessment, Hash
serialize :translations, Hash
diff --git a/app/views/layouts/training.html.haml b/app/views/layouts/training.html.haml
index 6c1897e394..1e7a373c27 100644
--- a/app/views/layouts/training.html.haml
+++ b/app/views/layouts/training.html.haml
@@ -11,7 +11,7 @@
= render "shared/flash"
%div.wrapper
#nav_root{"data-rooturl" => main_app.root_url, "data-logopath" => logo_path, "data-fluid" => "false", "data-exploreurl" => main_app.explore_path, "data-explorename" => t(Features.wiki_ed? ? "application.explore" : "courses_generic.explore"), "data-usersignedin" => user_signed_in?.to_s, "data-ifadmin" => current_user&.admin?.to_s, "data-trainingurl": main_app.training_url, "data-help_disabled" => Features.disable_help?.to_s, "data-wiki_ed" => Features.wiki_ed?.to_s, "data-language_switcher_enabled" => language_switcher_enabled, "data-username" => current_user&.username, "data-destroyurl" => main_app.destroy_user_session_url, "data-omniauth_url" => main_app.user_mediawiki_omniauth_authorize_url}
- %main#main{"data-user-id" => current_user&.id, :role => "main"}
+ %main#main{"data-user-id" => current_user&.id, :role => "main", "data-training_mode" => @current_training_mode.to_json}
= yield
.push
= render "shared/foot"
diff --git a/app/views/training/index.html.haml b/app/views/training/index.html.haml
index 8d3c039177..ad3fb6398f 100644
--- a/app/views/training/index.html.haml
+++ b/app/views/training/index.html.haml
@@ -1,4 +1,5 @@
- content_for :before_title, "Training Libraries - "
+#react_root{ "data-current_user" => current_user ? { admin: current_user.admin?, id: current_user.id }.to_json : '{ "admin": false, "id": null }' }
.container.training
%h1 Training Libraries
.search-bar{style: 'position:relative'}
diff --git a/app/views/training/show.html.haml b/app/views/training/show.html.haml
index bc9ca752b4..7af0a82c88 100644
--- a/app/views/training/show.html.haml
+++ b/app/views/training/show.html.haml
@@ -1,4 +1,6 @@
- content_for :before_title, "#{@library.translated_name} - "
+#react_root{ "data-translated_categories" => @library.translated_categories.to_json,
+ "data-current_user" => current_user ? { admin: current_user.admin?, id: current_user.id }.to_json : '{ "admin": false, "id": null }' }
.training__show.container
%ol.breadcrumbs= render_breadcrumbs tag: :li, separator: ' > '
.training__section-overview.container
@@ -34,3 +36,9 @@
%small.block-element.capitalize{ class: pm.assignment_status_css_class }
= pm.assignment_status
%span= lib_module.description
+ - if @current_training_mode['editMode']
+ .training-modification.training-modification-buttons
+ = link_to t("training.add_module"), add_module_training_path(@library.slug, category_id: lib_category['title']), class: 'button dark'
+ - if lib_category.modules.empty?
+ = link_to delete_category_path(library_id: @library.slug, category_id: lib_category['title']), method: :delete, data: { confirm: t('training.confirmation.delete_category') }, class: 'button danger' do
+ = t("training.delete_category")
diff --git a/app/views/training/slide_view.html.haml b/app/views/training/slide_view.html.haml
index 6e8b86eb86..cd71b7b94b 100644
--- a/app/views/training/slide_view.html.haml
+++ b/app/views/training/slide_view.html.haml
@@ -3,4 +3,5 @@
.container
%ol.breadcrumbs= render_breadcrumbs tag: :li, separator: ' > '
.container.training
- #react_root{ "data-module-id" => params[:module_id], "data-return-to" => session[:training_return_to] }
+ #react_root{ "data-module-id" => params[:module_id], "data-return-to" => session[:training_return_to], "data-current_user" => current_user ? { admin: current_user.admin?, id: current_user.id }.to_json : '{ "admin": false, "id": null }'
+ }
diff --git a/app/views/training/training_module.html.haml b/app/views/training/training_module.html.haml
index 88b84d11eb..e8788a3a5f 100644
--- a/app/views/training/training_module.html.haml
+++ b/app/views/training/training_module.html.haml
@@ -19,4 +19,6 @@
Estimated time to complete:
%br/
= @pres.training_module.estimated_ttc
- #react_root{ "data-module-id" => params[:module_id] }
+ #react_root{ "data-module-id" => params[:module_id],
+ "data-current_user" => current_user ? { admin: current_user.admin?, id: current_user.id }.to_json : '{ "admin": false, "id": null }'
+ }
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 4eeb255040..b509c0aa3d 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -250,7 +250,6 @@ en:
not_choosen_article: You have not chosen an article to work on. When you have found an
article to work on, use the button above to assign it.
use_dashboard: How to use the Dashboard
- how_to_find: How to find an article
evaluate_and_source: Evaluating articles and sources
items:
@@ -1380,36 +1379,106 @@ en:
view_full_timeline: View Full Timeline
week_number: "Week %{number}"
+
training:
+ add: Add
+ add_module: Add Module
+ add_slide: Add Slide
+ add_slide_msg: >
+ This is where you can add slides to this training module.
+ add_module_msg: >
+ This is where you can add a new module to the library.
+ Once this module is created, you can add slides to it.
+ add_new_module: Add New Module
+ back: Back
+ cancel: Cancel
+ category: Category
+ category_title: Category Title
+ category_description: Category Description
+ change_order: Change Order
+ change_order_msg: >
+ This is where you can drag and customise the order of the slides of this training module.
+ choose_modules_msg: >
+ Choose the modules you want to transfer.
+ You can also select multiple modules.
+ confirmation:
+ delete_category: Are you sure you want to delete this category?
+ create: Create
+ create_category: Create New Category
+ create_category_msg: >
+ Once this category is created, users are able to add modules and slides under this category
+ create_library: Create New Library
+ create_library_msg: >
+ This is where you can create a new library.
+ Once this library is created, user can add modules and slides to it and
+ can segregate them into categories.
+ check_answer: Check Answer
continue: Continue (%{progress})
- start: Start
- view: View
+ destination_category_msg: >
+ Choose a category to which you want to transfer the selected modules.
+ delete_category: Delete Category
+ done: Done!
+ enter: Enter
+ exercise_sandbox: Exercise Sandbox
+ included_modules: Included Modules
+ invalid: Sorry that slide does not exist!
+ kind:
+ discussion: Discussion
+ exercise: Exercise
+ training: Training
+ library_name: Library Name
+ library_slug: Library Slug
+ library_introduction: Library Introduction
+ logged_out: "You are logged out. You will not receive credit for completing this module unless you log in."
+ module_name: Module Name
+ module_slug: Module Slug
+ module_description: Module Description
next: Next Page
+ next_button: Next
+ notice:
+ created: Created Successfully
+ deleted: Deleted Successfully
+ no_training_library_records_non_wiki_ed_mode: There are no TrainingLibrary records in the database.
+ no_training_library_records_wiki_ed_mode: There are no TrainingLibrary records in the database. Click here to load training data from the training_content/ yaml files.
+ no_training_resource_match_your_search: No training resource matches your search
page: Page
page_number: Page %{number} of %{total}
previous: < Previous
+ reload_from_source: reload from source
+ remove: Remove
+ remove_slide: Remove Slide
+ remove_slide_msg: This will permanently remove the seleceted slides from this training module.
+ save: Save
+ slide_slug: Slide Slug
+ slide_title: Slide Title
+ slide_wiki_page: Slide Wikipage
+ search_training_resources: Search for training resources
+ source_category_msg: >
+ Choose a category from which you want to transfer the modules.
+ start: Start
+ success_message:
+ library_created: Library created successfully
+ category_created: Category created successfully
+ transfer: Transfer
+ transfer_module: Transfer Module
+ switch_edit: Switch to Edit Mode
+ switch_view: Switch to View Mode
table_of_contents: Table of Contents
training_library: Training Library
+ validation_message: This field is required
+ validation:
+ category: Category with this title already exists
+ category_not_found: Category not found
+ lib_not_found: Library not found
+ slide_already_exist: Wikipage already exist in this module
+ slide_slug_already_exist: Slug is already taken by another wikipage
+ wikipage_not_found: Wikipage not found
+ view: View
additional_training: Additional training modules
orientation_modules: Instructor orientation modules
- logged_out: "You are logged out. You will not receive credit for completing this module unless you log in."
- done: Done!
- invalid: Sorry that slide does not exist!
wait: Please do not leave the page as your training progress is being updated.
- check_answer: Check Answer
- included_modules: Included Modules
view_library_source: view library source
view_module_source: view module source
- reload_from_source: reload from source
- kind:
- exercise: Exercise
- discussion: Discussion
- training: Training
- no_training_library_records_wiki_ed_mode: There are no TrainingLibrary records in the database. Click here to load training data from the training_content/ yaml files.
- no_training_library_records_non_wiki_ed_mode: There are no TrainingLibrary records in the database.
- search_training_resources: Search for training resources
- no_training_resource_match_your_search: No training resource matches your search
- exercise_sandbox: Exercise Sandbox
remaining_exercise: "%{count} additional exercises remaining."
view_all: View all exercises
diff --git a/config/routes.rb b/config/routes.rb
index fc1b083f9b..f0ff0733bf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -348,7 +348,7 @@
get 'user_training_status' => 'training_status#user'
# for React
- get 'training/:library_id/:module_id(/*any)' => 'training#slide_view'
+ get 'training/:library_id/:module_id/:slide_id' => 'training#slide_view'
# API for slides for a module
get 'training_modules' => 'training_modules#index'
@@ -360,6 +360,28 @@
# To find individual slides by id
get 'find_training_slide/:slide_id' => 'training#find_slide'
+ ## For Training Mode
+ get 'training_mode/fetch' => 'training#fetch_training_mode'
+ post 'training_mode/update' => 'training#update_training_mode'
+
+ ## For Modifying Training Content through the Dashboard
+ # Create
+ post 'training/create_library' => 'training_library#create_library'
+ post 'training/:library_id/create_category' => 'training_library#create_category'
+ post 'training/:library_id/:category_id/add_module' => 'training_modules#add_module'
+ post 'training/:library_id/:module_id/add_slide' => 'training_slides#add_slide'
+
+ # Read
+ get 'training/:library_id/edit/:category_id/add_module', to: 'training#show', as: :add_module_training
+
+ # Update
+ put '/training/:library_id/transfer_modules' => 'training_modules#transfer_modules'
+ put '/training/:module_id/reorder_slides' => 'training_modules#reorder_slides'
+
+ # Delete
+ delete 'training/:library_id/categories/:category_id', to: 'training_library#delete_category', as: :delete_category
+ delete '/training/:module_id/remove_slide' => 'training_slides#remove_slide'
+
# Misc
# get 'courses' => 'courses#index'
get 'explore' => 'explore#index'
diff --git a/spec/factories/training_slides.rb b/spec/factories/training_slides.rb
index 128f720502..1e15471f99 100644
--- a/spec/factories/training_slides.rb
+++ b/spec/factories/training_slides.rb
@@ -19,7 +19,6 @@
FactoryBot.define do
factory :training_slide do
- id { 456875 }
title { 'How to create a slide' }
slug { 'how-to-create-a-slide' }
end
diff --git a/spec/features/training_modification_spec.rb b/spec/features/training_modification_spec.rb
new file mode 100644
index 0000000000..7428cd3f9f
--- /dev/null
+++ b/spec/features/training_modification_spec.rb
@@ -0,0 +1,393 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'TrainingContent', type: :feature, js: true do
+ let(:admin) { create(:admin) }
+ let(:user) { create(:user) }
+ let(:training_library) do
+ create(:training_library,
+ name: 'Example-Library',
+ slug: 'example-library',
+ introduction: 'For Testing')
+ end
+
+ before(:all) do
+ TrainingModule.load_all
+ end
+
+ # For Training Library
+ describe 'TrainingLibrary' do
+ context 'when logged in as an admin' do
+ before do
+ login_as(admin, scope: :user)
+ visit '/training'
+ click_button 'Switch to Edit Mode'
+ end
+
+ it 'creates a new training library and verifies its creation' do
+ click_button 'Create New Library'
+
+ fill_in 'Library Name', with: 'Testing Library'
+ fill_in 'Library Slug', with: 'testing-library'
+ fill_in 'Library Introduction', with: 'This library is only created for testing purposes.'
+
+ click_button 'Create'
+
+ expect(page).to have_content('Testing Library')
+ end
+
+ it 'prevents the creation of two libraries with the same slug' do
+ # Create the first library
+ click_button 'Create New Library'
+ fill_in 'Library Name', with: 'First Testing Library'
+ fill_in 'Library Slug', with: 'duplicate-slug'
+ fill_in 'Library Introduction', with: 'First instance of library creation.'
+ click_button 'Create'
+
+ # Try to create a second library with the same slug
+ click_button 'Create New Library'
+ fill_in 'Library Name', with: 'Second Testing Library'
+ fill_in 'Library Slug', with: 'duplicate-slug'
+ fill_in 'Library Introduction', with: 'Second instance of library creation.'
+ click_button 'Create'
+
+ # Verify the error message
+ expect(page).to have_content('Slug has already been taken')
+
+ # Verify the second library has not been created
+ visit '/training/duplicate-slug'
+ expect(page).to have_content('First Testing Library')
+ expect(page).to have_content('First instance of library creation.')
+ expect(page).not_to have_content('Second Testing Library')
+ expect(page).not_to have_content('Second instance of library creation.')
+ end
+ end
+
+ context 'when logged in as a regular user' do
+ before do
+ login_as(user, scope: :user)
+ visit '/training'
+ click_button 'Switch to Edit Mode'
+ end
+
+ it 'does not show the "Create Training Library" button' do
+ expect(page).not_to have_content('Create New Library')
+ end
+ end
+ end
+
+ describe 'TrainingCategory' do
+ before do
+ login_as(user, scope: :user)
+ visit '/training'
+ click_button 'Switch to Edit Mode'
+ visit "/training/#{training_library.slug}"
+ end
+
+ it 'creates a new category and verifies its creation' do
+ visit "/training/#{training_library.slug}"
+ click_button 'Create New Category'
+
+ fill_in 'title', with: 'Testing Category'
+ fill_in 'description', with: 'This category is only created for testing purposes.'
+ click_button 'Create'
+
+ expect(page).to have_content('Testing Category')
+ expect(page).to have_content('This category is only created for testing purposes.')
+ end
+
+ it 'displays validation errors for empty fields' do
+ visit "/training/#{training_library.slug}"
+ click_button 'Create New Category'
+
+ fill_in 'title', with: ''
+ fill_in 'description', with: ''
+ click_button 'Create'
+
+ expect(page).to have_content('This field is required')
+ end
+
+ it 'prevents creating a category with a duplicate title' do
+ visit "/training/#{training_library.slug}"
+ click_button 'Create New Category'
+
+ fill_in 'title', with: 'Duplicate Category'
+ fill_in 'description', with: 'First instance of this category.'
+ click_button 'Create'
+
+ expect(page).to have_content('Duplicate Category')
+ expect(page).to have_content('First instance of this category.')
+
+ click_button 'Create New Category'
+ fill_in 'title', with: 'Duplicate Category'
+ fill_in 'description', with: 'Second instance of this category.'
+ click_button 'Create'
+
+ expect(page).to have_content('Category with this title already exists')
+ end
+
+ it 'creates a new category and then deletes it' do
+ visit "/training/#{training_library.slug}"
+ click_button 'Create New Category'
+
+ fill_in 'title', with: 'Testing Category'
+ fill_in 'description', with: 'This category is only created for testing purposes.'
+ click_button 'Create'
+
+ expect(page).to have_content('Testing Category')
+ expect(page).to have_content('This category is only created for testing purposes.')
+
+ # Delete this newly created category as initially it has no modules in it
+ within find('li', text: 'Testing Category') do
+ expect(page).to have_selector('a.button.danger', text: I18n.t('training.delete_category'))
+ find('a.button.danger', text: I18n.t('training.delete_category')).click
+ end
+
+ # Confirm the deletion
+ page.driver.browser.switch_to.alert.accept
+
+ expect(page).not_to have_content('Testing Category')
+ expect(page).not_to have_content('This category is only created for testing purposes.')
+ end
+ end
+
+ # For Training Module
+ describe 'TrainingModule' do
+ before do
+ login_as(user, scope: :user)
+ visit '/training'
+ click_button 'Switch to Edit Mode'
+ visit "/training/#{training_library.slug}"
+ end
+
+ it 'add module after creating a category' do
+ visit "/training/#{training_library.slug}"
+ click_button 'Create New Category'
+
+ fill_in 'title', with: 'Testing Category'
+ fill_in 'description', with: 'This category is only created for testing purposes.'
+ click_button 'Create'
+ expect(page).to have_content('Testing Category')
+
+ click_link 'Add Module'
+ fill_in 'Module Name', with: 'Testing Module'
+ fill_in 'Module Slug', with: 'testing-module'
+ fill_in 'Module Description', with: 'This module is only created for testing purposes.'
+ click_button 'Add'
+ expect(page).to have_content('Testing Module')
+ expect(page).to have_content('This module is only created for testing purposes.')
+ end
+
+ it 'transfers modules between categories' do
+ visit "/training/#{training_library.slug}"
+
+ # Creating source category
+ click_button 'Create New Category'
+ fill_in 'title', with: 'Source Category'
+ fill_in 'description', with: 'This is my source category.'
+ click_button 'Create'
+ expect(page).to have_content('Source Category')
+
+ # Adding module in source category
+ click_link 'Add Module'
+ fill_in 'Module Name', with: 'Module 1'
+ fill_in 'Module Slug', with: 'module1'
+ fill_in 'Module Description', with: 'This module is only created for testing purposes.'
+ click_button 'Add'
+ expect(page).to have_content('Module 1')
+
+ # Creating destination category
+ click_button 'Create New Category'
+ fill_in 'title', with: 'Destination Category'
+ fill_in 'description', with: 'This is my destination category.'
+ click_button 'Create'
+ expect(page).to have_content('Destination Category')
+
+ click_button 'Transfer Module'
+ expect(page).to have_selector('.program-description', count: 1)
+
+ # Select source category
+ first('.program-description').click
+ click_button 'Next'
+
+ # Select module to transfer
+ first('.program-description').click
+ click_button 'Next'
+
+ # Select destination category
+ expect(page).to have_selector('.program-description', count: 1)
+ first('.program-description').click
+ click_button 'Transfer'
+
+ # Verify expected changes
+ within find('.training__categories') do
+ within find('li', text: 'Source Category') do
+ expect(page).not_to have_content('Module 1')
+ end
+
+ within find('li', text: 'Destination Category') do
+ expect(page).to have_content('Module 1')
+ end
+ end
+ end
+ end
+
+ # For Training Slides
+ describe 'TrainingSlide', type: :feature, js: true do
+ let(:existing_slide1) do
+ create(:training_slide, title: 'created-for-testing', slug: 'existing-slide1',
+ wiki_page: 'Training modules/dashboard/slides/10306-be-polite')
+ end
+ let(:existing_slide2) do
+ create(:training_slide, title: 'how-to-help', slug: 'existing-slide2',
+ wiki_page: 'Training modules/dashboard/slides/12606-how-to-help')
+ end
+ let(:existing_slide3) do
+ create(:training_slide, title: 'five-pillars', slug: 'existing-slide3',
+ wiki_page: 'Training modules/dashboard/slides/10302-five-pillars')
+ end
+ let(:existing_slide4) do
+ create(:training_slide, title: 'notability', slug: 'existing-slide4',
+ wiki_page: 'Training modules/dashboard/slides/10313-notability')
+ end
+
+ before do
+ login_as(user, scope: :user)
+ visit '/training'
+ click_button 'Switch to Edit Mode'
+ sleep 1
+
+ # Creating a category and adding module to it
+ visit "/training/#{training_library.slug}"
+ click_button 'Create New Category'
+
+ fill_in 'title', with: 'Testing Category'
+ fill_in 'description', with: 'This category is only created for testing purposes.'
+ click_button 'Create'
+ expect(page).to have_content('Testing Category')
+
+ click_link 'Add Module'
+ fill_in 'Module Name', with: 'Testing Module'
+ fill_in 'Module Slug', with: 'testing-module'
+ fill_in 'Module Description', with: 'This module is only created for testing purposes.'
+ click_button 'Add'
+ expect(page).to have_content('Testing Module')
+ expect(page).to have_content('This module is only created for testing purposes.')
+ visit '/training/example-library/testing-module'
+ existing_slide1
+ end
+
+ context 'when adding and removing a training slide' do
+ let(:training_module) { TrainingModule.find_by(slug: 'testing-module') }
+
+ it 'throws an error if slide with same slug but different wiki_page exists' do
+ click_button 'Add Slide'
+
+ fill_in 'Title', with: 'New Slide Title'
+ fill_in 'Slug', with: 'existing-slide1'
+ fill_in 'wiki_page', with: 'Training modules/dashboard/slides/10801-welcome-new-editor'
+ click_button 'Add'
+
+ expect(page).to have_content(I18n.t('training.validation.slide_slug_already_exist'))
+ end
+
+ it 'throws an error if slide with same slug
+ and same wiki_page exists but is already in the training module' do
+ click_button 'Add Slide'
+ training_module.slide_slugs << existing_slide1.slug
+ training_module.save
+
+ fill_in 'Title', with: 'New Slide Title'
+ fill_in 'Slug', with: 'existing-slide1'
+ fill_in 'wiki_page', with: 'Training modules/dashboard/slides/10306-be-polite'
+ click_button 'Add'
+
+ expect(page).to have_content(I18n.t('training.validation.slide_already_exist'))
+ end
+
+ it 'adds the slide if slide with same slug and
+ same wiki_page exists but is not in the training module' do
+ click_button 'Add Slide'
+
+ fill_in 'Title', with: 'New Slide Title'
+ fill_in 'Slug', with: 'existing-slide1'
+ fill_in 'wiki_page', with: 'Training modules/dashboard/slides/10306-be-polite'
+ click_button 'Add'
+
+ expect(training_module.reload.slide_slugs).to include('existing-slide1')
+ expect(page).to have_content(existing_slide1.title)
+ end
+
+ it 'adding a new slide with invalid wikipage' do
+ click_button 'Add Slide'
+ fill_in 'Title', with: 'New Slide Title'
+ fill_in 'Slug', with: 'new-slide-slug'
+ fill_in 'wiki_page', with: 'anyInvalidWikipage'
+ click_button 'Add'
+
+ expect(page).to have_content('Wikipage not found')
+ end
+
+ it 'removes the slide from the training module' do
+ # Adding the slide to the module
+ training_module.slide_slugs << existing_slide1.slug
+ training_module.save
+ expect(training_module.reload.slide_slugs).to include('existing-slide1')
+
+ # Removing the slide
+ visit '/training/example-library/testing-module'
+ click_button 'Remove Slide'
+ expect(page).to have_selector('.program-description', count: 1)
+ first('.program-description').click
+ click_button 'Remove'
+
+ expect(training_module.reload.slide_slugs).not_to include('existing-slide1')
+ end
+ end
+
+ context 'when reordering slides' do
+ let(:training_module) { TrainingModule.find_by(slug: 'testing-module') }
+
+ before do
+ # Make sure the slides are in the database
+ existing_slide1
+ existing_slide2
+ existing_slide3
+ existing_slide4
+ # Adding the slides to the module
+ training_module.slide_slugs = [
+ existing_slide1.slug,
+ existing_slide2.slug,
+ existing_slide3.slug,
+ existing_slide4.slug
+ ]
+ training_module.save
+ visit '/training/example-library/testing-module'
+ end
+
+ it 'allows reordering of slides' do
+ click_button I18n.t('training.change_order')
+ expect(page).to have_content('Change Order')
+
+ # Find the slide elements
+ slides = all('.program-description')
+
+ # Move the second slide to the bottom
+ target = slides.last
+ source = slides[1]
+ source.drag_to(target)
+
+ click_button 'Save'
+ visit '/training/example-library/testing-module'
+
+ # Verify the new order of slides
+ training_module.reload
+ expect(training_module.slide_slugs).to eq([existing_slide1.slug,
+ existing_slide3.slug,
+ existing_slide4.slug,
+ existing_slide2.slug])
+ end
+ end
+ end
+end