diff --git a/client/app/bundles/course/lesson-plan/constants.js b/client/app/bundles/course/lesson-plan/constants.js deleted file mode 100644 index 126cc9addb1..00000000000 --- a/client/app/bundles/course/lesson-plan/constants.js +++ /dev/null @@ -1,50 +0,0 @@ -import mirrorCreator from 'mirror-creator'; - -export const formNames = mirrorCreator(['EVENT', 'MILESTONE']); - -export const fields = mirrorCreator([ - 'ITEM_TYPE', - 'TITLE', - 'START_AT', - 'BONUS_END_AT', - 'END_AT', - 'PUBLISHED', - 'LOCATION', - 'DESCRIPTION', - 'EVENT_TYPE', -]); - -const actionTypes = mirrorCreator([ - 'SET_ITEM_TYPE_VISIBILITY', - 'SET_COLUMN_VISIBILITY', - 'LOAD_LESSON_PLAN_REQUEST', - 'LOAD_LESSON_PLAN_SUCCESS', - 'LOAD_LESSON_PLAN_FAILURE', - 'ITEM_UPDATE_REQUEST', - 'ITEM_UPDATE_SUCCESS', - 'ITEM_UPDATE_FAILURE', - 'EVENT_FORM_SHOW', - 'EVENT_FORM_HIDE', - 'EVENT_UPDATE_REQUEST', - 'EVENT_UPDATE_SUCCESS', - 'EVENT_UPDATE_FAILURE', - 'EVENT_CREATE_REQUEST', - 'EVENT_CREATE_SUCCESS', - 'EVENT_CREATE_FAILURE', - 'EVENT_DELETE_REQUEST', - 'EVENT_DELETE_SUCCESS', - 'EVENT_DELETE_FAILURE', - 'MILESTONE_FORM_SHOW', - 'MILESTONE_FORM_HIDE', - 'MILESTONE_UPDATE_REQUEST', - 'MILESTONE_UPDATE_SUCCESS', - 'MILESTONE_UPDATE_FAILURE', - 'MILESTONE_CREATE_REQUEST', - 'MILESTONE_CREATE_SUCCESS', - 'MILESTONE_CREATE_FAILURE', - 'MILESTONE_DELETE_REQUEST', - 'MILESTONE_DELETE_SUCCESS', - 'MILESTONE_DELETE_FAILURE', -]); - -export default actionTypes; diff --git a/client/app/bundles/course/lesson-plan/constants.ts b/client/app/bundles/course/lesson-plan/constants.ts new file mode 100644 index 00000000000..adb24530249 --- /dev/null +++ b/client/app/bundles/course/lesson-plan/constants.ts @@ -0,0 +1,13 @@ +import mirrorCreator from 'mirror-creator'; + +export const fields = mirrorCreator([ + 'ITEM_TYPE', + 'TITLE', + 'START_AT', + 'BONUS_END_AT', + 'END_AT', + 'PUBLISHED', + 'LOCATION', + 'DESCRIPTION', + 'EVENT_TYPE', +]); diff --git a/client/app/bundles/course/lesson-plan/containers/EventFormDialog/index.jsx b/client/app/bundles/course/lesson-plan/containers/EventFormDialog/index.jsx deleted file mode 100644 index e0b89c0a2dd..00000000000 --- a/client/app/bundles/course/lesson-plan/containers/EventFormDialog/index.jsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useState } from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; - -import FormDialogue from 'lib/components/form/FormDialogue'; - -import { actions } from '../../store'; - -import EventForm from './EventForm'; - -const EventFormDialog = ({ - visible, - disabled, - formTitle, - initialValues, - onSubmit, - dispatch, - items, -}) => { - const [isDirty, setIsDirty] = useState(false); - - const { eventTypes, eventLocations } = items.reduce( - (values, item) => { - if (!item.eventId) { - return values; - } - if (item.location) { - values.eventLocations.push(item.location); - } - values.eventTypes.push(item.lesson_plan_item_type[0]); - return values; - }, - { eventTypes: [], eventLocations: [] }, - ); - - return ( - dispatch(actions.hideEventForm())} - open={visible} - skipConfirmation={!isDirty} - title={formTitle} - > - - - ); -}; - -EventFormDialog.defaultProps = { - visible: false, - disabled: false, -}; - -EventFormDialog.propTypes = { - visible: PropTypes.bool, - disabled: PropTypes.bool, - formTitle: PropTypes.string, - initialValues: PropTypes.shape({ - id: PropTypes.number, - eventId: PropTypes.number, - title: PropTypes.string, - event_type: PropTypes.string, - location: PropTypes.string, - description: PropTypes.string, - start_at: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.instanceOf(Date), - ]), - end_at: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), - published: PropTypes.bool, - }), - items: PropTypes.arrayOf( - PropTypes.shape({ - eventId: PropTypes.number, - location: PropTypes.string, - lesson_plan_item_type: PropTypes.arrayOf(PropTypes.string), - }), - ), - onSubmit: PropTypes.func.isRequired, - dispatch: PropTypes.func.isRequired, -}; - -export default connect(({ lessonPlan }) => ({ - ...lessonPlan.eventForm, - items: lessonPlan.lessonPlan.items, -}))(EventFormDialog); diff --git a/client/app/bundles/course/lesson-plan/containers/EventFormDialog/index.tsx b/client/app/bundles/course/lesson-plan/containers/EventFormDialog/index.tsx new file mode 100644 index 00000000000..be0d3d23fdf --- /dev/null +++ b/client/app/bundles/course/lesson-plan/containers/EventFormDialog/index.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; + +import FormDialogue from 'lib/components/form/FormDialogue'; +import { useAppSelector } from 'lib/hooks/store'; + +import { EventFormValues, FormSubmitHandler } from '../../types'; + +import EventForm from './EventForm'; + +interface EventFormDialogProps { + open: boolean; + onClose: () => void; + formTitle?: string; + initialValues: EventFormValues; + onSubmit: FormSubmitHandler; +} + +interface EventSuggestions { + eventTypes: string[]; + eventLocations: string[]; +} + +/** + * Controlled by whoever opens it: the owner supplies the handler and the initial + * values, and the dialog closes itself once `onSubmit` reports success. The + * handler used to be stashed in the Redux store, which is not serialisable. + * + * The existing event types and locations are still read from the store, since + * they are derived from the lesson plan itself rather than from the opener. + */ +const EventFormDialog = (props: EventFormDialogProps): JSX.Element => { + const { open, onClose, formTitle, initialValues, onSubmit } = props; + + const [isDirty, setIsDirty] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const items = useAppSelector((state) => state.lessonPlan.lessonPlan.items); + + const { eventTypes, eventLocations } = items.reduce( + (values, item) => { + if (!item.eventId) { + return values; + } + if (item.location) { + values.eventLocations.push(item.location); + } + if (item.lesson_plan_item_type?.[0]) { + values.eventTypes.push(item.lesson_plan_item_type[0]); + } + return values; + }, + { eventTypes: [], eventLocations: [] }, + ); + + const handleSubmit: FormSubmitHandler = async ( + data, + setError, + ) => { + setSubmitting(true); + try { + const succeeded = await onSubmit(data, setError); + if (succeeded) onClose(); + return succeeded; + } finally { + setSubmitting(false); + } + }; + + return ( + + + + ); +}; + +export default EventFormDialog; diff --git a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/EnterEditModeButton.jsx b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/EnterEditModeButton.tsx similarity index 62% rename from client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/EnterEditModeButton.jsx rename to client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/EnterEditModeButton.tsx index 0441c9bcbdc..91024b24e28 100644 --- a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/EnterEditModeButton.jsx +++ b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/EnterEditModeButton.tsx @@ -1,8 +1,9 @@ -import { defineMessages, FormattedMessage } from 'react-intl'; +import { defineMessages } from 'react-intl'; import { useNavigate } from 'react-router-dom'; import { Button } from '@mui/material'; import { getCourseId } from 'lib/helpers/url-helpers'; +import useTranslation from 'lib/hooks/useTranslation'; const translations = defineMessages({ enterEditMode: { @@ -11,15 +12,17 @@ const translations = defineMessages({ }, }); -const EnterEditModeButton = () => { +const EnterEditModeButton = (): JSX.Element => { + const { t } = useTranslation(); const navigate = useNavigate(); const courseId = getCourseId(); + return ( navigate(`/courses/${courseId}/lesson_plan/edit`)} + onClick={(): void => navigate(`/courses/${courseId}/lesson_plan/edit`)} variant="outlined" > - + {t(translations.enterEditMode)} ); }; diff --git a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewEventButton.jsx b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewEventButton.jsx deleted file mode 100644 index b1b05ce01d4..00000000000 --- a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewEventButton.jsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; - -import AddButton from 'lib/components/core/buttons/AddButton'; - -import { createEvent } from '../../operations'; -import { actions } from '../../store'; - -const translations = defineMessages({ - newEvent: { - id: 'course.lessonPlan.LessonPlanLayout.NewEventButton.newEvent', - defaultMessage: 'New Event', - }, - success: { - id: 'course.lessonPlan.LessonPlanLayout.NewEventButton.success', - defaultMessage: 'Event created.', - }, - failure: { - id: 'course.lessonPlan.LessonPlanLayout.NewEventButton.failure', - defaultMessage: 'Failed to create event.', - }, -}); - -class NewEventButton extends Component { - createEventHandler = (data) => { - const { dispatch } = this.props; - const successMessage = ; - const failureMessage = ; - return dispatch(createEvent(data, successMessage, failureMessage)); - }; - - showForm = () => { - const { dispatch, intl } = this.props; - return dispatch( - actions.showEventForm({ - onSubmit: this.createEventHandler, - formTitle: intl.formatMessage(translations.newEvent), - initialValues: { - title: '', - event_type: '', - location: '', - description: '', - start_at: null, - end_at: null, - published: false, - }, - }), - ); - }; - - render() { - if (!this.props.canManageLessonPlan) return null; - - const { intl } = this.props; - - return ( - - {intl.formatMessage(translations.newEvent)} - - ); - } -} - -NewEventButton.propTypes = { - canManageLessonPlan: PropTypes.bool.isRequired, - - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, -}; - -export default connect(({ lessonPlan }) => ({ - canManageLessonPlan: lessonPlan.flags.canManageLessonPlan, -}))(injectIntl(NewEventButton)); diff --git a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewEventButton.tsx b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewEventButton.tsx new file mode 100644 index 00000000000..2b5e9487baa --- /dev/null +++ b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewEventButton.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; + +import AddButton from 'lib/components/core/buttons/AddButton'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { createEvent } from '../../operations'; +import { EventFormValues, FormSubmitHandler } from '../../types'; +import EventFormDialog from '../EventFormDialog'; + +const translations = defineMessages({ + newEvent: { + id: 'course.lessonPlan.LessonPlanLayout.NewEventButton.newEvent', + defaultMessage: 'New Event', + }, + success: { + id: 'course.lessonPlan.LessonPlanLayout.NewEventButton.success', + defaultMessage: 'Event created.', + }, + failure: { + id: 'course.lessonPlan.LessonPlanLayout.NewEventButton.failure', + defaultMessage: 'Failed to create event.', + }, +}); + +const initialValues: EventFormValues = { + title: '', + event_type: '', + location: '', + description: '', + start_at: null, + end_at: null, + published: false, +}; + +const NewEventButton = (): JSX.Element | null => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const canManageLessonPlan = useAppSelector( + (state) => state.lessonPlan.flags.canManageLessonPlan, + ); + + const [formVisible, setFormVisible] = useState(false); + + const createEventHandler: FormSubmitHandler = ( + data, + setError, + ) => + dispatch( + createEvent( + data, + t(translations.success), + t(translations.failure), + setError, + ), + ); + + if (!canManageLessonPlan) return null; + + return ( + <> + setFormVisible(true)}> + {t(translations.newEvent)} + + + setFormVisible(false)} + onSubmit={createEventHandler} + open={formVisible} + /> + > + ); +}; + +export default NewEventButton; diff --git a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewMilestoneButton.jsx b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewMilestoneButton.jsx deleted file mode 100644 index 8bdf7b29d1b..00000000000 --- a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewMilestoneButton.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; - -import AddButton from 'lib/components/core/buttons/AddButton'; - -import { createMilestone } from '../../operations'; -import { actions } from '../../store'; - -const translations = defineMessages({ - newMilestone: { - id: 'course.lessonPlan.LessonPlanLayout.NewMilestoneButton.newMilestone', - defaultMessage: 'New Milestone', - }, - success: { - id: 'course.lessonPlan.LessonPlanLayout.NewMilestoneButton.success', - defaultMessage: 'Milestone created.', - }, - failure: { - id: 'course.lessonPlan.LessonPlanLayout.NewMilestoneButton.failure', - defaultMessage: 'Failed to create milestone.', - }, -}); - -class NewMilestoneButton extends Component { - createMilestoneHandler = (data, setError) => { - const { dispatch } = this.props; - const successMessage = ; - const failureMessage = ; - return dispatch( - createMilestone(data, successMessage, failureMessage, setError), - ); - }; - - showForm = () => { - const { dispatch, intl } = this.props; - return dispatch( - actions.showMilestoneForm({ - onSubmit: this.createMilestoneHandler, - formTitle: intl.formatMessage(translations.newMilestone), - initialValues: { title: '', description: '', start_at: null }, - }), - ); - }; - - render() { - if (!this.props.canManageLessonPlan) return null; - - const { intl } = this.props; - - return ( - - {intl.formatMessage(translations.newMilestone)} - - ); - } -} - -NewMilestoneButton.propTypes = { - canManageLessonPlan: PropTypes.bool.isRequired, - - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, -}; - -export default connect(({ lessonPlan }) => ({ - canManageLessonPlan: lessonPlan.flags.canManageLessonPlan, -}))(injectIntl(NewMilestoneButton)); diff --git a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewMilestoneButton.tsx b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewMilestoneButton.tsx new file mode 100644 index 00000000000..328212cdd77 --- /dev/null +++ b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewMilestoneButton.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; + +import AddButton from 'lib/components/core/buttons/AddButton'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { createMilestone } from '../../operations'; +import { FormSubmitHandler, MilestoneFormValues } from '../../types'; +import MilestoneFormDialog from '../MilestoneFormDialog'; + +const translations = defineMessages({ + newMilestone: { + id: 'course.lessonPlan.LessonPlanLayout.NewMilestoneButton.newMilestone', + defaultMessage: 'New Milestone', + }, + success: { + id: 'course.lessonPlan.LessonPlanLayout.NewMilestoneButton.success', + defaultMessage: 'Milestone created.', + }, + failure: { + id: 'course.lessonPlan.LessonPlanLayout.NewMilestoneButton.failure', + defaultMessage: 'Failed to create milestone.', + }, +}); + +const initialValues: MilestoneFormValues = { + title: '', + description: '', + start_at: null, +}; + +const NewMilestoneButton = (): JSX.Element | null => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const canManageLessonPlan = useAppSelector( + (state) => state.lessonPlan.flags.canManageLessonPlan, + ); + + const [formVisible, setFormVisible] = useState(false); + + const createMilestoneHandler: FormSubmitHandler = ( + data, + setError, + ) => + dispatch( + createMilestone( + data, + t(translations.success), + t(translations.failure), + setError, + ), + ); + + if (!canManageLessonPlan) return null; + + return ( + <> + setFormVisible(true)}> + {t(translations.newMilestone)} + + + setFormVisible(false)} + onSubmit={createMilestoneHandler} + open={formVisible} + /> + > + ); +}; + +export default NewMilestoneButton; diff --git a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewEventButton.test.jsx b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewEventButton.test.tsx similarity index 83% rename from client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewEventButton.test.jsx rename to client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewEventButton.test.tsx index 861387c7ffc..ed28deac43f 100644 --- a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewEventButton.test.jsx +++ b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewEventButton.test.tsx @@ -1,13 +1,16 @@ +import { AppState } from 'store'; import { fireEvent, render, waitFor } from 'test-utils'; import CourseAPI from 'api/course'; -import EventFormDialog from 'course/lesson-plan/containers/EventFormDialog'; import NewEventButton from '../NewEventButton'; +// `Partial` only allows omitting whole slices, and these tests seed +// just the few fields the component reads, so the shape is asserted. + const state = { lessonPlan: { flags: { canManageLessonPlan: true } }, -}; +} as unknown as Partial; const startAt = '01-01-2017 12:12'; @@ -25,13 +28,7 @@ describe('', () => { it('allows event to be created via EventFormDialog', async () => { const spyCreate = jest.spyOn(CourseAPI.lessonPlan, 'createEvent'); - const page = render( - <> - - - >, - { state }, - ); + const page = render(, { state }); fireEvent.click(await page.findByRole('button', { name: 'New Event' })); diff --git a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewMilestoneButton.test.jsx b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewMilestoneButton.test.tsx similarity index 82% rename from client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewMilestoneButton.test.jsx rename to client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewMilestoneButton.test.tsx index 44cb7217f35..416cb77c8da 100644 --- a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewMilestoneButton.test.jsx +++ b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewMilestoneButton.test.tsx @@ -1,7 +1,7 @@ +import { AppState } from 'store'; import { fireEvent, render, waitFor } from 'test-utils'; import CourseAPI from 'api/course'; -import MilestoneFormDialog from 'course/lesson-plan/containers/MilestoneFormDialog'; import NewMilestoneButton from '../NewMilestoneButton'; @@ -13,21 +13,18 @@ const milestoneData = { start_at: new Date(startAt), }; +// `Partial` only allows omitting whole slices, and these tests seed +// just the few fields the component reads, so the shape is asserted. + const state = { lessonPlan: { flags: { canManageLessonPlan: true } }, -}; +} as unknown as Partial; describe('', () => { it('allows milestone to be created via MilestoneFormDialog', async () => { const spyCreate = jest.spyOn(CourseAPI.lessonPlan, 'createMilestone'); - const page = render( - <> - - - >, - { state }, - ); + const page = render(, { state }); fireEvent.click(await page.findByRole('button', { name: 'New Milestone' })); diff --git a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/index.test.jsx b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/index.test.tsx similarity index 100% rename from client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/index.test.jsx rename to client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/index.test.tsx diff --git a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/index.jsx b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/index.jsx deleted file mode 100644 index 390168beab7..00000000000 --- a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/index.jsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Component } from 'react'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { Outlet } from 'react-router-dom'; -import { ListSubheader } from '@mui/material'; -import PropTypes from 'prop-types'; - -import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import DeleteConfirmation from 'lib/containers/DeleteConfirmation'; -import { lessonPlanTypesGroups } from 'lib/types'; - -import { fetchLessonPlan } from '../../operations'; -import translations from '../../translations'; -import EventFormDialog from '../EventFormDialog'; -import LessonPlanFilter from '../LessonPlanFilter'; -import LessonPlanNav from '../LessonPlanNav'; -import MilestoneFormDialog from '../MilestoneFormDialog'; - -const styles = { - tools: { - position: 'fixed', - bottom: 12, - right: 24, - display: 'flex', - justifyContent: 'flex-end', - zIndex: 1, - }, - mainBody: { - // Allow end part of table to be unobstructed when scrolled all the way to the bottom - marginBottom: 100, - }, -}; - -class LessonPlanLayout extends Component { - componentDidMount() { - const { dispatch } = this.props; - dispatch(fetchLessonPlan()); - } - - render() { - const { isLoading, groups } = this.props; - - if (isLoading) return ; - - if (!groups || groups.length < 1) - return ( - - - - ); - - return ( - - - - - - - - - - - - - ); - } -} - -LessonPlanLayout.propTypes = { - isLoading: PropTypes.bool.isRequired, - groups: lessonPlanTypesGroups.isRequired, - dispatch: PropTypes.func.isRequired, - children: PropTypes.node.isRequired, -}; - -const handle = translations.lessonPlan; - -export default Object.assign( - connect(({ lessonPlan }) => ({ - isLoading: lessonPlan.lessonPlan.isLoading, - groups: lessonPlan.lessonPlan.groups, - }))(LessonPlanLayout), - { handle }, -); diff --git a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/index.tsx b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/index.tsx new file mode 100644 index 00000000000..207a6089fad --- /dev/null +++ b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/index.tsx @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; +import { Outlet } from 'react-router-dom'; +import { ListSubheader } from '@mui/material'; + +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import DeleteConfirmation from 'lib/containers/DeleteConfirmation'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { fetchLessonPlan } from '../../operations'; +import translations from '../../translations'; +import LessonPlanFilter from '../LessonPlanFilter'; +import LessonPlanNav from '../LessonPlanNav'; + +const styles = { + tools: { + position: 'fixed' as const, + bottom: 12, + right: 24, + display: 'flex', + justifyContent: 'flex-end', + zIndex: 1, + }, + mainBody: { + // Allow end part of table to be unobstructed when scrolled all the way to the bottom + marginBottom: 100, + }, +}; + +const LessonPlanLayout = (): JSX.Element => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const { isLoading, groups } = useAppSelector( + (state) => state.lessonPlan.lessonPlan, + ); + + useEffect(() => { + dispatch(fetchLessonPlan()); + }, []); + + if (isLoading) return ; + + if (!groups || groups.length < 1) + return {t(translations.empty)}; + + return ( + + + + + + + + + + + ); +}; + +const handle = translations.lessonPlan; + +export default Object.assign(LessonPlanLayout, { handle }); diff --git a/client/app/bundles/course/lesson-plan/containers/MilestoneFormDialog/index.jsx b/client/app/bundles/course/lesson-plan/containers/MilestoneFormDialog/index.jsx deleted file mode 100644 index 6c55e855290..00000000000 --- a/client/app/bundles/course/lesson-plan/containers/MilestoneFormDialog/index.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useState } from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; - -import FormDialogue from 'lib/components/form/FormDialogue'; - -import { actions } from '../../store'; - -import MilestoneForm from './MilestoneForm'; - -const MilestoneFormDialog = ({ - visible, - disabled, - formTitle, - initialValues, - onSubmit, - dispatch, -}) => { - const [isDirty, setIsDirty] = useState(false); - - return ( - dispatch(actions.hideMilestoneForm())} - open={visible} - skipConfirmation={!isDirty} - title={formTitle} - > - - - ); -}; - -MilestoneFormDialog.defaultProps = { - visible: false, - disabled: false, -}; - -MilestoneFormDialog.propTypes = { - visible: PropTypes.bool, - disabled: PropTypes.bool, - formTitle: PropTypes.string, - initialValues: PropTypes.shape({ - title: PropTypes.string, - description: PropTypes.string, - start_at: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.instanceOf(Date), - ]), - }), - onSubmit: PropTypes.func.isRequired, - dispatch: PropTypes.func.isRequired, -}; - -export default connect(({ lessonPlan }) => ({ - ...lessonPlan.milestoneForm, -}))(MilestoneFormDialog); diff --git a/client/app/bundles/course/lesson-plan/containers/MilestoneFormDialog/index.tsx b/client/app/bundles/course/lesson-plan/containers/MilestoneFormDialog/index.tsx new file mode 100644 index 00000000000..5f814702f0b --- /dev/null +++ b/client/app/bundles/course/lesson-plan/containers/MilestoneFormDialog/index.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; + +import FormDialogue from 'lib/components/form/FormDialogue'; + +import { FormSubmitHandler, MilestoneFormValues } from '../../types'; + +import MilestoneForm from './MilestoneForm'; + +interface MilestoneFormDialogProps { + open: boolean; + onClose: () => void; + formTitle?: string; + initialValues: MilestoneFormValues; + onSubmit: FormSubmitHandler; +} + +/** + * Controlled by whoever opens it: the owner supplies the handler and the initial + * values, and the dialog closes itself once `onSubmit` reports success. The + * handler used to be stashed in the Redux store, which is not serialisable. + */ +const MilestoneFormDialog = (props: MilestoneFormDialogProps): JSX.Element => { + const { open, onClose, formTitle, initialValues, onSubmit } = props; + + const [isDirty, setIsDirty] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit: FormSubmitHandler = async ( + data, + setError, + ) => { + setSubmitting(true); + try { + const succeeded = await onSubmit(data, setError); + if (succeeded) onClose(); + return succeeded; + } finally { + setSubmitting(false); + } + }; + + return ( + + + + ); +}; + +export default MilestoneFormDialog; diff --git a/client/app/bundles/course/lesson-plan/operations.ts b/client/app/bundles/course/lesson-plan/operations.ts index 7c33ec50908..021ce2ee4df 100644 --- a/client/app/bundles/course/lesson-plan/operations.ts +++ b/client/app/bundles/course/lesson-plan/operations.ts @@ -4,25 +4,18 @@ import CourseAPI from 'api/course'; import { setNotification } from 'lib/actions'; import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper'; -import actionTypes from './constants'; import { actions } from './store'; export function fetchLessonPlan(): Operation { return async (dispatch) => { - dispatch({ type: actionTypes.LOAD_LESSON_PLAN_REQUEST }); + dispatch(actions.loadRequested()); return CourseAPI.lessonPlan .fetch() .then((response) => { - dispatch({ - type: actionTypes.LOAD_LESSON_PLAN_SUCCESS, - items: response.data.items, - milestones: response.data.milestones, - flags: response.data.flags, - visibilitySettings: response.data.visibilitySettings, - }); + dispatch(actions.loadSucceeded(response.data)); }) .catch(() => { - dispatch({ type: actionTypes.LOAD_LESSON_PLAN_FAILURE }); + dispatch(actions.loadFailed()); }); }; } @@ -32,25 +25,21 @@ export function createMilestone( successMessage, failureMessage, setError, -): Operation { +): Operation { return async (dispatch) => { - dispatch({ type: actionTypes.MILESTONE_CREATE_REQUEST }); return CourseAPI.lessonPlan .createMilestone({ lesson_plan_milestone: values }) .then((response) => { - dispatch({ - type: actionTypes.MILESTONE_CREATE_SUCCESS, - milestone: response.data, - }); - dispatch(actions.hideMilestoneForm()); + dispatch(actions.milestoneCreated(response.data)); setNotification(successMessage)(dispatch); + return true; }) .catch((error) => { - dispatch({ type: actionTypes.MILESTONE_CREATE_FAILURE }); setNotification(failureMessage)(dispatch); if (error?.response?.data?.errors) { setReactHookFormError(setError, error.response.data.errors); } + return false; }); }; } @@ -61,44 +50,34 @@ export function updateMilestone( successMessage, failureMessage, setError, -): Operation { +): Operation { return async (dispatch) => { - dispatch({ type: actionTypes.MILESTONE_UPDATE_REQUEST }); return CourseAPI.lessonPlan .updateMilestone(id, { lesson_plan_milestone: values }) .then((response) => { - dispatch({ - type: actionTypes.MILESTONE_UPDATE_SUCCESS, - milestoneId: id, - milestone: response.data, - }); - dispatch(actions.hideMilestoneForm()); + dispatch(actions.milestoneUpdated(response.data)); setNotification(successMessage)(dispatch); + return true; }) .catch((error) => { - dispatch({ type: actionTypes.MILESTONE_UPDATE_FAILURE }); setNotification(failureMessage)(dispatch); if (error?.response?.data?.errors && setError) { setReactHookFormError(setError, error.response.data.errors); } + return false; }); }; } export function deleteMilestone(id, successMessage, failureMessage): Operation { return async (dispatch) => { - dispatch({ type: actionTypes.MILESTONE_DELETE_REQUEST }); return CourseAPI.lessonPlan .deleteMilestone(id) .then(() => { - dispatch({ - type: actionTypes.MILESTONE_DELETE_SUCCESS, - milestoneId: id, - }); + dispatch(actions.milestoneDeleted(id)); setNotification(successMessage)(dispatch); }) .catch(() => { - dispatch({ type: actionTypes.MILESTONE_DELETE_FAILURE }); setNotification(failureMessage)(dispatch); }); }; @@ -111,18 +90,13 @@ export function updateItem( failureMessage, ): Operation { return async (dispatch) => { - dispatch({ type: actionTypes.ITEM_UPDATE_REQUEST }); return CourseAPI.lessonPlan .updateItem(id, { item: values }) .then(() => { - dispatch({ - type: actionTypes.ITEM_UPDATE_SUCCESS, - item: { id, ...values }, - }); + dispatch(actions.itemUpdated({ id, ...values })); setNotification(successMessage)(dispatch); }) .catch(() => { - dispatch({ type: actionTypes.ITEM_UPDATE_FAILURE }); setNotification(failureMessage)(dispatch); }); }; @@ -133,25 +107,21 @@ export function createEvent( successMessage, failureMessage, setError, -): Operation { +): Operation { return async (dispatch) => { - dispatch({ type: actionTypes.EVENT_CREATE_REQUEST }); return CourseAPI.lessonPlan .createEvent({ lesson_plan_event: values }) .then((response) => { - dispatch({ - type: actionTypes.EVENT_CREATE_SUCCESS, - event: response.data, - }); - dispatch(actions.hideEventForm()); + dispatch(actions.eventCreated(response.data)); setNotification(successMessage)(dispatch); + return true; }) .catch((error) => { - dispatch({ type: actionTypes.EVENT_CREATE_FAILURE }); setNotification(failureMessage)(dispatch); if (error?.response?.data?.errors) { setReactHookFormError(setError, error.response.data.errors); } + return false; }); }; } @@ -162,26 +132,21 @@ export function updateEvent( successMessage, failureMessage, setError, -): Operation { +): Operation { return async (dispatch) => { - dispatch({ type: actionTypes.EVENT_UPDATE_REQUEST }); return CourseAPI.lessonPlan .updateEvent(eventId, { lesson_plan_event: values }) .then((response) => { - dispatch({ - type: actionTypes.EVENT_UPDATE_SUCCESS, - eventId, - event: response.data, - }); - dispatch(actions.hideEventForm()); + dispatch(actions.eventUpdated(response.data)); setNotification(successMessage)(dispatch); + return true; }) .catch((error) => { - dispatch({ type: actionTypes.EVENT_UPDATE_FAILURE }); setNotification(failureMessage)(dispatch); if (error?.response?.data?.errors) { setReactHookFormError(setError, error.response.data.errors); } + return false; }); }; } @@ -193,18 +158,13 @@ export function deleteEvent( failureMessage, ): Operation { return async (dispatch) => { - dispatch({ type: actionTypes.EVENT_DELETE_REQUEST }); return CourseAPI.lessonPlan .deleteEvent(eventId) .then(() => { - dispatch({ - type: actionTypes.EVENT_DELETE_SUCCESS, - itemId, - }); + dispatch(actions.eventDeleted(itemId)); setNotification(successMessage)(dispatch); }) .catch(() => { - dispatch({ type: actionTypes.EVENT_DELETE_FAILURE }); setNotification(failureMessage)(dispatch); }); }; diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/DateCell.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/DateCell.jsx deleted file mode 100644 index 687f5b612fb..00000000000 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/DateCell.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Component } from 'react'; -import PropTypes from 'prop-types'; - -import DateTimePicker from 'lib/components/core/fields/DateTimePicker'; -import moment from 'lib/moment'; - -const sameDate = (a, b) => - (!a && !b) || (a && b && moment(a).isSame(b, 'minute')); -const datePropType = PropTypes.oneOfType([ - PropTypes.string, - PropTypes.instanceOf(Date), -]); - -class DateCell extends Component { - /** - * Updates a date value for a lesson plan item if the date has changed. - * If it is start_at that is shifted, shift existing end dates by the same amount. - */ - updateItemDate = (_, newDate) => { - const { - fieldValue: oldDate, - fieldName, - updateItem, - startAt, - endAt, - bonusEndAt, - } = this.props; - - if (sameDate(oldDate, newDate)) { - return; - } - - const payload = { [fieldName]: moment(newDate).toISOString() }; - if (startAt && fieldName === 'start_at') { - const timeShift = moment.duration(moment(newDate).diff(moment(startAt))); - - if (endAt) { - const shiftedDate = moment(endAt); - shiftedDate.add(timeShift); - payload.end_at = shiftedDate.toISOString(); - } - - if (bonusEndAt) { - const shiftedDate = moment(bonusEndAt); - shiftedDate.add(timeShift); - payload.bonus_end_at = shiftedDate.toISOString(); - } - } - updateItem(payload); - }; - - render() { - const { fieldName, fieldValue } = this.props; - - return ( - - - - ); - } -} - -DateCell.propTypes = { - fieldValue: datePropType, - fieldName: PropTypes.string.isRequired, - startAt: datePropType.isRequired, - endAt: datePropType, - bonusEndAt: datePropType, - updateItem: PropTypes.func.isRequired, -}; - -export default DateCell; diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/DateCell.tsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/DateCell.tsx new file mode 100644 index 00000000000..5546f63a0de --- /dev/null +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/DateCell.tsx @@ -0,0 +1,63 @@ +import DateTimePicker from 'lib/components/core/fields/DateTimePicker'; +import moment from 'lib/moment'; + +import { + LessonPlanDate, + LessonPlanItemUpdate, + LessonPlanItemUpdateField, +} from '../../../types'; + +interface DateCellProps { + fieldName: Exclude; + fieldValue?: LessonPlanDate; + startAt: LessonPlanDate; + bonusEndAt?: LessonPlanDate; + endAt?: LessonPlanDate; + updateItem: (payload: LessonPlanItemUpdate) => void; +} + +/** + * Renders one datetime field of a lesson plan item. Changes are handed to + * `updateItem`, which is responsible for coalescing and sending them — this + * component deliberately does not talk to the server itself. + */ +const DateCell = (props: DateCellProps): JSX.Element => { + const { fieldName, fieldValue, startAt, bonusEndAt, endAt, updateItem } = + props; + + /** + * Reports a new value for this field. If it is start_at that is shifted, the + * existing end dates are shifted by the same amount. + */ + const updateItemDate = (_, newDate: Date | null): void => { + const payload: LessonPlanItemUpdate = { + [fieldName]: newDate ? moment(newDate).toISOString() : null, + }; + + if (startAt && fieldName === 'start_at' && newDate) { + const timeShift = moment.duration(moment(newDate).diff(moment(startAt))); + + if (endAt) { + payload.end_at = moment(endAt).add(timeShift).toISOString(); + } + + if (bonusEndAt) { + payload.bonus_end_at = moment(bonusEndAt).add(timeShift).toISOString(); + } + } + + updateItem(payload); + }; + + return ( + + + + ); +}; + +export default DateCell; diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/PublishedCell.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/PublishedCell.jsx deleted file mode 100644 index 99e56529e9a..00000000000 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/PublishedCell.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Switch } from '@mui/material'; -import PropTypes from 'prop-types'; - -const styles = { - toggle: { - zIndex: 1, - }, -}; - -const PublishedCell = (props) => { - const { published, onToggle } = props; - return ( - - - - ); -}; - -PublishedCell.propTypes = { - published: PropTypes.bool.isRequired, - onToggle: PropTypes.func.isRequired, -}; - -export default PublishedCell; diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/PublishedCell.tsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/PublishedCell.tsx new file mode 100644 index 00000000000..7ab5b4e4598 --- /dev/null +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/PublishedCell.tsx @@ -0,0 +1,32 @@ +import { ChangeEvent } from 'react'; +import { Switch } from '@mui/material'; + +const styles = { + toggle: { + zIndex: 1, + }, +}; + +interface PublishedCellProps { + published: boolean; + onToggle: (event: ChangeEvent, isToggled: boolean) => void; + disabled?: boolean; +} + +const PublishedCell = (props: PublishedCellProps): JSX.Element => { + const { published, onToggle, disabled } = props; + + return ( + + + + ); +}; + +export default PublishedCell; diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/index.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/index.jsx deleted file mode 100644 index c8e764e108d..00000000000 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/index.jsx +++ /dev/null @@ -1,122 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; - -import Link from 'lib/components/core/Link'; - -import { fields } from '../../../constants'; -import TranslatedItemType from '../../../containers/TranslatedItemType'; -import { updateItem } from '../../../operations'; - -import DateCell from './DateCell'; -import PublishedCell from './PublishedCell'; - -const translations = defineMessages({ - updateSuccess: { - id: 'course.lessonPlan.LessonPlanEdit.ItemRow.updateSuccess', - defaultMessage: '"{title}" was updated.', - }, - updateFailed: { - id: 'course.lessonPlan.LessonPlanEdit.ItemRow.updateFailed', - defaultMessage: 'Failed to update {title}.', - }, -}); - -const datePropType = PropTypes.oneOfType([ - PropTypes.string, - PropTypes.instanceOf(Date), -]); - -class ItemRow extends Component { - updateItem = (payload) => { - const { id, title, dispatch } = this.props; - const successMessage = ( - - ); - const failureMessage = ( - - ); - dispatch(updateItem(id, payload, successMessage, failureMessage)); - }; - - updatePublished = (_, isToggled) => this.updateItem({ published: isToggled }); - - render() { - const { - type, - title, - startAt, - bonusEndAt, - endAt, - published, - visibility, - columnsVisible, - itemPath, - } = this.props; - - const isHidden = !visibility[type]; - if (isHidden) { - return null; - } - - const dateProps = { - startAt, - bonusEndAt, - endAt, - updateItem: this.updateItem, - }; - - return ( - - {columnsVisible[fields.ITEM_TYPE] ? ( - - - - ) : null} - - {title} - - {columnsVisible[fields.START_AT] ? ( - - ) : null} - {columnsVisible[fields.BONUS_END_AT] ? ( - - ) : null} - {columnsVisible[fields.END_AT] ? ( - - ) : null} - {columnsVisible[fields.PUBLISHED] ? ( - - ) : null} - - ); - } -} - -ItemRow.propTypes = { - id: PropTypes.number.isRequired, - type: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - startAt: datePropType.isRequired, - endAt: datePropType, - bonusEndAt: datePropType, - published: PropTypes.bool.isRequired, - visibility: PropTypes.shape({}).isRequired, - columnsVisible: PropTypes.shape({}).isRequired, - itemPath: PropTypes.string, - - dispatch: PropTypes.func.isRequired, -}; - -export default connect(({ lessonPlan }) => ({ - visibility: lessonPlan.lessonPlan.visibilityByType, - columnsVisible: lessonPlan.flags.editPageColumnsVisible, -}))(ItemRow); diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/index.tsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/index.tsx new file mode 100644 index 00000000000..ebce95c7ee1 --- /dev/null +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/index.tsx @@ -0,0 +1,198 @@ +import { useRef, useState } from 'react'; +import { defineMessages } from 'react-intl'; + +import Link from 'lib/components/core/Link'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import { FIELD_LONG_DEBOUNCE_DELAY_MS } from 'lib/constants/sharedConstants'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import { useDebounce } from 'lib/hooks/useDebounce'; +import useTranslation from 'lib/hooks/useTranslation'; +import moment from 'lib/moment'; + +import { fields } from '../../../constants'; +import TranslatedItemType from '../../../containers/TranslatedItemType'; +import { updateItem } from '../../../operations'; +import { + LessonPlanDate, + LessonPlanItemUpdate, + LessonPlanItemUpdateField, +} from '../../../types'; +import { SaveContext } from '../types'; + +import DateCell from './DateCell'; +import PublishedCell from './PublishedCell'; + +const translations = defineMessages({ + updateSuccess: { + id: 'course.lessonPlan.LessonPlanEdit.ItemRow.updateSuccess', + defaultMessage: '"{title}" was updated.', + }, + updateFailed: { + id: 'course.lessonPlan.LessonPlanEdit.ItemRow.updateFailed', + defaultMessage: 'Failed to update {title}.', + }, +}); + +type ItemValue = LessonPlanItemUpdate[LessonPlanItemUpdateField]; + +// `start_at` is required server-side, so an empty field is a transient state +// while the instructor retypes the date rather than an edit worth sending. It is +// dropped here, leaving the item untouched until the field is valid again. +const REQUIRED_FIELDS: LessonPlanItemUpdateField[] = ['start_at']; + +const sameValue = ( + a: ItemValue | LessonPlanDate | undefined, + b: ItemValue | LessonPlanDate | undefined, +): boolean => { + if (typeof a === 'boolean' || typeof b === 'boolean') return a === b; + return Boolean((!a && !b) || (a && b && moment(a).isSame(b, 'minute'))); +}; + +interface ItemRowProps { + id: number; + type: string; + title: string; + startAt: LessonPlanDate; + bonusEndAt?: LessonPlanDate; + endAt?: LessonPlanDate; + published: boolean; + itemPath?: string; +} + +const ItemRow = (props: ItemRowProps): JSX.Element | null => { + const { id, type, title, startAt, bonusEndAt, endAt, published, itemPath } = + props; + + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const visibility = useAppSelector( + (state) => state.lessonPlan.lessonPlan.visibilityByType, + ); + const columnsVisible = useAppSelector( + (state) => state.lessonPlan.flags.editPageColumnsVisible, + ); + + // Changes queued but not yet sent. Merged so that editing several fields of the + // same row results in one request rather than one per field. + const pendingRef = useRef({}); + // The latest value we know for each field, whether or not it has been saved yet. + // Guards against re-sending a value the user has already queued. + const latestValuesRef = useRef({}); + // At most one request per row may be in flight. + const inFlightRef = useRef(false); + const [saving, setSaving] = useState(false); + + const flush = (context: SaveContext): void => { + if (inFlightRef.current) return; + + const payload = pendingRef.current; + pendingRef.current = {}; + + if (Object.keys(payload).length === 0) { + setSaving(false); + return; + } + + const successMessage = context.t(translations.updateSuccess, { + title: context.title, + }); + const failureMessage = context.t(translations.updateFailed, { + title: context.title, + }); + + inFlightRef.current = true; + context + .dispatch(updateItem(context.id, payload, successMessage, failureMessage)) + .finally(() => { + inFlightRef.current = false; + if (Object.keys(pendingRef.current).length > 0) { + flush(context); + } else { + setSaving(false); + } + }); + }; + + const debouncedFlush = useDebounce(flush, FIELD_LONG_DEBOUNCE_DELAY_MS, []); + + const queueUpdate = (payload: LessonPlanItemUpdate): void => { + const savedValues: Record = { + start_at: startAt, + bonus_end_at: bonusEndAt, + end_at: endAt, + published, + }; + + const changes = Object.entries(payload).reduce( + (acc, [field, value]) => { + const key = field as LessonPlanItemUpdateField; + if (REQUIRED_FIELDS.includes(key) && !value) return acc; + + const latest = + key in latestValuesRef.current + ? latestValuesRef.current[key] + : (savedValues[key] as ItemValue); + if (sameValue(latest, value)) return acc; + return { ...acc, [key]: value }; + }, + {}, + ); + + if (Object.keys(changes).length === 0) return; + + latestValuesRef.current = { ...latestValuesRef.current, ...changes }; + pendingRef.current = { ...pendingRef.current, ...changes }; + setSaving(true); + debouncedFlush({ id, title, dispatch, t }); + }; + + const updatePublished = (_, isToggled: boolean): void => + queueUpdate({ published: isToggled }); + + if (!visibility[type]) return null; + + const dateProps = { + startAt, + bonusEndAt, + endAt, + updateItem: queueUpdate, + }; + + return ( + + {columnsVisible[fields.ITEM_TYPE] ? ( + + + + ) : null} + + + {title} + {saving ? : null} + + + {columnsVisible[fields.START_AT] ? ( + + ) : null} + {columnsVisible[fields.BONUS_END_AT] ? ( + + ) : null} + {columnsVisible[fields.END_AT] ? ( + + ) : null} + {columnsVisible[fields.PUBLISHED] ? ( + + ) : null} + + ); +}; + +export default ItemRow; diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/MilestoneRow.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/MilestoneRow.jsx deleted file mode 100644 index 4985cf87286..00000000000 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/MilestoneRow.jsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Component } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { Element } from 'react-scroll'; -import PropTypes from 'prop-types'; - -import DateTimePicker from 'lib/components/core/fields/DateTimePicker'; -import moment from 'lib/moment'; - -import { fields } from '../../constants'; -import { updateMilestone } from '../../operations'; - -const translations = defineMessages({ - updateSuccess: { - id: 'course.lessonPlan.LessonPlanEdit.MilestoneRow.updateSuccess', - defaultMessage: '"{title}" was updated.', - }, - updateFailed: { - id: 'course.lessonPlan.LessonPlanEdit.MilestoneRow.updateFailed', - defaultMessage: 'Failed to update milestone date.', - }, -}); - -const sameDate = (a, b) => - (!a && !b) || (a && b && moment(a).isSame(b, 'minute')); - -class MilestoneRow extends Component { - updateMilestoneStartAt = (_, newDate, setError) => { - const { id, title, startAt, dispatch } = this.props; - if (sameDate(startAt, newDate)) { - return; - } - - const successMessage = ( - - ); - const failureMessage = ; - dispatch( - updateMilestone( - id, - { start_at: newDate }, - successMessage, - failureMessage, - setError, - ), - ); - }; - - render() { - const { title, startAt, groupId, columnsVisible } = this.props; - - return ( - - - - {title} - - - {columnsVisible[fields.START_AT] ? ( - - - - ) : null} - {columnsVisible[fields.BONUS_END_AT] ? : null} - {columnsVisible[fields.END_AT] ? : null} - {columnsVisible[fields.PUBLISHED] ? : null} - - ); - } -} - -MilestoneRow.propTypes = { - id: PropTypes.number.isRequired, - groupId: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - startAt: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]) - .isRequired, - columnsVisible: PropTypes.shape({}).isRequired, - - dispatch: PropTypes.func.isRequired, -}; - -export default connect(({ lessonPlan }) => ({ - columnsVisible: lessonPlan.flags.editPageColumnsVisible, -}))(MilestoneRow); diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/MilestoneRow.tsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/MilestoneRow.tsx new file mode 100644 index 00000000000..4b414744f69 --- /dev/null +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/MilestoneRow.tsx @@ -0,0 +1,147 @@ +import { useRef, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Element } from 'react-scroll'; + +import DateTimePicker from 'lib/components/core/fields/DateTimePicker'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import { FIELD_LONG_DEBOUNCE_DELAY_MS } from 'lib/constants/sharedConstants'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import { useDebounce } from 'lib/hooks/useDebounce'; +import useTranslation from 'lib/hooks/useTranslation'; +import moment from 'lib/moment'; + +import { fields } from '../../constants'; +import { updateMilestone } from '../../operations'; +import { LessonPlanDate } from '../../types'; + +import { SaveContext } from './types'; + +const translations = defineMessages({ + updateSuccess: { + id: 'course.lessonPlan.LessonPlanEdit.MilestoneRow.updateSuccess', + defaultMessage: '"{title}" was updated.', + }, + updateFailed: { + id: 'course.lessonPlan.LessonPlanEdit.MilestoneRow.updateFailed', + defaultMessage: 'Failed to update milestone date.', + }, +}); + +const sameDate = (a?: LessonPlanDate, b?: LessonPlanDate): boolean => + Boolean((!a && !b) || (a && b && moment(a).isSame(b, 'minute'))); + +type SetError = (...args: never[]) => void; + +interface PendingUpdate { + startAt: Date | null; + setError?: SetError; +} + +interface MilestoneRowProps { + id: number; + groupId: string; + title: string; + startAt: LessonPlanDate; +} + +const MilestoneRow = (props: MilestoneRowProps): JSX.Element => { + const { id, groupId, title, startAt } = props; + + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const columnsVisible = useAppSelector( + (state) => state.lessonPlan.flags.editPageColumnsVisible, + ); + + // See ItemRow: the queued value, not `startAt`, is what a repeat edit must be + // compared against, and only one request per row may be in flight at a time. + const pendingRef = useRef(null); + const latestValueRef = useRef(undefined); + const inFlightRef = useRef(false); + const [saving, setSaving] = useState(false); + + const flush = (context: SaveContext): void => { + if (inFlightRef.current) return; + + const pending = pendingRef.current; + pendingRef.current = null; + + if (!pending) { + setSaving(false); + return; + } + + const successMessage = context.t(translations.updateSuccess, { + title: context.title, + }); + const failureMessage = context.t(translations.updateFailed); + + inFlightRef.current = true; + context + .dispatch( + updateMilestone( + context.id, + { start_at: pending.startAt }, + successMessage, + failureMessage, + pending.setError, + ), + ) + .finally(() => { + inFlightRef.current = false; + if (pendingRef.current) { + flush(context); + } else { + setSaving(false); + } + }); + }; + + const debouncedFlush = useDebounce(flush, FIELD_LONG_DEBOUNCE_DELAY_MS, []); + + const updateMilestoneStartAt = ( + _, + newDate: Date | null, + setError?: SetError, + ): void => { + // `start_at` is required server-side, so an empty field is a transient state + // while the date is retyped rather than an edit worth sending. + if (!newDate) return; + + const latest = + latestValueRef.current === undefined ? startAt : latestValueRef.current; + if (sameDate(latest, newDate)) return; + + latestValueRef.current = newDate; + pendingRef.current = { startAt: newDate, setError }; + setSaving(true); + debouncedFlush({ id, title, dispatch, t }); + }; + + return ( + + + + + {title} + {saving ? : null} + + + + {columnsVisible[fields.START_AT] ? ( + + + + ) : null} + {columnsVisible[fields.BONUS_END_AT] ? : null} + {columnsVisible[fields.END_AT] ? : null} + {columnsVisible[fields.PUBLISHED] ? : null} + + ); +}; + +export default MilestoneRow; diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/ItemRow.test.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/ItemRow.test.jsx deleted file mode 100644 index c111410256f..00000000000 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/ItemRow.test.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import { createMockAdapter } from 'mocks/axiosMock'; -import { fireEvent, render, waitFor } from 'test-utils'; - -import CourseAPI from 'api/course'; - -import ItemRow from '../ItemRow'; - -const mock = createMockAdapter(CourseAPI.lessonPlan.client); - -const startAt = '01-01-2017'; -const endAt = '02-02-2017'; - -const itemData = { - id: 9, - published: false, - itemTypeKey: 'Other', - title: 'Other Event', - start_at: new Date(startAt), - bonus_end_at: '2017-01-06T02:03:00.000+08:00', - end_at: new Date(endAt), -}; - -const state = { - lessonPlan: { - lessonPlan: { - visibilityByType: { [itemData.itemTypeKey]: true }, - items: [itemData], - }, - }, -}; - -describe('', () => { - it('shifts end dates when start date is shifted', async () => { - const newStartAt = '02-02-2017'; - - const url = `/courses/${global.courseId}/lesson_plan/items/${itemData.id}`; - mock.onPatch(url).reply(200); - - const spy = jest.spyOn(CourseAPI.lessonPlan, 'updateItem'); - - const page = render( - , - { state }, - ); - - const input = await page.findByDisplayValue(startAt); - - fireEvent.change(input, { target: { value: newStartAt } }); - fireEvent.blur(input); - - await waitFor(() => - expect(spy).toHaveBeenCalledWith(itemData.id, { - item: { - start_at: '2017-02-01T16:00:00.000Z', - bonus_end_at: '2017-02-06T18:03:00.000Z', - end_at: '2017-03-05T16:00:00.000Z', - }, - }), - ); - }); - - it('clears end date', async () => { - const spy = jest.spyOn(CourseAPI.lessonPlan, 'updateItem'); - - const page = render( - , - { state }, - ); - - const input = await page.findByDisplayValue(endAt); - - fireEvent.change(input, { target: { value: '' } }); - fireEvent.blur(input); - - expect(spy).toHaveBeenCalledWith(itemData.id, { - item: { end_at: null }, - }); - }); -}); diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/ItemRow.test.tsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/ItemRow.test.tsx new file mode 100644 index 00000000000..5ad88d7a85d --- /dev/null +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/ItemRow.test.tsx @@ -0,0 +1,195 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { AppState } from 'store'; +import { fireEvent, render, RenderResult, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; +import { FIELD_LONG_DEBOUNCE_DELAY_MS } from 'lib/constants/sharedConstants'; + +import ItemRow from '../ItemRow'; + +const mock = createMockAdapter(CourseAPI.lessonPlan.client); + +const startAt = '01-01-2017'; +const endAt = '02-02-2017'; + +// Saves are debounced by FIELD_LONG_DEBOUNCE_DELAY_MS, so assertions have to +// outlast it. +const AFTER_DEBOUNCE = { timeout: 5000 }; + +const settleDebounce = (): Promise => + new Promise((resolve) => { + setTimeout(resolve, FIELD_LONG_DEBOUNCE_DELAY_MS + 500); + }); + +const itemData = { + id: 9, + published: false, + itemTypeKey: 'Other', + title: 'Other Event', + start_at: new Date(startAt), + bonus_end_at: '2017-01-06T02:03:00.000+08:00', + end_at: new Date(endAt), +}; + +// `Partial` only allows omitting whole slices, and these tests seed +// just the few fields the component reads, so the shape is asserted. +const state = { + lessonPlan: { + lessonPlan: { + visibilityByType: { [itemData.itemTypeKey]: true }, + items: [itemData], + }, + }, +} as unknown as Partial; + +const renderItemRow = (): RenderResult => + render( + , + { state }, + ); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('', () => { + it('shifts end dates when start date is shifted', async () => { + const newStartAt = '02-02-2017'; + + const url = `/courses/${global.courseId}/lesson_plan/items/${itemData.id}`; + mock.onPatch(url).reply(200); + + const spy = jest.spyOn(CourseAPI.lessonPlan, 'updateItem'); + + const page = renderItemRow(); + + const input = await page.findByDisplayValue(startAt); + + fireEvent.change(input, { target: { value: newStartAt } }); + fireEvent.blur(input); + + await waitFor( + () => + expect(spy).toHaveBeenCalledWith(itemData.id, { + item: { + start_at: '2017-02-01T16:00:00.000Z', + bonus_end_at: '2017-02-06T18:03:00.000Z', + end_at: '2017-03-05T16:00:00.000Z', + }, + }), + AFTER_DEBOUNCE, + ); + }); + + it('clears end date', async () => { + const spy = jest.spyOn(CourseAPI.lessonPlan, 'updateItem'); + + const page = renderItemRow(); + + const input = await page.findByDisplayValue(endAt); + + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + + await waitFor( + () => + expect(spy).toHaveBeenCalledWith(itemData.id, { + item: { end_at: null }, + }), + AFTER_DEBOUNCE, + ); + }); + + it('sends one request when a date is edited several times in quick succession', async () => { + const url = `/courses/${global.courseId}/lesson_plan/items/${itemData.id}`; + mock.onPatch(url).reply(200); + + const spy = jest.spyOn(CourseAPI.lessonPlan, 'updateItem'); + + const page = renderItemRow(); + + const input = await page.findByDisplayValue(endAt); + + // Typing a date passes through valid intermediate values, each of which + // fires `onChange`. Without debouncing these become concurrent PATCHes, + // which enqueue racing CoursewidePersonalizedTimelineUpdateJob runs. + fireEvent.change(input, { target: { value: '03-03-2017' } }); + fireEvent.change(input, { target: { value: '04-04-2017' } }); + fireEvent.blur(input); + + await waitFor( + () => + expect(spy).toHaveBeenCalledWith(itemData.id, { + item: { end_at: '2017-04-03T16:00:00.000Z' }, + }), + AFTER_DEBOUNCE, + ); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('sends nothing while start date is empty', async () => { + const spy = jest.spyOn(CourseAPI.lessonPlan, 'updateItem'); + + const page = renderItemRow(); + + const input = await page.findByDisplayValue(startAt); + fireEvent.change(input, { target: { value: '' } }); + + // start_at is required, so an empty field is a transient state on the way to + // a new date, not an update the server would accept. + await settleDebounce(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('sends the start date once it is valid again', async () => { + const url = `/courses/${global.courseId}/lesson_plan/items/${itemData.id}`; + mock.onPatch(url).reply(200); + + const spy = jest.spyOn(CourseAPI.lessonPlan, 'updateItem'); + + const page = renderItemRow(); + + const input = await page.findByDisplayValue(startAt); + fireEvent.change(input, { target: { value: '' } }); + fireEvent.change(input, { target: { value: '05-05-2017' } }); + + await waitFor( + () => + expect(spy).toHaveBeenCalledWith(itemData.id, { + item: expect.objectContaining({ + start_at: '2017-05-04T16:00:00.000Z', + }), + }), + AFTER_DEBOUNCE, + ); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('shows a saving indicator until the update resolves', async () => { + const url = `/courses/${global.courseId}/lesson_plan/items/${itemData.id}`; + mock.onPatch(url).reply(200); + + const page = renderItemRow(); + + const input = await page.findByDisplayValue(endAt); + fireEvent.change(input, { target: { value: '05-05-2017' } }); + + expect(await page.findByTestId('CircularProgress')).toBeVisible(); + + await waitFor( + () => expect(page.queryByTestId('CircularProgress')).toBeNull(), + AFTER_DEBOUNCE, + ); + }); +}); diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/MilestoneRow.test.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/MilestoneRow.test.jsx deleted file mode 100644 index 70c386317cf..00000000000 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/MilestoneRow.test.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import { createMockAdapter } from 'mocks/axiosMock'; -import { fireEvent, render, waitFor } from 'test-utils'; - -import CourseAPI from 'api/course'; - -import MilestoneRow from '../MilestoneRow'; - -const mock = createMockAdapter(CourseAPI.lessonPlan.client); - -beforeEach(() => { - mock.reset(); -}); - -const startAt = '03-03-2017'; -const newStartAt = '03-03-2018'; - -const milestoneData = { - id: 6, - title: 'Week 1', - start_at: new Date(startAt), -}; - -describe('', () => { - it('allows milestone start_at to be updated', async () => { - const url = `/courses/${global.courseId}/lesson_plan/milestones/${milestoneData.id}`; - mock.onPatch(url).reply(200); - - const spy = jest.spyOn(CourseAPI.lessonPlan, 'updateMilestone'); - - const page = render( - , - { state: { lessonPlan: { milestones: [milestoneData] } } }, - ); - - const input = await page.findByDisplayValue(startAt); - - fireEvent.change(input, { target: { value: newStartAt } }); - fireEvent.blur(input); - - await waitFor(() => - expect(spy).toHaveBeenCalledWith(milestoneData.id, { - lesson_plan_milestone: { start_at: new Date(newStartAt) }, - }), - ); - }); -}); diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/MilestoneRow.test.tsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/MilestoneRow.test.tsx new file mode 100644 index 00000000000..b9ef3076f04 --- /dev/null +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/MilestoneRow.test.tsx @@ -0,0 +1,113 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { AppState } from 'store'; +import { fireEvent, render, RenderResult, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; +import { FIELD_LONG_DEBOUNCE_DELAY_MS } from 'lib/constants/sharedConstants'; + +import MilestoneRow from '../MilestoneRow'; + +const mock = createMockAdapter(CourseAPI.lessonPlan.client); + +beforeEach(() => { + mock.reset(); + jest.clearAllMocks(); +}); + +const startAt = '03-03-2017'; +const newStartAt = '03-03-2018'; + +// Saves are debounced by FIELD_LONG_DEBOUNCE_DELAY_MS, so assertions have to +// outlast it. +const AFTER_DEBOUNCE = { timeout: 5000 }; + +const settleDebounce = (): Promise => + new Promise((resolve) => { + setTimeout(resolve, FIELD_LONG_DEBOUNCE_DELAY_MS + 500); + }); + +const milestoneData = { + id: 6, + title: 'Week 1', + start_at: new Date(startAt), +}; + +// `Partial` only allows omitting whole slices, and these tests seed +// just the few fields the component reads, so the shape is asserted. +const state = { + lessonPlan: { milestones: [milestoneData] }, +} as unknown as Partial; + +const renderMilestoneRow = (): RenderResult => + render( + , + { state }, + ); + +describe('', () => { + it('allows milestone start_at to be updated', async () => { + const url = `/courses/${global.courseId}/lesson_plan/milestones/${milestoneData.id}`; + mock.onPatch(url).reply(200); + + const spy = jest.spyOn(CourseAPI.lessonPlan, 'updateMilestone'); + + const page = renderMilestoneRow(); + + const input = await page.findByDisplayValue(startAt); + + fireEvent.change(input, { target: { value: newStartAt } }); + fireEvent.blur(input); + + await waitFor( + () => + expect(spy).toHaveBeenCalledWith(milestoneData.id, { + lesson_plan_milestone: { start_at: new Date(newStartAt) }, + }), + AFTER_DEBOUNCE, + ); + }); + + it('sends one request when the date is edited several times in quick succession', async () => { + const url = `/courses/${global.courseId}/lesson_plan/milestones/${milestoneData.id}`; + mock.onPatch(url).reply(200); + + const spy = jest.spyOn(CourseAPI.lessonPlan, 'updateMilestone'); + + const page = renderMilestoneRow(); + + const input = await page.findByDisplayValue(startAt); + + fireEvent.change(input, { target: { value: '04-04-2018' } }); + fireEvent.change(input, { target: { value: newStartAt } }); + fireEvent.blur(input); + + await waitFor( + () => + expect(spy).toHaveBeenCalledWith(milestoneData.id, { + lesson_plan_milestone: { start_at: new Date(newStartAt) }, + }), + AFTER_DEBOUNCE, + ); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('sends nothing while the date is empty', async () => { + const spy = jest.spyOn(CourseAPI.lessonPlan, 'updateMilestone'); + + const page = renderMilestoneRow(); + + const input = await page.findByDisplayValue(startAt); + fireEvent.change(input, { target: { value: '' } }); + + // A milestone is a lesson plan item, so its start_at is required too. + await settleDebounce(); + + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/index.test.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/index.test.jsx deleted file mode 100644 index 2da192fb97c..00000000000 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/index.test.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import { render, waitFor } from 'test-utils'; - -import { LessonPlanEdit } from '../index'; - -const groups = [ - { - id: 'milestone-group-6', - milestone: { - id: 6, - title: 'Week 1', - start_at: '2017-01-01T02:03:00.000+08:00', - }, - items: [ - { - id: 9, - published: false, - title: 'Other Event', - start_at: '2017-01-04T02:03:00.000+08:00', - bonus_end_at: '2017-01-06T02:03:00.000+08:00', - end_at: '2017-01-08T02:03:00.000+08:00', - itemTypeKey: 'Event', - }, - ], - }, -]; - -const columnsVisible = { - ITEM_TYPE: true, - START_AT: true, - BONUS_END_AT: false, - END_AT: true, - PUBLISHED: true, -}; - -const state = { - lessonPlan: { - lessonPlan: { - visibilityByType: { Event: true }, - columnsVisible, - }, - }, -}; - -describe('', () => { - it('renders item and milestone rows', async () => { - const page = render( - , - { state }, - ); - - await waitFor(() => { - expect(page.getByText(groups[0].items[0].title)).toBeVisible(); - expect(page.getByText(groups[0].milestone.title)).toBeVisible(); - }); - }); -}); diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/index.test.tsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/index.test.tsx new file mode 100644 index 00000000000..149586abd86 --- /dev/null +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/index.test.tsx @@ -0,0 +1,70 @@ +import { AppState } from 'store'; +import { render, waitFor } from 'test-utils'; + +import { + LessonPlanGroup, + LessonPlanItem, + LessonPlanMilestone, +} from '../../../types'; +import { LessonPlanEdit } from '../index'; + +const milestone: LessonPlanMilestone = { + id: 6, + title: 'Week 1', + start_at: '2017-01-01T02:03:00.000+08:00', +}; + +const item: LessonPlanItem = { + id: 9, + published: false, + title: 'Other Event', + start_at: '2017-01-04T02:03:00.000+08:00', + bonus_end_at: '2017-01-06T02:03:00.000+08:00', + end_at: '2017-01-08T02:03:00.000+08:00', + itemTypeKey: 'Event', +}; + +const groups: LessonPlanGroup[] = [ + { + id: 'milestone-group-6', + milestone, + items: [item], + }, +]; + +const columnsVisible = { + ITEM_TYPE: true, + START_AT: true, + BONUS_END_AT: false, + END_AT: true, + PUBLISHED: true, +}; + +// `Partial` only allows omitting whole slices, and these tests seed +// just the few fields the component reads, so the shape is asserted. +const state = { + lessonPlan: { + lessonPlan: { + visibilityByType: { Event: true }, + columnsVisible, + }, + }, +} as unknown as Partial; + +describe('', () => { + it('renders item and milestone rows', async () => { + const page = render( + , + { state }, + ); + + await waitFor(() => { + expect(page.getByText(item.title)).toBeVisible(); + expect(page.getByText(milestone.title)).toBeVisible(); + }); + }); +}); diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/index.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/index.jsx deleted file mode 100644 index b5bec7bb962..00000000000 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/index.jsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Component } from 'react'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; - -import Page from 'lib/components/core/layouts/Page'; -import { getCourseId } from 'lib/helpers/url-helpers'; -import { lessonPlanTypesGroups } from 'lib/types'; - -import { fields } from '../../constants'; -import ColumnVisibilityDropdown from '../../containers/ColumnVisibilityDropdown'; -import NewEventButton from '../../containers/LessonPlanLayout/NewEventButton'; -import NewMilestoneButton from '../../containers/LessonPlanLayout/NewMilestoneButton'; -import translations from '../../translations'; - -import ItemRow from './ItemRow'; -import MilestoneRow from './MilestoneRow'; - -const { ITEM_TYPE, TITLE, START_AT, BONUS_END_AT, END_AT, PUBLISHED } = fields; - -export class LessonPlanEdit extends Component { - // eslint-disable-next-line class-methods-use-this - renderGroup = (group) => { - const { id, milestone, items } = group; - - const rows = items - ? items.map((item) => ( - - )) - : []; - - if (milestone) { - rows.unshift( - , - ); - } - - return rows; - }; - - renderTableHeader() { - const { columnsVisible } = this.props; - - const headerFor = (field) => ( - - - - ); - return ( - - - {columnsVisible[ITEM_TYPE] ? headerFor(ITEM_TYPE) : null} - {headerFor(TITLE)} - {columnsVisible[START_AT] ? headerFor(START_AT) : null} - {columnsVisible[BONUS_END_AT] ? headerFor(BONUS_END_AT) : null} - {columnsVisible[END_AT] ? headerFor(END_AT) : null} - {columnsVisible[PUBLISHED] ? headerFor(PUBLISHED) : null} - - - ); - } - - render() { - const { groups } = this.props; - const courseId = getCourseId(); - - return ( - - - - - - ) - } - backTo={`/courses/${courseId}/lesson_plan`} - title={} - > - - - {this.renderTableHeader()} - {groups.map(this.renderGroup)} - - - - ); - } -} - -LessonPlanEdit.propTypes = { - groups: lessonPlanTypesGroups.isRequired, - columnsVisible: PropTypes.shape({}).isRequired, - canManageLessonPlan: PropTypes.bool.isRequired, -}; - -export default connect(({ lessonPlan }) => ({ - groups: lessonPlan.lessonPlan.groups, - columnsVisible: lessonPlan.flags.editPageColumnsVisible, - canManageLessonPlan: lessonPlan.flags.canManageLessonPlan, -}))(LessonPlanEdit); diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/index.tsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/index.tsx new file mode 100644 index 00000000000..22febf576de --- /dev/null +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/index.tsx @@ -0,0 +1,114 @@ +import Page from 'lib/components/core/layouts/Page'; +import { getCourseId } from 'lib/helpers/url-helpers'; +import { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { fields } from '../../constants'; +import ColumnVisibilityDropdown from '../../containers/ColumnVisibilityDropdown'; +import NewEventButton from '../../containers/LessonPlanLayout/NewEventButton'; +import NewMilestoneButton from '../../containers/LessonPlanLayout/NewMilestoneButton'; +import translations from '../../translations'; +import { LessonPlanGroup } from '../../types'; + +import ItemRow from './ItemRow'; +import MilestoneRow from './MilestoneRow'; + +const { ITEM_TYPE, TITLE, START_AT, BONUS_END_AT, END_AT, PUBLISHED } = fields; + +interface LessonPlanEditProps { + groups: LessonPlanGroup[]; + columnsVisible: Record; + canManageLessonPlan: boolean; +} + +export const LessonPlanEdit = (props: LessonPlanEditProps): JSX.Element => { + const { groups, columnsVisible, canManageLessonPlan } = props; + + const { t } = useTranslation(); + const courseId = getCourseId(); + + const renderGroup = (group: LessonPlanGroup): JSX.Element[] => { + const { id, milestone, items } = group; + + const rows = items + ? items.map((item) => ( + + )) + : []; + + if (milestone) { + rows.unshift( + , + ); + } + + return rows; + }; + + const headerFor = (field: string): JSX.Element => ( + {t(translations[field])} + ); + + return ( + + + + + + ) + } + backTo={`/courses/${courseId}/lesson_plan`} + title={t(translations.editLessonPlan)} + > + + + + + {columnsVisible[ITEM_TYPE] ? headerFor(ITEM_TYPE) : null} + {headerFor(TITLE)} + {columnsVisible[START_AT] ? headerFor(START_AT) : null} + {columnsVisible[BONUS_END_AT] ? headerFor(BONUS_END_AT) : null} + {columnsVisible[END_AT] ? headerFor(END_AT) : null} + {columnsVisible[PUBLISHED] ? headerFor(PUBLISHED) : null} + + + {groups.map(renderGroup)} + + + + ); +}; + +const ConnectedLessonPlanEdit = (): JSX.Element => { + const groups = useAppSelector((state) => state.lessonPlan.lessonPlan.groups); + const flags = useAppSelector((state) => state.lessonPlan.flags); + + return ( + + ); +}; + +export default ConnectedLessonPlanEdit; diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/types.ts b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/types.ts new file mode 100644 index 00000000000..bce26229371 --- /dev/null +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/types.ts @@ -0,0 +1,19 @@ +import { AppDispatch } from 'store'; + +import { MessageTranslator } from 'lib/hooks/useTranslation'; + +/** + * What a queued row save needs. Passed to the debounced flush as an argument so + * that it never closes over props that may have moved on since the edit was + * queued. + * + * This lives here rather than in the bundle's `types` because it depends on the + * app store, which imports this bundle's reducers — putting it there would make + * the reducers and the store circular. + */ +export interface SaveContext { + id: number; + title: string; + dispatch: AppDispatch; + t: MessageTranslator; +} diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/AdminTools.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/AdminTools.jsx deleted file mode 100644 index 7ff13f41489..00000000000 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/AdminTools.jsx +++ /dev/null @@ -1,146 +0,0 @@ -/* eslint-disable camelcase */ -import { PureComponent } from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import Delete from '@mui/icons-material/Delete'; -import Edit from '@mui/icons-material/Edit'; -import { IconButton } from '@mui/material'; -import PropTypes from 'prop-types'; - -import { showDeleteConfirmation } from 'lib/actions'; - -import { deleteEvent, updateEvent } from '../../../operations'; -import { actions } from '../../../store'; - -const translations = defineMessages({ - editEvent: { - id: 'course.lessonPlan.LessonPlanShow.LessonPlanItem.AdminTools.editEvent', - defaultMessage: 'Edit Event', - }, - updateSuccess: { - id: 'course.lessonPlan.LessonPlanShow.LessonPlanItem.AdminTools.updateSuccess', - defaultMessage: 'Event updated.', - }, - updateFailure: { - id: 'course.lessonPlan.LessonPlanShow.LessonPlanItem.AdminTools.updateFailure', - defaultMessage: 'Failed to update event.', - }, - deleteSuccess: { - id: 'course.lessonPlan.LessonPlanShow.LessonPlanItem.AdminTools.deleteSuccess', - defaultMessage: 'Event deleted.', - }, - deleteFailure: { - id: 'course.lessonPlan.LessonPlanShow.LessonPlanItem.AdminTools.deleteFailure', - defaultMessage: 'Failed to delete event.', - }, -}); - -const styles = { - tools: { - top: 16, - right: 20, - position: 'absolute', - }, -}; - -class AdminTools extends PureComponent { - deleteEventHandler = () => { - const { - dispatch, - intl, - item: { id, eventId }, - } = this.props; - const successMessage = intl.formatMessage(translations.deleteSuccess); - const failureMessage = intl.formatMessage(translations.deleteFailure); - const handleDelete = () => - dispatch(deleteEvent(id, eventId, successMessage, failureMessage)); - return dispatch(showDeleteConfirmation(handleDelete)); - }; - - showEditEventDialog = () => { - const { dispatch, intl, item } = this.props; - const { - title, - lesson_plan_item_type, - location, - description, - start_at, - end_at, - published, - } = item; - - return dispatch( - actions.showEventForm({ - onSubmit: this.updateEventHandler, - formTitle: intl.formatMessage(translations.editEvent), - initialValues: { - title, - location, - description, - start_at, - end_at, - published, - event_type: lesson_plan_item_type[0], - }, - }), - ); - }; - - updateEventHandler = (data) => { - const { - dispatch, - intl, - item: { eventId }, - } = this.props; - const successMessage = intl.formatMessage(translations.updateSuccess); - const failureMessage = intl.formatMessage(translations.updateFailure); - return dispatch(updateEvent(eventId, data, successMessage, failureMessage)); - }; - - render() { - const { - item: { eventId }, - canManageLessonPlan, - } = this.props; - if (!canManageLessonPlan || eventId === undefined) { - return null; - } - - return ( - - - - - - - - - - ); - } -} - -AdminTools.propTypes = { - item: PropTypes.shape({ - id: PropTypes.number, - eventId: PropTypes.number, - title: PropTypes.string, - published: PropTypes.bool, - location: PropTypes.string, - description: PropTypes.string, - start_at: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.instanceOf(Date), - ]), - end_at: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), - lesson_plan_item_type: PropTypes.arrayOf(PropTypes.string), - }).isRequired, - canManageLessonPlan: PropTypes.bool.isRequired, - - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, -}; - -export default connect(({ lessonPlan }) => ({ - canManageLessonPlan: lessonPlan.flags.canManageLessonPlan, -}))(injectIntl(AdminTools)); diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/AdminTools.tsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/AdminTools.tsx new file mode 100644 index 00000000000..b19cef1c2e4 --- /dev/null +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/AdminTools.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import Delete from '@mui/icons-material/Delete'; +import Edit from '@mui/icons-material/Edit'; +import { IconButton } from '@mui/material'; + +import { showDeleteConfirmation } from 'lib/actions'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import EventFormDialog from '../../../containers/EventFormDialog'; +import { deleteEvent, updateEvent } from '../../../operations'; +import { + EventFormValues, + FormSubmitHandler, + LessonPlanEventItem, +} from '../../../types'; + +const translations = defineMessages({ + editEvent: { + id: 'course.lessonPlan.LessonPlanShow.LessonPlanItem.AdminTools.editEvent', + defaultMessage: 'Edit Event', + }, + updateSuccess: { + id: 'course.lessonPlan.LessonPlanShow.LessonPlanItem.AdminTools.updateSuccess', + defaultMessage: 'Event updated.', + }, + updateFailure: { + id: 'course.lessonPlan.LessonPlanShow.LessonPlanItem.AdminTools.updateFailure', + defaultMessage: 'Failed to update event.', + }, + deleteSuccess: { + id: 'course.lessonPlan.LessonPlanShow.LessonPlanItem.AdminTools.deleteSuccess', + defaultMessage: 'Event deleted.', + }, + deleteFailure: { + id: 'course.lessonPlan.LessonPlanShow.LessonPlanItem.AdminTools.deleteFailure', + defaultMessage: 'Failed to delete event.', + }, +}); + +const styles = { + tools: { + top: 16, + right: 20, + position: 'absolute' as const, + }, +}; + +interface AdminToolsProps { + item: LessonPlanEventItem; +} + +const AdminTools = (props: AdminToolsProps): JSX.Element | null => { + const { item } = props; + + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const canManageLessonPlan = useAppSelector( + (state) => state.lessonPlan.flags.canManageLessonPlan, + ); + + const [formVisible, setFormVisible] = useState(false); + + const deleteEventHandler = (): void => { + const handleDelete = (): Promise => + dispatch( + deleteEvent( + item.id, + item.eventId, + t(translations.deleteSuccess), + t(translations.deleteFailure), + ), + ); + + dispatch(showDeleteConfirmation(handleDelete)); + }; + + const updateEventHandler: FormSubmitHandler = ( + data, + setError, + ) => + dispatch( + updateEvent( + item.eventId, + data, + t(translations.updateSuccess), + t(translations.updateFailure), + setError, + ), + ); + + if (!canManageLessonPlan || item.eventId === undefined) return null; + + const { + title, + lesson_plan_item_type: itemType, + location, + description, + start_at: startAt, + end_at: endAt, + published, + } = item; + + return ( + + setFormVisible(true)}> + + + + + + + + setFormVisible(false)} + onSubmit={updateEventHandler} + open={formVisible} + /> + + ); +}; + +export default AdminTools; diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/__test__/AdminTools.test.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/__test__/AdminTools.test.tsx similarity index 73% rename from client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/__test__/AdminTools.test.jsx rename to client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/__test__/AdminTools.test.tsx index 97ac7d9cfe9..1afbf05d86a 100644 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/__test__/AdminTools.test.jsx +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/__test__/AdminTools.test.tsx @@ -1,16 +1,21 @@ -import { fireEvent, render, waitFor } from 'test-utils'; +import { AppState } from 'store'; +import { fireEvent, render, RenderResult, waitFor } from 'test-utils'; import CourseAPI from 'api/course'; -import EventFormDialog from 'course/lesson-plan/containers/EventFormDialog'; import DeleteConfirmation from 'lib/containers/DeleteConfirmation'; +import { LessonPlanEventItem } from '../../../../types'; import AdminTools from '../AdminTools'; +// `Partial` only allows omitting whole slices, and these tests seed +// just the few fields the component reads, so the shape is asserted. + const state = { lessonPlan: { flags: { canManageLessonPlan: true } }, -}; +} as unknown as Partial; -const renderElement = (item) => render(, { state }); +const renderElement = (item: LessonPlanEventItem): RenderResult => + render(, { state }); describe('', () => { it('does not show admin menu for lesson plan events', async () => { @@ -57,20 +62,16 @@ describe('', () => { }; const page = render( - <> - - - - >, + , { state }, ); diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/MilestoneAdminTools.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/MilestoneAdminTools.jsx deleted file mode 100644 index e4fc28a6ab1..00000000000 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/MilestoneAdminTools.jsx +++ /dev/null @@ -1,123 +0,0 @@ -/* eslint-disable camelcase */ -import { PureComponent } from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import Delete from '@mui/icons-material/Delete'; -import Edit from '@mui/icons-material/Edit'; -import { IconButton } from '@mui/material'; -import PropTypes from 'prop-types'; - -import { showDeleteConfirmation } from 'lib/actions'; - -import { deleteMilestone, updateMilestone } from '../../operations'; -import { actions } from '../../store'; - -const translations = defineMessages({ - editMilestone: { - id: 'course.lessonPlan.LessonPlanShow.MilestoneAdminTools.editMilestone', - defaultMessage: 'Edit Milestone', - }, - updateSuccess: { - id: 'course.lessonPlan.LessonPlanShow.MilestoneAdminTools.updateSuccess', - defaultMessage: 'Milestone updated.', - }, - updateFailure: { - id: 'course.lessonPlan.LessonPlanShow.MilestoneAdminTools.updateFailure', - defaultMessage: 'Failed to update milestone.', - }, - deleteSuccess: { - id: 'course.lessonPlan.LessonPlanShow.MilestoneAdminTools.deleteSuccess', - defaultMessage: 'Milestone deleted.', - }, - deleteFailure: { - id: 'course.lessonPlan.LessonPlanShow.MilestoneAdminTools.deleteFailure', - defaultMessage: 'Failed to delete milestone.', - }, -}); - -class MilestoneAdminTools extends PureComponent { - deleteMilestoneHandler = () => { - const { - dispatch, - intl, - milestone: { id }, - } = this.props; - const successMessage = intl.formatMessage(translations.deleteSuccess); - const failureMessage = intl.formatMessage(translations.deleteFailure); - const handleDelete = () => - dispatch(deleteMilestone(id, successMessage, failureMessage)); - return dispatch(showDeleteConfirmation(handleDelete)); - }; - - showEditMilestoneDialog = () => { - const { - dispatch, - intl, - milestone: { title, description, start_at }, - } = this.props; - - return dispatch( - actions.showMilestoneForm({ - onSubmit: this.updateMilestoneHandler, - formTitle: intl.formatMessage(translations.editMilestone), - initialValues: { title, description, start_at }, - }), - ); - }; - - updateMilestoneHandler = (data, setError) => { - const { - dispatch, - intl, - milestone: { id }, - } = this.props; - - const successMessage = intl.formatMessage(translations.updateSuccess); - const failureMessage = intl.formatMessage(translations.updateFailure); - return dispatch( - updateMilestone(id, data, successMessage, failureMessage, setError), - ); - }; - - render() { - const { milestone, canManageLessonPlan } = this.props; - if (!milestone.id || !canManageLessonPlan) { - return null; - } - - return ( - - - - - - - - - - ); - } -} - -MilestoneAdminTools.propTypes = { - milestone: PropTypes.shape({ - id: PropTypes.number, - description: PropTypes.string, - start_at: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.instanceOf(Date), - ]), - title: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.node, // Allow node containing translation - ]), - }), - canManageLessonPlan: PropTypes.bool, - - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, -}; - -export default connect(({ lessonPlan }) => ({ - canManageLessonPlan: lessonPlan.flags.canManageLessonPlan, -}))(injectIntl(MilestoneAdminTools)); diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/MilestoneAdminTools.tsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/MilestoneAdminTools.tsx new file mode 100644 index 00000000000..3261e39d0b6 --- /dev/null +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/MilestoneAdminTools.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import Delete from '@mui/icons-material/Delete'; +import Edit from '@mui/icons-material/Edit'; +import { IconButton } from '@mui/material'; + +import { showDeleteConfirmation } from 'lib/actions'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import MilestoneFormDialog from '../../containers/MilestoneFormDialog'; +import { deleteMilestone, updateMilestone } from '../../operations'; +import { + FormSubmitHandler, + MilestoneFormValues, + MilestoneOrPlaceholder, +} from '../../types'; + +const translations = defineMessages({ + editMilestone: { + id: 'course.lessonPlan.LessonPlanShow.MilestoneAdminTools.editMilestone', + defaultMessage: 'Edit Milestone', + }, + updateSuccess: { + id: 'course.lessonPlan.LessonPlanShow.MilestoneAdminTools.updateSuccess', + defaultMessage: 'Milestone updated.', + }, + updateFailure: { + id: 'course.lessonPlan.LessonPlanShow.MilestoneAdminTools.updateFailure', + defaultMessage: 'Failed to update milestone.', + }, + deleteSuccess: { + id: 'course.lessonPlan.LessonPlanShow.MilestoneAdminTools.deleteSuccess', + defaultMessage: 'Milestone deleted.', + }, + deleteFailure: { + id: 'course.lessonPlan.LessonPlanShow.MilestoneAdminTools.deleteFailure', + defaultMessage: 'Failed to delete milestone.', + }, +}); + +interface MilestoneAdminToolsProps { + milestone: MilestoneOrPlaceholder; +} + +const MilestoneAdminTools = ( + props: MilestoneAdminToolsProps, +): JSX.Element | null => { + const { milestone } = props; + + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const canManageLessonPlan = useAppSelector( + (state) => state.lessonPlan.flags.canManageLessonPlan, + ); + + const [formVisible, setFormVisible] = useState(false); + + const deleteMilestoneHandler = (): void => { + const handleDelete = (): Promise => + dispatch( + deleteMilestone( + milestone.id, + t(translations.deleteSuccess), + t(translations.deleteFailure), + ), + ); + + dispatch(showDeleteConfirmation(handleDelete)); + }; + + const updateMilestoneHandler: FormSubmitHandler = ( + data, + setError, + ) => + dispatch( + updateMilestone( + milestone.id, + data, + t(translations.updateSuccess), + t(translations.updateFailure), + setError, + ), + ); + + if (!milestone.id || !canManageLessonPlan) return null; + + const { title, description, start_at: startAt } = milestone; + // Only the synthesised milestone carries a node title, and it is filtered out + // by the guard above, so anything reaching the form is a plain string. + const editableTitle = typeof title === 'string' ? title : undefined; + + return ( + + setFormVisible(true)}> + + + + + + + + setFormVisible(false)} + onSubmit={updateMilestoneHandler} + open={formVisible} + /> + + ); +}; + +export default MilestoneAdminTools; diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/__test__/MilestoneAdminTools.test.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/__test__/MilestoneAdminTools.test.tsx similarity index 72% rename from client/app/bundles/course/lesson-plan/pages/LessonPlanShow/__test__/MilestoneAdminTools.test.jsx rename to client/app/bundles/course/lesson-plan/pages/LessonPlanShow/__test__/MilestoneAdminTools.test.tsx index 3a070983da1..2cb065b2608 100644 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/__test__/MilestoneAdminTools.test.jsx +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/__test__/MilestoneAdminTools.test.tsx @@ -1,15 +1,26 @@ -import { fireEvent, render, waitFor } from 'test-utils'; +import { AppState } from 'store'; +import { fireEvent, render, RenderResult, waitFor } from 'test-utils'; import CourseAPI from 'api/course'; -import MilestoneFormDialog from 'course/lesson-plan/containers/MilestoneFormDialog'; import DeleteConfirmation from 'lib/containers/DeleteConfirmation'; +import { MilestoneOrPlaceholder } from '../../../types'; import MilestoneAdminTools from '../MilestoneAdminTools'; -const renderElement = (canManageLessonPlan, milestone) => { - const state = { lessonPlan: { flags: { canManageLessonPlan } } }; - return render(, { state }); -}; +// `Partial` only allows omitting whole slices, and these tests seed +// just the few fields the component reads, so the shape is asserted. +const stateWith = (canManageLessonPlan: boolean): Partial => + ({ + lessonPlan: { flags: { canManageLessonPlan } }, + }) as unknown as Partial; + +const renderElement = ( + canManageLessonPlan: boolean, + milestone: MilestoneOrPlaceholder, +): RenderResult => + render(, { + state: stateWith(canManageLessonPlan), + }); describe('', () => { it('hides admin tools for dummy milestone', async () => { @@ -59,7 +70,7 @@ describe('', () => { }} /> >, - { state: { lessonPlan: { flags: { canManageLessonPlan: true } } } }, + { state: stateWith(true) }, ); fireEvent.click((await page.findAllByRole('button'))[1]); @@ -84,17 +95,14 @@ describe('', () => { const spy = jest.spyOn(CourseAPI.lessonPlan, 'updateMilestone'); const page = render( - <> - - - >, - { state: { lessonPlan: { flags: { canManageLessonPlan: true } } } }, + , + { state: stateWith(true) }, ); fireEvent.click((await page.findAllByRole('button'))[0]); diff --git a/client/app/bundles/course/lesson-plan/reducers/eventForm.js b/client/app/bundles/course/lesson-plan/reducers/eventForm.js deleted file mode 100644 index f7c2b1dfe47..00000000000 --- a/client/app/bundles/course/lesson-plan/reducers/eventForm.js +++ /dev/null @@ -1,33 +0,0 @@ -import actionTypes from '../constants'; - -export const initialState = { - visible: false, - disabled: false, - onSubmit: () => {}, - formTitle: '', - initialValues: {}, -}; - -export default function (state = initialState, action) { - const { type } = action; - switch (type) { - case actionTypes.EVENT_FORM_SHOW: { - return { ...state, ...action.formParams, visible: true }; - } - case actionTypes.EVENT_FORM_HIDE: { - return { ...state, visible: false }; - } - case actionTypes.EVENT_UPDATE_REQUEST: - case actionTypes.EVENT_CREATE_REQUEST: { - return { ...state, disabled: true }; - } - case actionTypes.EVENT_UPDATE_SUCCESS: - case actionTypes.EVENT_UPDATE_FAILURE: - case actionTypes.EVENT_CREATE_SUCCESS: - case actionTypes.EVENT_CREATE_FAILURE: { - return { ...state, disabled: false }; - } - default: - return state; - } -} diff --git a/client/app/bundles/course/lesson-plan/reducers/flags.js b/client/app/bundles/course/lesson-plan/reducers/flags.js deleted file mode 100644 index 37d6f9428b5..00000000000 --- a/client/app/bundles/course/lesson-plan/reducers/flags.js +++ /dev/null @@ -1,36 +0,0 @@ -import actionTypes, { fields } from '../constants'; - -export const initialState = { - canManageLessonPlan: false, - milestonesExpanded: 'current', - editPageColumnsVisible: { - [fields.ITEM_TYPE]: true, - [fields.START_AT]: true, - [fields.BONUS_END_AT]: false, - [fields.END_AT]: true, - [fields.PUBLISHED]: true, - }, -}; - -export default function (state = initialState, action) { - const { type } = action; - - switch (type) { - case actionTypes.SET_COLUMN_VISIBILITY: { - const editPageColumnsVisible = { - ...state.editPageColumnsVisible, - [action.field]: action.isVisible, - }; - return { ...state, editPageColumnsVisible }; - } - case actionTypes.LOAD_LESSON_PLAN_SUCCESS: { - const nextState = { ...state, ...action.flags }; - if (!nextState.milestonesExpanded) { - nextState.milestonesExpanded = initialState.milestonesExpanded; - } - return nextState; - } - default: - return state; - } -} diff --git a/client/app/bundles/course/lesson-plan/reducers/flags.ts b/client/app/bundles/course/lesson-plan/reducers/flags.ts new file mode 100644 index 00000000000..59fc6a27fb4 --- /dev/null +++ b/client/app/bundles/course/lesson-plan/reducers/flags.ts @@ -0,0 +1,51 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import { fields } from '../constants'; + +import { lessonPlanActions } from './lessonPlan'; + +export interface LessonPlanFlagsState { + canManageLessonPlan: boolean; + milestonesExpanded: string; + editPageColumnsVisible: Record; +} + +export const initialState: LessonPlanFlagsState = { + canManageLessonPlan: false, + milestonesExpanded: 'current', + editPageColumnsVisible: { + [fields.ITEM_TYPE]: true, + [fields.START_AT]: true, + [fields.BONUS_END_AT]: false, + [fields.END_AT]: true, + [fields.PUBLISHED]: true, + }, +}; + +export const flagsSlice = createSlice({ + name: 'lessonPlanFlags', + initialState, + reducers: { + setColumnVisibility( + state, + action: PayloadAction<{ field: string; isVisible: boolean }>, + ) { + const { field, isVisible } = action.payload; + state.editPageColumnsVisible[field] = isVisible; + }, + }, + // The flags arrive with the lesson plan itself, so this slice listens to the + // load rather than owning a fetch of its own. + extraReducers: (builder) => { + builder.addCase(lessonPlanActions.loadSucceeded, (state, action) => { + const { flags } = action.payload; + state.canManageLessonPlan = flags.canManageLessonPlan; + state.milestonesExpanded = + flags.milestonesExpanded || initialState.milestonesExpanded; + }); + }, +}); + +export const flagsActions = flagsSlice.actions; + +export default flagsSlice.reducer; diff --git a/client/app/bundles/course/lesson-plan/reducers/lessonPlan.js b/client/app/bundles/course/lesson-plan/reducers/lessonPlan.js deleted file mode 100644 index 16f164f8013..00000000000 --- a/client/app/bundles/course/lesson-plan/reducers/lessonPlan.js +++ /dev/null @@ -1,117 +0,0 @@ -import { deleteIfFound, updateOrAppend } from 'lib/helpers/reducer-helpers'; - -import actionTypes from '../constants'; - -import { - generateTypeKey, - generateVisibilitySettings, - groupItemsUnderMilestones, - initializeVisibility, -} from './utils'; - -const initialState = { - items: [], - milestones: [], - groups: [], - visibilityByType: {}, - isLoading: false, -}; - -export default function (state = initialState, action) { - switch (action.type) { - case actionTypes.SET_ITEM_TYPE_VISIBILITY: { - const visibilityByType = { - ...state.visibilityByType, - [action.itemType]: action.isVisible, - }; - return { ...state, visibilityByType }; - } - case actionTypes.LOAD_LESSON_PLAN_REQUEST: { - return { ...state, isLoading: true }; - } - case actionTypes.LOAD_LESSON_PLAN_FAILURE: { - return { ...state, isLoading: false }; - } - case actionTypes.LOAD_LESSON_PLAN_SUCCESS: { - const items = action.items.map(generateTypeKey); - const visibilitySettings = generateVisibilitySettings( - action.visibilitySettings, - ); - return { - ...state, - items, - milestones: action.milestones, - groups: groupItemsUnderMilestones(items, action.milestones), - visibilityByType: initializeVisibility(items, visibilitySettings), - isLoading: false, - }; - } - case actionTypes.ITEM_UPDATE_SUCCESS: { - const item = action.item.lesson_plan_item_type - ? generateTypeKey(action.item) - : action.item; - const items = updateOrAppend(state.items, item); - return { - ...state, - items, - groups: groupItemsUnderMilestones(items, state.milestones), - }; - } - case actionTypes.MILESTONE_CREATE_SUCCESS: { - const milestones = [...state.milestones, action.milestone]; - return { - ...state, - milestones, - groups: groupItemsUnderMilestones(state.items, milestones), - }; - } - case actionTypes.MILESTONE_UPDATE_SUCCESS: { - const milestones = updateOrAppend(state.milestones, action.milestone); - return { - ...state, - milestones, - groups: groupItemsUnderMilestones(state.items, milestones), - }; - } - case actionTypes.MILESTONE_DELETE_SUCCESS: { - const milestones = deleteIfFound(state.milestones, action.milestoneId); - return { - ...state, - milestones, - groups: groupItemsUnderMilestones(state.items, milestones), - }; - } - case actionTypes.EVENT_CREATE_SUCCESS: { - const items = [...state.items, generateTypeKey(action.event)]; - const { visibilityByType } = state; - return { - ...state, - items, - groups: groupItemsUnderMilestones(items, state.milestones), - visibilityByType: initializeVisibility(items, visibilityByType), - }; - } - case actionTypes.EVENT_UPDATE_SUCCESS: { - const items = updateOrAppend(state.items, generateTypeKey(action.event)); - const { visibilityByType } = state; - return { - ...state, - items, - groups: groupItemsUnderMilestones(items, state.milestones), - visibilityByType: initializeVisibility(items, visibilityByType), - }; - } - case actionTypes.EVENT_DELETE_SUCCESS: { - const items = deleteIfFound(state.items, action.itemId); - const { visibilityByType } = state; - return { - ...state, - items, - groups: groupItemsUnderMilestones(items, state.milestones), - visibilityByType: initializeVisibility(items, visibilityByType), - }; - } - default: - return state; - } -} diff --git a/client/app/bundles/course/lesson-plan/reducers/lessonPlan.ts b/client/app/bundles/course/lesson-plan/reducers/lessonPlan.ts new file mode 100644 index 00000000000..d50ebef29d9 --- /dev/null +++ b/client/app/bundles/course/lesson-plan/reducers/lessonPlan.ts @@ -0,0 +1,172 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import { + LessonPlanEventItem, + LessonPlanGroup, + LessonPlanItem, + LessonPlanItemUpdate, + LessonPlanMilestone, + LessonPlanPayload, +} from '../types'; + +import { + generateTypeKey, + generateVisibilitySettings, + groupItemsUnderMilestones, + initializeVisibility, +} from './utils'; + +export interface LessonPlanState { + items: LessonPlanItem[]; + milestones: LessonPlanMilestone[]; + groups: LessonPlanGroup[]; + visibilityByType: Record; + isLoading: boolean; +} + +const initialState: LessonPlanState = { + items: [], + milestones: [], + groups: [], + visibilityByType: {}, + isLoading: false, +}; + +/** + * Ids arrive as both numbers and strings depending on the payload, so they are + * compared as strings, matching the `reducer-helpers` this slice replaced. + */ +const findById = ( + array: T[], + id?: number | string | null, +): number => array.findIndex((element) => String(element.id) === String(id)); + +/** + * Merges `element` into the matching entry, or appends it. The merge matters: + * an item update carries only the fields that changed, and the rest of the row + * must survive. + */ +const updateOrAppend = ( + array: T[], + element: T, +): T[] => { + const index = findById(array, element.id); + if (index === -1) return [...array, element]; + + const updated = [...array]; + updated[index] = { ...updated[index], ...element }; + return updated; +}; + +const deleteIfFound = ( + array: T[], + id?: number | string | null, +): T[] => { + const index = findById(array, id); + if (index === -1) return array; + + return array.filter((_, position) => position !== index); +}; + +export const lessonPlanSlice = createSlice({ + name: 'lessonPlan', + initialState, + reducers: { + setItemTypeVisibility( + state, + action: PayloadAction<{ itemType: string; isVisible: boolean }>, + ) { + const { itemType, isVisible } = action.payload; + state.visibilityByType[itemType] = isVisible; + }, + + loadRequested(state) { + state.isLoading = true; + }, + + loadFailed(state) { + state.isLoading = false; + }, + + loadSucceeded(state, action: PayloadAction) { + const items = action.payload.items.map(generateTypeKey); + const visibilitySettings = generateVisibilitySettings( + action.payload.visibilitySettings, + ); + + state.items = items; + state.milestones = action.payload.milestones; + state.groups = groupItemsUnderMilestones( + items, + action.payload.milestones, + ); + state.visibilityByType = initializeVisibility(items, visibilitySettings); + state.isLoading = false; + }, + + itemUpdated( + state, + action: PayloadAction, + ) { + const payload = action.payload as LessonPlanItem; + const item = payload.lesson_plan_item_type + ? generateTypeKey(payload) + : payload; + + state.items = updateOrAppend(state.items, item); + state.groups = groupItemsUnderMilestones(state.items, state.milestones); + }, + + milestoneCreated(state, action: PayloadAction) { + state.milestones = [...state.milestones, action.payload]; + state.groups = groupItemsUnderMilestones(state.items, state.milestones); + }, + + milestoneUpdated(state, action: PayloadAction) { + state.milestones = updateOrAppend(state.milestones, action.payload); + state.groups = groupItemsUnderMilestones(state.items, state.milestones); + }, + + milestoneDeleted(state, action: PayloadAction) { + state.milestones = deleteIfFound(state.milestones, action.payload); + state.groups = groupItemsUnderMilestones(state.items, state.milestones); + }, + + eventCreated(state, action: PayloadAction) { + state.items = [ + ...state.items, + generateTypeKey(action.payload as LessonPlanItem), + ]; + state.groups = groupItemsUnderMilestones(state.items, state.milestones); + state.visibilityByType = initializeVisibility( + state.items, + state.visibilityByType, + ); + }, + + eventUpdated(state, action: PayloadAction) { + state.items = updateOrAppend( + state.items, + generateTypeKey(action.payload as LessonPlanItem), + ); + state.groups = groupItemsUnderMilestones(state.items, state.milestones); + state.visibilityByType = initializeVisibility( + state.items, + state.visibilityByType, + ); + }, + + eventDeleted(state, action: PayloadAction) { + state.items = deleteIfFound(state.items, action.payload); + state.groups = groupItemsUnderMilestones(state.items, state.milestones); + state.visibilityByType = initializeVisibility( + state.items, + state.visibilityByType, + ); + }, + }, +}); + +export const lessonPlanActions = lessonPlanSlice.actions; + +export default lessonPlanSlice.reducer; diff --git a/client/app/bundles/course/lesson-plan/reducers/milestoneForm.js b/client/app/bundles/course/lesson-plan/reducers/milestoneForm.js deleted file mode 100644 index 0e8caef501c..00000000000 --- a/client/app/bundles/course/lesson-plan/reducers/milestoneForm.js +++ /dev/null @@ -1,33 +0,0 @@ -import actionTypes from '../constants'; - -export const initialState = { - visible: false, - disabled: false, - onSubmit: () => {}, - formTitle: '', - initialValues: {}, -}; - -export default function (state = initialState, action) { - const { type } = action; - switch (type) { - case actionTypes.MILESTONE_FORM_SHOW: { - return { ...state, ...action.formParams, visible: true }; - } - case actionTypes.MILESTONE_FORM_HIDE: { - return { ...state, visible: false }; - } - case actionTypes.MILESTONE_UPDATE_REQUEST: - case actionTypes.MILESTONE_CREATE_REQUEST: { - return { ...state, disabled: true }; - } - case actionTypes.MILESTONE_UPDATE_SUCCESS: - case actionTypes.MILESTONE_UPDATE_FAILURE: - case actionTypes.MILESTONE_CREATE_SUCCESS: - case actionTypes.MILESTONE_CREATE_FAILURE: { - return { ...state, disabled: false }; - } - default: - return state; - } -} diff --git a/client/app/bundles/course/lesson-plan/reducers/utils.js b/client/app/bundles/course/lesson-plan/reducers/utils.ts similarity index 55% rename from client/app/bundles/course/lesson-plan/reducers/utils.js rename to client/app/bundles/course/lesson-plan/reducers/utils.ts index fec42ff4fb5..ce3c02bcd23 100644 --- a/client/app/bundles/course/lesson-plan/reducers/utils.js +++ b/client/app/bundles/course/lesson-plan/reducers/utils.ts @@ -1,15 +1,22 @@ import moment from 'lib/moment'; +import { + LessonPlanGroup, + LessonPlanItem, + LessonPlanMilestone, + VisibilitySetting, +} from '../types'; + /** * Adds a new attribute itemTypeKey to the lesson plan item. * itemTypeKey has two functions: * 1. It serves as key for the visibilityByType hash * 2. It is used as the display string for the 'type' of the item. */ -export function generateTypeKey(item) { +export function generateTypeKey(item: LessonPlanItem): LessonPlanItem { return { ...item, - itemTypeKey: item.lesson_plan_item_type.join(': '), + itemTypeKey: (item.lesson_plan_item_type ?? []).join(': '), }; } @@ -24,15 +31,20 @@ export function generateTypeKey(item) { * This becomes the visibilitySetting hash { 'Standard Assessment: Tab 2': false } where the key * is in the same format as itemTypeKey. */ -export function generateVisibilitySettings(visibilitySettings) { - const newVisibilitySettings = {}; +export function generateVisibilitySettings( + visibilitySettings: VisibilitySetting[], +): Record { + const newVisibilitySettings: Record = {}; visibilitySettings.forEach((setting) => { newVisibilitySettings[setting.setting_key.join(': ')] = setting.visible; }); return newVisibilitySettings; } -function sortByStartAt(a, b) { +function sortByStartAt( + a: { start_at?: LessonPlanItem['start_at'] }, + b: { start_at?: LessonPlanItem['start_at'] }, +): number { const aStartAt = moment(a.start_at); if (aStartAt.isAfter(b.start_at)) { return 1; @@ -44,60 +56,60 @@ function sortByStartAt(a, b) { } /** - * Groups lesson plan items under their respective milestones. - * An item falls under a milestone if the milestone is the latest milestone - * to have an earlier start_at date-time than the item. - * Items that precedes all milestones are grouped with an empty milestone. - * Items are sorted by startAt, then itemTypeKey, then title. - * - * @param {Array} items - * @param {Array} milestones - * @return {Array.<{ milestone: Object, items: Array }>} + * Groups lesson plan items under the milestone they fall after. Items before the + * first milestone are grouped on their own. */ -export function groupItemsUnderMilestones(items, milestones) { +export function groupItemsUnderMilestones( + items: LessonPlanItem[], + milestones: LessonPlanMilestone[], +): LessonPlanGroup[] { const sortedMilestones = [...milestones].sort(sortByStartAt); const sortedItems = [...items].sort((a, b) => { const startAtSortResult = sortByStartAt(a, b); if (startAtSortResult !== 0) { return startAtSortResult; } - const itemTypeSortResult = a.itemTypeKey.localeCompare(b.itemTypeKey); + const itemTypeSortResult = (a.itemTypeKey ?? '').localeCompare( + b.itemTypeKey ?? '', + ); if (itemTypeSortResult !== 0) { return itemTypeSortResult; } return a.title.localeCompare(b.title); }); - const groups = []; - const group = { id: null, milestone: null, items: [] }; + const groups: LessonPlanGroup[] = []; + let milestone: LessonPlanMilestone | null = null; + let groupItems: LessonPlanItem[] = []; - // Adds current group to groups and resets group - const addGroup = () => { - if (group.items.length > 0 || group.milestone) { - const milestoneId = group.milestone ? group.milestone.id : 'ungrouped'; - group.id = `milestone-group-${milestoneId}`; - groups.push({ ...group }); + // Adds the current group to groups and resets it + const addGroup = (): void => { + if (groupItems.length > 0 || milestone) { + groups.push({ + id: `milestone-group-${milestone ? milestone.id : 'ungrouped'}`, + milestone, + items: groupItems, + }); - group.id = null; - group.milestone = null; - group.items = []; + milestone = null; + groupItems = []; } }; - sortedMilestones.forEach((milestone) => { + sortedMilestones.forEach((nextMilestone) => { // Group items that come before the current milestone under the previous milestone while ( sortedItems.length > 0 && - moment(sortedItems[0].start_at).isBefore(milestone.start_at) + moment(sortedItems[0].start_at).isBefore(nextMilestone.start_at) ) { - group.items.push(sortedItems.shift()); + groupItems.push(sortedItems.shift() as LessonPlanItem); } // Finalize the group, then start a new group with the current milestone addGroup(); - group.milestone = milestone; + milestone = nextMilestone; }); // The remaining items belong with the last milestone - group.items = group.items.concat(sortedItems); + groupItems = groupItems.concat(sortedItems); addGroup(); return groups; @@ -109,14 +121,13 @@ export function groupItemsUnderMilestones(items, milestones) { * as read from the given visibilitySettings. * * All other items are visible by default. - * - * @param {Array} items - * @param {{itemTypeKey: Boolean}} visibilitySettings keyed by itemTypeKey - * @return {Object} */ -export function initializeVisibility(items, visibilitySettings) { - const itemTypes = new Set(items.map((item) => item.itemTypeKey)); - const visibility = {}; +export function initializeVisibility( + items: LessonPlanItem[], + visibilitySettings: Record, +): Record { + const itemTypes = new Set(items.map((item) => item.itemTypeKey ?? '')); + const visibility: Record = {}; itemTypes.forEach((itemType) => { const hasVisibilitySetting = Object.prototype.hasOwnProperty.call( visibilitySettings, diff --git a/client/app/bundles/course/lesson-plan/store.ts b/client/app/bundles/course/lesson-plan/store.ts index 2558d862979..7bb14ab070b 100644 --- a/client/app/bundles/course/lesson-plan/store.ts +++ b/client/app/bundles/course/lesson-plan/store.ts @@ -1,40 +1,13 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { combineReducers } from 'redux'; -import eventFormReducer from './reducers/eventForm'; -import flagsReducer from './reducers/flags'; -import lessonPlanReducer from './reducers/lessonPlan'; -import milestoneFormReducer from './reducers/milestoneForm'; -import actionTypes from './constants'; +import flagsReducer, { flagsActions } from './reducers/flags'; +import lessonPlanReducer, { lessonPlanActions } from './reducers/lessonPlan'; const reducer = combineReducers({ flags: flagsReducer, lessonPlan: lessonPlanReducer, - eventForm: eventFormReducer, - milestoneForm: milestoneFormReducer, }); -export const actions = { - setItemTypeVisibility: (itemType, isVisible) => ({ - type: actionTypes.SET_ITEM_TYPE_VISIBILITY, - itemType, - isVisible, - }), - setColumnVisibility: (field, isVisible) => ({ - type: actionTypes.SET_COLUMN_VISIBILITY, - field, - isVisible, - }), - showMilestoneForm: (formParams) => ({ - type: actionTypes.MILESTONE_FORM_SHOW, - formParams, - }), - hideMilestoneForm: () => ({ type: actionTypes.MILESTONE_FORM_HIDE }), - showEventForm: (formParams) => ({ - type: actionTypes.EVENT_FORM_SHOW, - formParams, - }), - hideEventForm: () => ({ type: actionTypes.EVENT_FORM_HIDE }), -}; +export const actions = { ...lessonPlanActions, ...flagsActions }; export default reducer; diff --git a/client/app/bundles/course/lesson-plan/types.ts b/client/app/bundles/course/lesson-plan/types.ts new file mode 100644 index 00000000000..297546287ba --- /dev/null +++ b/client/app/bundles/course/lesson-plan/types.ts @@ -0,0 +1,131 @@ +import { ReactNode } from 'react'; +import { UseFormSetError } from 'react-hook-form'; + +/** A datetime as it arrives from the server, or as constructed by the pickers. */ +export type LessonPlanDate = string | Date | null; + +/** + * A partial update to a lesson plan item. Dates are ISO strings, or `null` when + * the field is being cleared. + */ +export interface LessonPlanItemUpdate { + start_at?: string | null; + bonus_end_at?: string | null; + end_at?: string | null; + published?: boolean; +} + +export type LessonPlanItemUpdateField = keyof LessonPlanItemUpdate; + +/** + * A lesson plan item as the server serialises it: the fields common to every + * item (see `_item.json.jbuilder`), plus the actable-specific ones these pages + * read. + * + * There is no discriminant on the payload — `lesson_plan_item_type` is an array + * of instructor-defined tab titles, not a type tag — so the actable-specific + * fields are optional rather than a discriminated union. + */ +export interface LessonPlanItem { + id: number; + title: string; + published: boolean; + start_at: LessonPlanDate; + bonus_end_at?: LessonPlanDate; + end_at?: LessonPlanDate; + lesson_plan_item_type?: string[]; + /** Derived client-side by `generateTypeKey`, not sent by the server. */ + itemTypeKey?: string; + item_path?: string; + eventId?: number; + location?: string; + description?: string; +} + +/** + * A lesson plan item that is a course event. Every field is optional, mirroring + * the shape `AdminTools` accepted before the conversion: `id` is the lesson plan + * item, `eventId` the event resource behind it. + */ +export interface LessonPlanEventItem { + id?: number; + eventId?: number; + title?: string; + published?: boolean; + location?: string; + description?: string; + start_at?: LessonPlanDate; + end_at?: LessonPlanDate; + lesson_plan_item_type?: string[]; +} + +/** A milestone as the server sends it. */ +export interface LessonPlanMilestone { + id: number; + title: string; + description?: string | null; + start_at?: LessonPlanDate; +} + +/** + * What the show page's admin tools receive. `LessonPlanGroup` synthesises an + * "Ungrouped Items" placeholder with a null id and a translated element for a + * title, and the tools hide themselves for it. + */ +export interface MilestoneOrPlaceholder { + id?: number | null; + title?: ReactNode; + description?: string | null; + start_at?: LessonPlanDate; +} + +/** Values submitted by the milestone form. */ +export interface MilestoneFormValues { + title?: string; + description?: string; + start_at?: LessonPlanDate; +} + +/** Values submitted by the event form. */ +export interface EventFormValues { + title?: string; + event_type?: string; + location?: string; + description?: string; + start_at?: LessonPlanDate; + end_at?: LessonPlanDate; + published?: boolean; +} + +/** + * A dialog's submit handler, supplied by whoever opens the dialog. Resolves to + * whether the request succeeded, which is what tells the dialog to close itself. + */ +export type FormSubmitHandler = ( + values: Values, + setError: UseFormSetError>, +) => Promise; + +/** Items grouped under the milestone they fall after; see `groupItemsUnderMilestones`. */ +export interface LessonPlanGroup { + id: string; + milestone: LessonPlanMilestone | null; + items: LessonPlanItem[]; +} + +/** An item type's visibility as the server sends it, keyed by its type path. */ +export interface VisibilitySetting { + setting_key: string[]; + visible: boolean; +} + +/** The payload of a lesson plan fetch. */ +export interface LessonPlanPayload { + items: LessonPlanItem[]; + milestones: LessonPlanMilestone[]; + visibilitySettings: VisibilitySetting[]; + flags: { + canManageLessonPlan: boolean; + milestonesExpanded: string; + }; +}