From 52e34da3b91dbf913dcf919c4c7170bd355356f5 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Sun, 21 Jan 2024 15:51:23 +0530 Subject: [PATCH 001/198] Add notes editor component and pass 'toggleEditable' prop from 'editable_redux' This commit introduces a new notes editor component. The 'toggleEditable' prop is passed from the 'editable_redux' module, enabling dynamic control over the editor's editability state. --- .../components/high_order/editable_redux.jsx | 2 +- .../components/notes/notes_editor.jsx | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/components/notes/notes_editor.jsx diff --git a/app/assets/javascripts/components/high_order/editable_redux.jsx b/app/assets/javascripts/components/high_order/editable_redux.jsx index 8072c12e40..7a0b22b698 100644 --- a/app/assets/javascripts/components/high_order/editable_redux.jsx +++ b/app/assets/javascripts/components/high_order/editable_redux.jsx @@ -85,7 +85,7 @@ const EditableRedux = (Component, Label) => { }, render() { - return ; + return ; } }); return connect(mapStateToProps, mapDispatchToProps)(editableComponent); diff --git a/app/assets/javascripts/components/notes/notes_editor.jsx b/app/assets/javascripts/components/notes/notes_editor.jsx new file mode 100644 index 0000000000..6761cf791b --- /dev/null +++ b/app/assets/javascripts/components/notes/notes_editor.jsx @@ -0,0 +1,60 @@ +import React, { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { fetchSingleNoteDetails, updateCurrentCourseNote, resetStateToDefault } from '../../actions/course_notes_action'; +import EditableRedux from '../high_order/editable_redux'; +import TextAreaInput from '../common/text_area_input.jsx'; + +const NotesEditor = ({ controls, editable, toggleEditable, setState, note_id, current_user }) => { + const notes = useSelector(state => state.courseNotes.note); + const dispatch = useDispatch(); + + useEffect(() => { + if (note_id) { + dispatch(fetchSingleNoteDetails(note_id)); + } else { + dispatch(resetStateToDefault()); + dispatch(updateCurrentCourseNote({ edited_by: current_user.username })); + toggleEditable(); + } + }, []); + + const updateNoteText = (_valueKey, value) => { + dispatch(updateCurrentCourseNote({ text: value })); + }; + + const updateNoteTitle = (_valueKey, value) => { + dispatch(updateCurrentCourseNote({ title: value })); + }; + + const textAreaInputComponent = (onChange, noteDetail, placeHolder, key) => ( + + ); + + + return ( +
+
+ ); +}; + +export default EditableRedux(NotesEditor, ('Edit Note')); From 5e7de44aba848ceb15ad1e23e8e0a72be7c9fef5 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Sun, 21 Jan 2024 15:53:16 +0530 Subject: [PATCH 002/198] Introduce notes list and notes_row components This commit adds the 'NotesList' component responsible for rendering a list of notes, and the 'NotesRow' component for displaying individual note items within the list. --- .../components/notes/notes_list.jsx | 41 +++++++++++++++++++ .../components/notes/notes_row.jsx | 26 ++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 app/assets/javascripts/components/notes/notes_list.jsx create mode 100644 app/assets/javascripts/components/notes/notes_row.jsx diff --git a/app/assets/javascripts/components/notes/notes_list.jsx b/app/assets/javascripts/components/notes/notes_list.jsx new file mode 100644 index 0000000000..fc1fb922b7 --- /dev/null +++ b/app/assets/javascripts/components/notes/notes_list.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import List from '../common/list.jsx'; +import NotesRow from './notes_row.jsx'; + +const NotesList = ({ setState, notesList }) => { + const keys = { + title: { + label: I18n.t('notes.title'), + desktop_only: false + }, + revisor: { + label: I18n.t('notes.edited_by'), + desktop_only: true, + info_key: 'notes.last_edited_by' + }, + date: { + label: 'Date/Time', + desktop_only: true, + info_key: 'notes.edit' + } + }; + + let notesRow = []; + + if (notesList.length > 0) { + notesRow = ; + } + + return ( + + ); +}; + +export default (NotesList); diff --git a/app/assets/javascripts/components/notes/notes_row.jsx b/app/assets/javascripts/components/notes/notes_row.jsx new file mode 100644 index 0000000000..7b1d857311 --- /dev/null +++ b/app/assets/javascripts/components/notes/notes_row.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { format, toDate, parseISO } from 'date-fns'; + +const NotesRow = ({ setState, notesList }) => { + return notesList.map((note) => { + return ( + { setState(note.id, 'NoteEditor'); }} className="students"> + +
+ {note.title} +
+ + + {note.edited_by} + + + {format(toDate(parseISO(note.updated_at)), 'PPPP p')} + + + ); + }); +}; + +export default NotesRow; + + From d41cd1390f57c878e54bd3ce7122303322c5305b Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Sun, 21 Jan 2024 15:55:17 +0530 Subject: [PATCH 003/198] Introduce NotesPanel and NotesPanelEditButton components This commit adds the 'NotesPanel' component, responsible for managing a panel displaying notes for a course. It fetches and updates the list of notes and includes an edit button. The 'NotesPanelEditButton' component is introduced to handle creating new and deleting individual notes within the panel. --- .../components/notes/notes_panel.jsx | 43 +++++++++++ .../notes/notes_panel_edit_button.jsx | 74 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 app/assets/javascripts/components/notes/notes_panel.jsx create mode 100644 app/assets/javascripts/components/notes/notes_panel_edit_button.jsx diff --git a/app/assets/javascripts/components/notes/notes_panel.jsx b/app/assets/javascripts/components/notes/notes_panel.jsx new file mode 100644 index 0000000000..952d2ed0f2 --- /dev/null +++ b/app/assets/javascripts/components/notes/notes_panel.jsx @@ -0,0 +1,43 @@ +import React, { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { fetchAllCourseNotes } from '../../actions/course_notes_action'; +import NotesPanelEditButton from './notes_panel_edit_button'; +import NotesList from './notes_list'; + +const NotesPanel = ({ setState, modalType, currentUser, courseId, buttonText, headerText }) => { + const notesList = useSelector(state => state.courseNotes.notes_list); + const dispatch = useDispatch(); + + useEffect(() => { + const fetchData = () => { + dispatch(fetchAllCourseNotes(courseId)); + }; + + fetchData(); + + const pollInterval = setInterval(fetchData, 60000); + + return () => clearInterval(pollInterval); + }, []); + + if (modalType !== 'DefaultPanel' && modalType === null) { + return ; + } + + return ( +
+
+ ); +}; + +export default NotesPanel; diff --git a/app/assets/javascripts/components/notes/notes_panel_edit_button.jsx b/app/assets/javascripts/components/notes/notes_panel_edit_button.jsx new file mode 100644 index 0000000000..f763f8af95 --- /dev/null +++ b/app/assets/javascripts/components/notes/notes_panel_edit_button.jsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { deleteNoteFromList } from '../../actions/course_notes_action'; +import { initiateConfirm } from '../../actions/confirm_actions'; +import useExpandablePopover from '../../hooks/useExpandablePopover'; +import Popover from '../common/popover.jsx'; + +export const NotesPanelEditButton = ({ setState, currentUser, notesList }) => { + const getKey = () => { + return 'Create Notes'; + }; + + const stop = (e) => { + return e.stopPropagation(); + }; + + const deleteNote = (noteId) => { + const onConfirm = () => { + dispatch(deleteNoteFromList(noteId)); + }; + const confirmMessage = I18n.t('notes.delete_note_confirmation'); + dispatch(initiateConfirm({ confirmMessage, onConfirm })); + }; + + const { isOpen, ref, open } = useExpandablePopover(getKey); + const dispatch = useDispatch(); + const editRows = []; + + const notesRow = notesList.map((note) => { + let removeButton; + if (currentUser.admin) { + removeButton = ( + + ); + } + return ( + + {note.title}{removeButton} + + ); + }); + + const button = ( + + ); + + editRows.push( + + + + + + ); + + return ( +
+ {button} + +
+ ); +}; + +export default NotesPanelEditButton; From 9a51c2734f5949910503b050d565b5924ac4d56d Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Sun, 21 Jan 2024 16:33:16 +0530 Subject: [PATCH 004/198] Implement NotesHandler component for course notes management This commit introduces the 'NotesHandler' component, responsible for managing the display and interaction flow of course notes. It utilizes state management to handle different modal types, allowing users to view and edit course notes. The component includes integration with 'NotesPanel' and 'NotesEditor' for displaying notes and enabling note editing, respectively. --- .../components/notes/notes_handler.jsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 app/assets/javascripts/components/notes/notes_handler.jsx diff --git a/app/assets/javascripts/components/notes/notes_handler.jsx b/app/assets/javascripts/components/notes/notes_handler.jsx new file mode 100644 index 0000000000..185e25d9a4 --- /dev/null +++ b/app/assets/javascripts/components/notes/notes_handler.jsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { nameHasChanged } from '../../actions/course_actions'; +import { resetCourseNote, persistCourseNote } from '../../actions/course_notes_action'; +import { getCurrentUser } from '../../selectors/index'; +import NotesEditor from './notes_editor'; +import NotesPanel from './notes_panel'; + +const NotesHandler = () => { + const [modalType, setModalType] = useState(null); + const [noteId, setNoteId] = useState(null); + const course = useSelector(state => state.course); + const currentUser = useSelector(getCurrentUser); + const dispatch = useDispatch(); + + const dispatchNameHasChanged = () => { + dispatch(nameHasChanged()); + }; + const dispatchPersistCourseNote = () => { + dispatch(persistCourseNote(course.id, currentUser.username)); + }; + const dispatchResetCourseNote = () => { + dispatch(resetCourseNote()); + }; + + const setState = (id = null, type = 'DefaultPanel') => { + setNoteId(id); + setModalType(type); + }; + + const defaultAdminNotesPanel = ( + + ); + + const AdminNotesEditPanel = ( + + ); + + switch (modalType) { + case 'NoteEditor': + return AdminNotesEditPanel; + default: + return defaultAdminNotesPanel; + } +}; + +export default (NotesHandler); From 0e4ea15938640d829566803501dfe2a8550846e3 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Sun, 21 Jan 2024 16:38:58 +0530 Subject: [PATCH 005/198] Introduce state management for course notes This commit adds state management functionality for course notes in the 'NotesHandler' component. The state allows for dynamic control and interaction with course notes, enhancing the overall course notes management capabilities. --- .../actions/course_notes_action.js | 109 ++++++++++++++++++ app/assets/javascripts/constants/index.js | 1 + app/assets/javascripts/constants/notes.js | 8 ++ .../javascripts/reducers/course_notes.js | 43 +++++++ app/assets/javascripts/reducers/index.js | 4 + .../reducers/persisted_course_note.js | 14 +++ 6 files changed, 179 insertions(+) create mode 100644 app/assets/javascripts/actions/course_notes_action.js create mode 100644 app/assets/javascripts/constants/notes.js create mode 100644 app/assets/javascripts/reducers/course_notes.js create mode 100644 app/assets/javascripts/reducers/persisted_course_note.js diff --git a/app/assets/javascripts/actions/course_notes_action.js b/app/assets/javascripts/actions/course_notes_action.js new file mode 100644 index 0000000000..ef006fcc20 --- /dev/null +++ b/app/assets/javascripts/actions/course_notes_action.js @@ -0,0 +1,109 @@ +import API from '../utils/api'; +import logErrorMessage from '../utils/log_error_message'; +import { ADD_NOTIFICATION } from '../constants/notifications'; + +import { + UPDATE_CURRENT_NOTE, + RECEIVE_NOTE_DETAILS, + RESET_TO_ORIGINAL_NOTE, + PERSISTED_COURSE_NOTE, + RESET_TO_DEFAULT, + RECEIVE_NOTES_LIST, + ADD_NEW_NOTE_TO_LIST, + DELETE_NOTE_FROM_LIST +} from '../constants'; + + +const sendNotification = (dispatch, type, messageKey, dynamicValue) => { + const notificationConfig = { + message: I18n.t(messageKey, dynamicValue), + closable: true, + type: type === 'Success' ? 'success' : 'error', + }; + + dispatch({ + type: ADD_NOTIFICATION, + notification: notificationConfig, + }); +}; + +export const fetchAllCourseNotes = courseId => async (dispatch) => { + try { + const notesList = await API.fetchAllCourseNotes(courseId); + dispatch({ type: RECEIVE_NOTES_LIST, notes_list: notesList }); + } catch (error) { + logErrorMessage('Error fetching course notes:', error); + } +}; + +export const fetchSingleNoteDetails = courseNoteId => async (dispatch) => { + try { + const note = await API.fetchCourseNotesById(courseNoteId); + dispatch({ type: RECEIVE_NOTE_DETAILS, note }); + } catch (error) { + logErrorMessage('Error fetching single course note details:', error); + } +}; + +export const updateCurrentCourseNote = data => (dispatch) => { + dispatch({ type: UPDATE_CURRENT_NOTE, note: { ...data } }); +}; + +export const resetCourseNote = () => (dispatch, getState) => { + const CourseNote = getState().persistedCourseNote; + dispatch({ type: RESET_TO_ORIGINAL_NOTE, note: { ...CourseNote } }); +}; + +export const saveCourseNote = async (currentUser, courseNoteDetails, dispatch) => { + const status = await API.saveCourseNote(currentUser, courseNoteDetails); + + if (status.success) { + sendNotification(dispatch, 'Success', 'notes.updated'); + dispatch({ type: PERSISTED_COURSE_NOTE, note: courseNoteDetails }); + } else { + const messageKey = 'notes.failure'; + const dynamicValue = { operation: 'update' }; + sendNotification(dispatch, 'Error', messageKey, dynamicValue); + } +}; + +export const createCourseNote = async (courseId, courseNoteDetails, dispatch) => { + const noteDetails = await API.createCourseNote(courseId, courseNoteDetails); + + if (noteDetails.id) { + sendNotification(dispatch, 'Success', 'notes.created'); + dispatch({ type: ADD_NEW_NOTE_TO_LIST, newNote: noteDetails }); + dispatch({ type: PERSISTED_COURSE_NOTE, note: noteDetails }); + } else { + const messageKey = 'notes.failure'; + const dynamicValue = { operation: 'create' }; + sendNotification(dispatch, 'Error', messageKey, dynamicValue); + } +}; + +export const persistCourseNote = (courseId = null, currentUser) => (dispatch, getState) => { + const courseNoteDetails = getState().courseNotes.note; + + if ((courseNoteDetails.title.trim().length === 0) || (courseNoteDetails.text.trim().length === 0)) { + return sendNotification(dispatch, 'Error', 'notes.empty_fields'); + } else if (courseNoteDetails.id) { + return saveCourseNote(currentUser, courseNoteDetails, dispatch); + } + + createCourseNote(courseId, courseNoteDetails, dispatch); +}; + +export const deleteNoteFromList = noteId => async (dispatch) => { + const status = await API.deleteCourseNote(noteId); + + if (status.success) { + sendNotification(dispatch, 'Success', 'notes.deleted'); + dispatch({ type: DELETE_NOTE_FROM_LIST, deletedNoteId: noteId }); + } else { + sendNotification(dispatch, 'Error', 'notes.delete_note_error'); + } +}; + +export const resetStateToDefault = () => (dispatch) => { + dispatch({ type: RESET_TO_DEFAULT }); +}; diff --git a/app/assets/javascripts/constants/index.js b/app/assets/javascripts/constants/index.js index 309c3bb08c..1652965080 100644 --- a/app/assets/javascripts/constants/index.js +++ b/app/assets/javascripts/constants/index.js @@ -11,6 +11,7 @@ export * from './categories'; export * from './confirm'; export * from './course'; export * from './course_alerts'; +export * from './notes'; export * from './course_search_results'; export * from './did_you_know'; export * from './exercises'; diff --git a/app/assets/javascripts/constants/notes.js b/app/assets/javascripts/constants/notes.js new file mode 100644 index 0000000000..55476594e0 --- /dev/null +++ b/app/assets/javascripts/constants/notes.js @@ -0,0 +1,8 @@ +export const RECEIVE_NOTE_DETAILS = 'RECEIVE_NOTE_DETAILS'; +export const RECEIVE_NOTES_LIST = 'RECEIVE_NOTE_LIST'; +export const ADD_NEW_NOTE_TO_LIST = 'ADD_NEW_NOTE_TO_LIST'; +export const DELETE_NOTE_FROM_LIST = 'DELETE_NOTE_FROM_LIST'; +export const UPDATE_CURRENT_NOTE = 'UPDATE_CURRENT_NOTE_DETAILS'; +export const RESET_TO_ORIGINAL_NOTE = 'RESET_NOTE_DETAILS'; +export const PERSISTED_COURSE_NOTE = 'PERSISTED_COURSE_NOTE'; +export const RESET_TO_DEFAULT = 'RESET_TO_DEFAULT'; diff --git a/app/assets/javascripts/reducers/course_notes.js b/app/assets/javascripts/reducers/course_notes.js new file mode 100644 index 0000000000..3a78f45f7f --- /dev/null +++ b/app/assets/javascripts/reducers/course_notes.js @@ -0,0 +1,43 @@ +import { + UPDATE_CURRENT_NOTE, + RECEIVE_NOTE_DETAILS, + RESET_TO_ORIGINAL_NOTE, + PERSISTED_COURSE_NOTE, + RESET_TO_DEFAULT, + RECEIVE_NOTES_LIST, + ADD_NEW_NOTE_TO_LIST, + DELETE_NOTE_FROM_LIST +} from '../constants'; + +const initialState = { + notes_list: [], + note: { + title: '', + text: '', + edited_by: '', + }, +}; + +export default function courseNotes(state = initialState, action) { + switch (action.type) { + case RECEIVE_NOTES_LIST: + return { ...state, notes_list: action.notes_list }; + case ADD_NEW_NOTE_TO_LIST: + return { ...state, notes_list: [...state.notes_list, action.newNote] }; + case DELETE_NOTE_FROM_LIST: + return { ...state, notes_list: state.notes_list.filter(note => note.id !== action.deletedNoteId) }; + case RECEIVE_NOTE_DETAILS: + return { ...state, note: { ...state.note, ...action.note } }; + case UPDATE_CURRENT_NOTE: + return { ...state, note: { ...state.note, ...action.note } }; + case PERSISTED_COURSE_NOTE: + return { ...state, note: { ...state.note, ...action.note } }; + case RESET_TO_ORIGINAL_NOTE: + return { ...state, note: { ...state.note, ...action.note } }; + case RESET_TO_DEFAULT: + return { ...initialState, notes_list: state.notes_list }; + default: + return state; + } +} + diff --git a/app/assets/javascripts/reducers/index.js b/app/assets/javascripts/reducers/index.js index fc132b4396..2df3c01a2c 100644 --- a/app/assets/javascripts/reducers/index.js +++ b/app/assets/javascripts/reducers/index.js @@ -19,6 +19,7 @@ import needHelpAlert from './need_help_alert'; import newAccount from './new_account'; import notifications from './notifications'; import persistedCourse from './persisted_course'; +import persistedCourseNote from './persisted_course_note'; import recentEdits from './recent_edits.js'; import recentUploads from './recent_uploads'; import revisions from './revisions'; @@ -44,6 +45,7 @@ import active_courses from './active_courses'; import wiki_courses from './wiki_courses'; import refreshing from './refreshing'; import scopingMethods from './scoping_methods'; +import courseNotes from './course_notes'; const reducer = combineReducers({ active_courses, @@ -70,6 +72,7 @@ const reducer = combineReducers({ newAccount, notifications, persistedCourse, + persistedCourseNote, recentEdits, recentUploads, refreshing, @@ -93,6 +96,7 @@ const reducer = combineReducers({ wikidataLabels, wiki_courses, wizard, + courseNotes }); export default reducer; diff --git a/app/assets/javascripts/reducers/persisted_course_note.js b/app/assets/javascripts/reducers/persisted_course_note.js new file mode 100644 index 0000000000..d25e0a524c --- /dev/null +++ b/app/assets/javascripts/reducers/persisted_course_note.js @@ -0,0 +1,14 @@ +/* eslint-disable no-unused-vars */ +import { RECEIVE_NOTE_DETAILS, PERSISTED_COURSE_NOTE, RESET_TO_DEFAULT } from '../constants'; + +export default function persistedCourseNote(state = {}, action) { + switch (action.type) { + case RECEIVE_NOTE_DETAILS: + case PERSISTED_COURSE_NOTE: + return { ...action.note }; + case RESET_TO_DEFAULT: + return {}; + default: + return state; + } +} From 23b219aff134e506c982496b68a642b3a8036313 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Sun, 21 Jan 2024 16:39:43 +0530 Subject: [PATCH 006/198] Add CSS for course notes. --- app/assets/stylesheets/modules/_basic_modal.styl | 16 ++++++++++++++++ app/assets/stylesheets/modules/_popover.styl | 3 +++ app/assets/stylesheets/modules/_tables.styl | 5 +++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/modules/_basic_modal.styl b/app/assets/stylesheets/modules/_basic_modal.styl index 656fbbb25f..597207894c 100644 --- a/app/assets/stylesheets/modules/_basic_modal.styl +++ b/app/assets/stylesheets/modules/_basic_modal.styl @@ -27,3 +27,19 @@ .basic-modal.left text-align left + +.basic-modal.admin-note + + button + padding 10px + + + .admin-header + margin-bottom 30px + + .note-title + min-width 180px + text-align left + + .note-text + text-align left diff --git a/app/assets/stylesheets/modules/_popover.styl b/app/assets/stylesheets/modules/_popover.styl index cecc77afb0..fbf0a7262a 100644 --- a/app/assets/stylesheets/modules/_popover.styl +++ b/app/assets/stylesheets/modules/_popover.styl @@ -70,6 +70,9 @@ width inherit > * vertical-align middle + &.admin-note-create { + text-align: center + } .button + & margin-left 10px diff --git a/app/assets/stylesheets/modules/_tables.styl b/app/assets/stylesheets/modules/_tables.styl index 0d2e954afe..80adc2e34f 100644 --- a/app/assets/stylesheets/modules/_tables.styl +++ b/app/assets/stylesheets/modules/_tables.styl @@ -17,7 +17,7 @@ th width 20% -.campaign_main.alerts +.campaign_main.alerts thead th background: transparent @@ -167,7 +167,8 @@ cursor pointer background-color #fafafa - +.table--admin_note + text-align: left; // Alternating row background colors .table--striped From 884c4153eaf6200b3d93310d5fb4a4bd1e51d3d0 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Sun, 21 Jan 2024 16:40:40 +0530 Subject: [PATCH 007/198] Add NotesHandler to AdminQuickActions --- .../javascripts/components/overview/admin_quick_actions.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/assets/javascripts/components/overview/admin_quick_actions.jsx b/app/assets/javascripts/components/overview/admin_quick_actions.jsx index ca372c5422..403ecc1b06 100644 --- a/app/assets/javascripts/components/overview/admin_quick_actions.jsx +++ b/app/assets/javascripts/components/overview/admin_quick_actions.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import GreetStudentsButton from './greet_students_button.jsx'; import { format, toDate, parseISO } from 'date-fns'; import { getUTCDateString } from '../../utils/date_utils.js'; +import NotesHandler from '../../components/notes/notes_handler.jsx'; // Helper Functions const DetailsText = ({ flags }) => ( @@ -47,6 +48,8 @@ export const AdminQuickActions = ({ course, current_user, persistCourse, greetSt

+
+ {current_user.admin ?
: <>} ); From dcf070079ead0bcd19c0fea832d0b9694aa709ca Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Sun, 21 Jan 2024 16:43:16 +0530 Subject: [PATCH 008/198] Implement CRUD operations for course notes in api.js This commit adds functions for fetching, saving, creating, and deleting course notes in the 'api.js' file. The new methods include 'fetchAllCourseNotes', 'fetchCourseNotesById', 'saveCourseNote', 'createCourseNote', and 'deleteCourseNote'. These functions provide the necessary API calls to interact with course notes, enabling full CRUD functionality within the application. --- app/assets/javascripts/utils/api.js | 90 +++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/utils/api.js b/app/assets/javascripts/utils/api.js index c2043b0d71..baaaee909d 100644 --- a/app/assets/javascripts/utils/api.js +++ b/app/assets/javascripts/utils/api.js @@ -168,7 +168,7 @@ const API = { async cloneCourse(id, campaign, copyAssignments) { const campaignQueryParam = campaign ? `?campaign_slug=${campaign}` : '' - const copyAssignmentsQueryParam = copyAssignments ? `?copy_assignments=${copyAssignments}` : '?copy_assignments=false' + const copyAssignmentsQueryParam = copyAssignments ? `?copy_assignments=${copyAssignments}` : '?copy_assignments=false' const response = await request(`/clone_course/${id}${campaignQueryParam}${copyAssignmentsQueryParam}`, { method: 'POST' }); @@ -257,7 +257,32 @@ const API = { }); }, + async fetchAllCourseNotes(courseId) { + try { + const response = await request(`/course_notes/${courseId}`); + const data = await response.json(); + return data.courseNotes; + } catch (error) { + logErrorMessage('Error fetching course notes:', error); + throw error; + } + }, + async fetchCourseNotesById(courseNoteId = null) { + try { + if (courseNoteId === null) { + throw new Error('courseNoteId must be provided'); + } + + const url = `/course_notes/${courseNoteId}/find_course_note`; + const response = await request(url); + const data = await response.json(); + return data.courseNote; + } catch (error) { + logErrorMessage('Error fetching course note by ID:', error); + throw error; + } + }, // ///////// // Setters # @@ -269,7 +294,7 @@ const API = { delete object.is_new; } }; - + const weeks = [] data.weeks.forEach(week => { const cleanWeek = { ...week }; @@ -336,12 +361,57 @@ const API = { throw response; } return response.json(); + }, + + async saveCourseNote(currentUser, courseNoteDetails) { + if(currentUser !== courseNoteDetails.edited_by){ + courseNoteDetails.edited_by = currentUser; + } + try { + const response = await request(`/course_notes/${courseNoteDetails.id}`, { + method: 'PUT', + body: JSON.stringify(courseNoteDetails) + }); - // return promise; + const status = await response.json(); + return status; + } catch (error) { + logErrorMessage('Error fetching course notes:', error); + throw error; + } + }, + + async createCourseNote(courseId, courseNoteDetails) { + const modifiedDetails = { ...courseNoteDetails, courses_id: courseId }; + try { + const response = await request('/course_notes', { + method: 'POST', + body: JSON.stringify(modifiedDetails) + }); + + const { createdNote } = await response.json(); + return createdNote; + } catch (error) { + logErrorMessage('Error saving course notes:', error) + throw error; + } + }, + + async deleteCourseNote(noteId) { + try { + const response = await request(`/course_notes/${noteId}`, { + method: 'DELETE', + }); + + const status = await response.json(); + return status; + } catch (error) { + logErrorMessage('Error Deleting course notes:', error) + throw error; + } }, async deleteCourse(courseId) { - console.log("deleting") const response = await request(`/courses/${courseId}.json`, { method: 'DELETE' }); @@ -489,7 +559,7 @@ const API = { async requestNewAccount(passcode, courseSlug, username, email, createAccountNow) { const response = await request('/requested_accounts', { method: 'PUT', - body: JSON.stringify( + body: JSON.stringify( { passcode, course_slug: courseSlug, username, email, create_account_now: createAccountNow } ) }); @@ -541,9 +611,9 @@ const API = { async getCategoriesWithPrefix(wiki, search_term, depth, limit=10){ return this.searchForPages( - wiki, - search_term, - 14, + wiki, + search_term, + 14, // replace everything until first colon, then trim (title)=>title.replace(/^[^:]+:/,'').trim(), depth, @@ -554,7 +624,7 @@ const API = { async getTemplatesWithPrefix(wiki, search_term, depth, limit=10){ return this.searchForPages( wiki, - search_term, + search_term, 10, (title)=>title.replace(/^[^:]+:/,'').trim(), depth, @@ -584,7 +654,7 @@ const API = { `https://${toWikiDomain(wiki)}/w/api.php?${stringify(params)}` ); const json = await response.json(); - + return json.query.search.map((category) => { const label = formatCategoryName({ category: map(category.title), From 9daf18cb59edc98da6beaf73dc3c0cd1fc0c6dbe Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Sun, 21 Jan 2024 16:43:56 +0530 Subject: [PATCH 009/198] Add localization for notes-related messages in en.yml --- config/locales/en.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 08f99f05ee..3cfb9579c8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -402,7 +402,7 @@ en: search: Search categories: - searching_for_category: Searching for categories in + searching_for_category: Searching for categories in add_category: Add category add_psid: Add PetScan PSID add_pileid: Add PagePile id @@ -1673,3 +1673,23 @@ en: multi_wiki: selector_placeholder: "Begin typing a wiki domain" + + notes: + title: "Title" + note_title: "Note Title" + note_text: "Note Text" + created: "The note has been created successfully." + updated: "The note has been updated successfully." + failure: "Unable to %{operation} the note. Please try again later." + empty_fields: "Please ensure all input fields are filled and try again." + edit: "The time of each edit is shown in your local time." + edited_by: "Edited By" + last_edited_by: "Displays the name of the admin who last edited the note." + deleted: "The note has been successfully deleted. It is no longer available in your notes." + delete_note_confirmation: "Delete Note: This action will permanently delete the note. Are you sure you want to proceed?" + delete_note_error: "Unable to delete the note. Please try again." + no_notes: "This course currently has no associated notes." + admin: + button_text: "Admin Notes" + header_text: "Admin Notes Panel" + From 0640d6663e38f67834451292355b63c66c43d1ad Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Sun, 21 Jan 2024 16:53:42 +0530 Subject: [PATCH 010/198] Create 'course_notes' table migration and update schema.rb --- db/migrate/20240105094818_create_course_notes.rb | 11 +++++++++++ db/schema.rb | 13 ++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240105094818_create_course_notes.rb diff --git a/db/migrate/20240105094818_create_course_notes.rb b/db/migrate/20240105094818_create_course_notes.rb new file mode 100644 index 0000000000..b4d689ba71 --- /dev/null +++ b/db/migrate/20240105094818_create_course_notes.rb @@ -0,0 +1,11 @@ +class CreateCourseNotes < ActiveRecord::Migration[7.0] + def change + create_table :course_notes do |t| + t.references :courses, foreign_key: true, type: :integer # Make sure to specify the type + t.string :title + t.text :text + t.string :edited_by + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index adf4ea40b8..f47e4b3d17 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2022_08_15_143028) do +ActiveRecord::Schema[7.0].define(version: 2024_01_05_094818) do create_table "alerts", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "course_id" t.integer "user_id" @@ -182,6 +182,16 @@ t.index ["user_id"], name: "index_commons_uploads_on_user_id" end + create_table "course_notes", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.integer "courses_id" + t.string "title" + t.text "text" + t.string "edited_by" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["courses_id"], name: "index_course_notes_on_courses_id" + end + create_table "course_stats", charset: "utf8mb4", force: :cascade do |t| t.text "stats_hash" t.integer "course_id" @@ -601,6 +611,7 @@ t.index ["language", "project"], name: "index_wikis_on_language_and_project", unique: true end + add_foreign_key "course_notes", "courses", column: "courses_id" add_foreign_key "course_stats", "courses" add_foreign_key "course_wiki_namespaces", "courses_wikis", column: "courses_wikis_id", on_delete: :cascade end From 1f5f2921d60d79f5b4fc621140aa0d5003683e0d Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Sun, 21 Jan 2024 16:54:17 +0530 Subject: [PATCH 011/198] Implement CRUD routes, controller, and model for Course Notes This commit introduces CRUD routes for Course Notes in 'routes.rb' and implements corresponding actions in 'CourseNotesController'. Additionally, the 'CourseNote' model is updated to include validations and methods for creating and updating notes. These changes collectively enable the full range of Create, Read, Update, and Delete (CRUD) operations for Course Notes within the application. --- app/controllers/course_notes_controller.rb | 51 ++++++++++++++++++++++ app/models/course_note.rb | 23 ++++++++++ config/routes.rb | 7 ++- 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 app/controllers/course_notes_controller.rb create mode 100644 app/models/course_note.rb diff --git a/app/controllers/course_notes_controller.rb b/app/controllers/course_notes_controller.rb new file mode 100644 index 0000000000..5a431ee282 --- /dev/null +++ b/app/controllers/course_notes_controller.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class CourseNotesController < ApplicationController + before_action :set_course_note, only: [:find_course_note, :update, :destroy] + + def show + course_notes = CourseNote.where(courses_id: params[:id]) + render json: { courseNotes: course_notes } + end + + def find_course_note + render json: { courseNote: @course_note } + end + + def update + if @course_note.update_note(course_note_params) + render json: { success: true } + else + render json: { error: 'Failed to update course note' }, status: :unprocessable_entity + end + end + + def create + note_details = CourseNote.new.create_new_note(course_note_params) + if note_details + render json: { createdNote: note_details }, status: :created + else + render json: { error: new_course_note.errors.full_messages }, status: :unprocessable_entity + end + end + + def destroy + if @course_note.destroy + render json: { success: true } + else + render json: { error: 'Failed to delete course note' }, status: :unprocessable_entity + end + end + + private + + def set_course_note + @course_note = CourseNote.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: 'Note not found' }, status: :not_found + end + + def course_note_params + params.require(:course_note).permit(:courses_id, :title, :text, :edited_by) + end +end diff --git a/app/models/course_note.rb b/app/models/course_note.rb new file mode 100644 index 0000000000..d6c873e8bb --- /dev/null +++ b/app/models/course_note.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class CourseNote < ApplicationRecord + belongs_to :course + + validates :courses_id, presence: true + validates :title, presence: true + validates :text, presence: true + validates :edited_by, presence: true + + def create_new_note(attributes) + self.attributes = attributes + if save + self + else + false + end + end + + def update_note(attributes) + update(attributes.slice(:title, :text, :edited_by)) + end +end diff --git a/config/routes.rb b/config/routes.rb index d3c3d15447..e933357c55 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,7 +13,7 @@ # intercepting the click and issuing a post request. Omniauth login is post-only. get 'users/auth/mediawiki', to: redirect('/') devise_for :users, controllers: { omniauth_callbacks: 'omniauth_callbacks' } - + devise_scope :user do # OmniAuth may fall back to :new_user_session when the OAuth flow fails. # So, we treat it as a login error. @@ -184,6 +184,11 @@ get 'find_course/:course_id' => 'courses#find' end + # Course Notes + resources :course_notes, constraints: { id: /\d+/ } do + get 'find_course_note', on: :member + end + # Categories post 'categories' => 'categories#add_categories' delete 'categories' => 'categories#remove_category' From 4fe335beefeaa03782424ca5922aa012fe30e39d Mon Sep 17 00:00:00 2001 From: gabina Date: Wed, 7 Feb 2024 20:57:10 -0300 Subject: [PATCH 012/198] Add a class to update default campaign if current term exists as a campaign --- lib/deafult_campaign_update.rb | 19 ++++++++ spec/lib/default_campaign_update_spec.rb | 57 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 lib/deafult_campaign_update.rb create mode 100644 spec/lib/default_campaign_update_spec.rb diff --git a/lib/deafult_campaign_update.rb b/lib/deafult_campaign_update.rb new file mode 100644 index 0000000000..7dcf88e2ec --- /dev/null +++ b/lib/deafult_campaign_update.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +require "#{Rails.root}/app/presenters/campaigns_presenter.rb" + +# Set current term as default campaign if it exists as a campaign. +class DefaultCampaignUpdate + def initialize + CampaignsPresenter.update_default_campaign(current_term) if Campaign.find_by(slug: current_term) + end + + private + + def current_term + year = Time.zone.today.year + month = Time.zone.today.month + # Determine if it's spring or fall in northern hemisphere + season = month.between?(3, 8) ? 'spring' : 'fall' + season + '_' + year.to_s + end +end diff --git a/spec/lib/default_campaign_update_spec.rb b/spec/lib/default_campaign_update_spec.rb new file mode 100644 index 0000000000..4b8d8b7ebe --- /dev/null +++ b/spec/lib/default_campaign_update_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' +require "#{Rails.root}/lib/deafult_campaign_update.rb" + +describe DefaultCampaignUpdate do + before do + Setting.create(key: 'default_campaign', + value: { :slug => 'default_campaign' }) + end + + describe 'when fall starts' do + before do + travel_to Date.new(2024, 9, 21) + end + + context 'when current term exists as campaign' do + it 'sets it as default campaign' do + create(:campaign, id: 1, slug: 'fall_2024') + expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) + described_class.new + expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"fall_2024"}) + end + end + + context 'when current term does not as campaign' do + it 'default campaign keeps being the same' do + expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) + described_class.new + expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) + end + end + end + + describe 'when spring starts' do + before do + travel_to Date.new(2024, 3, 21) + end + + context 'when current term exists as campaign' do + it 'sets it as default campaign' do + create(:campaign, id: 1, slug: 'spring_2024') + expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) + described_class.new + expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"spring_2024"}) + end + end + + context 'when current term does not as campaign' do + it 'default campaign keeps being the same' do + expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) + described_class.new + expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) + end + end + end +end From 784693a4e68bc997843239b1cab150d29d13ea79 Mon Sep 17 00:00:00 2001 From: gabina Date: Wed, 7 Feb 2024 20:58:03 -0300 Subject: [PATCH 013/198] Schedule default campaign update at the beginning of spring and fall (northern hemisphere) --- app/workers/default_campaign_update_worker.rb | 12 ++++++++++++ config/schedule.yml | 6 ++++++ spec/workers/sidekiq_cron_jobs_spec.rb | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 app/workers/default_campaign_update_worker.rb diff --git a/app/workers/default_campaign_update_worker.rb b/app/workers/default_campaign_update_worker.rb new file mode 100644 index 0000000000..f935abe11d --- /dev/null +++ b/app/workers/default_campaign_update_worker.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +require "#{Rails.root}/lib/deafult_campaign_update.rb" + +class DefaultCampaignUpdateWorker + include Sidekiq::Worker + sidekiq_options lock: :until_executed + + def perform + return unless Features.wiki_ed? + DefaultCampaignUpdate.new + end +end diff --git a/config/schedule.yml b/config/schedule.yml index c89a3a6487..652b16ef77 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -38,3 +38,9 @@ open_ticket_emails: cron: "0 13,20 * * 1,2,3,4,5" # every weekday at 6am and 1pm PT (UTC -7) class: "TicketNotificationsWorker" queue: constant_update + +# Update default campaign at the beginning of spring and fall (northern hemisphere) +update_default_campaign: + cron: "1 0 21 3,9 *" # every March, 21st and September, 21st at 00:01 (UTC -7) + class: "DefaultCampaignUpdateWorker" + queue: default diff --git a/spec/workers/sidekiq_cron_jobs_spec.rb b/spec/workers/sidekiq_cron_jobs_spec.rb index ab3e204eb5..c29a648f72 100644 --- a/spec/workers/sidekiq_cron_jobs_spec.rb +++ b/spec/workers/sidekiq_cron_jobs_spec.rb @@ -3,6 +3,7 @@ require "#{Rails.root}/app/workers/daily_update_worker" require "#{Rails.root}/app/workers/survey_update_worker" require "#{Rails.root}/app/workers/ticket_notifications_worker" +require "#{Rails.root}/app/workers/default_campaign_update_worker.rb" describe 'workers scheduled via sidekiq-cron' do it 'run daily updates' do @@ -19,4 +20,21 @@ expect(TicketNotificationEmails).to receive(:notify) TicketNotificationsWorker.perform_async end + + context 'when WikiEd Feature enabled' do + before { allow(Features).to receive(:wiki_ed?).and_return(true) } + it 'run default campaign update' do + expect(DefaultCampaignUpdate).to receive(:new) + DefaultCampaignUpdateWorker.perform_async + end + end + + context 'when WikiEd Feature disabled' do + before { allow(Features).to receive(:wiki_ed?).and_return(false) } + it 'run default campaign update' do + expect(DefaultCampaignUpdate).not_to receive(:new) + DefaultCampaignUpdateWorker.perform_async + end + end + end From cdb380d34282d7af7c11ad542d3810ef4df51ade Mon Sep 17 00:00:00 2001 From: gabina Date: Thu, 8 Feb 2024 12:16:13 -0300 Subject: [PATCH 014/198] small updates to DefaultCampaignUpdate specs --- spec/lib/default_campaign_update_spec.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/lib/default_campaign_update_spec.rb b/spec/lib/default_campaign_update_spec.rb index 4b8d8b7ebe..f3f20d1130 100644 --- a/spec/lib/default_campaign_update_spec.rb +++ b/spec/lib/default_campaign_update_spec.rb @@ -9,13 +9,13 @@ value: { :slug => 'default_campaign' }) end - describe 'when fall starts' do + context 'when fall starts' do before do travel_to Date.new(2024, 9, 21) end context 'when current term exists as campaign' do - it 'sets it as default campaign' do + it 'sets current term as default campaign' do create(:campaign, id: 1, slug: 'fall_2024') expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) described_class.new @@ -23,7 +23,7 @@ end end - context 'when current term does not as campaign' do + context 'when current term does not exist as campaign' do it 'default campaign keeps being the same' do expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) described_class.new @@ -32,13 +32,13 @@ end end - describe 'when spring starts' do + context 'when spring starts' do before do travel_to Date.new(2024, 3, 21) end context 'when current term exists as campaign' do - it 'sets it as default campaign' do + it 'sets current term as default campaign' do create(:campaign, id: 1, slug: 'spring_2024') expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) described_class.new From 65f1aa7bcfe395d034e60436be74c45629261d34 Mon Sep 17 00:00:00 2001 From: gabina Date: Thu, 8 Feb 2024 13:21:00 -0300 Subject: [PATCH 015/198] Fix linting --- spec/lib/default_campaign_update_spec.rb | 28 +++++++++++++++--------- spec/workers/sidekiq_cron_jobs_spec.rb | 3 ++- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/spec/lib/default_campaign_update_spec.rb b/spec/lib/default_campaign_update_spec.rb index f3f20d1130..f662aa78a1 100644 --- a/spec/lib/default_campaign_update_spec.rb +++ b/spec/lib/default_campaign_update_spec.rb @@ -3,10 +3,10 @@ require 'rails_helper' require "#{Rails.root}/lib/deafult_campaign_update.rb" -describe DefaultCampaignUpdate do +describe DefaultCampaignUpdate do before do Setting.create(key: 'default_campaign', - value: { :slug => 'default_campaign' }) + value: { slug: 'default_campaign' }) end context 'when fall starts' do @@ -17,17 +17,21 @@ context 'when current term exists as campaign' do it 'sets current term as default campaign' do create(:campaign, id: 1, slug: 'fall_2024') - expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) + expect(CampaignsPresenter.default_campaign_setting.value) + .to eq({ slug: 'default_campaign' }) described_class.new - expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"fall_2024"}) + expect(CampaignsPresenter.default_campaign_setting.value) + .to eq({ slug: 'fall_2024' }) end end context 'when current term does not exist as campaign' do it 'default campaign keeps being the same' do - expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) + expect(CampaignsPresenter.default_campaign_setting.value) + .to eq({ slug: 'default_campaign' }) described_class.new - expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) + expect(CampaignsPresenter.default_campaign_setting.value) + .to eq({ slug: 'default_campaign' }) end end end @@ -40,17 +44,21 @@ context 'when current term exists as campaign' do it 'sets current term as default campaign' do create(:campaign, id: 1, slug: 'spring_2024') - expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) + expect(CampaignsPresenter.default_campaign_setting.value) + .to eq({ slug: 'default_campaign' }) described_class.new - expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"spring_2024"}) + expect(CampaignsPresenter.default_campaign_setting.value) + .to eq({ slug: 'spring_2024' }) end end context 'when current term does not as campaign' do it 'default campaign keeps being the same' do - expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) + expect(CampaignsPresenter.default_campaign_setting.value) + .to eq({ slug: 'default_campaign' }) described_class.new - expect(CampaignsPresenter.default_campaign_setting.value).to eq({:slug=>"default_campaign"}) + expect(CampaignsPresenter.default_campaign_setting.value) + .to eq({ slug: 'default_campaign' }) end end end diff --git a/spec/workers/sidekiq_cron_jobs_spec.rb b/spec/workers/sidekiq_cron_jobs_spec.rb index c29a648f72..6197d774e1 100644 --- a/spec/workers/sidekiq_cron_jobs_spec.rb +++ b/spec/workers/sidekiq_cron_jobs_spec.rb @@ -23,6 +23,7 @@ context 'when WikiEd Feature enabled' do before { allow(Features).to receive(:wiki_ed?).and_return(true) } + it 'run default campaign update' do expect(DefaultCampaignUpdate).to receive(:new) DefaultCampaignUpdateWorker.perform_async @@ -31,10 +32,10 @@ context 'when WikiEd Feature disabled' do before { allow(Features).to receive(:wiki_ed?).and_return(false) } + it 'run default campaign update' do expect(DefaultCampaignUpdate).not_to receive(:new) DefaultCampaignUpdateWorker.perform_async end end - end From 762729f8317d21e923fa51b8a7b6663fa1c0f1bb Mon Sep 17 00:00:00 2001 From: gabina Date: Mon, 12 Feb 2024 19:19:24 -0300 Subject: [PATCH 016/198] Update the description for the workers spec when WikiEd features is disabled. --- spec/workers/sidekiq_cron_jobs_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/workers/sidekiq_cron_jobs_spec.rb b/spec/workers/sidekiq_cron_jobs_spec.rb index 6197d774e1..6e179a3f45 100644 --- a/spec/workers/sidekiq_cron_jobs_spec.rb +++ b/spec/workers/sidekiq_cron_jobs_spec.rb @@ -33,7 +33,7 @@ context 'when WikiEd Feature disabled' do before { allow(Features).to receive(:wiki_ed?).and_return(false) } - it 'run default campaign update' do + it 'do not run default campaign update' do expect(DefaultCampaignUpdate).not_to receive(:new) DefaultCampaignUpdateWorker.perform_async end From 487768eb261a52f156e19d4333118815c9287ed6 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Tue, 13 Feb 2024 14:28:28 +0530 Subject: [PATCH 017/198] Change RESET_TO_DEFAULT constant to RESET_NOTE_TO_DEFAULT Also added comments to course_notes_action --- .../actions/course_notes_action.js | 19 ++++++++++++++----- app/assets/javascripts/constants/notes.js | 2 +- .../reducers/persisted_course_note.js | 5 ++--- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/actions/course_notes_action.js b/app/assets/javascripts/actions/course_notes_action.js index ef006fcc20..b2f10af057 100644 --- a/app/assets/javascripts/actions/course_notes_action.js +++ b/app/assets/javascripts/actions/course_notes_action.js @@ -7,15 +7,15 @@ import { RECEIVE_NOTE_DETAILS, RESET_TO_ORIGINAL_NOTE, PERSISTED_COURSE_NOTE, - RESET_TO_DEFAULT, + RESET_NOTE_TO_DEFAULT, RECEIVE_NOTES_LIST, ADD_NEW_NOTE_TO_LIST, DELETE_NOTE_FROM_LIST } from '../constants'; - +// Helper function to dispatch notifications const sendNotification = (dispatch, type, messageKey, dynamicValue) => { - const notificationConfig = { + const notificationConfig = { message: I18n.t(messageKey, dynamicValue), closable: true, type: type === 'Success' ? 'success' : 'error', @@ -27,6 +27,7 @@ const sendNotification = (dispatch, type, messageKey, dynamicValue) => { }); }; +// Action creator to fetch all course notes for a given courseId export const fetchAllCourseNotes = courseId => async (dispatch) => { try { const notesList = await API.fetchAllCourseNotes(courseId); @@ -36,24 +37,28 @@ export const fetchAllCourseNotes = courseId => async (dispatch) => { } }; +// Action creator to fetch details of a single course note by its ID export const fetchSingleNoteDetails = courseNoteId => async (dispatch) => { try { const note = await API.fetchCourseNotesById(courseNoteId); dispatch({ type: RECEIVE_NOTE_DETAILS, note }); } catch (error) { - logErrorMessage('Error fetching single course note details:', error); + logErrorMessage('Error fetching single course note details:', error); } }; +// Action creator to update the current course note with new data export const updateCurrentCourseNote = data => (dispatch) => { dispatch({ type: UPDATE_CURRENT_NOTE, note: { ...data } }); }; +// Action creator to reset the current course note to its original state export const resetCourseNote = () => (dispatch, getState) => { const CourseNote = getState().persistedCourseNote; dispatch({ type: RESET_TO_ORIGINAL_NOTE, note: { ...CourseNote } }); }; +// Action creator to save/update the current course note export const saveCourseNote = async (currentUser, courseNoteDetails, dispatch) => { const status = await API.saveCourseNote(currentUser, courseNoteDetails); @@ -67,6 +72,7 @@ export const saveCourseNote = async (currentUser, courseNoteDetails, dispatch) = } }; +// Action creator to create a new course note for a given courseId export const createCourseNote = async (courseId, courseNoteDetails, dispatch) => { const noteDetails = await API.createCourseNote(courseId, courseNoteDetails); @@ -81,6 +87,7 @@ export const createCourseNote = async (courseId, courseNoteDetails, dispatch) => } }; +// Action creator to persist the current course note, handling validation and deciding whether to save/update or create export const persistCourseNote = (courseId = null, currentUser) => (dispatch, getState) => { const courseNoteDetails = getState().courseNotes.note; @@ -93,6 +100,7 @@ export const persistCourseNote = (courseId = null, currentUser) => (dispatch, ge createCourseNote(courseId, courseNoteDetails, dispatch); }; +// Action creator to delete a course note from the list based on its ID export const deleteNoteFromList = noteId => async (dispatch) => { const status = await API.deleteCourseNote(noteId); @@ -104,6 +112,7 @@ export const deleteNoteFromList = noteId => async (dispatch) => { } }; +// Action creator to reset the state of the course note to its default values export const resetStateToDefault = () => (dispatch) => { - dispatch({ type: RESET_TO_DEFAULT }); + dispatch({ type: RESET_NOTE_TO_DEFAULT }); }; diff --git a/app/assets/javascripts/constants/notes.js b/app/assets/javascripts/constants/notes.js index 55476594e0..a10b88be37 100644 --- a/app/assets/javascripts/constants/notes.js +++ b/app/assets/javascripts/constants/notes.js @@ -5,4 +5,4 @@ export const DELETE_NOTE_FROM_LIST = 'DELETE_NOTE_FROM_LIST'; export const UPDATE_CURRENT_NOTE = 'UPDATE_CURRENT_NOTE_DETAILS'; export const RESET_TO_ORIGINAL_NOTE = 'RESET_NOTE_DETAILS'; export const PERSISTED_COURSE_NOTE = 'PERSISTED_COURSE_NOTE'; -export const RESET_TO_DEFAULT = 'RESET_TO_DEFAULT'; +export const RESET_NOTE_TO_DEFAULT = 'RESET_NOTE_TO_DEFAULT'; diff --git a/app/assets/javascripts/reducers/persisted_course_note.js b/app/assets/javascripts/reducers/persisted_course_note.js index d25e0a524c..c3a4a672e1 100644 --- a/app/assets/javascripts/reducers/persisted_course_note.js +++ b/app/assets/javascripts/reducers/persisted_course_note.js @@ -1,12 +1,11 @@ -/* eslint-disable no-unused-vars */ -import { RECEIVE_NOTE_DETAILS, PERSISTED_COURSE_NOTE, RESET_TO_DEFAULT } from '../constants'; +import { RECEIVE_NOTE_DETAILS, PERSISTED_COURSE_NOTE, RESET_NOTE_TO_DEFAULT } from '../constants'; export default function persistedCourseNote(state = {}, action) { switch (action.type) { case RECEIVE_NOTE_DETAILS: case PERSISTED_COURSE_NOTE: return { ...action.note }; - case RESET_TO_DEFAULT: + case RESET_NOTE_TO_DEFAULT: return {}; default: return state; From 7c3a5043e07188efd55ebbfdd7648332b5ea8b29 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Tue, 13 Feb 2024 14:30:54 +0530 Subject: [PATCH 018/198] Add comment and update to RESET_NOTE_TO_DEFAULT --- app/assets/javascripts/components/notes/notes_editor.jsx | 5 ++++- app/assets/javascripts/components/notes/notes_panel.jsx | 2 ++ app/assets/javascripts/reducers/course_notes.js | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/components/notes/notes_editor.jsx b/app/assets/javascripts/components/notes/notes_editor.jsx index 6761cf791b..563e7c9c55 100644 --- a/app/assets/javascripts/components/notes/notes_editor.jsx +++ b/app/assets/javascripts/components/notes/notes_editor.jsx @@ -9,9 +9,12 @@ const NotesEditor = ({ controls, editable, toggleEditable, setState, note_id, cu const dispatch = useDispatch(); useEffect(() => { + // If note_id is provided, display the note of the given id, otherwise, the admin wants to create a new note if (note_id) { dispatch(fetchSingleNoteDetails(note_id)); } else { + // Clear the current state to prepare for the creation of a new note, + // and update the name of the admin for the new note. Finally, toggle to editable mode. dispatch(resetStateToDefault()); dispatch(updateCurrentCourseNote({ edited_by: current_user.username })); toggleEditable(); @@ -45,7 +48,7 @@ const NotesEditor = ({ controls, editable, toggleEditable, setState, note_id, cu
-

{textAreaInputComponent(updateNoteTitle, notes.title, I18n.t('notes.note_title'), 'note_title', false)}

+

{textAreaInputComponent(updateNoteTitle, notes.title, I18n.t('notes.note_title'), 'note_title', false)}

{controls()}
diff --git a/app/assets/javascripts/components/notes/notes_panel.jsx b/app/assets/javascripts/components/notes/notes_panel.jsx index 952d2ed0f2..476f0bed42 100644 --- a/app/assets/javascripts/components/notes/notes_panel.jsx +++ b/app/assets/javascripts/components/notes/notes_panel.jsx @@ -15,11 +15,13 @@ const NotesPanel = ({ setState, modalType, currentUser, courseId, buttonText, he fetchData(); + // Set up a polling interval to fetch data periodically (every 60 seconds) const pollInterval = setInterval(fetchData, 60000); return () => clearInterval(pollInterval); }, []); + // Conditionally render a button if the modalType is not 'DefaultPanel' and modalType is null if (modalType !== 'DefaultPanel' && modalType === null) { return ; } diff --git a/app/assets/javascripts/reducers/course_notes.js b/app/assets/javascripts/reducers/course_notes.js index 3a78f45f7f..0ac8773398 100644 --- a/app/assets/javascripts/reducers/course_notes.js +++ b/app/assets/javascripts/reducers/course_notes.js @@ -3,7 +3,7 @@ import { RECEIVE_NOTE_DETAILS, RESET_TO_ORIGINAL_NOTE, PERSISTED_COURSE_NOTE, - RESET_TO_DEFAULT, + RESET_NOTE_TO_DEFAULT, RECEIVE_NOTES_LIST, ADD_NEW_NOTE_TO_LIST, DELETE_NOTE_FROM_LIST @@ -34,7 +34,7 @@ export default function courseNotes(state = initialState, action) { return { ...state, note: { ...state.note, ...action.note } }; case RESET_TO_ORIGINAL_NOTE: return { ...state, note: { ...state.note, ...action.note } }; - case RESET_TO_DEFAULT: + case RESET_NOTE_TO_DEFAULT: return { ...initialState, notes_list: state.notes_list }; default: return state; From 4d10b0c34cfd25438b7a9e84d04f5684a16e3e23 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Tue, 13 Feb 2024 14:32:08 +0530 Subject: [PATCH 019/198] move CSS to _admin_note Separated the CSS for admin only notes to a new file --- .../stylesheets/modules/_admin_note.styl | 18 ++++++++++++++++++ .../stylesheets/modules/_basic_modal.styl | 16 ---------------- app/assets/stylesheets/modules/_tables.styl | 3 --- 3 files changed, 18 insertions(+), 19 deletions(-) create mode 100644 app/assets/stylesheets/modules/_admin_note.styl diff --git a/app/assets/stylesheets/modules/_admin_note.styl b/app/assets/stylesheets/modules/_admin_note.styl new file mode 100644 index 0000000000..6c38d3e8ab --- /dev/null +++ b/app/assets/stylesheets/modules/_admin_note.styl @@ -0,0 +1,18 @@ +.admin-note + + button + padding: 10px + + .admin-header + margin-bottom: 30px + + .note-title + text-align: left + min-width 150px + + .note-text + text-align: left + +.table--admin_note + text-align: left; + diff --git a/app/assets/stylesheets/modules/_basic_modal.styl b/app/assets/stylesheets/modules/_basic_modal.styl index 597207894c..656fbbb25f 100644 --- a/app/assets/stylesheets/modules/_basic_modal.styl +++ b/app/assets/stylesheets/modules/_basic_modal.styl @@ -27,19 +27,3 @@ .basic-modal.left text-align left - -.basic-modal.admin-note - - button - padding 10px - - - .admin-header - margin-bottom 30px - - .note-title - min-width 180px - text-align left - - .note-text - text-align left diff --git a/app/assets/stylesheets/modules/_tables.styl b/app/assets/stylesheets/modules/_tables.styl index 80adc2e34f..bf93e23eb2 100644 --- a/app/assets/stylesheets/modules/_tables.styl +++ b/app/assets/stylesheets/modules/_tables.styl @@ -167,9 +167,6 @@ cursor pointer background-color #fafafa -.table--admin_note - text-align: left; - // Alternating row background colors .table--striped > tbody > tr:nth-child(even) From 5a9fb81b91f37c2601622221482f1f60fce67c1b Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Tue, 13 Feb 2024 14:32:40 +0530 Subject: [PATCH 020/198] Fix minor error in controller and model --- app/controllers/course_notes_controller.rb | 2 +- app/models/course_note.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/course_notes_controller.rb b/app/controllers/course_notes_controller.rb index 5a431ee282..01fe4b8cf3 100644 --- a/app/controllers/course_notes_controller.rb +++ b/app/controllers/course_notes_controller.rb @@ -25,7 +25,7 @@ def create if note_details render json: { createdNote: note_details }, status: :created else - render json: { error: new_course_note.errors.full_messages }, status: :unprocessable_entity + render json: { error: 'Failed to create course note' }, status: :unprocessable_entity end end diff --git a/app/models/course_note.rb b/app/models/course_note.rb index d6c873e8bb..09b4cd758d 100644 --- a/app/models/course_note.rb +++ b/app/models/course_note.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class CourseNote < ApplicationRecord - belongs_to :course + belongs_to :course, foreign_key: 'courses_id' validates :courses_id, presence: true validates :title, presence: true From 0b08e6e3653ac0865439b3a25b2b0fbed381509b Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Tue, 13 Feb 2024 14:36:22 +0530 Subject: [PATCH 021/198] Make admin quick actions visible to only admins. From AdminQuickAaction only admin notes will be available to a admins and if the admin is staff too then all the all the AdminQuickActions are visible. --- .../overview/admin_quick_actions.jsx | 47 ++++++++++--------- .../components/overview/overview_handler.jsx | 2 +- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/app/assets/javascripts/components/overview/admin_quick_actions.jsx b/app/assets/javascripts/components/overview/admin_quick_actions.jsx index 403ecc1b06..4f95f8b71f 100644 --- a/app/assets/javascripts/components/overview/admin_quick_actions.jsx +++ b/app/assets/javascripts/components/overview/admin_quick_actions.jsx @@ -28,28 +28,31 @@ const NoDetailsText = () => ( export const AdminQuickActions = ({ course, current_user, persistCourse, greetStudents }) => (
- { - course.flags && course.flags.last_reviewed && course.flags.last_reviewed.username - ? - : - } - -
-
- -
- {current_user.admin ?
: <>} + {current_user.isStaff && ( + <> + {course.flags && course.flags.last_reviewed && course.flags.last_reviewed.username + ? + : + } + +
+
+ +
+ + )} + {current_user.admin &&
}
); diff --git a/app/assets/javascripts/components/overview/overview_handler.jsx b/app/assets/javascripts/components/overview/overview_handler.jsx index e9a5d8b035..be240044ef 100644 --- a/app/assets/javascripts/components/overview/overview_handler.jsx +++ b/app/assets/javascripts/components/overview/overview_handler.jsx @@ -141,7 +141,7 @@ const Overview = createReactClass({ const sidebar = course.id ? (
{ - Features.wikiEd && current_user.isStaff && ( + Features.wikiEd && current_user.admin && ( Date: Tue, 13 Feb 2024 15:40:06 +0530 Subject: [PATCH 022/198] Refactor AdminQuickActions component for readability and indentation --- .../overview/admin_quick_actions.jsx | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/app/assets/javascripts/components/overview/admin_quick_actions.jsx b/app/assets/javascripts/components/overview/admin_quick_actions.jsx index 4f95f8b71f..37bc12d8bf 100644 --- a/app/assets/javascripts/components/overview/admin_quick_actions.jsx +++ b/app/assets/javascripts/components/overview/admin_quick_actions.jsx @@ -29,28 +29,29 @@ const NoDetailsText = () => ( export const AdminQuickActions = ({ course, current_user, persistCourse, greetStudents }) => (
{current_user.isStaff && ( - <> - {course.flags && course.flags.last_reviewed && course.flags.last_reviewed.username - ? - : - } - -
-
- -
- + <> + {course.flags && course.flags.last_reviewed && course.flags.last_reviewed.username ? ( + + ) : ( + + )} + +
+
+ +
+ )} {current_user.admin &&
}
From 20f37ccd8489d4779b6b7b3aa991563b07f86ef4 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Tue, 13 Feb 2024 15:44:32 +0530 Subject: [PATCH 023/198] Remove unwanted comment --- db/migrate/20240105094818_create_course_notes.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrate/20240105094818_create_course_notes.rb b/db/migrate/20240105094818_create_course_notes.rb index b4d689ba71..0e0cafff00 100644 --- a/db/migrate/20240105094818_create_course_notes.rb +++ b/db/migrate/20240105094818_create_course_notes.rb @@ -1,7 +1,7 @@ class CreateCourseNotes < ActiveRecord::Migration[7.0] def change create_table :course_notes do |t| - t.references :courses, foreign_key: true, type: :integer # Make sure to specify the type + t.references :courses, foreign_key: true, type: :integer t.string :title t.text :text t.string :edited_by From d9385de4036e1be6886cc5c2d4a825bb4bb3c1cc Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Thu, 15 Feb 2024 22:42:17 +0530 Subject: [PATCH 024/198] Change variable name to camelCase in NotesHandler component --- app/assets/javascripts/components/notes/notes_handler.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/components/notes/notes_handler.jsx b/app/assets/javascripts/components/notes/notes_handler.jsx index 185e25d9a4..6e11107a10 100644 --- a/app/assets/javascripts/components/notes/notes_handler.jsx +++ b/app/assets/javascripts/components/notes/notes_handler.jsx @@ -39,7 +39,7 @@ const NotesHandler = () => { /> ); - const AdminNotesEditPanel = ( + const adminNotesEditPanel = ( { switch (modalType) { case 'NoteEditor': - return AdminNotesEditPanel; + return adminNotesEditPanel; default: return defaultAdminNotesPanel; } From 61cb5917045ae219ebad9a82ba4fc658094a7a9f Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Mon, 19 Feb 2024 12:03:50 +0530 Subject: [PATCH 025/198] Add a comment to explain the purpose of the modalType. This commit adds a comment to clarify the role and purpose of the modalType variable in the code. --- .../javascripts/components/notes/notes_handler.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/components/notes/notes_handler.jsx b/app/assets/javascripts/components/notes/notes_handler.jsx index 6e11107a10..d636df51f7 100644 --- a/app/assets/javascripts/components/notes/notes_handler.jsx +++ b/app/assets/javascripts/components/notes/notes_handler.jsx @@ -9,6 +9,7 @@ import NotesPanel from './notes_panel'; const NotesHandler = () => { const [modalType, setModalType] = useState(null); const [noteId, setNoteId] = useState(null); + const course = useSelector(state => state.course); const currentUser = useSelector(getCurrentUser); const dispatch = useDispatch(); @@ -16,18 +17,25 @@ const NotesHandler = () => { const dispatchNameHasChanged = () => { dispatch(nameHasChanged()); }; + const dispatchPersistCourseNote = () => { dispatch(persistCourseNote(course.id, currentUser.username)); }; + const dispatchResetCourseNote = () => { dispatch(resetCourseNote()); }; + // Function to set the modal type and note ID based on the parameters: + // a) If id is null and type is 'DefaultPanel', display 'NotesPanel'. + // b) If id is null and type is 'NoteEditor', display 'CreateNewNote'. + // c) If id is notesId and type is 'NoteEditor', display 'NoteDetails' of the current NoteId. const setState = (id = null, type = 'DefaultPanel') => { setNoteId(id); setModalType(type); }; + // If modalType is null, this will simply return a button which, on click, displays admin 'NotesPanel' const defaultAdminNotesPanel = ( { /> ); + // Admin notes edit panel for reading, editing/updating and creating admin notes const adminNotesEditPanel = ( { /> ); + // Switch statement to determine which panel to display based on the modalType switch (modalType) { case 'NoteEditor': return adminNotesEditPanel; @@ -60,4 +70,4 @@ const NotesHandler = () => { } }; -export default (NotesHandler); +export default NotesHandler; From a7a5f9d0ca8c80684dc6f9a59fbeba7d041ff0ff Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Mon, 19 Feb 2024 12:05:44 +0530 Subject: [PATCH 026/198] Remove unnecessary condition to render 'Admin Notes' button. --- app/assets/javascripts/components/notes/notes_panel.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/components/notes/notes_panel.jsx b/app/assets/javascripts/components/notes/notes_panel.jsx index 476f0bed42..f1290a6d9d 100644 --- a/app/assets/javascripts/components/notes/notes_panel.jsx +++ b/app/assets/javascripts/components/notes/notes_panel.jsx @@ -21,8 +21,8 @@ const NotesPanel = ({ setState, modalType, currentUser, courseId, buttonText, he return () => clearInterval(pollInterval); }, []); - // Conditionally render a button if the modalType is not 'DefaultPanel' and modalType is null - if (modalType !== 'DefaultPanel' && modalType === null) { + // Conditionally render a button if modalType is null + if (modalType === null) { return ; } From 7f67c8dad3bbd8f8be884e4c7130072dbe8f76b4 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Tue, 20 Feb 2024 22:52:02 +0530 Subject: [PATCH 027/198] Add test case for backend, reducers and actions Test code for Admin only notes feature. --- .../course_notes_controller_spec.rb | 116 ++++++++ spec/factories/course_notes.rb | 10 + spec/models/course_note_spec.rb | 46 ++++ test/actions/course_notes_action.spec.js | 259 ++++++++++++++++++ test/reducers/course_notes.spec.js | 132 +++++++++ test/reducers/persisted_course_note.spec.js | 27 ++ 6 files changed, 590 insertions(+) create mode 100644 spec/controllers/course_notes_controller_spec.rb create mode 100644 spec/factories/course_notes.rb create mode 100644 spec/models/course_note_spec.rb create mode 100644 test/actions/course_notes_action.spec.js create mode 100644 test/reducers/course_notes.spec.js create mode 100644 test/reducers/persisted_course_note.spec.js diff --git a/spec/controllers/course_notes_controller_spec.rb b/spec/controllers/course_notes_controller_spec.rb new file mode 100644 index 0000000000..e30021f712 --- /dev/null +++ b/spec/controllers/course_notes_controller_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe CourseNotesController, type: :controller do + describe 'GET #show' do + it 'returns a success response with course notes' do + course = create(:course) + course_note = create(:course_note, courses_id: course.id) + + get :show, params: { id: course.id } + expect(response).to be_successful + + json_response = JSON.parse(response.body) + expect(json_response['courseNotes']).to be_an(Array) + expect(json_response['courseNotes'].length).to eq(1) + expect(json_response['courseNotes'][0]['id']).to eq(course_note.id) + end + end + + describe 'GET #find_course_note' do + let(:course) { create(:course) } + + it 'returns a success response with a single course note' do + course_note = create(:course_note, courses_id: course.id) + + get :find_course_note, params: { id: course_note.id } + expect(response).to be_successful + + json_response = JSON.parse(response.body) + expect(json_response['courseNote']).to be_a(Hash) + expect(json_response['courseNote']['id']).to eq(course_note.id) + end + + it 'returns not found for an unknown course note' do + get :find_course_note, params: { id: 999 } + expect(response).to have_http_status(:not_found) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Note not found') + end + end + + describe 'PATCH #update' do + let(:course_note) { create(:course_note) } + + it 'updates the course note and returns success' do + patch :update, params: { id: course_note.id, course_note: { title: 'Updated Title' } } + expect(response).to be_successful + + json_response = JSON.parse(response.body) + expect(json_response['success']).to be_truthy + + expect(course_note.reload.title).to eq('Updated Title') + end + + it 'returns unprocessable entity on update failure' do + allow_any_instance_of(CourseNote).to receive(:update_note).and_return(false) + + patch :update, params: { id: course_note.id, course_note: { title: 'Updated Title' } } + expect(response).to have_http_status(:unprocessable_entity) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Failed to update course note') + end + end + + describe 'POST #create' do + let(:course) { create(:course) } + + it 'creates a new course note and returns created' do + post :create, params: { course_note: { courses_id: course.id, title: 'New Note', + text: 'Note Text', edited_by: 'User' } } + + expect(response).to have_http_status(:created) + + json_response = JSON.parse(response.body) + expect(json_response['createdNote']).to be_a(Hash) + end + + it 'returns unprocessable entity on creation failure' do + allow_any_instance_of(CourseNote).to receive(:create_new_note).and_return(false) + + post :create, params: { course_note: { courses_id: 1, title: 'New Note', + text: 'Note Text', edited_by: 'User' } } + + expect(response).to have_http_status(:unprocessable_entity) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Failed to create course note') + end + end + + describe 'DELETE #destroy' do + let(:course_note) { create(:course_note) } + + it 'destroys the course note and returns success' do + delete :destroy, params: { id: course_note.id } + expect(response).to be_successful + + json_response = JSON.parse(response.body) + expect(json_response['success']).to be_truthy + + expect { course_note.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'returns unprocessable entity on destroy failure' do + allow_any_instance_of(CourseNote).to receive(:destroy).and_return(false) + + delete :destroy, params: { id: course_note.id } + expect(response).to have_http_status(:unprocessable_entity) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Failed to delete course note') + end + end +end diff --git a/spec/factories/course_notes.rb b/spec/factories/course_notes.rb new file mode 100644 index 0000000000..36e71e933f --- /dev/null +++ b/spec/factories/course_notes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :course_note do + title { 'Sample Note Title' } + text { 'Sample Note Text' } + edited_by { 'Sample User' } + courses_id { association(:course).id } + end +end diff --git a/spec/models/course_note_spec.rb b/spec/models/course_note_spec.rb new file mode 100644 index 0000000000..0aa78abbc5 --- /dev/null +++ b/spec/models/course_note_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CourseNote, type: :model do + let(:course) { create(:course) } + + describe 'validations' do + it { is_expected.to validate_presence_of(:courses_id) } + it { is_expected.to validate_presence_of(:title) } + it { is_expected.to validate_presence_of(:text) } + it { is_expected.to validate_presence_of(:edited_by) } + end + + describe 'associations' do + it { is_expected.to belong_to(:course) } + end + + describe '#create_new_note' do + it 'creates a new course note with valid attributes' do + attributes = { courses_id: course.id, title: 'Note Title', text: 'Note Text', edited_by: 'User' } + course_note = described_class.new + expect(course_note.create_new_note(attributes)).to be_truthy + expect(course_note).to be_persisted + end + + it 'fails to create a new course note with invalid attributes' do + attributes = { courses_id: course.id, title: nil, text: 'Note Text', edited_by: 'User' } + course_note = described_class.new + expect(course_note.create_new_note(attributes)).to be_falsey + expect(course_note).not_to be_persisted + end + end + + describe '#update_note' do + let(:course_note) { create(:course_note, courses_id: course.id) } + + it 'updates the course note with valid attributes' do + attributes = { title: 'Updated Title', text: 'Updated Text', edited_by: 'Updated User' } + expect(course_note.update_note(attributes)).to be_truthy + expect(course_note.reload.title).to eq('Updated Title') + expect(course_note.reload.text).to eq('Updated Text') + expect(course_note.reload.edited_by).to eq('Updated User') + end + end +end diff --git a/test/actions/course_notes_action.spec.js b/test/actions/course_notes_action.spec.js new file mode 100644 index 0000000000..485aafffec --- /dev/null +++ b/test/actions/course_notes_action.spec.js @@ -0,0 +1,259 @@ +import thunk from 'redux-thunk'; +import configureMockStore from 'redux-mock-store'; +import logErrorMessage from '../../app/assets/javascripts/utils/log_error_message'; +import * as actions from '../../app/assets/javascripts/actions/course_notes_action'; +import * as types from '../../app/assets/javascripts/constants/notes'; +import * as api from '../../app/assets/javascripts/utils/api'; +import { ADD_NOTIFICATION } from '../../app/assets/javascripts/constants'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +jest.mock('../../app/assets/javascripts/utils/api', () => ({ + fetchAllCourseNotes: jest.fn(), + fetchCourseNotesById: jest.fn(), + saveCourseNote: jest.fn(), + createCourseNote: jest.fn(), + deleteCourseNote: jest.fn(), +})); + +jest.mock('../../app/assets/javascripts/utils/log_error_message', () => jest.fn()); + +describe('Course Notes Actions', () => { + let store; + + + beforeEach(() => { + store = mockStore({}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + + it('should dispatch RECEIVE_NOTES_LIST after successfully fetching all course notes', async () => { + const courseId = 'some-course-id'; + const notesList = [{ id: 1, title: 'Note 1' }]; + + api.fetchAllCourseNotes.mockResolvedValue(notesList); + + await store.dispatch(actions.fetchAllCourseNotes(courseId)); + + expect(store.getActions()).toEqual([{ type: types.RECEIVE_NOTES_LIST, notes_list: notesList }]); + }); + + it('should log an error if there is an issue fetching all course notes', async () => { + const courseId = 'some-course-id'; + + api.fetchAllCourseNotes.mockRejectedValue(new Error('Some error')); + + await store.dispatch(actions.fetchAllCourseNotes(courseId)); + + expect(logErrorMessage).toHaveBeenCalledWith('Error fetching course notes:', expect.any(Error)); + }); + + it('should dispatch RECEIVE_NOTE_DETAILS after successfully fetching a single course note', async () => { + const courseNoteId = 'some-note-id'; + const noteDetails = { id: 1, title: 'Note 1' }; + + api.fetchCourseNotesById.mockResolvedValue(noteDetails); + + await store.dispatch(actions.fetchSingleNoteDetails(courseNoteId)); + + expect(store.getActions()).toEqual([{ type: types.RECEIVE_NOTE_DETAILS, note: noteDetails }]); + }); + + it('should log an error if there is an issue fetching a single course note', async () => { + const courseNoteId = 'some-note-id'; + + const errorMessage = 'Some error message'; + api.fetchCourseNotesById.mockRejectedValue(new Error(errorMessage)); + + await store.dispatch(actions.fetchSingleNoteDetails(courseNoteId)); + + expect(logErrorMessage).toHaveBeenCalledWith('Error fetching single course note details:', expect.any(Error)); + }); + + it('should dispatch UPDATE_CURRENT_NOTE with the updated note data', () => { + const data = { id: 1, title: 'Updated Title' }; + + store.dispatch(actions.updateCurrentCourseNote(data)); + + const expectedAction = { type: types.UPDATE_CURRENT_NOTE, note: data }; + expect(store.getActions()).toEqual([expectedAction]); + }); + + it('should dispatch RESET_TO_ORIGINAL_NOTE with the persistedCourseNote from the state', () => { + const getStateMock = jest.fn(() => ({ persistedCourseNote: {} })); + + const storeWithState = mockStore({}, getStateMock); + + storeWithState.dispatch(actions.resetCourseNote()); + + const expectedAction = { type: types.RESET_TO_ORIGINAL_NOTE, note: {} }; + expect(storeWithState.getActions()).toEqual([expectedAction]); + }); + + it('should dispatch success actions when saving course note is successful', async () => { + const currentUser = 'CurrentUser'; + const courseNoteDetails = { + title: 'Note #1', + text: 'Soon to be updated ...', + edited_by: 'CurrentUser', + id: 52, + courses_id: 10001, + created_at: '2024-01-19T13:32:38.850Z', + updated_at: '2024-01-21T13:32:26.736Z' + }; + + api.saveCourseNote.mockResolvedValue({ success: true }); + + await actions.saveCourseNote(currentUser, courseNoteDetails, store.dispatch); + + expect(store.getActions()).toEqual([ + { type: ADD_NOTIFICATION, notification: expect.any(Object) }, + { type: types.PERSISTED_COURSE_NOTE, note: courseNoteDetails }, + ]); + + expect(logErrorMessage).not.toHaveBeenCalled(); + }); + + it('should dispatch success actions when creating a course note is successful', async () => { + const courseId = 'some-course-id'; + const courseNoteDetails = { + title: 'Note #1', + text: 'Soon to be updated ...', + edited_by: 'CurrentUser', + id: 52, + courses_id: 10001, + created_at: '2024-01-19T13:32:38.850Z', + updated_at: '2024-01-21T13:32:26.736Z' + }; + const noteDetails = { id: 1, title: 'Note 1' }; + + api.createCourseNote.mockResolvedValue(noteDetails); + + await actions.createCourseNote(courseId, courseNoteDetails, store.dispatch); + + expect(store.getActions()).toEqual([ + { type: ADD_NOTIFICATION, notification: expect.any(Object) }, + { type: types.ADD_NEW_NOTE_TO_LIST, newNote: noteDetails }, + { type: types.PERSISTED_COURSE_NOTE, note: noteDetails }, + ]); + }); + + it('should dispatch error action when persisting a course note with empty fields', async () => { + store = mockStore({ + courseNotes: { + note: { + id: null, + title: '', + text: '', + }, + }, + }); + + await store.dispatch(actions.persistCourseNote(null, 'CurrentUser')); + + expect(store.getActions()).toEqual([ + { type: ADD_NOTIFICATION, notification: expect.any(Object) }, + ]); + }); + + it('should dispatch success action when persisting a course note with id', async () => { + const noteDetails = { + id: 21, + title: 'note title', + text: 'note text', + }; + + store = mockStore({ + courseNotes: { + note: { + ...noteDetails + }, + }, + }); + + + await store.dispatch(actions.persistCourseNote(null, 'CurrentUser')); + + expect(store.getActions()).toEqual([ + { type: ADD_NOTIFICATION, notification: expect.any(Object) }, + { type: types.PERSISTED_COURSE_NOTE, note: noteDetails } + ]); + }); + + it('should dispatch success actions when creating a course note is successful', async () => { + const noteDetails = { + courses_id: 1001, + created_at: '2024-01-31T06:01:31.406Z', + edited_by: 'CurrentUser', + id: 64, + text: 'Note text #1', + title: 'Note title #1', + updated_at: '2024-01-31T06:01:31.406Z' + }; + + store = mockStore({ + courseNotes: { + note: { + title: 'Note title #1', + text: 'Note text #1', + edited_by: 'CurrentUser' + }, + }, + }); + + + jest.spyOn(api, 'createCourseNote').mockResolvedValue(noteDetails); + + await (actions.createCourseNote('1001', noteDetails, store.dispatch)); + + expect(store.getActions()).toEqual([ + { type: ADD_NOTIFICATION, notification: expect.any(Object) }, + { type: types.ADD_NEW_NOTE_TO_LIST, newNote: noteDetails }, + { type: types.PERSISTED_COURSE_NOTE, note: noteDetails } + ]); + }); + + it('dispatches success actions when delete is successful', async () => { + const noteId = 123; + const successResponse = { success: true }; + + api.deleteCourseNote.mockResolvedValue(successResponse); + + await store.dispatch(actions.deleteNoteFromList(noteId)); + + expect(api.deleteCourseNote).toHaveBeenCalledWith(noteId); + expect(store.getActions()).toEqual([ + { type: ADD_NOTIFICATION, notification: expect.any(Object) }, + { type: types.DELETE_NOTE_FROM_LIST, deletedNoteId: noteId }, + ]); + }); + + it('dispatches error actions when delete fails', async () => { + const noteId = 123; + const errorResponse = { success: false }; + + api.deleteCourseNote.mockResolvedValue(errorResponse); + + await store.dispatch(actions.deleteNoteFromList(noteId)); + + expect(api.deleteCourseNote).toHaveBeenCalledWith(noteId); + expect(store.getActions()).toEqual([ + { type: ADD_NOTIFICATION, notification: expect.any(Object) }, + ]); + }); + + it('dispatches RESET_TO_DEFAULT action', () => { + store.dispatch(actions.resetStateToDefault()); + + expect(store.getActions()).toEqual([ + { type: types.RESET_NOTE_TO_DEFAULT }, + ]); + }); +}); + + diff --git a/test/reducers/course_notes.spec.js b/test/reducers/course_notes.spec.js new file mode 100644 index 0000000000..f81da69dff --- /dev/null +++ b/test/reducers/course_notes.spec.js @@ -0,0 +1,132 @@ +import courseNotesReducer from '../../app/assets/javascripts/reducers/course_notes'; +import * as types from '../../app/assets/javascripts/constants'; + +describe('courseNotes reducer', () => { + const initialState = { + notes_list: [], + note: { + title: '', + text: '', + edited_by: '', + }, + }; + + it('should return the initial state', () => { + expect(courseNotesReducer(undefined, {})).toEqual(initialState); + }); + + it('should handle RECEIVE_NOTES_LIST', () => { + const notesList = [{ id: 1, title: 'Note 1' }]; + const action = { type: types.RECEIVE_NOTES_LIST, notes_list: notesList }; + + expect(courseNotesReducer(initialState, action)).toEqual({ + ...initialState, + notes_list: notesList, + }); + }); + + it('should handle ADD_NEW_NOTE_TO_LIST', () => { + const newNote = { id: 2, title: 'Note 2' }; + const action = { type: types.ADD_NEW_NOTE_TO_LIST, newNote: newNote }; + + expect(courseNotesReducer(initialState, action)).toEqual({ + ...initialState, + notes_list: [...initialState.notes_list, newNote], + }); + }); + + it('should handle DELETE_NOTE_FROM_LIST', () => { + const currentState = { + notes_list: [ + { id: 1, title: 'Note 1' }, + { id: 2, title: 'Note 2' }, + ], + note: initialState.note, + }; + + const action = { type: types.DELETE_NOTE_FROM_LIST, deletedNoteId: 1 }; + + expect(courseNotesReducer(currentState, action)).toEqual({ + ...currentState, + notes_list: currentState.notes_list.filter(note => note.id !== action.deletedNoteId), + }); + }); + + it('should handle RECEIVE_NOTE_DETAILS', () => { + const currentState = { + notes_list: [], + note: initialState.note, + }; + + const noteDetails = { id: 1, title: 'Note 1', text: 'Note Text', edited_by: 'User' }; + const action = { type: types.RECEIVE_NOTE_DETAILS, note: noteDetails }; + + expect(courseNotesReducer(currentState, action)).toEqual({ + ...currentState, + note: { ...currentState.note, ...noteDetails }, + }); + }); + + it('should handle UPDATE_CURRENT_NOTE', () => { + const currentState = { + notes_list: [], + note: { title: 'Old Title', text: 'Old Text', edited_by: 'User' }, + }; + + const updatedNote = { title: 'New Title', text: 'New Text' }; + const action = { type: types.UPDATE_CURRENT_NOTE, note: updatedNote }; + + expect(courseNotesReducer(currentState, action)).toEqual({ + ...currentState, + note: { ...currentState.note, ...updatedNote }, + }); + }); + + it('should handle PERSISTED_COURSE_NOTE', () => { + const currentState = { + notes_list: [], + note: initialState.note, + }; + + const persistedNote = { id: 1, title: 'Persisted Title', text: 'Persisted Text' }; + const action = { type: types.PERSISTED_COURSE_NOTE, note: persistedNote }; + + expect(courseNotesReducer(currentState, action)).toEqual({ + ...currentState, + note: { ...currentState.note, ...persistedNote }, + }); + }); + + it('should handle RESET_TO_ORIGINAL_NOTE', () => { + const currentState = { + notes_list: [], + note: { title: 'Current Title', text: 'Current Text' }, + }; + + const originalNote = { title: 'Original Title', text: 'Original Text' }; + const action = { type: types.RESET_TO_ORIGINAL_NOTE, note: originalNote }; + + expect(courseNotesReducer(currentState, action)).toEqual({ + ...currentState, + note: { ...originalNote }, + }); + }); + + it('should handle RESET_NOTE_TO_DEFAULT', () => { + const currentState = { + notes_list: [{ id: 1, title: 'Note 1' }], + note: { + title: 'Note Title', + text: 'Note Text', + edited_by: 'User', + }, + }; + + const action = { type: types.RESET_NOTE_TO_DEFAULT }; + + expect(courseNotesReducer(currentState, action)).toEqual({ + ...currentState, + note: initialState.note, + }); + }); +}); diff --git a/test/reducers/persisted_course_note.spec.js b/test/reducers/persisted_course_note.spec.js new file mode 100644 index 0000000000..2879483eed --- /dev/null +++ b/test/reducers/persisted_course_note.spec.js @@ -0,0 +1,27 @@ +import persistedCourseNoteReducer from '../../app/assets/javascripts/reducers/persisted_course_note'; +import * as types from '../../app/assets/javascripts/constants'; + +describe('persistedCourseNote reducer', () => { + it('should handle RECEIVE_NOTE_DETAILS', () => { + const initialState = {}; + const noteDetails = { id: 1, title: 'Received Title', text: 'Received Text' }; + + const action = { type: types.RECEIVE_NOTE_DETAILS, note: noteDetails }; + expect(persistedCourseNoteReducer(initialState, action)).toEqual(noteDetails); + }); + + it('should handle PERSISTED_COURSE_NOTE', () => { + const initialState = {}; + const persistedNote = { id: 2, title: 'Persisted Title', text: 'Persisted Text' }; + + const action = { type: types.PERSISTED_COURSE_NOTE, note: persistedNote }; + expect(persistedCourseNoteReducer(initialState, action)).toEqual(persistedNote); + }); + + it('should handle RESET_NOTE_TO_DEFAULT', () => { + const currentState = { id: 3, title: 'Current Title', text: 'Current Text' }; + + const action = { type: types.RESET_NOTE_TO_DEFAULT }; + expect(persistedCourseNoteReducer(currentState, action)).toEqual({}); + }); +}); From 1dc59460ec74b9f977ce4d1ff25fe24d71f4a7ee Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Tue, 20 Feb 2024 23:32:14 +0530 Subject: [PATCH 028/198] Fix ruby linting error --- spec/models/course_note_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/models/course_note_spec.rb b/spec/models/course_note_spec.rb index 0aa78abbc5..5534c1a06c 100644 --- a/spec/models/course_note_spec.rb +++ b/spec/models/course_note_spec.rb @@ -18,7 +18,8 @@ describe '#create_new_note' do it 'creates a new course note with valid attributes' do - attributes = { courses_id: course.id, title: 'Note Title', text: 'Note Text', edited_by: 'User' } + attributes = { courses_id: course.id, title: 'Note Title', text: 'Note Text', + edited_by: 'User' } course_note = described_class.new expect(course_note.create_new_note(attributes)).to be_truthy expect(course_note).to be_persisted From 3f2b98b2d4783b322b5a10fb7a2523884196cf90 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Tue, 27 Feb 2024 16:35:34 +0530 Subject: [PATCH 029/198] Make sure Admin username is present. Ensure admin username for no-Wiki Ed Staff admins by fetching it from 'nav_root' when not already present in the currentUser. --- .../javascripts/components/notes/notes_handler.jsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/components/notes/notes_handler.jsx b/app/assets/javascripts/components/notes/notes_handler.jsx index d636df51f7..f6a93e6b4b 100644 --- a/app/assets/javascripts/components/notes/notes_handler.jsx +++ b/app/assets/javascripts/components/notes/notes_handler.jsx @@ -2,18 +2,23 @@ import React, { useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { nameHasChanged } from '../../actions/course_actions'; import { resetCourseNote, persistCourseNote } from '../../actions/course_notes_action'; -import { getCurrentUser } from '../../selectors/index'; import NotesEditor from './notes_editor'; import NotesPanel from './notes_panel'; -const NotesHandler = () => { +const NotesHandler = ({ currentUser }) => { const [modalType, setModalType] = useState(null); const [noteId, setNoteId] = useState(null); const course = useSelector(state => state.course); - const currentUser = useSelector(getCurrentUser); const dispatch = useDispatch(); + // If user is Admin and not Wiki Ed Staff, fetch Admin username from 'nav_root'. + // If user is Admin and Wiki Ed Staff, username is already in CurrentUser. + if (!currentUser.username) { + const { username } = document.getElementById('nav_root').dataset; + currentUser = { ...currentUser, username }; + } + const dispatchNameHasChanged = () => { dispatch(nameHasChanged()); }; From 456c69eca4d054813d9914404e420ef4ef3c83e4 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Tue, 27 Feb 2024 16:36:07 +0530 Subject: [PATCH 030/198] Pass current_user prop from admin_quick_action --- .../javascripts/components/overview/admin_quick_actions.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/components/overview/admin_quick_actions.jsx b/app/assets/javascripts/components/overview/admin_quick_actions.jsx index 37bc12d8bf..73f2f72be2 100644 --- a/app/assets/javascripts/components/overview/admin_quick_actions.jsx +++ b/app/assets/javascripts/components/overview/admin_quick_actions.jsx @@ -53,7 +53,7 @@ export const AdminQuickActions = ({ course, current_user, persistCourse, greetSt
)} - {current_user.admin &&
} + {current_user.admin &&
}
); From 62c0dbe65708a0dcde61abac85a3e4d74f8f2fd1 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Tue, 27 Feb 2024 16:36:47 +0530 Subject: [PATCH 031/198] Integrate Optional Chaining for improved Error Handling --- app/assets/javascripts/actions/course_notes_action.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/actions/course_notes_action.js b/app/assets/javascripts/actions/course_notes_action.js index b2f10af057..0db28f4deb 100644 --- a/app/assets/javascripts/actions/course_notes_action.js +++ b/app/assets/javascripts/actions/course_notes_action.js @@ -62,7 +62,7 @@ export const resetCourseNote = () => (dispatch, getState) => { export const saveCourseNote = async (currentUser, courseNoteDetails, dispatch) => { const status = await API.saveCourseNote(currentUser, courseNoteDetails); - if (status.success) { + if (status?.success) { sendNotification(dispatch, 'Success', 'notes.updated'); dispatch({ type: PERSISTED_COURSE_NOTE, note: courseNoteDetails }); } else { @@ -76,7 +76,7 @@ export const saveCourseNote = async (currentUser, courseNoteDetails, dispatch) = export const createCourseNote = async (courseId, courseNoteDetails, dispatch) => { const noteDetails = await API.createCourseNote(courseId, courseNoteDetails); - if (noteDetails.id) { + if (noteDetails?.id) { sendNotification(dispatch, 'Success', 'notes.created'); dispatch({ type: ADD_NEW_NOTE_TO_LIST, newNote: noteDetails }); dispatch({ type: PERSISTED_COURSE_NOTE, note: noteDetails }); @@ -104,7 +104,7 @@ export const persistCourseNote = (courseId = null, currentUser) => (dispatch, ge export const deleteNoteFromList = noteId => async (dispatch) => { const status = await API.deleteCourseNote(noteId); - if (status.success) { + if (status?.success) { sendNotification(dispatch, 'Success', 'notes.deleted'); dispatch({ type: DELETE_NOTE_FROM_LIST, deletedNoteId: noteId }); } else { From 72e7450643a3db1f0c157df1697815f386813c22 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Tue, 27 Feb 2024 16:37:47 +0530 Subject: [PATCH 032/198] Remove create_new_note and it's use. --- app/controllers/course_notes_controller.rb | 2 +- app/models/course_note.rb | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/app/controllers/course_notes_controller.rb b/app/controllers/course_notes_controller.rb index 01fe4b8cf3..5a8c599661 100644 --- a/app/controllers/course_notes_controller.rb +++ b/app/controllers/course_notes_controller.rb @@ -21,7 +21,7 @@ def update end def create - note_details = CourseNote.new.create_new_note(course_note_params) + note_details = CourseNote.create(course_note_params) if note_details render json: { createdNote: note_details }, status: :created else diff --git a/app/models/course_note.rb b/app/models/course_note.rb index 09b4cd758d..4ad96dc6d1 100644 --- a/app/models/course_note.rb +++ b/app/models/course_note.rb @@ -8,15 +8,6 @@ class CourseNote < ApplicationRecord validates :text, presence: true validates :edited_by, presence: true - def create_new_note(attributes) - self.attributes = attributes - if save - self - else - false - end - end - def update_note(attributes) update(attributes.slice(:title, :text, :edited_by)) end From 16e96647385c3da1a485a61861319e1abd105043 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Tue, 27 Feb 2024 16:38:15 +0530 Subject: [PATCH 033/198] Update test case for new changes. --- .../course_notes_controller_spec.rb | 16 +---- spec/factories/course_notes.rb | 2 +- spec/models/course_note_spec.rb | 63 ++++++++++--------- 3 files changed, 35 insertions(+), 46 deletions(-) diff --git a/spec/controllers/course_notes_controller_spec.rb b/spec/controllers/course_notes_controller_spec.rb index e30021f712..5b28e1f3df 100644 --- a/spec/controllers/course_notes_controller_spec.rb +++ b/spec/controllers/course_notes_controller_spec.rb @@ -5,7 +5,7 @@ describe 'GET #show' do it 'returns a success response with course notes' do course = create(:course) - course_note = create(:course_note, courses_id: course.id) + course_note = create(:course_note, course:) get :show, params: { id: course.id } expect(response).to be_successful @@ -21,7 +21,7 @@ let(:course) { create(:course) } it 'returns a success response with a single course note' do - course_note = create(:course_note, courses_id: course.id) + course_note = create(:course_note, course:) get :find_course_note, params: { id: course_note.id } expect(response).to be_successful @@ -76,18 +76,6 @@ json_response = JSON.parse(response.body) expect(json_response['createdNote']).to be_a(Hash) end - - it 'returns unprocessable entity on creation failure' do - allow_any_instance_of(CourseNote).to receive(:create_new_note).and_return(false) - - post :create, params: { course_note: { courses_id: 1, title: 'New Note', - text: 'Note Text', edited_by: 'User' } } - - expect(response).to have_http_status(:unprocessable_entity) - - json_response = JSON.parse(response.body) - expect(json_response['error']).to eq('Failed to create course note') - end end describe 'DELETE #destroy' do diff --git a/spec/factories/course_notes.rb b/spec/factories/course_notes.rb index 36e71e933f..4d1f09b6dc 100644 --- a/spec/factories/course_notes.rb +++ b/spec/factories/course_notes.rb @@ -5,6 +5,6 @@ title { 'Sample Note Title' } text { 'Sample Note Text' } edited_by { 'Sample User' } - courses_id { association(:course).id } + association :course end end diff --git a/spec/models/course_note_spec.rb b/spec/models/course_note_spec.rb index 5534c1a06c..13bb984afe 100644 --- a/spec/models/course_note_spec.rb +++ b/spec/models/course_note_spec.rb @@ -5,43 +5,44 @@ RSpec.describe CourseNote, type: :model do let(:course) { create(:course) } - describe 'validations' do - it { is_expected.to validate_presence_of(:courses_id) } - it { is_expected.to validate_presence_of(:title) } - it { is_expected.to validate_presence_of(:text) } - it { is_expected.to validate_presence_of(:edited_by) } + it 'is valid with valid attributes' do + course_note = build(:course_note, course:) + expect(course_note).to be_valid end - describe 'associations' do - it { is_expected.to belong_to(:course) } + it 'is invalid without a course' do + course_note = build(:course_note, course: nil) + expect(course_note).to be_invalid + expect(course_note.errors[:courses_id]).to include("can't be blank") end - describe '#create_new_note' do - it 'creates a new course note with valid attributes' do - attributes = { courses_id: course.id, title: 'Note Title', text: 'Note Text', - edited_by: 'User' } - course_note = described_class.new - expect(course_note.create_new_note(attributes)).to be_truthy - expect(course_note).to be_persisted - end - - it 'fails to create a new course note with invalid attributes' do - attributes = { courses_id: course.id, title: nil, text: 'Note Text', edited_by: 'User' } - course_note = described_class.new - expect(course_note.create_new_note(attributes)).to be_falsey - expect(course_note).not_to be_persisted - end + it 'is invalid without a title' do + course_note = build(:course_note, title: nil, course:) + expect(course_note).to be_invalid + expect(course_note.errors[:title]).to include("can't be blank") end - describe '#update_note' do - let(:course_note) { create(:course_note, courses_id: course.id) } + it 'is invalid without text' do + course_note = build(:course_note, text: nil, course:) + expect(course_note).to be_invalid + expect(course_note.errors[:text]).to include("can't be blank") + end + + it 'is invalid without edited_by' do + course_note = build(:course_note, edited_by: nil, course:) + expect(course_note).to be_invalid + expect(course_note.errors[:edited_by]).to include("can't be blank") + end + + it 'updates note attributes successfully' do + course_note = create(:course_note, course:) + new_attributes = { title: 'Updated Title', text: 'Updated Text', edited_by: 'New Editor' } + + course_note.update_note(new_attributes) + course_note.reload - it 'updates the course note with valid attributes' do - attributes = { title: 'Updated Title', text: 'Updated Text', edited_by: 'Updated User' } - expect(course_note.update_note(attributes)).to be_truthy - expect(course_note.reload.title).to eq('Updated Title') - expect(course_note.reload.text).to eq('Updated Text') - expect(course_note.reload.edited_by).to eq('Updated User') - end + expect(course_note.title).to eq('Updated Title') + expect(course_note.text).to eq('Updated Text') + expect(course_note.edited_by).to eq('New Editor') end end From fd9a70f9c5cded399d1a84ae6c04271312717bd8 Mon Sep 17 00:00:00 2001 From: gabina Date: Tue, 27 Feb 2024 15:34:03 -0300 Subject: [PATCH 034/198] Fix misspelled filename --- app/workers/default_campaign_update_worker.rb | 2 +- lib/{deafult_campaign_update.rb => default_campaign_update.rb} | 0 spec/lib/default_campaign_update_spec.rb | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename lib/{deafult_campaign_update.rb => default_campaign_update.rb} (100%) diff --git a/app/workers/default_campaign_update_worker.rb b/app/workers/default_campaign_update_worker.rb index f935abe11d..079b78f355 100644 --- a/app/workers/default_campaign_update_worker.rb +++ b/app/workers/default_campaign_update_worker.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -require "#{Rails.root}/lib/deafult_campaign_update.rb" +require "#{Rails.root}/lib/default_campaign_update.rb" class DefaultCampaignUpdateWorker include Sidekiq::Worker diff --git a/lib/deafult_campaign_update.rb b/lib/default_campaign_update.rb similarity index 100% rename from lib/deafult_campaign_update.rb rename to lib/default_campaign_update.rb diff --git a/spec/lib/default_campaign_update_spec.rb b/spec/lib/default_campaign_update_spec.rb index f662aa78a1..f740ed4145 100644 --- a/spec/lib/default_campaign_update_spec.rb +++ b/spec/lib/default_campaign_update_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'rails_helper' -require "#{Rails.root}/lib/deafult_campaign_update.rb" +require "#{Rails.root}/lib/default_campaign_update.rb" describe DefaultCampaignUpdate do before do From 927f8072c3583407f22aab62746d0339f192b4e7 Mon Sep 17 00:00:00 2001 From: gabina Date: Tue, 27 Feb 2024 15:55:17 -0300 Subject: [PATCH 035/198] Update dates based on academic calendar instead of using current season --- config/schedule.yml | 4 ++-- lib/default_campaign_update.rb | 6 +++--- spec/lib/default_campaign_update_spec.rb | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/config/schedule.yml b/config/schedule.yml index 652b16ef77..db5cda1392 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -39,8 +39,8 @@ open_ticket_emails: class: "TicketNotificationsWorker" queue: constant_update -# Update default campaign at the beginning of spring and fall (northern hemisphere) +# Update default campaign at the beginning of spring and fall semesters (based on academic calendar - northern hemisphere) update_default_campaign: - cron: "1 0 21 3,9 *" # every March, 21st and September, 21st at 00:01 (UTC -7) + cron: "1 0 1 1,7 *" # every January, 1st and July, 1st at 00:01 (UTC -7) class: "DefaultCampaignUpdateWorker" queue: default diff --git a/lib/default_campaign_update.rb b/lib/default_campaign_update.rb index 7dcf88e2ec..f1344289c4 100644 --- a/lib/default_campaign_update.rb +++ b/lib/default_campaign_update.rb @@ -12,8 +12,8 @@ def initialize def current_term year = Time.zone.today.year month = Time.zone.today.month - # Determine if it's spring or fall in northern hemisphere - season = month.between?(3, 8) ? 'spring' : 'fall' - season + '_' + year.to_s + # Determine if it's spring or fall semester based on academic calendar + semester = month.between?(1, 6) ? 'spring' : 'fall' + semester + '_' + year.to_s end end diff --git a/spec/lib/default_campaign_update_spec.rb b/spec/lib/default_campaign_update_spec.rb index f740ed4145..c49d7cf748 100644 --- a/spec/lib/default_campaign_update_spec.rb +++ b/spec/lib/default_campaign_update_spec.rb @@ -9,9 +9,9 @@ value: { slug: 'default_campaign' }) end - context 'when fall starts' do + context 'when fall semester starts' do before do - travel_to Date.new(2024, 9, 21) + travel_to Date.new(2024, 7, 1) end context 'when current term exists as campaign' do @@ -36,9 +36,9 @@ end end - context 'when spring starts' do + context 'when spring semester starts' do before do - travel_to Date.new(2024, 3, 21) + travel_to Date.new(2024, 1, 1) end context 'when current term exists as campaign' do From c118862ef4b0278f8a2f0427eb785356d45f5f72 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Wed, 28 Feb 2024 14:57:06 +0530 Subject: [PATCH 036/198] Get the current user i.e (admin username) from the server-side. --- app/controllers/course_notes_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/course_notes_controller.rb b/app/controllers/course_notes_controller.rb index 5a8c599661..ba06c49737 100644 --- a/app/controllers/course_notes_controller.rb +++ b/app/controllers/course_notes_controller.rb @@ -46,6 +46,7 @@ def set_course_note end def course_note_params - params.require(:course_note).permit(:courses_id, :title, :text, :edited_by) + params.require(:course_note).permit(:courses_id, :title, + :text).merge(edited_by: current_user.username) end end From 2fde0f09d59e9712ca63fa561714d0626b9cf488 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Wed, 28 Feb 2024 14:59:56 +0530 Subject: [PATCH 037/198] Remove the need to get admin username from the client side for admin notes --- .../javascripts/actions/course_notes_action.js | 8 ++++---- .../javascripts/components/notes/notes_editor.jsx | 4 +--- .../javascripts/components/notes/notes_handler.jsx | 13 ++----------- .../javascripts/components/notes/notes_panel.jsx | 4 ++-- .../components/notes/notes_panel_edit_button.jsx | 11 ++++------- .../components/overview/admin_quick_actions.jsx | 2 +- app/assets/javascripts/utils/api.js | 5 +---- 7 files changed, 15 insertions(+), 32 deletions(-) diff --git a/app/assets/javascripts/actions/course_notes_action.js b/app/assets/javascripts/actions/course_notes_action.js index 0db28f4deb..d95606c134 100644 --- a/app/assets/javascripts/actions/course_notes_action.js +++ b/app/assets/javascripts/actions/course_notes_action.js @@ -59,8 +59,8 @@ export const resetCourseNote = () => (dispatch, getState) => { }; // Action creator to save/update the current course note -export const saveCourseNote = async (currentUser, courseNoteDetails, dispatch) => { - const status = await API.saveCourseNote(currentUser, courseNoteDetails); +export const saveCourseNote = async (courseNoteDetails, dispatch) => { + const status = await API.saveCourseNote(courseNoteDetails); if (status?.success) { sendNotification(dispatch, 'Success', 'notes.updated'); @@ -88,13 +88,13 @@ export const createCourseNote = async (courseId, courseNoteDetails, dispatch) => }; // Action creator to persist the current course note, handling validation and deciding whether to save/update or create -export const persistCourseNote = (courseId = null, currentUser) => (dispatch, getState) => { +export const persistCourseNote = (courseId = null) => (dispatch, getState) => { const courseNoteDetails = getState().courseNotes.note; if ((courseNoteDetails.title.trim().length === 0) || (courseNoteDetails.text.trim().length === 0)) { return sendNotification(dispatch, 'Error', 'notes.empty_fields'); } else if (courseNoteDetails.id) { - return saveCourseNote(currentUser, courseNoteDetails, dispatch); + return saveCourseNote(courseNoteDetails, dispatch); } createCourseNote(courseId, courseNoteDetails, dispatch); diff --git a/app/assets/javascripts/components/notes/notes_editor.jsx b/app/assets/javascripts/components/notes/notes_editor.jsx index 563e7c9c55..8b14aa76ad 100644 --- a/app/assets/javascripts/components/notes/notes_editor.jsx +++ b/app/assets/javascripts/components/notes/notes_editor.jsx @@ -4,7 +4,7 @@ import { fetchSingleNoteDetails, updateCurrentCourseNote, resetStateToDefault } import EditableRedux from '../high_order/editable_redux'; import TextAreaInput from '../common/text_area_input.jsx'; -const NotesEditor = ({ controls, editable, toggleEditable, setState, note_id, current_user }) => { +const NotesEditor = ({ controls, editable, toggleEditable, setState, note_id }) => { const notes = useSelector(state => state.courseNotes.note); const dispatch = useDispatch(); @@ -14,9 +14,7 @@ const NotesEditor = ({ controls, editable, toggleEditable, setState, note_id, cu dispatch(fetchSingleNoteDetails(note_id)); } else { // Clear the current state to prepare for the creation of a new note, - // and update the name of the admin for the new note. Finally, toggle to editable mode. dispatch(resetStateToDefault()); - dispatch(updateCurrentCourseNote({ edited_by: current_user.username })); toggleEditable(); } }, []); diff --git a/app/assets/javascripts/components/notes/notes_handler.jsx b/app/assets/javascripts/components/notes/notes_handler.jsx index f6a93e6b4b..3b64de34d8 100644 --- a/app/assets/javascripts/components/notes/notes_handler.jsx +++ b/app/assets/javascripts/components/notes/notes_handler.jsx @@ -5,26 +5,19 @@ import { resetCourseNote, persistCourseNote } from '../../actions/course_notes_a import NotesEditor from './notes_editor'; import NotesPanel from './notes_panel'; -const NotesHandler = ({ currentUser }) => { +const NotesHandler = () => { const [modalType, setModalType] = useState(null); const [noteId, setNoteId] = useState(null); const course = useSelector(state => state.course); const dispatch = useDispatch(); - // If user is Admin and not Wiki Ed Staff, fetch Admin username from 'nav_root'. - // If user is Admin and Wiki Ed Staff, username is already in CurrentUser. - if (!currentUser.username) { - const { username } = document.getElementById('nav_root').dataset; - currentUser = { ...currentUser, username }; - } - const dispatchNameHasChanged = () => { dispatch(nameHasChanged()); }; const dispatchPersistCourseNote = () => { - dispatch(persistCourseNote(course.id, currentUser.username)); + dispatch(persistCourseNote(course.id)); }; const dispatchResetCourseNote = () => { @@ -45,7 +38,6 @@ const NotesHandler = ({ currentUser }) => { { { +const NotesPanel = ({ setState, modalType, courseId, buttonText, headerText }) => { const notesList = useSelector(state => state.courseNotes.notes_list); const dispatch = useDispatch(); @@ -34,7 +34,7 @@ const NotesPanel = ({ setState, modalType, currentUser, courseId, buttonText, he

{I18n.t(headerText)}

- +
diff --git a/app/assets/javascripts/components/notes/notes_panel_edit_button.jsx b/app/assets/javascripts/components/notes/notes_panel_edit_button.jsx index f763f8af95..1e2f79d597 100644 --- a/app/assets/javascripts/components/notes/notes_panel_edit_button.jsx +++ b/app/assets/javascripts/components/notes/notes_panel_edit_button.jsx @@ -5,7 +5,7 @@ import { initiateConfirm } from '../../actions/confirm_actions'; import useExpandablePopover from '../../hooks/useExpandablePopover'; import Popover from '../common/popover.jsx'; -export const NotesPanelEditButton = ({ setState, currentUser, notesList }) => { +export const NotesPanelEditButton = ({ setState, notesList }) => { const getKey = () => { return 'Create Notes'; }; @@ -27,12 +27,9 @@ export const NotesPanelEditButton = ({ setState, currentUser, notesList }) => { const editRows = []; const notesRow = notesList.map((note) => { - let removeButton; - if (currentUser.admin) { - removeButton = ( - - ); - } + const removeButton = ( + + ); return ( {note.title}{removeButton} diff --git a/app/assets/javascripts/components/overview/admin_quick_actions.jsx b/app/assets/javascripts/components/overview/admin_quick_actions.jsx index 73f2f72be2..57d27c6b49 100644 --- a/app/assets/javascripts/components/overview/admin_quick_actions.jsx +++ b/app/assets/javascripts/components/overview/admin_quick_actions.jsx @@ -53,7 +53,7 @@ export const AdminQuickActions = ({ course, current_user, persistCourse, greetSt
)} - {current_user.admin &&
} + {current_user.admin &&
}
); diff --git a/app/assets/javascripts/utils/api.js b/app/assets/javascripts/utils/api.js index baaaee909d..fd13d3c09a 100644 --- a/app/assets/javascripts/utils/api.js +++ b/app/assets/javascripts/utils/api.js @@ -363,10 +363,7 @@ const API = { return response.json(); }, - async saveCourseNote(currentUser, courseNoteDetails) { - if(currentUser !== courseNoteDetails.edited_by){ - courseNoteDetails.edited_by = currentUser; - } + async saveCourseNote(courseNoteDetails) { try { const response = await request(`/course_notes/${courseNoteDetails.id}`, { method: 'PUT', From 6aa273ce51eaf834e1be97b4b7bc31bb5d2f8909 Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Wed, 28 Feb 2024 15:02:08 +0530 Subject: [PATCH 038/198] Remove edited_by from redux course_notes state. As admin username is handle in the server-side, no need for edited_by( which will be admin username) in the client-side. --- app/assets/javascripts/reducers/course_notes.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/javascripts/reducers/course_notes.js b/app/assets/javascripts/reducers/course_notes.js index 0ac8773398..85715536d2 100644 --- a/app/assets/javascripts/reducers/course_notes.js +++ b/app/assets/javascripts/reducers/course_notes.js @@ -14,7 +14,6 @@ const initialState = { note: { title: '', text: '', - edited_by: '', }, }; From 189f30db403afa653e0e417a4186b4fc18ba2c0c Mon Sep 17 00:00:00 2001 From: Abishek Das Date: Wed, 28 Feb 2024 15:03:00 +0530 Subject: [PATCH 039/198] Minor test change --- spec/controllers/course_notes_controller_spec.rb | 5 +++++ test/actions/course_notes_action.spec.js | 3 +-- test/reducers/course_notes.spec.js | 1 - 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/spec/controllers/course_notes_controller_spec.rb b/spec/controllers/course_notes_controller_spec.rb index 5b28e1f3df..ad9a357306 100644 --- a/spec/controllers/course_notes_controller_spec.rb +++ b/spec/controllers/course_notes_controller_spec.rb @@ -2,6 +2,11 @@ require 'rails_helper' RSpec.describe CourseNotesController, type: :controller do + before do + allow(controller).to receive(:current_user).and_return(create(:user, + username: 'example_username')) + end + describe 'GET #show' do it 'returns a success response with course notes' do course = create(:course) diff --git a/test/actions/course_notes_action.spec.js b/test/actions/course_notes_action.spec.js index 485aafffec..5ffb072ebc 100644 --- a/test/actions/course_notes_action.spec.js +++ b/test/actions/course_notes_action.spec.js @@ -96,7 +96,6 @@ describe('Course Notes Actions', () => { }); it('should dispatch success actions when saving course note is successful', async () => { - const currentUser = 'CurrentUser'; const courseNoteDetails = { title: 'Note #1', text: 'Soon to be updated ...', @@ -109,7 +108,7 @@ describe('Course Notes Actions', () => { api.saveCourseNote.mockResolvedValue({ success: true }); - await actions.saveCourseNote(currentUser, courseNoteDetails, store.dispatch); + await actions.saveCourseNote(courseNoteDetails, store.dispatch); expect(store.getActions()).toEqual([ { type: ADD_NOTIFICATION, notification: expect.any(Object) }, diff --git a/test/reducers/course_notes.spec.js b/test/reducers/course_notes.spec.js index f81da69dff..75ed115f70 100644 --- a/test/reducers/course_notes.spec.js +++ b/test/reducers/course_notes.spec.js @@ -7,7 +7,6 @@ describe('courseNotes reducer', () => { note: { title: '', text: '', - edited_by: '', }, }; From 41f6eceefe6bbba6c04c71bdb1f87ab370a07f47 Mon Sep 17 00:00:00 2001 From: om-chauhan1 Date: Mon, 26 Feb 2024 20:46:58 +0530 Subject: [PATCH 040/198] Extended error logging for 'update_categories' --- app/models/wiki_content/category.rb | 21 ++++++++++++--------- app/services/update_course_stats.rb | 2 +- lib/importers/category_importer.rb | 5 +++-- lib/importers/transclusion_importer.rb | 5 +++-- lib/pagepile_api.rb | 18 ++++++++++++++---- lib/petscan_api.rb | 21 ++++++++++++++------- 6 files changed, 47 insertions(+), 25 deletions(-) diff --git a/app/models/wiki_content/category.rb b/app/models/wiki_content/category.rb index b184c70be2..638f28cd60 100644 --- a/app/models/wiki_content/category.rb +++ b/app/models/wiki_content/category.rb @@ -38,17 +38,19 @@ class Category < ApplicationRecord less_than_or_equal_to: 3 } - def self.refresh_categories_for(course) + def self.refresh_categories_for(course, update_service: nil) # Updating categories only if they were last updated since # more than a day, or those which are newly created course.categories .where('categories.updated_at < ? OR categories.created_at = categories.updated_at', 1.day.ago) - .find_each(&:refresh_titles) + .find_each do |category| + category.refresh_titles(update_service:) + end end - def refresh_titles - self.article_titles = title_list_from_wiki.map do |title| + def refresh_titles(update_service: nil) + self.article_titles = title_list_from_wiki(update_service:).map do |title| sanitize_4_byte_string ArticleUtils.format_article_title(title) end save @@ -69,16 +71,17 @@ def name_with_prefix private - def title_list_from_wiki + def title_list_from_wiki(update_service: nil) case source when 'category' - CategoryImporter.new(wiki).mainspace_page_titles_for_category(name_with_prefix, depth) + CategoryImporter.new(wiki, update_service:) + .mainspace_page_titles_for_category(name_with_prefix, depth) when 'psid' - PetScanApi.new.page_titles_for_psid(name) + PetScanApi.new.page_titles_for_psid(name, update_service:) when 'pileid' - PagePileApi.new(self).page_titles_for_pileid + PagePileApi.new(self).page_titles_for_pileid(update_service:) when 'template' - TransclusionImporter.new(self).transcluded_titles + TransclusionImporter.new(self, update_service:).transcluded_titles end end end diff --git a/app/services/update_course_stats.rb b/app/services/update_course_stats.rb index a47930837a..6d0772ed3d 100644 --- a/app/services/update_course_stats.rb +++ b/app/services/update_course_stats.rb @@ -56,7 +56,7 @@ def fetch_data end def update_categories - Category.refresh_categories_for(@course) + Category.refresh_categories_for(@course, update_service: self) log_update_progress :categories_updated end diff --git a/lib/importers/category_importer.rb b/lib/importers/category_importer.rb index 53c85b9130..1b956c2e67 100644 --- a/lib/importers/category_importer.rb +++ b/lib/importers/category_importer.rb @@ -12,11 +12,12 @@ class CategoryImporter ################ # Entry points # ################ - def initialize(wiki, opts={}) + def initialize(wiki, opts={}, update_service: nil) @wiki = wiki @depth = opts[:depth] || 0 @min_views = opts[:min_views] || 0 @max_wp10 = opts[:max_wp10] || 100 + @update_service = update_service end def mainspace_page_titles_for_category(category, depth=0) @@ -46,7 +47,7 @@ def get_category_member_data(query) pages = [] continue = true until continue.nil? - cat_response = WikiApi.new(@wiki).query query + cat_response = WikiApi.new(@wiki, update_service: @update_service).query query pages_batch = cat_response.data['categorymembers'] pages += pages_batch continue = cat_response['continue'] diff --git a/lib/importers/transclusion_importer.rb b/lib/importers/transclusion_importer.rb index 6a6e306715..6cc5b4a460 100644 --- a/lib/importers/transclusion_importer.rb +++ b/lib/importers/transclusion_importer.rb @@ -5,10 +5,11 @@ # Fetches data about which wiki pages transclude a given page class TransclusionImporter - def initialize(template) + def initialize(template, update_service: nil) @template = template @wiki = template.wiki @name = template.name + @update_service = update_service end def transcluded_titles @@ -18,7 +19,7 @@ def transcluded_titles private def all_transcluded_pages - wiki_api = WikiApi.new(@wiki) + wiki_api = WikiApi.new(@wiki, update_service: @update_service) @query = transclusion_query @transcluded_in = [] until @continue == 'done' diff --git a/lib/pagepile_api.rb b/lib/pagepile_api.rb index b8f069c402..f1cd3faa75 100644 --- a/lib/pagepile_api.rb +++ b/lib/pagepile_api.rb @@ -1,13 +1,18 @@ # frozen_string_literal: true + +require_dependency "#{Rails.root}/lib/errors/api_error_handling" + class PagePileApi + include ApiErrorHandling + def initialize(category) raise 'Wrong category type' unless category.source == 'pileid' @category = category @wiki = @category.wiki end - def page_titles_for_pileid - fetch_pile_data + def page_titles_for_pileid(update_service: nil) + fetch_pile_data(update_service:) return [] if @pile_data.empty? update_language_and_project @@ -25,11 +30,13 @@ def pileid @category.name end - def fetch_pile_data + def fetch_pile_data(update_service: nil) response = pagepile.get query_url @pile_data = Oj.load(response.body) + url = query_url rescue StandardError => e - Sentry.capture_exception e + log_error(e, update_service:, + sentry_extra: { api_url: url }) @pile_data = {} end @@ -52,4 +59,7 @@ def pagepile conn.headers['User-Agent'] = ENV['dashboard_url'] + ' ' + Rails.env conn end + + TYPICAL_ERRORS = [Faraday::TimeoutError, + Faraday::ConnectionFailed].freeze end diff --git a/lib/petscan_api.rb b/lib/petscan_api.rb index 28eed5cfbe..521f78a119 100644 --- a/lib/petscan_api.rb +++ b/lib/petscan_api.rb @@ -1,17 +1,25 @@ # frozen_string_literal: true + +require_dependency "#{Rails.root}/lib/errors/api_error_handling" + class PetScanApi - def get_data(psid) + include ApiErrorHandling + + def get_data(psid, update_service: nil) response = petscan.get query_url(psid) title_data = Oj.load(response.body) + url = query_url(psid) title_data rescue StandardError => e - raise e unless typical_errors.include?(e.class) + log_error(e, update_service:, + sentry_extra: { psid:, api_url: url }) + raise e unless TYPICAL_ERRORS.include?(e.class) return {} end - def page_titles_for_psid(psid) + def page_titles_for_psid(psid, update_service: nil) titles = [] - titles_response = get_data(psid) + titles_response = get_data(psid, update_service:) return titles if titles_response.empty? # Using an invalid PSID, such as a non-integer or nonexistent ID, # returns something like {"error":"ParseIntError { kind: InvalidDigit }"} @@ -39,7 +47,6 @@ def petscan conn end - def typical_errors - [Errno::EHOSTUNREACH, Faraday::TimeoutError] - end + TYPICAL_ERRORS = [Faraday::TimeoutError, + Errno::EHOSTUNREACH].freeze end From 5c0145af0df0e456994d260d3a16c71e8e554b18 Mon Sep 17 00:00:00 2001 From: Pratham Vaidya Date: Mon, 25 Mar 2024 00:29:22 +0530 Subject: [PATCH 041/198] added func to get module names --- lib/course_training_progress_manager.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/course_training_progress_manager.rb b/lib/course_training_progress_manager.rb index db26d9c793..c01e56157e 100644 --- a/lib/course_training_progress_manager.rb +++ b/lib/course_training_progress_manager.rb @@ -56,6 +56,18 @@ def incomplete_assigned_modules(user) modules.sort_by(&:due_date) end + def completed_training_modules_names(user) + @user = user + completed_modules = TrainingModulesUsers + .joins(:training_module) + .select('training_modules.name') + .where(training_modules_users: { user_id: @user.id }) + .where(training_module_id: training_modules_for_course) + .where.not(completed_at: nil) + .pluck('training_modules.name') + completed_modules + end + private def off_dashboard_training? From 3058beb8bf04b1c6f05dbe66c6b2783407c0ed8f Mon Sep 17 00:00:00 2001 From: Pratham Vaidya Date: Mon, 25 Mar 2024 00:29:52 +0530 Subject: [PATCH 042/198] added training data in csv --- lib/analytics/course_students_csv_builder.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/analytics/course_students_csv_builder.rb b/lib/analytics/course_students_csv_builder.rb index c9eb48a42f..ec7d7b9c64 100644 --- a/lib/analytics/course_students_csv_builder.rb +++ b/lib/analytics/course_students_csv_builder.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_dependency "#{Rails.root}/lib/course_training_progress_manager" require 'csv' class CourseStudentsCsvBuilder @@ -7,6 +8,8 @@ def initialize(course) @course = course @created_articles = Hash.new(0) @edited_articles = Hash.new(0) + @training_data = Hash.new(0) + @training_progress_manager ||= CourseTrainingProgressManager.new(course) end def generate_csv @@ -14,6 +17,7 @@ def generate_csv .pluck(:article_id, :user_id).to_h populate_created_articles populate_edited_articles + populate_training_data csv_data = [CSV_HEADERS] courses_users.each do |courses_user| csv_data << row(courses_user) @@ -23,6 +27,15 @@ def generate_csv private + def populate_training_data + courses_users.each do |courses_user| + @training_data[courses_user.user_id] = + @training_progress_manager.course_training_progress(courses_user.user) + @training_data[courses_user.user_id][:modules] = + @training_progress_manager.completed_training_modules_names(courses_user.user).join(', ') + end + end + def populate_created_articles # A user has created an article during the course if # the user is in the user_ids of new_articles_courses @@ -62,6 +75,9 @@ def courses_users registered_during_project total_articles_created total_articles_edited + completed_training_modules_count + assigned_training_modules_count + completed_training_modules ].freeze # rubocop:disable Metrics/AbcSize def row(courses_user) @@ -77,6 +93,9 @@ def row(courses_user) row << newbie?(courses_user.user) row << @created_articles[courses_user.user_id] row << @edited_articles[courses_user.user_id] + row << @training_data[courses_user.user_id][:completed_count] + row << @training_data[courses_user.user_id][:assigned_count] + row << @training_data[courses_user.user_id][:modules] end # rubocop:enable Metrics/AbcSize From 1a953fb662c99039b910eabb627a5e6a55249848 Mon Sep 17 00:00:00 2001 From: Pratham Vaidya Date: Wed, 27 Mar 2024 18:39:25 +0530 Subject: [PATCH 043/198] added class length exception --- .rubocop.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.rubocop.yml b/.rubocop.yml index 7a9b95e067..0b8216f35b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -41,6 +41,7 @@ Metrics/ClassLength: - 'lib/wiki_course_edits.rb' - 'lib/training/wiki_slide_parser.rb' - 'lib/wiki_assignment_output.rb' + - 'lib/course_training_progress_manager.rb' Metrics/AbcSize: Max: 23 # We should bring this down, ideally to the default of 15 From b34be5ea94dc87ecda5757131efc9f8622c21e9c Mon Sep 17 00:00:00 2001 From: Pratham Vaidya Date: Thu, 28 Mar 2024 20:22:16 +0530 Subject: [PATCH 044/198] fix: failed test --- lib/analytics/course_students_csv_builder.rb | 23 ++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/analytics/course_students_csv_builder.rb b/lib/analytics/course_students_csv_builder.rb index ec7d7b9c64..82913f6438 100644 --- a/lib/analytics/course_students_csv_builder.rb +++ b/lib/analytics/course_students_csv_builder.rb @@ -29,10 +29,25 @@ def generate_csv def populate_training_data courses_users.each do |courses_user| - @training_data[courses_user.user_id] = - @training_progress_manager.course_training_progress(courses_user.user) - @training_data[courses_user.user_id][:modules] = - @training_progress_manager.completed_training_modules_names(courses_user.user).join(', ') + training_progress = @training_progress_manager.course_training_progress(courses_user.user) + + unless training_progress.is_a?(Hash) + # Set default values if no training assigned or course is before Spring 2016 + @training_data[courses_user.user_id] = { + completed_count: "0", + assigned_count: "0", + modules: "" + } + next + end + + completed_modules = @training_progress_manager.completed_training_modules_names(courses_user.user).join(', ') + + @training_data[courses_user.user_id] = { + completed_count: training_progress[:completed_count], + assigned_count: training_progress[:assigned_count], + modules: completed_modules + } end end From 001838345101e8de1eb168f0de5634cbbae5e28b Mon Sep 17 00:00:00 2001 From: Pratham Vaidya Date: Sat, 6 Apr 2024 02:20:39 +0530 Subject: [PATCH 045/198] fix: linting error, use small size methods --- lib/analytics/course_students_csv_builder.rb | 46 ++++++++++++-------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/lib/analytics/course_students_csv_builder.rb b/lib/analytics/course_students_csv_builder.rb index 82913f6438..43e24729bf 100644 --- a/lib/analytics/course_students_csv_builder.rb +++ b/lib/analytics/course_students_csv_builder.rb @@ -29,28 +29,40 @@ def generate_csv def populate_training_data courses_users.each do |courses_user| - training_progress = @training_progress_manager.course_training_progress(courses_user.user) - - unless training_progress.is_a?(Hash) - # Set default values if no training assigned or course is before Spring 2016 - @training_data[courses_user.user_id] = { - completed_count: "0", - assigned_count: "0", - modules: "" - } - next - end + populate_user_training_data(courses_user) + end + end - completed_modules = @training_progress_manager.completed_training_modules_names(courses_user.user).join(', ') + def populate_user_training_data(courses_user) + training_progress = @training_progress_manager.course_training_progress(courses_user.user) - @training_data[courses_user.user_id] = { - completed_count: training_progress[:completed_count], - assigned_count: training_progress[:assigned_count], - modules: completed_modules - } + if training_progress.is_a?(Hash) + completed_modules = @training_progress_manager + .completed_training_modules_names(courses_user.user) + .join(', ') + set_training_data(courses_user.user_id, training_progress[:completed_count], + training_progress[:assigned_count], completed_modules) + else + default_training_data(courses_user.user_id) end end + def set_training_data(user_id, completed_count, assigned_count, completed_modules) + @training_data[user_id] = { + completed_count:, + assigned_count:, + modules: completed_modules + } + end + + def default_training_data(user_id) + @training_data[user_id] = { + completed_count: '0', + assigned_count: '0', + modules: '' + } + end + def populate_created_articles # A user has created an article during the course if # the user is in the user_ids of new_articles_courses From 5bc7fbb9ffc687389db08454765bd932af2e43bc Mon Sep 17 00:00:00 2001 From: om-chauhan1 Date: Tue, 9 Apr 2024 23:14:05 +0530 Subject: [PATCH 046/198] Converted settings_handler.jsx to functional component --- .../components/settings/settings_handler.jsx | 151 ++++++++---------- 1 file changed, 68 insertions(+), 83 deletions(-) diff --git a/app/assets/javascripts/components/settings/settings_handler.jsx b/app/assets/javascripts/components/settings/settings_handler.jsx index d0007cc848..bb2e8de98d 100644 --- a/app/assets/javascripts/components/settings/settings_handler.jsx +++ b/app/assets/javascripts/components/settings/settings_handler.jsx @@ -1,8 +1,6 @@ -import React from 'react'; - -import { connect } from 'react-redux'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import AddAdminButton from './views/add_admin_button'; import AddSpecialUserButton from './views/add_special_user_button'; import AdminUserList from './admin_users_list'; @@ -17,90 +15,77 @@ import AddFeaturedCampaign from './views/add_featured_campaign'; import FeaturedCampaignsList from './featured_campaigns_list'; import SiteNoticeSetting from './site_notice_setting'; -export const SettingsHandler = createReactClass({ - propTypes: { - fetchAdminUsers: PropTypes.func, - fetchSpecialUsers: PropTypes.func, - fetchDefaultCampaign: PropTypes.func, - adminUsers: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.number, - username: PropTypes.string.isRequired, - real_name: PropTypes.string, - permissions: PropTypes.number.isRequired, - }) - ), - specialUsers: PropTypes.object, - courseCreation: PropTypes.object, - defaultCampaign: PropTypes.string, - featuredCampaigns: PropTypes.array - }, +const SettingsHandler = () => { + const dispatch = useDispatch(); - componentDidMount() { - this.props.fetchAdminUsers(); - this.props.fetchSpecialUsers(); - this.props.fetchCourseCreationSettings(); - this.props.fetchDefaultCampaign(); - this.props.fetchFeaturedCampaigns(); - }, + const adminUsers = useSelector(state => state.settings.adminUsers); + const specialUsers = useSelector(state => state.settings.specialUsers); + const courseCreation = useSelector(state => state.settings.courseCreation); + const defaultCampaign = useSelector(state => state.settings.defaultCampaign); + const featuredCampaigns = useSelector(state => state.settings.featuredCampaigns); - render() { - let otherSettings; - if (Features.wikiEd) { - otherSettings = ( - -

{I18n.t('settings.categories.other_settings')}

-
-

{I18n.t('settings.categories.impact_stats')}

- -

-

{I18n.t('settings.categories.salesforce')}

- -

- -

-

{I18n.t('settings.categories.featured_campaigns')}

- - -

- -
- ); - } - return ( -
- - -
-

{I18n.t('settings.categories.users')}

-
-

{I18n.t('settings.categories.admin_users')}

- - -

{I18n.t('settings.categories.special_users')}

- - - {otherSettings} -
+ useEffect(() => { + dispatch(fetchAdminUsers()); + dispatch(fetchSpecialUsers()); + dispatch(fetchCourseCreationSettings()); + dispatch(fetchDefaultCampaign()); + dispatch(fetchFeaturedCampaigns()); + }, [dispatch]); + let otherSettings; + if (Features.wikiEd) { + otherSettings = ( + +

{I18n.t('settings.categories.other_settings')}

+
+

{I18n.t('settings.categories.impact_stats')}

+ +

+

{I18n.t('settings.categories.salesforce')}

+ +

+ +

+

{I18n.t('settings.categories.featured_campaigns')}

+ + +

+ +
); - }, -}); + } -const mapStateToProps = state => ({ - adminUsers: state.settings.adminUsers, - specialUsers: state.settings.specialUsers, - courseCreation: state.settings.courseCreation, - defaultCampaign: state.settings.defaultCampaign, - featuredCampaigns: state.settings.featuredCampaigns -}); + return ( +
+ + +
+

{I18n.t('settings.categories.users')}

+
+

{I18n.t('settings.categories.admin_users')}

+ + +

{I18n.t('settings.categories.special_users')}

+ + + {otherSettings} +
+ ); +}; -const mapDispatchToProps = { - fetchAdminUsers, - fetchSpecialUsers, - fetchCourseCreationSettings, - fetchDefaultCampaign, - fetchFeaturedCampaigns +SettingsHandler.propTypes = { + adminUsers: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + username: PropTypes.string.isRequired, + real_name: PropTypes.string, + permissions: PropTypes.number.isRequired, + }) + ), + specialUsers: PropTypes.object, + courseCreation: PropTypes.object, + defaultCampaign: PropTypes.string, + featuredCampaigns: PropTypes.array }; -export default connect(mapStateToProps, mapDispatchToProps)(SettingsHandler); +export default SettingsHandler; From a8e8b026a295e639744bd7bb7578710392348854 Mon Sep 17 00:00:00 2001 From: om-chauhan1 Date: Fri, 5 Apr 2024 16:10:06 +0530 Subject: [PATCH 047/198] Converted upload_viewer.jsx to functional component --- .../components/uploads/upload_viewer.jsx | 295 ++++++++---------- 1 file changed, 137 insertions(+), 158 deletions(-) diff --git a/app/assets/javascripts/components/uploads/upload_viewer.jsx b/app/assets/javascripts/components/uploads/upload_viewer.jsx index 4640709f55..ce4aa7a8b8 100644 --- a/app/assets/javascripts/components/uploads/upload_viewer.jsx +++ b/app/assets/javascripts/components/uploads/upload_viewer.jsx @@ -1,181 +1,160 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; -import { forEach, get } from 'lodash-es'; -import OnClickOutside from 'react-onclickoutside'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { setUploadViewerMetadata, setUploadPageViews, resetUploadsViews } from '../../actions/uploads_actions.js'; import { formatDateWithoutTime } from '../../utils/date_utils.js'; +import { forEach, get } from 'lodash-es'; +import OnClickOutside from 'react-onclickoutside'; -const UploadViewer = createReactClass({ - displayName: 'UploadViewer', +const UploadViewer = ({ closeUploadViewer, upload, imageFile }) => { + const dispatch = useDispatch(); + const uploadMetadata = useSelector(state => state.uploads.uploadMetadata); + const pageViews = useSelector(state => state.uploads.averageViews); - propTypes: { - upload: PropTypes.object, - closeUploadViewer: PropTypes.func, - }, + const [loadingViews, setLoadingViews] = useState(true); - getInitialState() { - return { - loadingViews: true + useEffect(() => { + dispatch(setUploadViewerMetadata(upload)); + return () => { + dispatch(resetUploadsViews()); }; - }, - - componentDidMount() { - this.props.setUploadViewerMetadata(this.props.upload); - }, + }, [upload]); - componentDidUpdate() { - const metadata = get(this.props.uploadMetadata, `query.pages[${this.props.upload.id}]`); + useEffect(() => { + const metadata = get(uploadMetadata, `query.pages[${upload.id}]`); const fileUsage = get(metadata, 'globalusage', []); - if (fileUsage) { - if (this.state.loadingViews) { - this.handleGetFileViews(fileUsage); - } + if (fileUsage && loadingViews) { + handleGetFileViews(fileUsage); } - }, - - componentWillUnmount() { - this.props.resetUploadsViews(); - }, - - handleGetFileViews(files) { - this.props.setUploadPageViews(files); - this.setState({ - loadingViews: false + }, [uploadMetadata, upload.id, loadingViews]); + + const handleGetFileViews = files => { + dispatch(setUploadPageViews(files)); + setLoadingViews(false); + }; + + const handleClickOutside = () => { + closeUploadViewer(); + }; + + const metadata = get(uploadMetadata, `query.pages[${upload.id}]`); + const imageDescription = get(metadata, 'imageinfo[0].extmetadata.ImageDescription.value'); + const width = get(metadata, 'imageinfo[0].width'); + const height = get(metadata, 'imageinfo[0].height'); + + let size = get(metadata, 'imageinfo[0].size'); + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const i = Math.floor(Math.log(size) / Math.log(1024)); + size = `${parseFloat((size / (1024 ** i)).toFixed(2))} ${sizes[i]}`; + + const imageUrl = get(metadata, 'imageinfo[0].url'); + + const profileLink = `/users/${encodeURIComponent(upload.uploader)}`; + const author = {upload.uploader}; + const source = get(metadata, 'imageinfo[0].extmetadata.Credit.value'); + const license = get(metadata, 'imageinfo[0].extmetadata.LicenseShortName.value'); + const globalUsage = get(metadata, 'globalusage', []); + let usageTableElements; + if (globalUsage && pageViews !== undefined) { + usageTableElements = globalUsage.map((usage, index) => { + return ( + + {usage.wiki}    + {usage.title}    + {pageViews[index]} + + ); }); - }, - - handleClickOutside() { - this.props.closeUploadViewer(); - }, - - - render() { - const metadata = get(this.props.uploadMetadata, `query.pages[${this.props.upload.id}]`); - const imageDescription = get(metadata, 'imageinfo[0].extmetadata.ImageDescription.value'); - const width = get(metadata, 'imageinfo[0].width'); - const height = get(metadata, 'imageinfo[0].height'); - - let size = get(metadata, 'imageinfo[0].size'); - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - const i = Math.floor(Math.log(size) / Math.log(1024)); - size = `${parseFloat((size / (1024 ** i)).toFixed(2))} ${sizes[i]}`; - - const imageUrl = get(metadata, 'imageinfo[0].url'); + } - const profileLink = `/users/${encodeURIComponent(this.props.upload.uploader)}`; - const author = {this.props.upload.uploader}; - const source = get(metadata, 'imageinfo[0].extmetadata.Credit.value'); - const license = get(metadata, 'imageinfo[0].extmetadata.LicenseShortName.value'); - const globalUsage = get(metadata, 'globalusage', []); - let usageTableElements; - if (globalUsage && (this.props.pageViews !== undefined)) { - usageTableElements = globalUsage.map((usage, index) => { - return ( - - {usage.wiki}    - {usage.title}    - {this.props.pageViews[index]} + let fileUsageTable; + if (globalUsage.length > 0) { + fileUsageTable = ( +
+

{'\n'}

+

File usage on other wikis

+ + + + + + - ); - }); - } + + + {usageTableElements} + +
WikiArticle NameViews per day
+
+ ); + } + let categoriesList = []; + let categories; + forEach(get(metadata, 'categories', []), (category) => { + categoriesList.push( | ); + categoriesList.push({category.title.slice('Category:'.length)}); + }); + if (categoriesList.length > 0) { + categoriesList = categoriesList.splice(1); + categories = ( +
+

{'\n'}

+

Categories

+ {categoriesList} +
+ ); + } - let fileUsageTable; - if (globalUsage.length > 0) { - fileUsageTable = ( -
-

{'\n'}

-

File usage on other wikis

- - + return ( +
+
+
+
+
+ {upload.file_name} +

Original File{` (${width} X ${height} pixels, file size: ${size})`}

+

Description

+

+

+
+
+ - - - + + + + + + + + + + + + + + - - - {usageTableElements}
WikiArticle NameViews per dayDate: {formatDateWithoutTime(upload.uploaded_at)}
Author: {author}
Source:  +
License: {license}{'\n'}
-
- ); - } - let categoriesList = []; - let categories; - forEach(get(metadata, 'categories', []), (category) => { - categoriesList.push( | ); - categoriesList.push({category.title.slice('Category:'.length)}); - }); - if (categoriesList.length > 0) { - categoriesList = categoriesList.splice(1); - categories = ( -
-

{'\n'}

-

Categories

- {categoriesList} -
- ); - } - - return ( -
-
-
-
-
- {this.props.upload.file_name} -

Original File{` (${width} X ${height} pixels, file size: ${size})`}

-

Description

-

-

-
- - - - - - - - - - - - - - - - - - - -
Date: {formatDateWithoutTime(this.props.upload.uploaded_at)}
Author: {author}
Source:  -
License: {license}{'\n'}
- {categories} - {fileUsageTable} -
-
-
- View on Commons + {categories} + {fileUsageTable}
- ); - } -}); - -const mapStateToProps = state => ({ - uploadMetadata: state.uploads.uploadMetadata, - pageViews: state.uploads.averageViews -}); + + + ); +}; -const mapDispatchToProps = { - setUploadViewerMetadata, - setUploadPageViews, - resetUploadsViews +UploadViewer.propTypes = { + upload: PropTypes.object, + closeUploadViewer: PropTypes.func, + imageFile: PropTypes.string }; -export default connect(mapStateToProps, mapDispatchToProps)(OnClickOutside(UploadViewer)); +export default OnClickOutside(UploadViewer); From b9663cdbeca16a7675d95fc0a540c5b9e81d54f0 Mon Sep 17 00:00:00 2001 From: om-chauhan1 Date: Fri, 5 Apr 2024 16:38:53 +0530 Subject: [PATCH 048/198] Extended i18n over string literals --- .../components/uploads/upload_viewer.jsx | 26 +++++++++---------- config/locales/en.yml | 12 +++++++++ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/app/assets/javascripts/components/uploads/upload_viewer.jsx b/app/assets/javascripts/components/uploads/upload_viewer.jsx index ce4aa7a8b8..11e3243c8e 100644 --- a/app/assets/javascripts/components/uploads/upload_viewer.jsx +++ b/app/assets/javascripts/components/uploads/upload_viewer.jsx @@ -28,7 +28,7 @@ const UploadViewer = ({ closeUploadViewer, upload, imageFile }) => { } }, [uploadMetadata, upload.id, loadingViews]); - const handleGetFileViews = files => { + const handleGetFileViews = (files) => { dispatch(setUploadPageViews(files)); setLoadingViews(false); }; @@ -72,13 +72,13 @@ const UploadViewer = ({ closeUploadViewer, upload, imageFile }) => { fileUsageTable = (

{'\n'}

-

File usage on other wikis

+

{I18n.t('uploads.file_usage')}

- - - + + + @@ -99,7 +99,7 @@ const UploadViewer = ({ closeUploadViewer, upload, imageFile }) => { categories = (

{'\n'}

-

Categories

+

{I18n.t('uploads.categories')}

{categoriesList}
); @@ -114,27 +114,27 @@ const UploadViewer = ({ closeUploadViewer, upload, imageFile }) => {
{upload.file_name} -

Original File{` (${width} X ${height} pixels, file size: ${size})`}

-

Description

+

{I18n.t('uploads.original_file')}{` (${width} X ${height} pixels, file size: ${size})`}

+

{I18n.t('uploads.description')}

WikiArticle NameViews per day{I18n.t('uploads.wiki_big')}{I18n.t('uploads.article_name')}{I18n.t('uploads.views_per_day')}
- + - + - + - + @@ -145,7 +145,7 @@ const UploadViewer = ({ closeUploadViewer, upload, imageFile }) => { ); diff --git a/config/locales/en.yml b/config/locales/en.yml index ccdf6b0ebe..c37456555a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1405,13 +1405,21 @@ en: button: Update uploads: + article_name: Article Name + author: "Author:" + categories: Categories credit: Credit + date: "Date:" + description: Description file_name: File Name + file_usage: File usage on other wikis header: Files Uploaded to Wikimedia Commons image: Image label: Uploads + license: "License:" loading: Loading uploads... none: This class has not contributed any images or other media files to Wikimedia Commons. + original_file: Original File select_label: Filter by uploader time_doc: The time of each upload in your local time uploaded_at: Upload Time @@ -1424,7 +1432,11 @@ en: usages: Usages gallery_view: Gallery View list_view: List View + source: "Source:" tile_view: Tile View + views_per_day: Views per day + view_commons: View on Commons + wiki_big: Wiki users: already_enrolled: That user is already enrolled! From 7788004ab8ba513f864e41fdf502d91223af8cf6 Mon Sep 17 00:00:00 2001 From: om-chauhan1 Date: Thu, 18 Apr 2024 22:05:58 +0530 Subject: [PATCH 049/198] fixed onOutside click functioning and converted loadsh 'forEach' to native js --- .../javascripts/components/uploads/upload_viewer.jsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/components/uploads/upload_viewer.jsx b/app/assets/javascripts/components/uploads/upload_viewer.jsx index 11e3243c8e..2a4dca1b44 100644 --- a/app/assets/javascripts/components/uploads/upload_viewer.jsx +++ b/app/assets/javascripts/components/uploads/upload_viewer.jsx @@ -3,8 +3,8 @@ import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { setUploadViewerMetadata, setUploadPageViews, resetUploadsViews } from '../../actions/uploads_actions.js'; import { formatDateWithoutTime } from '../../utils/date_utils.js'; -import { forEach, get } from 'lodash-es'; -import OnClickOutside from 'react-onclickoutside'; +import { get } from 'lodash-es'; +import useOutsideClick from '../../hooks/useOutsideClick.js'; const UploadViewer = ({ closeUploadViewer, upload, imageFile }) => { const dispatch = useDispatch(); @@ -36,6 +36,7 @@ const UploadViewer = ({ closeUploadViewer, upload, imageFile }) => { const handleClickOutside = () => { closeUploadViewer(); }; + const ref = useOutsideClick(handleClickOutside); const metadata = get(uploadMetadata, `query.pages[${upload.id}]`); const imageDescription = get(metadata, 'imageinfo[0].extmetadata.ImageDescription.value'); @@ -90,7 +91,7 @@ const UploadViewer = ({ closeUploadViewer, upload, imageFile }) => { } let categoriesList = []; let categories; - forEach(get(metadata, 'categories', []), (category) => { + (metadata?.categories ?? []).forEach((category) => { categoriesList.push( | ); categoriesList.push({category.title.slice('Category:'.length)}); }); @@ -106,7 +107,7 @@ const UploadViewer = ({ closeUploadViewer, upload, imageFile }) => { } return ( -
+
diff --git a/app/assets/javascripts/components/common/ArticleViewer/containers/ArticleViewer.jsx b/app/assets/javascripts/components/common/ArticleViewer/containers/ArticleViewer.jsx index d77255f507..5e903a1af0 100644 --- a/app/assets/javascripts/components/common/ArticleViewer/containers/ArticleViewer.jsx +++ b/app/assets/javascripts/components/common/ArticleViewer/containers/ArticleViewer.jsx @@ -54,6 +54,7 @@ const ArticleViewer = ({ showOnMount, users, showArticleFinder, showButtonLabel, const [parsedArticle, setParsedArticle] = useState(null); const [unhighlightedEditors, setUnhighlightedEditors] = useState([]); const [revisionId, setRevisionId] = useState(null); + const [pendingRequest, setPendingRequest] = useState(false); const lastRevisionId = useSelector(state => state.articleDetails[article.id]?.last_revision?.mw_rev_id); const dispatch = useDispatch(); @@ -189,13 +190,9 @@ const ArticleViewer = ({ showOnMount, users, showArticleFinder, showButtonLabel, if (editorsID.length) { // If there are unhighlighted editors, call the function to check their contributions in wikitext metadata usersContributionExists(editorsID); - } else { - const status = 'No Highlighted Editors'; - // Set the unhighlightedEditors state with a status message to make legendStatus ready in the - // Footer Component for loading the authorship data - setUnhighlightedEditors([status]); } setHighlightedHtml(html); + setPendingRequest(false); }; // Function to check if contributions of unhighlighted editors exist in the wikitext metadata @@ -220,9 +217,9 @@ const ArticleViewer = ({ showOnMount, users, showArticleFinder, showButtonLabel, if (foundToken) { // Add the user ID to the unhighlightedEditors state to display in the UI setUnhighlightedEditors(x => [...x, userID]); - } - }); - }).catch((error) => { + } + }); + }).catch((error) => { setFailureMessage(error.message); }); }; @@ -230,6 +227,7 @@ const ArticleViewer = ({ showOnMount, users, showArticleFinder, showButtonLabel, const fetchParsedArticle = () => { const builder = new URLBuilder({ article: article }); const api = new ArticleViewerAPI({ builder }); + setPendingRequest(true); api.fetchParsedArticle(revisionId) .then((response) => { setParsedArticle(response.parsedArticle.html); @@ -369,6 +367,7 @@ const ArticleViewer = ({ showOnMount, users, showArticleFinder, showButtonLabel, }
Date: Fri, 19 Apr 2024 23:57:00 +0530 Subject: [PATCH 054/198] Defined status for each situation and fixed bug in Article Viewer --- .../ArticleViewer/components/Footer.jsx | 6 ++-- .../containers/ArticleViewer.jsx | 32 ++++++++++++------- .../common/article_viewer_legend.jsx | 6 ++-- .../stylesheets/modules/_article_viewer.styl | 2 +- config/locales/en.yml | 2 +- 5 files changed, 29 insertions(+), 19 deletions(-) diff --git a/app/assets/javascripts/components/common/ArticleViewer/components/Footer.jsx b/app/assets/javascripts/components/common/ArticleViewer/components/Footer.jsx index f3072f2c4c..4c14e9e844 100644 --- a/app/assets/javascripts/components/common/ArticleViewer/components/Footer.jsx +++ b/app/assets/javascripts/components/common/ArticleViewer/components/Footer.jsx @@ -7,14 +7,14 @@ import { printArticleViewer } from '../../../../utils/article_viewer'; export const Footer = ({ article, colors, failureMessage, showArticleFinder, highlightedHtml, isWhocolorLang, - whocolorFailed, users, unhighlightedEditors, revisionId, toggleRevisionHandler, pendingRequest + whocolorFailed, users, unhighlightedContributors, revisionId, toggleRevisionHandler, pendingRequest }) => { // Determine the Article Viewer Legend status based on what information // has returned from various API calls. let articleViewerLegend; if (!showArticleFinder) { let legendStatus; - if (highlightedHtml) { + if (highlightedHtml && unhighlightedContributors.length) { legendStatus = 'ready'; } else if (whocolorFailed) { legendStatus = 'failed'; @@ -29,7 +29,7 @@ export const Footer = ({ colors={colors} status={legendStatus} failureMessage={failureMessage} - unhighlightedEditors={unhighlightedEditors} + unhighlightedContributors={unhighlightedContributors} /> ); } diff --git a/app/assets/javascripts/components/common/ArticleViewer/containers/ArticleViewer.jsx b/app/assets/javascripts/components/common/ArticleViewer/containers/ArticleViewer.jsx index 5e903a1af0..0d683dc84d 100644 --- a/app/assets/javascripts/components/common/ArticleViewer/containers/ArticleViewer.jsx +++ b/app/assets/javascripts/components/common/ArticleViewer/containers/ArticleViewer.jsx @@ -52,7 +52,7 @@ const ArticleViewer = ({ showOnMount, users, showArticleFinder, showButtonLabel, const [userIdsFetched, setUserIdsFetched] = useState(false); const [whoColorHtml, setWhoColorHtml] = useState(null); const [parsedArticle, setParsedArticle] = useState(null); - const [unhighlightedEditors, setUnhighlightedEditors] = useState([]); + const [unhighlightedContributors, setUnhighlightedContributors] = useState([]); const [revisionId, setRevisionId] = useState(null); const [pendingRequest, setPendingRequest] = useState(false); const lastRevisionId = useSelector(state => state.articleDetails[article.id]?.last_revision?.mw_rev_id); @@ -164,7 +164,7 @@ const ArticleViewer = ({ showOnMount, users, showArticleFinder, showButtonLabel, let html = whoColorHtml; if (!html) { return; } // Array to store user IDs whose contributions couldn't be highlighted - const editorsID = []; + const unHighlightedUsers = []; forEach(usersState, (user, i) => { // Move spaces inside spans, so that background color is continuous @@ -182,14 +182,18 @@ const ArticleViewer = ({ showOnMount, users, showArticleFinder, showButtonLabel, user.activeRevision = true; } else { // If highlighting failed , store the un-highlighted user's ID in the editorsID array - editorsID.push(user.userid); + unHighlightedUsers.push(user.userid); } }); // Check if there are any editors whose contributions couldn't be highlighted - if (editorsID.length) { + if (unHighlightedUsers.length) { // If there are unhighlighted editors, call the function to check their contributions in wikitext metadata - usersContributionExists(editorsID); + usersContributionExists(unHighlightedUsers); + } else { + const status = 'No Unhighlighted Contributors'; + // Set the status of the unhighlightedContributors state to display in the UI + setUnhighlightedContributors([status]); } setHighlightedHtml(html); setPendingRequest(false); @@ -197,9 +201,9 @@ const ArticleViewer = ({ showOnMount, users, showArticleFinder, showButtonLabel, // Function to check if contributions of unhighlighted editors exist in the wikitext metadata const usersContributionExists = (usersID) => { - // Create a URL builder and API instance for fetching wikitext metadata - const builder = new URLBuilder({ article: article }); - const api = new ArticleViewerAPI({ builder }); + // Create a URL builder and API instance for fetching wikitext metadata + const builder = new URLBuilder({ article: article }); + const api = new ArticleViewerAPI({ builder }); // Fetch wikitext metadata for the current article revision api.fetchWikitextMetaData() @@ -215,8 +219,13 @@ const ArticleViewer = ({ showOnMount, users, showArticleFinder, showButtonLabel, // If a token with a matching editor ID is found, it means the user has a contribution // in the current revision's wikitext if (foundToken) { - // Add the user ID to the unhighlightedEditors state to display in the UI - setUnhighlightedEditors(x => [...x, userID]); + // Add the user ID to the unhighlightedContributors state to display in the UI + setUnhighlightedContributors(x => [...x, userID]); + } else { + const status = `No Contributions Found in this current version for User ID', ${userID}`; + // If the user ID doesn't have a contribution in the current revision's wikitext, + // add a message to the unhighlightedContributors state to display in the UI + setUnhighlightedContributors(x => [...x, status]); } }); }).catch((error) => { @@ -296,6 +305,7 @@ const ArticleViewer = ({ showOnMount, users, showArticleFinder, showButtonLabel, setHighlightedHtml(null); setWhoColorHtml(null); fetchParsedArticle(); + setUnhighlightedContributors([]); if (isWhocolorLang()) { fetchWhocolorHtml(); } @@ -376,7 +386,7 @@ const ArticleViewer = ({ showOnMount, users, showArticleFinder, showButtonLabel, showArticleFinder={showArticleFinder} whoColorFailed={whoColorFailed} users={usersState} - unhighlightedEditors={unhighlightedEditors} + unhighlightedContributors={unhighlightedContributors} revisionId={revisionId} toggleRevisionHandler={toggleRevisionHandler} /> diff --git a/app/assets/javascripts/components/common/article_viewer_legend.jsx b/app/assets/javascripts/components/common/article_viewer_legend.jsx index 0c2aba2b84..a3552d2763 100644 --- a/app/assets/javascripts/components/common/article_viewer_legend.jsx +++ b/app/assets/javascripts/components/common/article_viewer_legend.jsx @@ -5,7 +5,7 @@ import UserUtils from '../../utils/user_utils.js'; import ArticleScroll from '@components/common/ArticleViewer/utils/ArticleScroll'; -const ArticleViewerLegend = ({ article, users, colors, status, allUsers, failureMessage, unhighlightedEditors }) => { +const ArticleViewerLegend = ({ article, users, colors, status, allUsers, failureMessage, unhighlightedContributors }) => { const [userLinks, setUserLinks] = useState(''); const [usersStatus, setUsersStatus] = useState(''); const Scroller = new ArticleScroll(); @@ -26,7 +26,7 @@ const ArticleViewerLegend = ({ article, users, colors, status, allUsers, failure let res; // The 'unhighlightedContributions' keeps track of the userids of users whose contributions // were not successfully highlighted in the article viewer. - const UnhighlightedContributions = unhighlightedEditors?.find(x => x === user.userid); + const UnhighlightedContributions = unhighlightedContributors?.find(x => x === user.userid); const userLink = UserUtils.userTalkUrl(user.name, article.language, article.project); const fullUserRecord = allUsers.find(_user => _user.username === user.name); const realName = fullUserRecord && fullUserRecord.real_name; @@ -45,7 +45,7 @@ const ArticleViewerLegend = ({ article, users, colors, status, allUsers, failure } else { setUserLinks(
   
); } - }, [users, status, unhighlightedEditors]); + }, [users, status, unhighlightedContributors]); useEffect(() => { if (status === 'loading') { diff --git a/app/assets/stylesheets/modules/_article_viewer.styl b/app/assets/stylesheets/modules/_article_viewer.styl index 90503c7371..c1a1c686ad 100644 --- a/app/assets/stylesheets/modules/_article_viewer.styl +++ b/app/assets/stylesheets/modules/_article_viewer.styl @@ -20,7 +20,7 @@ #popup-style position: fixed - top: 99% + top: calc(100% - 80px) left: 0 border: 0 background: #676eb4 diff --git a/config/locales/en.yml b/config/locales/en.yml index c37456555a..5bef5aebcd 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1486,7 +1486,7 @@ en: no_articles: No articles no_articles_wikidata: No items no_revisions: (no revisions) - no_highlighting: "No text from the current version of the article is attributed to user %{editor}. Check the page history to see their edits." + no_highlighting: "No text from this version of the article is attributed to user %{editor}. Check the page history to see their edits." contributions_not_highlighted: "Contributions from user %{username} are present, but cannot be highlighted. This typically occurs for added references or templates. Check the page history to see their edits." number_of_articles: one: "%{count} article" From 7e2bb35c96cc0013a83a5c8c147df3cd054e82cd Mon Sep 17 00:00:00 2001 From: om-chauhan1 Date: Sat, 20 Apr 2024 19:50:50 +0530 Subject: [PATCH 055/198] Refactored revision_list.jsx from class to functional component --- .../components/revisions/revision_list.jsx | 158 ++++++++---------- 1 file changed, 74 insertions(+), 84 deletions(-) diff --git a/app/assets/javascripts/components/revisions/revision_list.jsx b/app/assets/javascripts/components/revisions/revision_list.jsx index ad5e31e7aa..ddc573f6f7 100644 --- a/app/assets/javascripts/components/revisions/revision_list.jsx +++ b/app/assets/javascripts/components/revisions/revision_list.jsx @@ -1,97 +1,87 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import List from '../common/list.jsx'; import Revision from './revision.jsx'; import CourseUtils from '../../utils/course_utils.js'; -import createReactClass from 'create-react-class'; import ArticleUtils from '../../utils/article_utils.js'; -const RevisionList = createReactClass({ - displayName: 'RevisionList', +const RevisionList = ({ revisions, course, sortBy, wikidataLabels, sort, loaded }) => { + const [selectedIndex, setSelectedIndex] = useState(-1); - propTypes: { - revisions: PropTypes.array, - course: PropTypes.object, - sortBy: PropTypes.func, - wikidataLabels: PropTypes.object, - sort: PropTypes.object - }, + const showDiff = (index) => { + setSelectedIndex(index); + }; - getInitialState() { - return { - selectedIndex: -1, - }; - }, + const elements = revisions.map((revision, index) => ( + + )); - showDiff(index) { - this.setState({ - selectedIndex: index - }); - }, - - render() { - const elements = this.props.revisions.map((revision, index) => { - return ; - }); - - const keys = { - rating_num: { - label: I18n.t('revisions.class'), - desktop_only: true - }, - title: { - label: I18n.t('revisions.title'), - desktop_only: false - }, - revisor: { - label: I18n.t('revisions.edited_by'), - desktop_only: true - }, - characters: { - label: I18n.t('revisions.chars_added'), - desktop_only: true - }, - references_added: { - label: I18n.t('revisions.references'), - desktop_only: true, - info_key: `metrics.${ArticleUtils.projectSuffix(this.props.course.home_wiki.project, 'references_doc')}` - }, - date: { - label: I18n.t('revisions.date_time'), - desktop_only: true, - info_key: 'revisions.time_doc' - } - }; - if (this.props.sort.key) { - const order = (this.props.sort.sortKey) ? 'asc' : 'desc'; - keys[this.props.sort.key].order = order; + const keys = { + rating_num: { + label: I18n.t('revisions.class'), + desktop_only: true + }, + title: { + label: I18n.t('revisions.title'), + desktop_only: false + }, + revisor: { + label: I18n.t('revisions.edited_by'), + desktop_only: true + }, + characters: { + label: I18n.t('revisions.chars_added'), + desktop_only: true + }, + references_added: { + label: I18n.t('revisions.references'), + desktop_only: true, + info_key: `metrics.${ArticleUtils.projectSuffix(course.home_wiki.project, 'references_doc')}` + }, + date: { + label: I18n.t('revisions.date_time'), + desktop_only: true, + info_key: 'revisions.time_doc' } + }; + if (sort.key) { + const order = sort.sortKey ? 'asc' : 'desc'; + keys[sort.key].order = order; + } + + // Until the revisions are loaded, we do not pass the none_message prop + // This is done to avoid showing the none_message when the revisions are loading + // initially because at that time the revisions is an empty array + // Whether or not the revisions is really an empty array is confirmed after the revisions + // are successfully loaded + return ( + + ); +}; - // Until the revisions are loaded, we do not pass the none_message prop - // This is done to avoid showing the none_message when the revisions are loading - // initially because at that time the revisions is an empty array - // Whether or not the revisions is really an empty array is confirmed after the revisions - // are successfully loaded - return ( - - ); - }, -}); +RevisionList.propTypes = { + revisions: PropTypes.array, + course: PropTypes.object, + sortBy: PropTypes.func, + wikidataLabels: PropTypes.object, + sort: PropTypes.object, + loaded: PropTypes.bool +}; export default RevisionList; From fd1eae00d69acbbee44659f20214b020959120e0 Mon Sep 17 00:00:00 2001 From: om-chauhan1 Date: Sat, 20 Apr 2024 23:59:06 +0530 Subject: [PATCH 056/198] Refactored revisions_handler.jsx from class to functional component --- .../revisions/revisions_handler.jsx | 243 ++++++++---------- 1 file changed, 105 insertions(+), 138 deletions(-) diff --git a/app/assets/javascripts/components/revisions/revisions_handler.jsx b/app/assets/javascripts/components/revisions/revisions_handler.jsx index c7f2ea2d19..3eb130070d 100644 --- a/app/assets/javascripts/components/revisions/revisions_handler.jsx +++ b/app/assets/javascripts/components/revisions/revisions_handler.jsx @@ -1,7 +1,6 @@ -import React from 'react'; -import createReactClass from 'create-react-class'; +import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import RevisionList from './revision_list.jsx'; import { fetchRevisions, sortRevisions, fetchCourseScopedRevisions } from '../../actions/revisions_actions.js'; import Loading from '../common/loading.jsx'; @@ -10,173 +9,141 @@ import { ARTICLE_FETCH_LIMIT } from '../../constants/revisions.js'; import Select from 'react-select'; import sortSelectStyles from '../../styles/sort_select'; -const RevisionHandler = createReactClass({ - displayName: 'RevisionHandler', - - propTypes: { - course_id: PropTypes.string, - course: PropTypes.object, - courseScopedLimit: PropTypes.number, - courseScopedLimitReached: PropTypes.bool, - courseSpecificRevisions: PropTypes.array, - fetchRevisions: PropTypes.func, - limit: PropTypes.number, - limitReached: PropTypes.bool, - revisions: PropTypes.array, - wikidataLabels: PropTypes.object, - revisionsLoaded: PropTypes.bool, - courseScopedRevisionsLoaded: PropTypes.bool - }, - - getInitialState() { - return { - isCourseScoped: false, - isInitialFetchCourseScoped: true - }; - }, - - componentDidMount() { +const RevisionHandler = ({ course, courseScopedLimit }) => { + const [isCourseScoped, setIsCourseScoped] = useState(false); + + const dispatch = useDispatch(); + + const revisionsDisplayed = useSelector(state => state.revisions.revisionsDisplayed); + const revisionsDisplayedCourseSpecific = useSelector(state => state.revisions.revisionsDisplayedCourseSpecific); + const courseScopedLimitReached = useSelector(state => state.revisions.courseScopedLimitReached); + const limitReached = useSelector(state => state.revisions.limitReached); + const courseSpecificAssessmentsLoaded = useSelector(state => state.revisions.courseSpecificAssessmentsLoaded); + const wikidataLabels = useSelector(state => state.wikidataLabels.labels); + const courseScopedRevisionsLoaded = useSelector(state => state.revisions.courseScopedRevisionsLoaded); + const revisionsLoaded = useSelector(state => state.revisions.revisionsLoaded); + const sort = useSelector(state => state.revisions.sort); + const referencesLoaded = useSelector(state => state.revisions.referencesLoaded); + const assessmentsLoaded = useSelector(state => state.revisions.assessmentsLoaded); + const courseSpecificReferencesLoaded = useSelector(state => state.revisions.courseSpecificReferencesLoaded); + + useEffect(() => { // sets the title of this tab - document.title = `${this.props.course.title} - ${I18n.t('application.recent_activity')}`; - - if (!this.props.revisionsLoaded) { + document.title = `${course.title} - ${I18n.t('application.recent_activity')}`; + if (!revisionsLoaded) { // Fetching in advance initially only for all revisions // For Course Scoped Revisions, fetching in componentDidUpdate // because in most cases, users would not be using these, so we // will fetch only when the user initially goes there, hence saving extra queries - if (this.state.isCourseScoped) { - this.props.fetchCourseScopedRevisions(this.props.course, this.props.courseScopedLimit); + if (isCourseScoped) { + dispatch(fetchCourseScopedRevisions(course, courseScopedLimit)); } else { - this.props.fetchRevisions(this.props.course); + dispatch(fetchRevisions(course)); } } - }, + }, [course, isCourseScoped, revisionsLoaded]); - getLoadingMessage() { - if (!this.state.isCourseScoped) { - if (!this.props.assessmentsLoaded) { + const getLoadingMessage = () => { + if (!isCourseScoped) { + if (!assessmentsLoaded) { return 'Loading page assessments'; } - if (!this.props.referencesLoaded) { + if (!referencesLoaded) { return 'Loading references'; } } else { - if (!this.props.courseSpecificAssessmentsLoaded) { + if (!courseSpecificAssessmentsLoaded) { return 'Loading page assessments'; } - if (!this.props.courseSpecificReferencesLoaded) { + if (!courseSpecificReferencesLoaded) { return 'Loading references'; } } - }, + }; - toggleCourseSpecific() { - const toggledIsCourseScoped = !this.state.isCourseScoped; - this.setState({ isCourseScoped: toggledIsCourseScoped }); + const toggleCourseSpecific = () => { + const toggledIsCourseScoped = !isCourseScoped; + setIsCourseScoped(toggledIsCourseScoped); // If user reaches the course scoped part initially, and there are no // loaded course scoped revisions, we fetch course scoped revisions - if (toggledIsCourseScoped && !this.props.courseScopedRevisionsLoaded) { - this.props.fetchCourseScopedRevisions(this.props.course, this.props.courseScopedLimit); + if (toggledIsCourseScoped && !courseScopedRevisionsLoaded) { + dispatch(fetchCourseScopedRevisions(course, courseScopedLimit)); } - }, + }; - revisionFilterButtonText() { - if (this.state.isCourseScoped) { - return I18n.t('revisions.show_all'); - } - return I18n.t('revisions.show_course_specific'); - }, + const revisionFilterButtonText = () => { + return isCourseScoped ? I18n.t('revisions.show_all') : I18n.t('revisions.show_course_specific'); + }; - sortSelect(e) { - return this.props.sortRevisions(e.value); - }, + const sortSelect = (e) => { + dispatch(sortRevisions(e.value)); + }; // We disable show more button if there is a request which is still resolving // by keeping track of revisionsLoaded and courseScopedRevisionsLoaded - showMore() { - if (this.state.isCourseScoped) { - return this.props.fetchCourseScopedRevisions(this.props.course, this.props.courseScopedLimit + 100); - } - return this.props.fetchRevisions(this.props.course); - }, - - render() { - // Boolean to indicate whether the revisions in the current section (all scoped or course scoped are loaded) - const loaded = (!this.state.isCourseScoped && this.props.revisionsLoaded) || (this.state.isCourseScoped && this.props.courseScopedRevisionsLoaded); - const revisions = this.state.isCourseScoped ? this.props.revisionsDisplayedCourseSpecific : this.props.revisionsDisplayed; - let metaDataLoading; - - if (!this.state.isCourseScoped) { - metaDataLoading = !this.props.referencesLoaded || !this.props.assessmentsLoaded; + const showMore = () => { + if (isCourseScoped) { + dispatch(fetchCourseScopedRevisions(course, courseScopedLimit + 100)); } else { - metaDataLoading = !this.props.courseSpecificReferencesLoaded || !this.props.courseSpecificAssessmentsLoaded; + dispatch(fetchRevisions(course)); } - let showMoreButton; - if ((!this.state.isCourseScoped && !this.props.limitReached) || (this.state.isCourseScoped && !this.props.courseScopedLimitReached)) { - showMoreButton =
; - } - - // we only fetch articles data for a max of 500 articles(for course specific revisions). - // If there are more than 500 articles, the toggle button is not shown - const revisionFilterButton =
; - const options = [ - { value: 'rating_num', label: I18n.t('revisions.class') }, - { value: 'title', label: I18n.t('revisions.title') }, - { value: 'revisor', label: I18n.t('revisions.edited_by') }, - { value: 'characters', label: I18n.t('revisions.chars_added') }, - { value: 'references_added', label: I18n.t('revisions.references') }, - { value: 'date', label: I18n.t('revisions.date_time') }, - ]; - return ( -
-
-

{I18n.t('application.recent_activity')}

- {this.props.course.article_count <= ARTICLE_FETCH_LIMIT && revisionFilterButton} -
-
- - {!loaded && } - {loaded && showMoreButton} - {loaded && metaDataLoading && }
- ); - } -}); - -const mapStateToProps = state => ({ - courseScopedLimitReached: state.revisions.courseScopedLimitReached, - limitReached: state.revisions.limitReached, - revisionsDisplayed: state.revisions.revisionsDisplayed, - revisionsDisplayedCourseSpecific: state.revisions.revisionsDisplayedCourseSpecific, - courseSpecificAssessmentsLoaded: state.revisions.courseSpecificAssessmentsLoaded, - wikidataLabels: state.wikidataLabels.labels, - courseScopedRevisionsLoaded: state.revisions.courseScopedRevisionsLoaded, - revisionsLoaded: state.revisions.revisionsLoaded, - sort: state.revisions.sort, - referencesLoaded: state.revisions.referencesLoaded, - assessmentsLoaded: state.revisions.assessmentsLoaded, - courseSpecificReferencesLoaded: state.revisions.courseSpecificReferencesLoaded -}); - -const mapDispatchToProps = { - fetchRevisions, - sortRevisions, - fetchCourseScopedRevisions + + {!loaded && } + {loaded && showMoreButton} + {loaded && metaDataLoading && } +
+ ); +}; + +RevisionHandler.propTypes = { + course: PropTypes.object, + courseScopedLimit: PropTypes.number, }; -export default connect(mapStateToProps, mapDispatchToProps)(RevisionHandler); +export default RevisionHandler; From 3eeda65af491f57f0781ed71903b7c8873a6363c Mon Sep 17 00:00:00 2001 From: Pratham Vaidya Date: Sun, 21 Apr 2024 17:33:11 +0530 Subject: [PATCH 057/198] refactor: text_area_input.jsx to functional component --- .../components/common/text_area_input.jsx | 238 +++++++++--------- 1 file changed, 120 insertions(+), 118 deletions(-) diff --git a/app/assets/javascripts/components/common/text_area_input.jsx b/app/assets/javascripts/components/common/text_area_input.jsx index 3b13315c59..d5e0e2a6a3 100644 --- a/app/assets/javascripts/components/common/text_area_input.jsx +++ b/app/assets/javascripts/components/common/text_area_input.jsx @@ -1,147 +1,149 @@ import { Editor } from '@tinymce/tinymce-react'; -import React from 'react'; +import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; -import InputHOC from '../high_order/input_hoc.jsx'; +import InputHOC from '../high_order/input_hoc'; +import markdown_it from '../../utils/markdown_it'; + +const md = markdown_it({ openLinksExternally: true }); -const md = require('../../utils/markdown_it.js').default({ openLinksExternally: true }); // This is a flexible text input box. It switches between edit and read mode, // and can either provide a wysiwyg editor or a plain text editor. -const TextAreaInput = createReactClass({ - displayName: 'TextAreaInput', - - propTypes: { - onChange: PropTypes.func, - onFocus: PropTypes.func, - onBlur: PropTypes.func, - value: PropTypes.string, - value_key: PropTypes.string, - editable: PropTypes.bool, // switch between read and edit mode - id: PropTypes.string, - focus: PropTypes.bool, - placeholder: PropTypes.string, - autoExpand: PropTypes.bool, // start with one line and expand as needed — plain text only - rows: PropTypes.string, // set the number of rows — plain text only - 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 - }, - getInitialState() { - return { tinymceLoaded: false }; - }, +const TextAreaInput = ({ + onChange, + onFocus, + onBlur, + value, + editable, + id, + focus, + placeholder, + autoExpand, + rows, + wysiwyg, + markdown, + className, + clearOnSubmit, + invalid +}) => { + const [tinymceLoaded, setTinymceLoaded] = useState(false); + const [activeEditor, setActiveEditor] = useState(null); - componentDidMount() { - if (this.props.wysiwyg) { - this.loadTinyMCE(); + useEffect(() => { + if (wysiwyg) { + loadTinyMCE(); } - }, + }, [wysiwyg]); - loadTinyMCE() { - const user_signed_in = Features.user_signed_in; + const loadTinyMCE = () => { + const user_signed_in = Features.user_signed_in; // Ensure Features is accessible if (user_signed_in) { import('../../tinymce').then(() => { - this.setState({ - tinymceLoaded: true - }); + setTinymceLoaded(true); }); } - }, + }; - handleRichTextEditorChange(e) { - this.props.onChange( - { target: { value: e } }, - e - ); - }, + const handleRichTextEditorChange = (e) => { + onChange({ target: { value: e } }, e); + }; - handleSubmit() { - if (this.props.clearOnSubmit) { - this.state.activeEditor.setContent(''); + const handleSubmit = () => { + if (clearOnSubmit) { + activeEditor.setContent(''); } - }, + }; - render() { - let inputElement; - let rawHtml; + let inputElement; + let rawHtml; - // //////////// - // Edit mode // - // //////////// - if (this.props.editable) { - let inputClass; - if (this.props.invalid) { - inputClass = 'invalid'; - } + // //////////// + // Edit mode // + // //////////// + if (editable) { + let inputClass = ''; + if (invalid) { + inputClass = 'invalid'; + } - // Use TinyMCE if props.wysiwyg, otherwise, use a basic textarea. - if (this.props.wysiwyg && this.state.tinymceLoaded) { - inputElement = ( - { this.setState({ activeEditor: editor }); }, - inline: true, - convert_urls: false, - plugins: 'lists link code', - toolbar: [ - 'undo redo | styleselect | bold italic', - 'alignleft alignright', - 'bullist numlist outdent indent', - 'link' - ], - }} - /> - ); - } else { - inputElement = ( -
Date: {I18n.t('uploads.date')}  {formatDateWithoutTime(upload.uploaded_at)}
Author: {I18n.t('uploads.author')}  {author}
Source: {I18n.t('uploads.source')} 
License: {I18n.t('uploads.license')}  {license} {'\n'}