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