From cc6555e8dfaf048230931ea1d86bc222a4278eb9 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sun, 15 Jan 2023 12:32:25 +0800 Subject: [PATCH 01/22] style(coursesettingform): fix course start time label typo --- .../bundles/course/admin/pages/CourseSettings/translations.ts | 2 +- client/locales/en.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/app/bundles/course/admin/pages/CourseSettings/translations.ts b/client/app/bundles/course/admin/pages/CourseSettings/translations.ts index 7d9e128470a..e9b7279fda2 100644 --- a/client/app/bundles/course/admin/pages/CourseSettings/translations.ts +++ b/client/app/bundles/course/admin/pages/CourseSettings/translations.ts @@ -53,7 +53,7 @@ export default defineMessages({ }, startsAt: { id: 'course.admin.CourseSettings.startsAt', - defaultMessage: 'Starts as', + defaultMessage: 'Starts at', }, endsAt: { id: 'course.admin.CourseSettings.endsAt', diff --git a/client/locales/en.json b/client/locales/en.json index 76789097959..4372e010cef 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -486,7 +486,7 @@ "defaultMessage": "Start time is required." }, "course.admin.CourseSettings.startsAt": { - "defaultMessage": "Starts as" + "defaultMessage": "Starts at" }, "course.admin.CourseSettings.stragglers": { "defaultMessage": "Stragglers" @@ -1041,7 +1041,7 @@ "defaultMessage": "Students can already move between questions in manually graded assessments." }, "course.assessment.AssessmentForm.startAt": { - "defaultMessage": "Starts at *" + "defaultMessage": "Starts at" }, "course.assessment.AssessmentForm.startEndValidationError": { "defaultMessage": "Must be after starting time" From 53a452173dcebee5802cadf3cb75e0ca0014653e Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sun, 15 Jan 2023 12:37:57 +0800 Subject: [PATCH 02/22] feat(formdatetimepickerfield): supports `required` prop --- .../course/admin/pages/CourseSettings/CourseSettingsForm.tsx | 1 + .../course/assessment/components/AssessmentForm/index.tsx | 1 + .../assessment/components/AssessmentForm/translations.intl.js | 2 +- client/app/lib/components/form/fields/DateTimePickerField.jsx | 3 +++ 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/client/app/bundles/course/admin/pages/CourseSettings/CourseSettingsForm.tsx b/client/app/bundles/course/admin/pages/CourseSettings/CourseSettingsForm.tsx index a548fd4364c..0f00da7c817 100644 --- a/client/app/bundles/course/admin/pages/CourseSettings/CourseSettingsForm.tsx +++ b/client/app/bundles/course/admin/pages/CourseSettings/CourseSettingsForm.tsx @@ -169,6 +169,7 @@ const CourseSettingsForm = (props: CourseSettingsFormProps): JSX.Element => { field={field} fieldState={fieldState} label={t(translations.startsAt)} + required variant="filled" /> )} diff --git a/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx b/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx index a75f413b13b..636df029616 100644 --- a/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx +++ b/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx @@ -214,6 +214,7 @@ const AssessmentForm = (props: AssessmentFormProps): JSX.Element => { field={field} fieldState={fieldState} label={intl.formatMessage(t.startAt)} + required variant="filled" /> )} diff --git a/client/app/bundles/course/assessment/components/AssessmentForm/translations.intl.js b/client/app/bundles/course/assessment/components/AssessmentForm/translations.intl.js index b007fd13dc3..e5c5c073df1 100644 --- a/client/app/bundles/course/assessment/components/AssessmentForm/translations.intl.js +++ b/client/app/bundles/course/assessment/components/AssessmentForm/translations.intl.js @@ -11,7 +11,7 @@ const translations = defineMessages({ }, startAt: { id: 'course.assessment.AssessmentForm.startAt', - defaultMessage: 'Starts at *', + defaultMessage: 'Starts at', }, endAt: { id: 'course.assessment.AssessmentForm.endAt', diff --git a/client/app/lib/components/form/fields/DateTimePickerField.jsx b/client/app/lib/components/form/fields/DateTimePickerField.jsx index 66c4b6a7712..75ce2b757e9 100644 --- a/client/app/lib/components/form/fields/DateTimePickerField.jsx +++ b/client/app/lib/components/form/fields/DateTimePickerField.jsx @@ -39,6 +39,7 @@ const FormDateTimePickerField = (props) => { disabled, label, renderIf, + required, style, className, variant = 'standard', @@ -82,6 +83,7 @@ const FormDateTimePickerField = (props) => { InputLabelProps: { shrink: true }, })} ref={field.ref} + required={required} variant={variant} {...(disableMargins ? null : { style: styles.dateTimeTextField })} /> @@ -109,6 +111,7 @@ FormDateTimePickerField.propTypes = { variant: PropTypes.string, disableMargins: PropTypes.bool, disableShrinkingLabel: PropTypes.bool, + required: PropTypes.bool, }; export default injectIntl(FormDateTimePickerField); From 27108455effd9579840746ebcff26bf6a22cb29b Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sun, 15 Jan 2023 12:40:18 +0800 Subject: [PATCH 03/22] feat(formdatetimepickerfield): can not always check format errors --- .../lib/components/form/fields/DateTimePickerField.jsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/app/lib/components/form/fields/DateTimePickerField.jsx b/client/app/lib/components/form/fields/DateTimePickerField.jsx index 75ce2b757e9..813654adfae 100644 --- a/client/app/lib/components/form/fields/DateTimePickerField.jsx +++ b/client/app/lib/components/form/fields/DateTimePickerField.jsx @@ -45,6 +45,7 @@ const FormDateTimePickerField = (props) => { variant = 'standard', disableMargins, disableShrinkingLabel, + suppressesFormatErrors, ...custom } = props; @@ -70,12 +71,15 @@ const FormDateTimePickerField = (props) => { Date: Sun, 15 Jan 2023 12:42:32 +0800 Subject: [PATCH 04/22] feat(prompt): `onClick` now forwards native event --- client/app/lib/components/core/dialogs/Prompt.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/app/lib/components/core/dialogs/Prompt.tsx b/client/app/lib/components/core/dialogs/Prompt.tsx index d78c87c7d71..c1ed887a056 100644 --- a/client/app/lib/components/core/dialogs/Prompt.tsx +++ b/client/app/lib/components/core/dialogs/Prompt.tsx @@ -1,4 +1,4 @@ -import { ComponentProps, ReactNode } from 'react'; +import { ComponentProps, MouseEventHandler, ReactNode } from 'react'; import { Button, Dialog, @@ -23,7 +23,7 @@ interface BasePromptProps { type DefaultActionProps = { [key in Action as `${key}Label`]?: string; } & { - [key in Action as `onClick${Capitalize}`]?: () => void; + [key in Action as `onClick${Capitalize}`]?: MouseEventHandler; } & { [key in Action as `${key}Color`]?: ComponentProps['color']; } & { @@ -35,7 +35,7 @@ type DefaultActionProps = { type OverriddenActionProps = { [key in Action as `${key}Label`]?: never; } & { - [key in Action as `onClick${Capitalize}`]?: () => never; + [key in Action as `onClick${Capitalize}`]?: never; } & { [key in Action as `${key}Color`]?: never; } & { From 14cb77c263de10bcc51d4207d13471b9325526c9 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sun, 15 Jan 2023 12:43:02 +0800 Subject: [PATCH 05/22] feat(prompt): add event handler when prompt has fully closed --- client/app/lib/components/core/dialogs/Prompt.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/app/lib/components/core/dialogs/Prompt.tsx b/client/app/lib/components/core/dialogs/Prompt.tsx index c1ed887a056..9a7799424a7 100644 --- a/client/app/lib/components/core/dialogs/Prompt.tsx +++ b/client/app/lib/components/core/dialogs/Prompt.tsx @@ -16,6 +16,7 @@ interface BasePromptProps { title?: string | ReactNode; children?: string | ReactNode; onClose?: () => void; + onClosed?: () => void; disabled?: boolean; contentClassName?: string; } @@ -63,7 +64,12 @@ const Prompt = (props: PromptProps): JSX.Element => { }; return ( - + {props.title && {props.title}} {props.children && ( From e2b0a30535589374caaeb5bdea28c48625c8b1c1 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sun, 15 Jan 2023 12:44:14 +0800 Subject: [PATCH 06/22] feat(textfield): add event handlers when press enter and esc keys --- .../core/fields/SwitchableTextField.tsx | 25 ++----------------- .../lib/components/core/fields/TextField.tsx | 18 ++++++++++++- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/client/app/lib/components/core/fields/SwitchableTextField.tsx b/client/app/lib/components/core/fields/SwitchableTextField.tsx index 521ed7339f7..42ff28672f9 100644 --- a/client/app/lib/components/core/fields/SwitchableTextField.tsx +++ b/client/app/lib/components/core/fields/SwitchableTextField.tsx @@ -1,4 +1,4 @@ -import { ComponentProps, KeyboardEventHandler } from 'react'; +import { ComponentProps } from 'react'; import { Typography } from '@mui/material'; import { useTheme } from '@mui/material/styles'; @@ -7,33 +7,13 @@ import TextField from 'lib/components/core/fields/TextField'; type SwitchableTextFieldProps = ComponentProps & { editable: boolean; textProps?: ComponentProps; - onPressEnter?: () => void; - onPressEscape?: () => void; }; const SwitchableTextField = (props: SwitchableTextFieldProps): JSX.Element => { - const { - editable, - textProps, - onPressEnter, - onPressEscape, - ...textFieldProps - } = props; + const { editable, textProps, ...textFieldProps } = props; const { typography } = useTheme(); - const handleKeyDown: KeyboardEventHandler = (e): void => { - if (onPressEnter && e.key === 'Enter') { - e.preventDefault(); - onPressEnter(); - } - - if (onPressEscape && e.key === 'Escape') { - e.preventDefault(); - onPressEscape(); - } - }; - if (!editable) return ( { & { trims?: boolean; + onPressEnter?: () => void; + onPressEscape?: () => void; }; const TextField = forwardRef( (props, ref): JSX.Element => { - const { trims, ...textFieldProps } = props; + const { trims, onPressEnter, onPressEscape, ...textFieldProps } = props; const handleChange: ChangeEventHandler = (e): void => { if (trims) { @@ -31,12 +34,25 @@ const TextField = forwardRef( return props.onBlur?.(e); }; + const handleKeyDown: KeyboardEventHandler = (e): void => { + if (onPressEnter && e.key === 'Enter') { + e.preventDefault(); + onPressEnter(); + } + + if (onPressEscape && e.key === 'Escape') { + e.preventDefault(); + onPressEscape(); + } + }; + return ( ); }, From e13ca56169bd552645d25284c59b93b0a46b0491 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sun, 15 Jan 2023 12:48:09 +0800 Subject: [PATCH 07/22] feat(preload): support `void` promises --- client/app/lib/components/wrappers/Preload.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/client/app/lib/components/wrappers/Preload.tsx b/client/app/lib/components/wrappers/Preload.tsx index 40dfa344f7d..54191b65ba0 100644 --- a/client/app/lib/components/wrappers/Preload.tsx +++ b/client/app/lib/components/wrappers/Preload.tsx @@ -21,9 +21,15 @@ interface PreloadProps { after?: number; } +interface PreloadState { + preloaded: boolean; + data: Data; +} + const Preload = (props: PreloadProps): JSX.Element => { const { t } = useTranslation(); - const [data, setData] = useState(); + + const [state, setState] = useState>(); const [loading, setLoading] = useState(true); const [failed, toggleFailed] = useToggle(); @@ -32,8 +38,8 @@ const Preload = (props: PreloadProps): JSX.Element => { props .while() - .then((result) => { - if (!ignore) setData(result); + .then((data) => { + if (!ignore) setState({ preloaded: true, data }); }) .catch((error: AxiosError) => { toggleFailed(); @@ -68,7 +74,9 @@ const Preload = (props: PreloadProps): JSX.Element => { const refreshable = (element: JSX.Element): JSX.Element => loading ? props.render : element; - return data ? props.children(data, refreshable) : props.render; + if (!state?.preloaded) return props.render; + + return props.children(state.data, refreshable); }; export default Preload; From b1a45d027ff2808efea90f89075a3fbbd8a90ff3 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sun, 15 Jan 2023 12:48:47 +0800 Subject: [PATCH 08/22] feat(preload): support direct reactnode as `children` --- client/app/lib/components/wrappers/Preload.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/client/app/lib/components/wrappers/Preload.tsx b/client/app/lib/components/wrappers/Preload.tsx index 54191b65ba0..88a6ffc2741 100644 --- a/client/app/lib/components/wrappers/Preload.tsx +++ b/client/app/lib/components/wrappers/Preload.tsx @@ -10,10 +10,12 @@ import messagesTranslations from 'lib/translations/messages'; interface PreloadProps { while: () => Promise; render: JSX.Element; - children: ( - data: Data, - refreshable: (element: JSX.Element) => JSX.Element, - ) => JSX.Element; + children: + | JSX.Element + | (( + data: Data, + refreshable: (element: JSX.Element) => JSX.Element, + ) => JSX.Element); onErrorDo?: (error: unknown) => void; silently?: boolean; onErrorToast?: string; @@ -76,7 +78,10 @@ const Preload = (props: PreloadProps): JSX.Element => { if (!state?.preloaded) return props.render; - return props.children(state.data, refreshable); + if (typeof props.children === 'function') + return props.children(state.data, refreshable); + + return props.children; }; export default Preload; From 6e4400e7784b1f93319f10a85a4d4918bbe7882d Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sun, 15 Jan 2023 12:49:22 +0800 Subject: [PATCH 09/22] feat(checkbox): support custom typography variant for description --- client/app/lib/components/core/buttons/Checkbox.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/app/lib/components/core/buttons/Checkbox.tsx b/client/app/lib/components/core/buttons/Checkbox.tsx index e53c4ad6a3e..cbda1f382ff 100644 --- a/client/app/lib/components/core/buttons/Checkbox.tsx +++ b/client/app/lib/components/core/buttons/Checkbox.tsx @@ -13,6 +13,7 @@ type CheckboxProps = ComponentProps & { disabledHint?: string | JSX.Element; error?: string; variant?: ComponentProps['variant']; + descriptionVariant?: ComponentProps['variant']; labelClassName?: string; }; @@ -23,6 +24,7 @@ const Checkbox = forwardRef( label, dangerouslySetInnerHTML, description, + descriptionVariant, disabledHint, error, labelClassName, @@ -36,6 +38,7 @@ const Checkbox = forwardRef( className={`mb-0 ${props.readOnly ? 'cursor-auto' : ''} ${ labelClassName ?? '' }`} + componentsProps={{ typography: { variant } }} control={createElement(component ?? MuiCheckbox, { ref, ...checkboxProps, @@ -65,7 +68,7 @@ const Checkbox = forwardRef( {description && ( {description} From f14cd03544989f97a6951bae2743f62c89174e07 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sun, 15 Jan 2023 12:49:50 +0800 Subject: [PATCH 10/22] feat(form): support custom `className`s --- client/app/lib/components/form/Form.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/app/lib/components/form/Form.tsx b/client/app/lib/components/form/Form.tsx index c8cee49a076..ef88ac3296d 100644 --- a/client/app/lib/components/form/Form.tsx +++ b/client/app/lib/components/form/Form.tsx @@ -55,6 +55,7 @@ interface FormProps extends Emits { formState: FormState, ) => ReactNode; disabled?: boolean; + className?: string; /** * Dispatched when the form is reset. Return `true` to prevent the default form @@ -138,7 +139,10 @@ const Form = (props: FormProps): JSX.Element => { }; return ( -
+ {props.children?.(control, watch, formState)} {props.headsUp && ( From 8cc575f28c49b55a3c8e2a56ab34052a3d56d044 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sun, 15 Jan 2023 12:54:00 +0800 Subject: [PATCH 11/22] chore: remove redundant reselect redux toolkit already re-exports the same `createSelector` from reselect --- .../bundles/course/duplication/selectors/destinationCourse.js | 2 +- .../app/bundles/course/video/submission/selectors/discussion.js | 2 +- client/package.json | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/client/app/bundles/course/duplication/selectors/destinationCourse.js b/client/app/bundles/course/duplication/selectors/destinationCourse.js index 1223847cbb0..4d4e8028752 100644 --- a/client/app/bundles/course/duplication/selectors/destinationCourse.js +++ b/client/app/bundles/course/duplication/selectors/destinationCourse.js @@ -1,4 +1,4 @@ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; const destinationCourseIdSelector = (state) => state.duplication.destinationCourseId; diff --git a/client/app/bundles/course/video/submission/selectors/discussion.js b/client/app/bundles/course/video/submission/selectors/discussion.js index 3fa2a7d5773..47a1457f029 100644 --- a/client/app/bundles/course/video/submission/selectors/discussion.js +++ b/client/app/bundles/course/video/submission/selectors/discussion.js @@ -1,4 +1,4 @@ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; const topicsSelector = (state) => state.discussion.topics; diff --git a/client/package.json b/client/package.json index 2d0bb46fa54..6faf89f503a 100644 --- a/client/package.json +++ b/client/package.json @@ -92,7 +92,6 @@ "redux-immutable": "^4.0.0", "redux-persist": "^6.0.0", "redux-thunk": "^2.4.2", - "reselect": "^4.1.7", "rollbar": "^2.26.1", "sass": "^1.57.1", "webfontloader": "^1.6.28", From dea50d092c587177300e461690c9979b88075695 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sun, 15 Jan 2023 12:57:38 +0800 Subject: [PATCH 12/22] feat(reference-timelines): add react timeline designer --- .../course/reference_timelines.scss | 6 + client/app/api/course/ReferenceTimelines.ts | 61 +++++ client/app/api/course/index.js | 2 + .../reference-timelines/TimelineDesigner.tsx | 24 ++ .../components/CreateRenameTimelinePrompt.tsx | 154 ++++++++++++ .../components/DayCalendar/DayCalendar.tsx | 111 +++++++++ .../components/DayCalendar/DayColumn.tsx | 43 ++++ .../components/DayCalendar/index.ts | 3 + .../components/DeleteTimelinePrompt.tsx | 101 ++++++++ .../components/HorizontallyDraggable.tsx | 44 ++++ .../components/HorizontallyResizable.tsx | 119 ++++++++++ .../components/RowSpacer.tsx | 3 + .../components/SearchField.tsx | 59 +++++ .../components/SeriouslyAnchoredPopup.tsx | 50 ++++ .../components/SubmitIndicator.tsx | 110 +++++++++ .../components/TimeBar/DurationBar.tsx | 130 +++++++++++ .../components/TimeBar/TimeBar.tsx | 151 ++++++++++++ .../components/TimeBar/TimeBarHandle.tsx | 119 ++++++++++ .../components/TimeBar/index.ts | 3 + .../components/TimePopup/TimePopup.tsx | 164 +++++++++++++ .../components/TimePopup/TimePopupForm.tsx | 147 ++++++++++++ .../components/TimePopup/TimePopupTopBar.tsx | 57 +++++ .../components/TimePopup/index.ts | 3 + .../TimelinesOverview/TimelinesOverview.tsx | 77 ++++++ .../TimelinesOverviewItem.tsx | 131 +++++++++++ .../components/TimelinesOverview/index.ts | 3 + .../TimelinesStack/AssignableTimeline.tsx | 75 ++++++ .../TimelinesStack/AssignedTimeline.tsx | 82 +++++++ .../components/TimelinesStack/Timeline.tsx | 71 ++++++ .../TimelinesStack/TimelinesStack.tsx | 71 ++++++ .../components/TimelinesStack/index.ts | 3 + .../contexts/LastSavedContext.tsx | 59 +++++ .../reference-timelines/contexts/index.ts | 1 + .../course/reference-timelines/index.tsx | 31 +++ .../course/reference-timelines/operations.ts | 176 ++++++++++++++ .../course/reference-timelines/store/hooks.ts | 7 + .../course/reference-timelines/store/index.ts | 4 + .../course/reference-timelines/store/store.ts | 17 ++ .../store/timelinesSelectors.ts | 17 ++ .../store/timelinesSlice.ts | 132 +++++++++++ .../reference-timelines/translations.ts | 219 ++++++++++++++++++ .../course/reference-timelines/utils.ts | 37 +++ .../views/DayView/DayView.tsx | 112 +++++++++ .../views/DayView/ItemsSidebar.tsx | 64 +++++ .../views/DayView/TimelineSidebarItem.tsx | 51 ++++ .../views/DayView/index.ts | 3 + client/app/theme/index.css | 12 + client/app/types/course/referenceTimelines.ts | 44 ++++ client/package.json | 7 + client/yarn.lock | 56 ++++- 50 files changed, 3223 insertions(+), 3 deletions(-) create mode 100644 app/assets/stylesheets/course/reference_timelines.scss create mode 100644 client/app/api/course/ReferenceTimelines.ts create mode 100644 client/app/bundles/course/reference-timelines/TimelineDesigner.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/CreateRenameTimelinePrompt.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/DayCalendar/DayCalendar.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/DayCalendar/DayColumn.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/DayCalendar/index.ts create mode 100644 client/app/bundles/course/reference-timelines/components/DeleteTimelinePrompt.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/HorizontallyDraggable.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/HorizontallyResizable.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/RowSpacer.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/SearchField.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/SeriouslyAnchoredPopup.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/SubmitIndicator.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/TimeBar/DurationBar.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/TimeBar/TimeBar.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/TimeBar/TimeBarHandle.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/TimeBar/index.ts create mode 100644 client/app/bundles/course/reference-timelines/components/TimePopup/TimePopup.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/TimePopup/TimePopupForm.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/TimePopup/TimePopupTopBar.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/TimePopup/index.ts create mode 100644 client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverview.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverviewItem.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/TimelinesOverview/index.ts create mode 100644 client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignableTimeline.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignedTimeline.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/TimelinesStack/Timeline.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/TimelinesStack/TimelinesStack.tsx create mode 100644 client/app/bundles/course/reference-timelines/components/TimelinesStack/index.ts create mode 100644 client/app/bundles/course/reference-timelines/contexts/LastSavedContext.tsx create mode 100644 client/app/bundles/course/reference-timelines/contexts/index.ts create mode 100644 client/app/bundles/course/reference-timelines/index.tsx create mode 100644 client/app/bundles/course/reference-timelines/operations.ts create mode 100644 client/app/bundles/course/reference-timelines/store/hooks.ts create mode 100644 client/app/bundles/course/reference-timelines/store/index.ts create mode 100644 client/app/bundles/course/reference-timelines/store/store.ts create mode 100644 client/app/bundles/course/reference-timelines/store/timelinesSelectors.ts create mode 100644 client/app/bundles/course/reference-timelines/store/timelinesSlice.ts create mode 100644 client/app/bundles/course/reference-timelines/translations.ts create mode 100644 client/app/bundles/course/reference-timelines/utils.ts create mode 100644 client/app/bundles/course/reference-timelines/views/DayView/DayView.tsx create mode 100644 client/app/bundles/course/reference-timelines/views/DayView/ItemsSidebar.tsx create mode 100644 client/app/bundles/course/reference-timelines/views/DayView/TimelineSidebarItem.tsx create mode 100644 client/app/bundles/course/reference-timelines/views/DayView/index.ts create mode 100644 client/app/types/course/referenceTimelines.ts diff --git a/app/assets/stylesheets/course/reference_timelines.scss b/app/assets/stylesheets/course/reference_timelines.scss new file mode 100644 index 00000000000..ed1b82fad95 --- /dev/null +++ b/app/assets/stylesheets/course/reference_timelines.scss @@ -0,0 +1,6 @@ +// TODO: Remove once sidebar is fixed, and set Timeline Designer height +// to fill viewport until bottom of viewport. +// 126px is the height of the topbar + breadcrumb + margins in between. +#course-timeline-designer { + height: calc(100vh - 126px); +} diff --git a/client/app/api/course/ReferenceTimelines.ts b/client/app/api/course/ReferenceTimelines.ts new file mode 100644 index 00000000000..933d13d94ea --- /dev/null +++ b/client/app/api/course/ReferenceTimelines.ts @@ -0,0 +1,61 @@ +import { AxiosResponse } from 'axios'; +import { + TimeData, + TimelineData, + TimelinePostData, + TimelinesData, + TimePostData, +} from 'types/course/referenceTimelines'; + +import BaseCourseAPI from './Base'; + +type Response = Promise>; + +export default class ReferenceTimelinesAPI extends BaseCourseAPI { + _getUrlPrefix(id?: TimelineData['id']): string { + return `/courses/${this.getCourseId()}/timelines${id ? `/${id}` : ''}`; + } + + index(): Response { + return this.getClient().get(this._getUrlPrefix()); + } + + create(data: TimelinePostData): Response { + return this.getClient().post(this._getUrlPrefix(), data); + } + + delete( + id: TimelineData['id'], + alternativeTimelineId?: TimelineData['id'], + ): Response { + return this.getClient().delete(`${this._getUrlPrefix(id)}`, { + params: { revert_to: alternativeTimelineId }, + }); + } + + update(id: TimelineData['id'], data: TimelinePostData): Response { + return this.getClient().patch(`${this._getUrlPrefix(id)}`, data); + } + + createTime( + id: TimelineData['id'], + data: TimePostData, + ): Response<{ id: TimeData['id'] }> { + return this.getClient().post(`${this._getUrlPrefix(id)}/times`, data); + } + + deleteTime(id: TimelineData['id'], timeId: TimeData['id']): Response { + return this.getClient().delete(`${this._getUrlPrefix(id)}/times/${timeId}`); + } + + updateTime( + id: TimelineData['id'], + timeId: TimeData['id'], + data: TimePostData, + ): Response { + return this.getClient().patch( + `${this._getUrlPrefix(id)}/times/${timeId}`, + data, + ); + } +} diff --git a/client/app/api/course/index.js b/client/app/api/course/index.js index 58f32268e55..b8bd4267709 100644 --- a/client/app/api/course/index.js +++ b/client/app/api/course/index.js @@ -20,6 +20,7 @@ import LevelAPI from './Level'; import MaterialFoldersAPI from './MaterialFolders'; import MaterialsAPI from './Materials'; import PersonalTimesAPI from './PersonalTimes'; +import ReferenceTimelinesAPI from './ReferenceTimelines'; import StatisticsAPI from './Statistics'; import SurveyAPI from './Survey'; import UserEmailSubscriptionsAPI from './UserEmailSubscriptions'; @@ -51,6 +52,7 @@ const CourseAPI = { materials: new MaterialsAPI(), materialFolders: new MaterialFoldersAPI(), personalTimes: new PersonalTimesAPI(), + referenceTimelines: new ReferenceTimelinesAPI(), statistics: StatisticsAPI, submissions: new SubmissionsAPI(), survey: SurveyAPI, diff --git a/client/app/bundles/course/reference-timelines/TimelineDesigner.tsx b/client/app/bundles/course/reference-timelines/TimelineDesigner.tsx new file mode 100644 index 00000000000..b7089cd9ae5 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/TimelineDesigner.tsx @@ -0,0 +1,24 @@ +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; + +import DayView from './views/DayView'; +import { LastSavedProvider } from './contexts'; +import { fetchTimelines } from './operations'; +import { useAppDispatch } from './store'; + +const TimelineDesigner = (): JSX.Element => { + const dispatch = useAppDispatch(); + + return ( + + } + while={(): Promise => dispatch(fetchTimelines())} + > + + + + ); +}; + +export default TimelineDesigner; diff --git a/client/app/bundles/course/reference-timelines/components/CreateRenameTimelinePrompt.tsx b/client/app/bundles/course/reference-timelines/components/CreateRenameTimelinePrompt.tsx new file mode 100644 index 00000000000..941fb655593 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/CreateRenameTimelinePrompt.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import { Alert } from '@mui/material'; +import { TimelineData } from 'types/course/referenceTimelines'; + +import Prompt from 'lib/components/core/dialogs/Prompt'; +import TextField from 'lib/components/core/fields/TextField'; +import useTranslation from 'lib/hooks/useTranslation'; +import formTranslations from 'lib/translations/form'; + +import { useSetLastSaved } from '../contexts'; +import { createTimeline, updateTimeline } from '../operations'; +import { useAppDispatch } from '../store'; +import translations from '../translations'; + +interface CreateRenameTimelinePromptProps { + open: boolean; + onClose: () => void; + renames?: TimelineData; +} + +const isValidTitle = (title: string): boolean => + title !== '' && title.length < 255; + +const CreateRenameTimelinePrompt = ( + props: CreateRenameTimelinePromptProps, +): JSX.Element => { + const { renames: timeline } = props; + + const { t } = useTranslation(); + + const dispatch = useAppDispatch(); + + const [newTitle, setNewTitle] = useState(timeline?.title ?? ''); + const [submitted, setSubmitted] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const { startLoading, abortLoading, setLastSavedToNow } = useSetLastSaved(); + + const isInvalidTitle = submitted && !isValidTitle(newTitle); + + const titleErrorText = + newTitle.length >= 255 + ? t(formTranslations.characters) + : t(translations.mustValidTimelineTitle); + + const resetPrompt = (): void => { + setNewTitle(timeline?.title ?? ''); + setSubmitted(false); + }; + + const handleCreateTimeline = (): void => { + setSubmitting(true); + startLoading(); + + dispatch(createTimeline(newTitle)) + .then(() => { + props.onClose(); + setLastSavedToNow(); + }) + .catch((error) => { + abortLoading(); + toast.error( + error ?? t(translations.errorCreatingTimeline, { newTitle }), + ); + }) + .finally(() => setSubmitting(false)); + }; + + const handleRenameTimeline = (): void => { + if (!timeline) throw new Error(`Trying to rename ${timeline} timeline.`); + + if (newTitle !== timeline.title) { + startLoading(); + + dispatch(updateTimeline(timeline.id, { title: newTitle })) + .then(() => { + props.onClose(); + setLastSavedToNow(); + }) + .catch((error) => { + abortLoading(); + + toast.error( + error ?? t(translations.errorRenamingTimeline, { newTitle }), + ); + }); + } else { + props.onClose?.(); + } + }; + + const handleConfirmTitle = (): void => { + setSubmitted(true); + if (!isValidTitle(newTitle)) return; + + if (timeline) { + handleRenameTimeline(); + } else { + handleCreateTimeline(); + } + }; + + return ( + + setNewTitle(e.target.value)} + onPressEnter={handleConfirmTitle} + placeholder={timeline?.title} + trims + value={newTitle} + variant="filled" + /> + + {!timeline && ( + + {t(translations.hintCanAddCustomTimes)} + +
    +
  • {t(translations.hintAssignedStudentsSeeCustomTimes)}
  • +
  • {t(translations.hintAssignedStudentsSeeDefaultTimes)}
  • +
+
+ )} +
+ ); +}; + +export default CreateRenameTimelinePrompt; diff --git a/client/app/bundles/course/reference-timelines/components/DayCalendar/DayCalendar.tsx b/client/app/bundles/course/reference-timelines/components/DayCalendar/DayCalendar.tsx new file mode 100644 index 00000000000..3e4e8a518db --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/DayCalendar/DayCalendar.tsx @@ -0,0 +1,111 @@ +import { forwardRef, useImperativeHandle, useRef, useState } from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { FixedSizeList as List } from 'react-window'; +import { Button, Typography } from '@mui/material'; +import moment from 'moment'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../translations'; +import { + DAY_WIDTH_PIXELS, + getDaysFromSeconds, + getSecondsFromDays, +} from '../../utils'; + +import DayColumn from './DayColumn'; + +/** + * Exact maximum days supported by ECMAScript Date objects. + * + * See https://262.ecma-international.org/5.1/#sec-15.9.1.1 + */ +const MAX_DAYS = 100_000_000 as const; + +interface DayCalendarProps { + className?: string; + onScroll?: (offset: number) => void; +} + +export interface DayCalendarRef { + scrollTo: (offset: number) => void; + scrollToItem: (index: number) => void; +} + +const DayCalendar = forwardRef( + (props, ref): JSX.Element => { + const { t } = useTranslation(); + + const calendarRef = useRef(null); + + useImperativeHandle(ref, () => ({ + scrollTo: (offset): void => { + if (calendarRef.current) calendarRef.current.scrollTo(offset); + }, + scrollToItem: (index): void => { + if (calendarRef.current) + calendarRef.current.scrollToItem(index, 'start'); + }, + })); + + const [monthDisplay, setMonthDisplay] = useState( + moment().format('MMMM YYYY'), + ); + + return ( +
+ + + + {({ height, width }): JSX.Element => ( + { + const visibleStartDay = moment.unix( + getSecondsFromDays(visibleStartIndex), + ); + + setMonthDisplay(visibleStartDay.format('MMMM YYYY')); + }} + onScroll={({ scrollOffset }): void => + props.onScroll?.(scrollOffset) + } + overscanCount={5} + width={width} + > + {DayColumn} + + )} + +
+ ); + }, +); + +DayCalendar.displayName = 'DayCalendar'; + +export default DayCalendar; diff --git a/client/app/bundles/course/reference-timelines/components/DayCalendar/DayColumn.tsx b/client/app/bundles/course/reference-timelines/components/DayCalendar/DayColumn.tsx new file mode 100644 index 00000000000..4ca273feeb8 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/DayCalendar/DayColumn.tsx @@ -0,0 +1,43 @@ +import { CSSProperties, memo } from 'react'; +import { areEqual } from 'react-window'; +import { Typography } from '@mui/material'; +import moment from 'moment'; + +import { getSecondsFromDays, isToday, isWeekend } from '../../utils'; + +interface DayProps { + index: number; + style: CSSProperties; +} + +const DayColumn = (props: DayProps): JSX.Element => { + const day = moment.unix(getSecondsFromDays(props.index)); + + return ( +
+
+
+ + {day.format('dd')} + + + + {day.format('D')} + +
+
+
+ ); +}; + +export default memo(DayColumn, areEqual); diff --git a/client/app/bundles/course/reference-timelines/components/DayCalendar/index.ts b/client/app/bundles/course/reference-timelines/components/DayCalendar/index.ts new file mode 100644 index 00000000000..15e2305ed42 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/DayCalendar/index.ts @@ -0,0 +1,3 @@ +import DayCalendar from './DayCalendar'; + +export default DayCalendar; diff --git a/client/app/bundles/course/reference-timelines/components/DeleteTimelinePrompt.tsx b/client/app/bundles/course/reference-timelines/components/DeleteTimelinePrompt.tsx new file mode 100644 index 00000000000..fc46f23b246 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/DeleteTimelinePrompt.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react'; +import { Menu, MenuItem } from '@mui/material'; +import { TimelineData } from 'types/course/referenceTimelines'; + +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { selectTimelines, useAppSelector } from '../store'; +import translations from '../translations'; + +interface DeleteTimelinePromptProps { + open: boolean; + onClose: () => void; + onConfirmDelete: (alternativeTimelineId?: TimelineData['id']) => void; + deletes: TimelineData; + disabled?: boolean; +} + +const DeleteTimelinePrompt = ( + props: DeleteTimelinePromptProps, +): JSX.Element => { + const { deletes: timeline } = props; + + const { t } = useTranslation(); + + const timelines = useAppSelector(selectTimelines); + + const [primaryButton, setPrimaryButton] = useState(); + + return ( + <> + setPrimaryButton(e.currentTarget) + : (): void => props.onConfirmDelete() + } + onClose={props.onClose} + open={props.open} + primaryColor="error" + primaryLabel={ + timeline.assignees + ? t(translations.confirmRevertAndDeleteTimeline) + : t(translations.confirmDeleteTimeline) + } + title={t(translations.sureDeletingTimeline, { title: timeline.title })} + > + {Boolean(timeline.timesCount) && ( + + {t(translations.timelineHasNTimes, { n: timeline.timesCount })} + + )} + + {Boolean(timeline.assignees) && ( + + {t(translations.timelineHasNStudents, { n: timeline.assignees! })} + + )} + + + {Boolean(timeline.timesCount) && + `${t(translations.hintDeletingTimelineWillRemoveTimes)} `} + + {t(translations.hintDeletingTimelineWillNotAffectSubmissions)} + + + {Boolean(timeline.assignees) && ( + + {t(translations.hintChooseAlternativeTimeline)} + + )} + + + setPrimaryButton(undefined)} + open={Boolean(primaryButton)} + > + {timelines.reduce((menuItems, otherTimeline) => { + if (otherTimeline.id === timeline.id) return menuItems; + + menuItems.push( + props.onConfirmDelete?.(otherTimeline.id)} + > + {otherTimeline.title} + , + ); + + return menuItems; + }, [])} + + + ); +}; + +export default DeleteTimelinePrompt; diff --git a/client/app/bundles/course/reference-timelines/components/HorizontallyDraggable.tsx b/client/app/bundles/course/reference-timelines/components/HorizontallyDraggable.tsx new file mode 100644 index 00000000000..f06bf017a75 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/HorizontallyDraggable.tsx @@ -0,0 +1,44 @@ +import { ReactNode, useState } from 'react'; +import { DraggableCore } from 'react-draggable'; + +interface HorizontallyDraggableProps { + children?: ReactNode; + disabled?: boolean; + snapsBy?: number; + handleClassName?: string; + onDrag?: (deltaX: number) => void; + onChangeDragState?: (dragging: boolean) => void; + onClick?: (target: HTMLElement | null) => void; +} + +/** + * The `children` must accept `onMouseDown`, `oneMouseUp`, and `onTouchEnd` as props. + */ +const HorizontallyDraggable = ( + props: HorizontallyDraggableProps, +): JSX.Element => { + const [didDrag, setDidDrag] = useState(false); + + return ( + { + setDidDrag(true); + props.onDrag?.(deltaX); + }} + onStart={(_): void => props.onChangeDragState?.(true)} + onStop={(e): void => { + e.stopPropagation(); + props.onChangeDragState?.(false); + if (!didDrag) props.onClick?.(e.target as HTMLElement); + setDidDrag(false); + }} + > + {props.children} + + ); +}; + +export default HorizontallyDraggable; diff --git a/client/app/bundles/course/reference-timelines/components/HorizontallyResizable.tsx b/client/app/bundles/course/reference-timelines/components/HorizontallyResizable.tsx new file mode 100644 index 00000000000..3ba90f9ce8c --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/HorizontallyResizable.tsx @@ -0,0 +1,119 @@ +import { + ForwardedRef, + MouseEventHandler, + ReactNode, + TouchEventHandler, + useState, +} from 'react'; +import { Resizable, ResizeHandle } from 'react-resizable'; + +type Pixels = number; + +type HandleCreator = ( + handleRef: ForwardedRef, + resizing: boolean, +) => JSX.Element; + +type ResizeEventHandler = (deltaWidth: Pixels) => void; + +interface HorizontallyResizableProps { + width: Pixels; + height: Pixels; + minWidth?: Pixels; + maxWidth?: Pixels; + handleLeft?: HandleCreator; + handleRight?: HandleCreator; + disabled?: boolean; + snapsBy?: Pixels; + onResizeLeft?: ResizeEventHandler; + onResizeRight?: ResizeEventHandler; + onChangeResizeState?: (resizing: boolean) => void; + className?: string; + left?: Pixels; + children?: ReactNode; + onMouseDown?: MouseEventHandler; + onMouseUp?: MouseEventHandler; + onTouchEnd?: TouchEventHandler; +} + +const HorizontallyResizable = ( + props: HorizontallyResizableProps, +): JSX.Element => { + const [resizingHandle, setResizingHandle] = useState(); + + const resizeHandles: ResizeHandle[] = []; + if (!props.disabled) { + if (props.handleLeft) resizeHandles.push('w'); + if (props.handleRight) resizeHandles.push('e'); + } + + return ( + { + const isHandleResizing = resizingHandle === handleAxis; + + if (handleAxis === 'w') + return props.handleLeft?.(handleRef, isHandleResizing); + + return props.handleRight?.(handleRef, isHandleResizing); + }} + height={props.height} + maxConstraints={ + props.maxWidth ? [props.maxWidth, props.maxWidth] : undefined + } + minConstraints={ + props.minWidth ? [props.minWidth, props.minWidth] : undefined + } + onResize={(_, { size, handle }): void => { + const snapWidth = props.snapsBy ?? 1; + const pureDelta = size.width - props.width; + + /** + * `Math.abs` to make sure `-0.00xx` does not become `-30`. This could cause the box to + * be mysteriously pushed to the -> right when resizing to the <- left. + * + * `Math.floor` and division/re-multiplication by `30` to snap deltas to multiples of 30. + * + * Known issue: When resizing to the <- left and the `TimelinesStack` auto-scrolls to + * the <- left when mouse is still down, the box does not follow to resize. + * Resizing to the -> right works fine, though. + */ + const absDelta = + Math.floor(Math.abs(pureDelta) / snapWidth) * snapWidth; + const delta = absDelta * Math.sign(pureDelta); + + if (handle === 'w') props.onResizeLeft?.(delta); + if (handle === 'e') props.onResizeRight?.(delta); + }} + onResizeStart={(_, { handle }): void => { + setResizingHandle(handle); + props.onChangeResizeState?.(true); + }} + onResizeStop={(): void => { + setResizingHandle(undefined); + props.onChangeResizeState?.(false); + }} + resizeHandles={resizeHandles} + width={props.width} + > +
+ {props.children} +
+
+ ); +}; + +export default HorizontallyResizable; diff --git a/client/app/bundles/course/reference-timelines/components/RowSpacer.tsx b/client/app/bundles/course/reference-timelines/components/RowSpacer.tsx new file mode 100644 index 00000000000..ea6a270ea9c --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/RowSpacer.tsx @@ -0,0 +1,3 @@ +const RowSpacer = (): JSX.Element =>
; + +export default RowSpacer; diff --git a/client/app/bundles/course/reference-timelines/components/SearchField.tsx b/client/app/bundles/course/reference-timelines/components/SearchField.tsx new file mode 100644 index 00000000000..7e627f58ee0 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/SearchField.tsx @@ -0,0 +1,59 @@ +import { useState, useTransition } from 'react'; +import { Clear, Search } from '@mui/icons-material'; +import { IconButton, InputAdornment } from '@mui/material'; + +import TextField from 'lib/components/core/fields/TextField'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; + +interface SearchFieldProps { + onChangeKeyword?: (keyword: string) => void; + placeholder?: string; +} + +const SearchField = (props: SearchFieldProps): JSX.Element => { + const [keyword, setKeyword] = useState(''); + const [isPending, startTransition] = useTransition(); + + const changeKeyword = (newKeyword: string): void => { + setKeyword(newKeyword); + startTransition(() => props.onChangeKeyword?.(newKeyword)); + }; + + const clearKeyword = (): void => { + setKeyword(''); + props.onChangeKeyword?.(''); + }; + + return ( + + + + ), + endAdornment: ( + + {isPending && } + + {keyword && ( + + + + )} + + ), + }} + onChange={(e): void => changeKeyword(e.target.value)} + placeholder={props.placeholder} + size="small" + trims + value={keyword} + variant="filled" + /> + ); +}; + +export default SearchField; diff --git a/client/app/bundles/course/reference-timelines/components/SeriouslyAnchoredPopup.tsx b/client/app/bundles/course/reference-timelines/components/SeriouslyAnchoredPopup.tsx new file mode 100644 index 00000000000..43b65db8973 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/SeriouslyAnchoredPopup.tsx @@ -0,0 +1,50 @@ +import { ComponentProps, useState } from 'react'; +import { Popover } from '@mui/material'; + +interface AnchorPosition { + left: number; + top: number; +} + +interface SeriouslyAnchoredPopupProps extends ComponentProps { + anchorEl: ComponentProps['anchorEl']; + anchorPosition?: never; + anchorReference?: never; + TransitionComponent?: never; + TransitionProps?: never; +} + +/** + * No joke, this popup will be anchored at `anchorEl` and will remain and that + * position, even when the parent component re-renders to a different position + * or becomes stale (no longer found in the DOM). + * + * It works by initially entering the DOM at `anchorEl`'s position. Once it enters, + * the CSS `left` and `top` are stored in state, and the popup's anchor is now fixed + * to these values instead of the `anchorEl`. This way, no matter what happens to + * `anchorEl`, this popup will never move. Hence, being *seriously anchored*. + */ +const SeriouslyAnchoredPopup = ( + props: SeriouslyAnchoredPopupProps, +): JSX.Element => { + const [anchorPosition, setAnchorPosition] = useState(); + + return ( + { + setAnchorPosition({ + left: parseInt(node.style.left, 10), + top: parseInt(node.style.top, 10), + }); + }, + onExited: (): void => setAnchorPosition(undefined), + }} + /> + ); +}; + +export default SeriouslyAnchoredPopup; diff --git a/client/app/bundles/course/reference-timelines/components/SubmitIndicator.tsx b/client/app/bundles/course/reference-timelines/components/SubmitIndicator.tsx new file mode 100644 index 00000000000..416fd39637f --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/SubmitIndicator.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from 'react'; +import { Cancel, CheckCircle } from '@mui/icons-material'; +import { Chip, Grow, Tooltip, Typography } from '@mui/material'; +import moment from 'moment'; + +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { useLastSaved } from '../contexts'; +import translations from '../translations'; + +const RELATIVE_TIME_UPDATE_INTERVAL_MS = 60000 as const; +const ANNOUNCE_ANIMATION_DURATION_MS = 500 as const; +const ANNOUNCE_FLASH_DURATION_MS = 2000 as const; + +interface SubmitIndicatorProps { + className?: string; +} + +interface SavedIndicatorProps { + at: moment.Moment; + success: boolean; +} + +const SavedIndicator = (props: SavedIndicatorProps): JSX.Element => { + const { at: lastSaved, success } = props; + + const { t } = useTranslation(); + + const [announcing, setAnnouncing] = useState(true); + const [relativeTime, setRelativeTime] = useState(''); + + const updateRelativeDescription = (): void => + setRelativeTime(lastSaved.fromNow()); + + useEffect(() => { + if (announcing) { + const timeout = setTimeout(() => { + setAnnouncing(false); + updateRelativeDescription(); + }, ANNOUNCE_FLASH_DURATION_MS); + + return () => clearTimeout(timeout); + } + + const timer = setInterval( + updateRelativeDescription, + RELATIVE_TIME_UPDATE_INTERVAL_MS, + ); + + return () => clearInterval(timer); + }, [announcing]); + + return announcing ? ( + + : } + label={success ? t(translations.saved) : t(translations.error)} + size="small" + variant="outlined" + /> + + ) : ( + + + + {success + ? t(translations.lastSaved, { at: relativeTime }) + : t(translations.unchangedSince, { time: relativeTime })} + + + + ); +}; + +const SubmitIndicator = (props: SubmitIndicatorProps): JSX.Element | null => { + const { t } = useTranslation(); + + const { status, lastSaved } = useLastSaved(); + + if (!status && !lastSaved) return null; + + return ( +
+ {status === 'loading' && ( + <> + + + + {t(translations.saving)} + + + )} + + {status !== 'loading' && lastSaved && ( + + )} +
+ ); +}; + +export default SubmitIndicator; diff --git a/client/app/bundles/course/reference-timelines/components/TimeBar/DurationBar.tsx b/client/app/bundles/course/reference-timelines/components/TimeBar/DurationBar.tsx new file mode 100644 index 00000000000..b5b64dc5b89 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimeBar/DurationBar.tsx @@ -0,0 +1,130 @@ +import { MouseEventHandler, ReactNode, TouchEventHandler } from 'react'; +import moment from 'moment'; + +import { + DAY_WIDTH_PIXELS, + getDaysFromSeconds, + getDaysFromWidth, + getDurationDays, +} from '../../utils'; +import HorizontallyResizable from '../HorizontallyResizable'; + +import TimeBarHandle from './TimeBarHandle'; + +type TimeObject = moment.Moment; + +type TimeChangeEventHandler = (deltaDays: number) => void; + +interface DurationBarProps { + starts: TimeObject; + children?: ReactNode; + bonusEnds?: TimeObject; + ends?: TimeObject; + showTimes?: boolean; + shadow?: boolean; + disabled?: boolean; + selected?: boolean; + onChangeStartTime?: TimeChangeEventHandler; + onChangeBonusEndTime?: TimeChangeEventHandler; + onChangeEndTime?: TimeChangeEventHandler; + onChangeResizeState?: (resizing: boolean) => void; + onMouseDown?: MouseEventHandler; + onMouseUp?: MouseEventHandler; + onTouchEnd?: TouchEventHandler; +} + +export const DURATION_BAR_HEIGHT_PIXELS = 25; + +const DurationBar = (props: DurationBarProps): JSX.Element => { + const start = moment(props.starts).startOf('day'); + const bonus = props.bonusEnds && moment(props.bonusEnds).startOf('day'); + const end = props.ends && moment(props.ends).startOf('day'); + + const startFromEpoch = getDaysFromSeconds(start.unix()) + 1; + const left = DAY_WIDTH_PIXELS * startFromEpoch; + + const bonusDuration = bonus && getDurationDays(start, bonus); + const bonusWidth = bonusDuration && DAY_WIDTH_PIXELS * bonusDuration; + + const duration = end && getDurationDays(start, end); + const width = DAY_WIDTH_PIXELS * ((end ? duration : bonusDuration) ?? 1); + + return ( + ( + + )} + handleRight={ + end + ? (handleRef, resizing): JSX.Element => ( + + ) + : undefined + } + height={DURATION_BAR_HEIGHT_PIXELS} + left={left} + onChangeResizeState={props.onChangeResizeState} + onMouseDown={props.onMouseDown} + onMouseUp={props.onMouseUp} + onResizeLeft={(deltaWidth): void => + props.onChangeStartTime?.(-getDaysFromWidth(deltaWidth)) + } + onResizeRight={ + end + ? (deltaWidth): void => + props.onChangeEndTime?.(getDaysFromWidth(deltaWidth)) + : undefined + } + onTouchEnd={props.onTouchEnd} + snapsBy={DAY_WIDTH_PIXELS} + width={width} + > + {bonus && bonusWidth && ( + ( + + )} + height={DURATION_BAR_HEIGHT_PIXELS} + onChangeResizeState={props.onChangeResizeState} + onResizeRight={(deltaWidth): void => + props.onChangeBonusEndTime?.(getDaysFromWidth(deltaWidth)) + } + snapsBy={DAY_WIDTH_PIXELS} + width={bonusWidth} + /> + )} + + {props.children} + + ); +}; + +export default DurationBar; diff --git a/client/app/bundles/course/reference-timelines/components/TimeBar/TimeBar.tsx b/client/app/bundles/course/reference-timelines/components/TimeBar/TimeBar.tsx new file mode 100644 index 00000000000..a6f81cc77e2 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimeBar/TimeBar.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from 'react'; +import moment from 'moment'; + +import { DAY_WIDTH_PIXELS, getDaysFromWidth } from '../../utils'; +import HorizontallyDraggable from '../HorizontallyDraggable'; + +import DurationBar from './DurationBar'; + +interface TimeBarProps { + startsAt: string | moment.Moment; + bonusEndsAt?: string | moment.Moment; + endsAt?: string | moment.Moment; + shadow?: boolean; + disabled?: boolean; + selected?: boolean; + onClick?: (target: HTMLElement | null) => void; + onChangeTime?: (newTime: TimeTriplet, rollback: () => void) => void; +} + +interface TimeTriplet { + start: moment.Moment; + bonus?: moment.Moment; + end?: moment.Moment; +} + +const isValidTimeTriplet = ({ start, bonus, end }: TimeTriplet): boolean => { + const isBonusSameOrAfterStart = bonus?.isSameOrAfter(start); + const isEndSameOrAfterBonus = bonus ? end?.isSameOrAfter(bonus) : undefined; + const isEndAfterStart = end?.isAfter(start); + + return ( + (isBonusSameOrAfterStart ?? true) && + (isEndSameOrAfterBonus ?? true) && + (isEndAfterStart ?? true) + ); +}; + +const isSameTimeTriplet = (time1: TimeTriplet, time2: TimeTriplet): boolean => { + const isStartSame = time1.start.isSame(time2.start); + + const isBonusSame = + time1.bonus === time2.bonus || Boolean(time1.bonus?.isSame(time2.bonus)); + + const isEndSame = + time1.end === time2.end || Boolean(time1.end?.isSame(time2.end)); + + return isStartSame && isBonusSame && isEndSame; +}; + +const generateTriplet = ( + start: TimeBarProps['startsAt'], + bonus: TimeBarProps['bonusEndsAt'], + end: TimeBarProps['endsAt'], +): TimeTriplet => ({ + start: moment(start), + bonus: bonus ? moment(bonus) : undefined, + end: end ? moment(end) : undefined, +}); + +const TimeBar = (props: TimeBarProps): JSX.Element => { + const [{ start, bonus, end }, setTime] = useState( + generateTriplet(props.startsAt, props.bonusEndsAt, props.endsAt), + ); + + const [dragging, setDragging] = useState(false); + const [oldTime, setOldTime] = useState(); + + useEffect(() => { + setTime(generateTriplet(props.startsAt, props.bonusEndsAt, props.endsAt)); + }, [props.startsAt, props.bonusEndsAt, props.endsAt]); + + const validateAndSetTime = ( + transform: (time: TimeTriplet) => TimeTriplet, + ): void => + setTime((time) => { + const newTime = transform(time); + return isValidTimeTriplet(newTime) ? newTime : time; + }); + + const handleChangeTime = (changing: boolean): void => { + if (changing) { + setOldTime({ start, bonus, end }); + } else { + const newTime = { start, bonus, end }; + if (!oldTime || isSameTimeTriplet(oldTime, newTime)) return; + + props.onChangeTime?.(newTime, () => setTime(oldTime)); + } + }; + + return ( + { + setDragging(dragState); + handleChangeTime(dragState); + }} + onClick={props.onClick} + onDrag={(deltaX): void => { + const deltaDays = getDaysFromWidth(deltaX); + + validateAndSetTime((time) => ({ + start: time.start.clone().add(deltaDays, 'days'), + bonus: time.bonus?.clone().add(deltaDays, 'days'), + end: time.end?.clone().add(deltaDays, 'days'), + })); + }} + snapsBy={DAY_WIDTH_PIXELS} + > + + validateAndSetTime((time) => ({ + ...time, + bonus: time.bonus?.clone().add(deltaDays, 'days'), + })) + } + onChangeEndTime={(deltaDays): void => + validateAndSetTime((time) => ({ + ...time, + end: time.end?.clone().add(deltaDays, 'days'), + })) + } + onChangeResizeState={handleChangeTime} + onChangeStartTime={(deltaDays): void => + validateAndSetTime((time) => ({ + ...time, + start: time.start.clone().add(deltaDays, 'days'), + })) + } + selected={props.selected} + shadow={props.shadow} + showTimes={dragging} + starts={start} + > + {!props.disabled && ( +
+ )} + + + ); +}; + +export default TimeBar; diff --git a/client/app/bundles/course/reference-timelines/components/TimeBar/TimeBarHandle.tsx b/client/app/bundles/course/reference-timelines/components/TimeBar/TimeBarHandle.tsx new file mode 100644 index 00000000000..9c5c2a28c03 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimeBar/TimeBarHandle.tsx @@ -0,0 +1,119 @@ +import { + forwardRef, + MouseEventHandler, + ReactNode, + TouchEventHandler, +} from 'react'; +import { Typography } from '@mui/material'; +import moment from 'moment'; + +interface HandleContentProps { + side: 'start' | 'end'; + persistent?: boolean; + children?: ReactNode; +} + +interface HandleContainerProps extends HandleContentProps { + onMouseDown?: MouseEventHandler; + onMouseUp?: MouseEventHandler; + onTouchEnd?: TouchEventHandler; +} + +type TimeBarHandleProps = Omit & { + time: moment.Moment; +}; + +const HandleBar = ( + props: Omit, +): JSX.Element => ( +
+); + +const HandleText = (props: HandleContentProps): JSX.Element => ( + + {props.children} + +); + +const HandleContainer = forwardRef< + HTMLDivElement, + Omit +>( + (props, ref): JSX.Element => ( +
+
+ {props.children} +
+
+ ), +); + +HandleContainer.displayName = 'HandleContainer'; + +const StartTimeBarHandle = forwardRef( + (props, ref): JSX.Element => ( + + + {props.time.format('MMM D')} + + + + + ), +); + +StartTimeBarHandle.displayName = 'StartTimeBarHandle'; + +const EndTimeBarHandle = forwardRef( + (props, ref): JSX.Element => ( + + + + + {props.time.format('MMM D')} + + + ), +); + +EndTimeBarHandle.displayName = 'EndTimeBarHandle'; + +export default { + Start: StartTimeBarHandle, + End: EndTimeBarHandle, +}; diff --git a/client/app/bundles/course/reference-timelines/components/TimeBar/index.ts b/client/app/bundles/course/reference-timelines/components/TimeBar/index.ts new file mode 100644 index 00000000000..b7b00a3f8ac --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimeBar/index.ts @@ -0,0 +1,3 @@ +import TimeBar from './TimeBar'; + +export default TimeBar; diff --git a/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopup.tsx b/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopup.tsx new file mode 100644 index 00000000000..e93e31551e8 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopup.tsx @@ -0,0 +1,164 @@ +import { toast } from 'react-toastify'; +import { Typography } from '@mui/material'; +import moment from 'moment'; +import { + ItemWithTimeData, + TimelineData, +} from 'types/course/referenceTimelines'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import { useSetLastSaved } from '../../contexts'; +import { createTime, deleteTime, updateTime } from '../../operations'; +import { useAppDispatch } from '../../store'; +import translations from '../../translations'; +import { DraftableTimeData } from '../../utils'; +import SeriouslyAnchoredPopup from '../SeriouslyAnchoredPopup'; + +import TimePopupForm from './TimePopupForm'; +import TimePopupTopBar from './TimePopupTopBar'; + +interface TimePopupProps { + for?: Partial; + assignedIn?: TimelineData; + assignedTo?: ItemWithTimeData; + anchorsOn?: HTMLElement; + default?: boolean; + gamified?: boolean; + newTime?: boolean; + onClose?: () => void; +} + +const TimePopup = (props: TimePopupProps): JSX.Element => { + const { + anchorsOn: anchorElement, + for: time, + assignedIn: timeline, + assignedTo: item, + } = props; + + const { t } = useTranslation(); + + const dispatch = useAppDispatch(); + + const { startLoading, abortLoading, setLastSavedToNow } = useSetLastSaved(); + + const handleCreateTime = (data: { + startAt: moment.Moment; + bonusEndAt?: moment.Moment; + endAt?: moment.Moment; + }): void => { + if (!timeline || !item) return; + + startLoading(); + + dispatch( + createTime(timeline.id, item.id, { + startAt: data.startAt.toISOString(), + bonusEndAt: data.bonusEndAt?.toISOString(), + endAt: data.endAt?.toISOString(), + }), + ) + .then(() => { + props.onClose?.(); + setLastSavedToNow(); + }) + .catch((error) => { + abortLoading(); + toast.error(error ?? t(translations.errorCreatingTime)); + }); + }; + + const handleUpdateTime = (data: { + startAt: moment.Moment; + bonusEndAt?: moment.Moment; + endAt?: moment.Moment; + }): void => { + if (!timeline || !item || !time?.id) return; + + startLoading(); + + dispatch( + updateTime(timeline.id, item.id, time.id, { + startAt: data.startAt.toISOString(), + bonusEndAt: data.bonusEndAt?.toISOString() ?? null, + endAt: data.endAt?.toISOString() ?? null, + }), + ) + .then(() => { + props.onClose?.(); + setLastSavedToNow(); + }) + .catch((error) => { + abortLoading(); + toast.error(error ?? t(translations.errorUpdatingTime)); + }); + }; + + const handleDeleteTime = (): void => { + if (!timeline || !item || !time?.id) return; + + startLoading(); + + dispatch(deleteTime(timeline.id, item.id, time.id)) + .then(() => { + props.onClose?.(); + setLastSavedToNow(); + }) + .catch((error) => { + abortLoading(); + toast.error(error ?? t(translations.errorDeletingTime)); + }); + }; + + return ( + + + +
+
+ + {props.newTime + ? t(translations.assigningToItem) + : t(translations.assignedToItem)} + + + {item?.title} +
+ +
+ + {props.newTime + ? t(translations.assigningInTimeline) + : t(translations.assignedInTimeline)} + + + {timeline?.title} +
+ + +
+
+ ); +}; + +export default TimePopup; diff --git a/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopupForm.tsx b/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopupForm.tsx new file mode 100644 index 00000000000..41c0e5a1ee0 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopupForm.tsx @@ -0,0 +1,147 @@ +import { Controller } from 'react-hook-form'; +import { Button, Collapse } from '@mui/material'; +import moment from 'moment'; +import { date, object, ref } from 'yup'; + +import FormDateTimePickerField from 'lib/components/form/fields/DateTimePickerField'; +import Form from 'lib/components/form/Form'; +import useTranslation from 'lib/hooks/useTranslation'; +import formTranslations from 'lib/translations/form'; + +import { useLastSaved } from '../../contexts'; +import translations from '../../translations'; +import { DraftableTimeData } from '../../utils'; + +const validationSchema = object({ + startAt: date() + .typeError(translations.mustValidDateTimeFormat) + .required(translations.mustSpecifyStartTime), + endAt: date() + .nullable() + .typeError(translations.mustValidDateTimeFormat) + .min(ref('startAt'), translations.endTimeMustAfterStart), + bonusEndAt: date() + .nullable() + .typeError(translations.mustValidDateTimeFormat) + .min(ref('startAt'), translations.bonusEndTimeMustAfterStart), +}); + +interface TimePopupFormProps { + for?: Partial; + showsBonus?: boolean; + onSubmit?: (data: { + startAt: moment.Moment; + bonusEndAt?: moment.Moment; + endAt?: moment.Moment; + }) => void; + new?: boolean; +} + +const TimePopupForm = (props: TimePopupFormProps): JSX.Element => { + const { for: time } = props; + + const { t } = useTranslation(); + + const { status } = useLastSaved(); + + return ( + + {(control, watch): JSX.Element => { + let unchanged = false; + + if (!props.new) { + const start = watch('startAt'); + const bonus = watch('bonusEndAt'); + const end = watch('endAt'); + + unchanged = Boolean( + time?.startAt && moment(start).isSame(time.startAt), + ); + + if (time?.bonusEndAt || bonus) + unchanged &&= moment(bonus).isSame(time?.bonusEndAt); + + if (time?.endAt || end) unchanged &&= moment(end).isSame(time?.endAt); + } + + return ( + <> + ( + + )} + /> + + {props.showsBonus && ( + ( + + )} + /> + )} + + ( + + )} + /> + + +
+ +
+
+ + ); + }} + + ); +}; + +export default TimePopupForm; diff --git a/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopupTopBar.tsx b/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopupTopBar.tsx new file mode 100644 index 00000000000..31ddf30b99c --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopupTopBar.tsx @@ -0,0 +1,57 @@ +import { Close, Delete } from '@mui/icons-material'; +import { Chip, IconButton, Tooltip } from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import { useLastSaved } from '../../contexts'; +import translations from '../../translations'; + +interface TimePopupTopBarProps { + default?: boolean; + new?: boolean; + onClickDelete?: () => void; + onClickClose?: () => void; +} + +const TimePopupTopBar = (props: TimePopupTopBarProps): JSX.Element => { + const { t } = useTranslation(); + + const { status } = useLastSaved(); + + return ( + + ); +}; + +export default TimePopupTopBar; diff --git a/client/app/bundles/course/reference-timelines/components/TimePopup/index.ts b/client/app/bundles/course/reference-timelines/components/TimePopup/index.ts new file mode 100644 index 00000000000..c24a0a2951f --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimePopup/index.ts @@ -0,0 +1,3 @@ +import TimePopup from './TimePopup'; + +export default TimePopup; diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverview.tsx b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverview.tsx new file mode 100644 index 00000000000..dfe89969e55 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverview.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { Add } from '@mui/icons-material'; +import { Button } from '@mui/material'; +import produce from 'immer'; +import { TimelineData } from 'types/course/referenceTimelines'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../translations'; +import CreateRenameTimelinePrompt from '../CreateRenameTimelinePrompt'; + +import TimelinesOverviewItem from './TimelinesOverviewItem'; + +type TimelineIdsSet = Set; + +interface TimelinesOverviewProps { + for: TimelineData[]; + hiding?: TimelineIdsSet; + onChangeHiddenTimelineIds?: ( + transform: (hiddenTimelineIds: TimelineIdsSet) => TimelineIdsSet, + ) => void; +} + +const TimelinesOverview = (props: TimelinesOverviewProps): JSX.Element => { + const { for: timelines, hiding: hiddenTimelineIds } = props; + + const { t } = useTranslation(); + + const [creating, setCreating] = useState(false); + + return ( +
+ + + + + setCreating(false)} + open={creating} + /> +
+ ); +}; + +export default TimelinesOverview; diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverviewItem.tsx b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverviewItem.tsx new file mode 100644 index 00000000000..67252154d6e --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverviewItem.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import { MoreVert } from '@mui/icons-material'; +import { Divider, IconButton, Menu, MenuItem } from '@mui/material'; +import { TimelineData } from 'types/course/referenceTimelines'; + +import Checkbox from 'lib/components/core/buttons/Checkbox'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { useLastSaved, useSetLastSaved } from '../../contexts'; +import { deleteTimeline } from '../../operations'; +import { useAppDispatch } from '../../store'; +import translations from '../../translations'; +import CreateRenameTimelinePrompt from '../CreateRenameTimelinePrompt'; +import DeleteTimelinePrompt from '../DeleteTimelinePrompt'; + +interface TimelinesOverviewItemProps { + for: TimelineData; + checked?: boolean; + onChangeCheck?: (checked: boolean) => void; +} + +const TimelinesOverviewItem = ( + props: TimelinesOverviewItemProps, +): JSX.Element => { + const { for: timeline } = props; + + const { t } = useTranslation(); + + const dispatch = useAppDispatch(); + + const [menuAnchor, setMenuAnchor] = useState(); + const [renaming, setRenaming] = useState(false); + const [deleting, setDeleting] = useState(false); + + const { status } = useLastSaved(); + const { startLoading, abortLoading, setLastSavedToNow } = useSetLastSaved(); + + const handleDelete = (alternativeTimelineId?: TimelineData['id']): void => { + startLoading(); + + dispatch(deleteTimeline(timeline.id, alternativeTimelineId)) + .then(setLastSavedToNow) + .catch((error) => { + abortLoading(); + + toast.error( + error ?? + t(translations.errorDeletingTimeline, { title: timeline.title }), + ); + }); + }; + + return ( +
+ props.onChangeCheck?.(checked)} + variant="body2" + /> + + {!timeline.default && ( + setMenuAnchor(e.currentTarget)} + size="small" + > + + + )} + + setMenuAnchor(undefined)} + onClose={(): void => setMenuAnchor(undefined)} + open={Boolean(menuAnchor)} + > + {!timeline.default && [ + { + if (timeline.timesCount || timeline.assignees) { + setDeleting(true); + } else { + handleDelete(); + } + }} + > + {t(translations.deleteTimeline)} + , + + , + ]} + + {!timeline.default && ( + setRenaming(true)}> + {t(translations.renameTimeline)} + + )} + + + setRenaming(false)} + open={renaming} + renames={timeline} + /> + + setDeleting(false)} + onConfirmDelete={handleDelete} + open={deleting} + /> +
+ ); +}; + +export default TimelinesOverviewItem; diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesOverview/index.ts b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/index.ts new file mode 100644 index 00000000000..df1314da154 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/index.ts @@ -0,0 +1,3 @@ +import TimelinesOverview from './TimelinesOverview'; + +export default TimelinesOverview; diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignableTimeline.tsx b/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignableTimeline.tsx new file mode 100644 index 00000000000..989b619bf31 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignableTimeline.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import moment from 'moment'; +import { + ItemWithTimeData, + TimeData, + TimelineData, +} from 'types/course/referenceTimelines'; + +import { useLastSaved } from '../../contexts'; +import { DraftableTimeData } from '../../utils'; +import TimePopup from '../TimePopup'; + +import Timeline from './Timeline'; + +interface AssignableTimelineProps { + for: ItemWithTimeData; + in: TimelineData; + basedOn: TimeData; + gamified?: boolean; +} + +const AssignableTimeline = (props: AssignableTimelineProps): JSX.Element => { + const { for: item, in: timeline, basedOn: defaultTime } = props; + + const { status } = useLastSaved(); + + const [timeBar, setTimeBar] = useState(); + const [draftTime, setDraftTime] = useState(); + + return ( + <> + { + setTimeBar(target); + + const defaultStart = moment(defaultTime.startAt); + + const defaultBonus = defaultTime.bonusEndAt + ? moment(defaultTime.bonusEndAt) + : undefined; + + const defaultEnd = defaultTime.endAt + ? moment(defaultTime.endAt) + : undefined; + + const deltaStart = startTime + .startOf('day') + .diff(defaultStart.clone().startOf('day'), 'days'); + + setDraftTime({ + startAt: defaultStart.add(deltaStart, 'days'), + bonusEndAt: defaultBonus?.add(deltaStart, 'days'), + endAt: defaultEnd?.add(deltaStart, 'days'), + }); + }} + selected={Boolean(timeBar)} + /> + + setTimeBar(undefined)} + /> + + ); +}; + +export default AssignableTimeline; diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignedTimeline.tsx b/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignedTimeline.tsx new file mode 100644 index 00000000000..a380976bc0b --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignedTimeline.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import { + ItemWithTimeData, + TimeData, + TimelineData, +} from 'types/course/referenceTimelines'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import { useLastSaved, useSetLastSaved } from '../../contexts'; +import { updateTime } from '../../operations'; +import { useAppDispatch } from '../../store'; +import translations from '../../translations'; +import TimeBar from '../TimeBar'; +import TimePopup from '../TimePopup'; + +import Timeline from './Timeline'; + +interface AssignedTimelineProps { + for: ItemWithTimeData; + in: TimelineData; + visualising: TimeData; + gamified?: boolean; +} + +const AssignedTimeline = (props: AssignedTimelineProps): JSX.Element => { + const { for: item, in: timeline, visualising: time } = props; + + const { t } = useTranslation(); + + const dispatch = useAppDispatch(); + + const { status } = useLastSaved(); + const { startLoading, abortLoading, setLastSavedToNow } = useSetLastSaved(); + + const [timeBar, setTimeBar] = useState(); + + return ( + <> + + { + startLoading(); + + dispatch( + updateTime(timeline.id, item.id, time.id, { + startAt: newTime.start.toISOString(), + bonusEndAt: newTime.bonus?.toISOString(), + endAt: newTime.end?.toISOString(), + }), + ) + .then(setLastSavedToNow) + .catch((error) => { + rollback(); + abortLoading(); + toast.error(error ?? t(translations.errorUpdatingTime)); + }); + }} + onClick={setTimeBar} + selected={Boolean(timeBar)} + startsAt={time.startAt} + /> + + + setTimeBar(undefined)} + /> + + ); +}; + +export default AssignedTimeline; diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesStack/Timeline.tsx b/client/app/bundles/course/reference-timelines/components/TimelinesStack/Timeline.tsx new file mode 100644 index 00000000000..c24c262ccc8 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimelinesStack/Timeline.tsx @@ -0,0 +1,71 @@ +import { ReactNode, useState } from 'react'; +import { Add } from '@mui/icons-material'; +import { Typography } from '@mui/material'; +import moment from 'moment'; +import { TimeData } from 'types/course/referenceTimelines'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../translations'; +import { DAY_WIDTH_PIXELS, getSecondsFromDays } from '../../utils'; + +interface TimelineProps { + default?: boolean; + children?: ReactNode; + defaultTime?: TimeData; + canCreate?: boolean; + selected?: boolean; + onClickShadow?: (startTime: moment.Moment, target: HTMLElement) => void; +} + +const Timeline = (props: TimelineProps): JSX.Element => { + const { t } = useTranslation(); + + const [hovered, setHovered] = useState(false); + const [hoveredLeft, setHoveredLeft] = useState(0); + + return ( +
setHovered(true), + onMouseLeave: (): void => setHovered(false), + onMouseMove: (e): void => { + const rectangle = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rectangle.left; + setHoveredLeft(Math.floor(x / DAY_WIDTH_PIXELS) * DAY_WIDTH_PIXELS); + }, + })} + > + {props.children} + + {props.canCreate && hovered && ( +
{ + const startTime = moment.unix( + getSecondsFromDays(Math.floor(hoveredLeft / DAY_WIDTH_PIXELS)), + ); + + props.onClickShadow?.(startTime, e.currentTarget); + }} + role="button" + style={{ left: hoveredLeft }} + tabIndex={0} + > + + + + {t(translations.clickToAssignTime)} + +
+ )} +
+ ); +}; + +export default Timeline; diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesStack/TimelinesStack.tsx b/client/app/bundles/course/reference-timelines/components/TimelinesStack/TimelinesStack.tsx new file mode 100644 index 00000000000..e220a0791f4 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimelinesStack/TimelinesStack.tsx @@ -0,0 +1,71 @@ +import { Fragment } from 'react'; +import { + ItemWithTimeData, + TimelineData, +} from 'types/course/referenceTimelines'; + +import { useAppSelector } from '../../store'; +import RowSpacer from '../RowSpacer'; + +import AssignableTimeline from './AssignableTimeline'; +import AssignedTimeline from './AssignedTimeline'; + +/** + * Maximum inner width of the calendar view. It is essentially equals to + * `MAX_DAYS` times `DAY_WIDTH_PIXELS`. + */ +const MAX_CALENDAR_INNER_WIDTH_PIXELS = '3e+09px' as const; + +interface TimeBarsProps { + for: ItemWithTimeData[]; + within: TimelineData[]; +} + +const TimelinesStack = (props: TimeBarsProps): JSX.Element => { + const { for: items, within: timelines } = props; + const gamified = useAppSelector((state) => state.timelines.gamified); + const defaultTimelineId = useAppSelector( + (state) => state.timelines.defaultTimeline, + ); + + return ( +
+ {items.map((item) => { + const defaultTime = item.times[defaultTimelineId]; + + return ( + + {timelines.map((timeline) => { + const time = item.times[timeline.id]; + + return time ? ( + + ) : ( + + ); + })} + + + + ); + })} +
+ ); +}; + +export default TimelinesStack; diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesStack/index.ts b/client/app/bundles/course/reference-timelines/components/TimelinesStack/index.ts new file mode 100644 index 00000000000..d7bc08cfe8b --- /dev/null +++ b/client/app/bundles/course/reference-timelines/components/TimelinesStack/index.ts @@ -0,0 +1,3 @@ +import TimelinesStack from './TimelinesStack'; + +export default TimelinesStack; diff --git a/client/app/bundles/course/reference-timelines/contexts/LastSavedContext.tsx b/client/app/bundles/course/reference-timelines/contexts/LastSavedContext.tsx new file mode 100644 index 00000000000..c868b65d4ba --- /dev/null +++ b/client/app/bundles/course/reference-timelines/contexts/LastSavedContext.tsx @@ -0,0 +1,59 @@ +import { + createContext, + Dispatch, + ReactNode, + SetStateAction, + useContext, + useState, +} from 'react'; +import moment from 'moment'; + +type FetchStatus = 'loading' | 'success' | 'failure'; + +interface LastSavedState { + status?: FetchStatus; + lastSaved?: moment.Moment; +} + +interface LastSavedUpdater { + abortLoading: () => void; + startLoading: () => void; + setLastSavedToNow: () => void; +} + +type LastSavedSetter = Dispatch>; + +const LastSavedStateContext = createContext({}); +const LastSavedSetterContext = createContext(() => {}); + +interface LoadingProviderProps { + children: ReactNode; +} + +export const LastSavedProvider = (props: LoadingProviderProps): JSX.Element => { + const [lastSavedState, setLastSavedState] = useState({}); + + return ( + + + {props.children} + + + ); +}; + +export const useLastSaved = (): LastSavedState => + useContext(LastSavedStateContext); + +export const useSetLastSaved = (): LastSavedUpdater => { + const setLastSaved = useContext(LastSavedSetterContext); + + return { + abortLoading: () => + setLastSaved((state) => ({ ...state, status: 'failure' })), + startLoading: () => + setLastSaved((state) => ({ ...state, status: 'loading' })), + setLastSavedToNow: () => + setLastSaved({ status: 'success', lastSaved: moment() }), + }; +}; diff --git a/client/app/bundles/course/reference-timelines/contexts/index.ts b/client/app/bundles/course/reference-timelines/contexts/index.ts new file mode 100644 index 00000000000..597958024ca --- /dev/null +++ b/client/app/bundles/course/reference-timelines/contexts/index.ts @@ -0,0 +1 @@ +export * from './LastSavedContext'; diff --git a/client/app/bundles/course/reference-timelines/index.tsx b/client/app/bundles/course/reference-timelines/index.tsx new file mode 100644 index 00000000000..bd2e7593480 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/index.tsx @@ -0,0 +1,31 @@ +import { createRoot } from 'react-dom/client'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { enableMapSet } from 'immer'; + +import ProviderWrapper from 'lib/components/wrappers/ProviderWrapper'; + +import { store } from './store'; +import TimelineDesigner from './TimelineDesigner'; + +$(() => { + const mountNode = document.getElementById('course-timeline-designer'); + if (!mountNode) return; + + const root = createRoot(mountNode); + + // TODO: Move this to React root once SPA + enableMapSet(); + + root.render( + + + + } + path="/courses/:course_id/timelines" + /> + + + , + ); +}); diff --git a/client/app/bundles/course/reference-timelines/operations.ts b/client/app/bundles/course/reference-timelines/operations.ts new file mode 100644 index 00000000000..72a6fe5164b --- /dev/null +++ b/client/app/bundles/course/reference-timelines/operations.ts @@ -0,0 +1,176 @@ +import { AxiosError } from 'axios'; +import { + ItemWithTimeData, + TimeData, + TimelineData, + TimelinePostData, + TimePostData, +} from 'types/course/referenceTimelines'; + +import CourseAPI from 'api/course'; + +import { actions, AppThunk } from './store'; + +export const fetchTimelines = (): AppThunk => async (dispatch) => { + const response = await CourseAPI.referenceTimelines.index(); + dispatch(actions.updateAll(response.data)); +}; + +export const createTimeline = + (title: TimelineData['title']): AppThunk => + async (dispatch) => { + const adaptedData: TimelinePostData = { reference_timeline: { title } }; + + try { + const response = await CourseAPI.referenceTimelines.create(adaptedData); + dispatch(actions.addEmptyTimeline(response.data)); + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } + }; + +export const deleteTimeline = + ( + id: TimelineData['id'], + alternativeTimelineId?: TimelineData['id'], + ): AppThunk => + async (dispatch) => { + try { + await CourseAPI.referenceTimelines.delete(id, alternativeTimelineId); + dispatch(actions.removeTimeline(id)); + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } + }; + +export const updateTimeline = + ( + id: TimelineData['id'], + changes: Partial>, + ): AppThunk => + async (dispatch) => { + const adaptedData: TimelinePostData = { + reference_timeline: { title: changes.title, weight: changes.weight }, + }; + + try { + await CourseAPI.referenceTimelines.update(id, adaptedData); + + dispatch( + actions.updateTimeline({ + id, + title: changes.title, + weight: changes.weight, + }), + ); + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } + }; + +export const createTime = + ( + timelineId: TimelineData['id'], + itemId: ItemWithTimeData['id'], + time: { + startAt: string; + bonusEndAt?: string; + endAt?: string; + }, + ): AppThunk => + async (dispatch) => { + const adaptedData: TimePostData = { + reference_time: { + lesson_plan_item_id: itemId, + start_at: time.startAt, + bonus_end_at: time.bonusEndAt, + end_at: time.endAt, + }, + }; + + try { + const response = await CourseAPI.referenceTimelines.createTime( + timelineId, + adaptedData, + ); + + const newTimeId = response.data.id; + + dispatch( + actions.addTimeToItem({ + timelineId, + itemId, + time: { + id: newTimeId, + ...time, + }, + }), + ); + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } + }; + +export const deleteTime = + ( + timelineId: TimelineData['id'], + itemId: ItemWithTimeData['id'], + timeId: TimeData['id'], + ): AppThunk => + async (dispatch) => { + try { + await CourseAPI.referenceTimelines.deleteTime(timelineId, timeId); + dispatch(actions.removeTimeFromItem({ timelineId, itemId })); + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } + }; + +export const updateTime = + ( + timelineId: TimelineData['id'], + itemId: ItemWithTimeData['id'], + timeId: TimeData['id'], + time: { + startAt?: string; + bonusEndAt?: string | null; + endAt?: string | null; + }, + ): AppThunk => + async (dispatch) => { + const adaptedData: TimePostData = { + reference_time: { + start_at: time.startAt, + bonus_end_at: time.bonusEndAt, + end_at: time.endAt, + }, + }; + + try { + await CourseAPI.referenceTimelines.updateTime( + timelineId, + timeId, + adaptedData, + ); + + dispatch( + actions.updateTimeInItem({ + timelineId, + itemId, + time: { + startAt: time.startAt, + bonusEndAt: time.bonusEndAt, + endAt: time.endAt, + }, + }), + ); + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } + }; diff --git a/client/app/bundles/course/reference-timelines/store/hooks.ts b/client/app/bundles/course/reference-timelines/store/hooks.ts new file mode 100644 index 00000000000..9eca6b76e4c --- /dev/null +++ b/client/app/bundles/course/reference-timelines/store/hooks.ts @@ -0,0 +1,7 @@ +import type { TypedUseSelectorHook } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; + +import type { AppDispatch, RootState } from './store'; + +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/client/app/bundles/course/reference-timelines/store/index.ts b/client/app/bundles/course/reference-timelines/store/index.ts new file mode 100644 index 00000000000..a51edf0713c --- /dev/null +++ b/client/app/bundles/course/reference-timelines/store/index.ts @@ -0,0 +1,4 @@ +export * from './hooks'; +export * from './store'; +export * from './timelinesSelectors'; +export { timelinesActions as actions } from './timelinesSlice'; diff --git a/client/app/bundles/course/reference-timelines/store/store.ts b/client/app/bundles/course/reference-timelines/store/store.ts new file mode 100644 index 00000000000..4987cbf8341 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/store/store.ts @@ -0,0 +1,17 @@ +import { AnyAction, configureStore, ThunkAction } from '@reduxjs/toolkit'; + +import timelinesReducer from './timelinesSlice'; + +export const store = configureStore({ + reducer: { timelines: timelinesReducer }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + +export type AppThunk> = ThunkAction< + ReturnType, + RootState, + unknown, + AnyAction +>; diff --git a/client/app/bundles/course/reference-timelines/store/timelinesSelectors.ts b/client/app/bundles/course/reference-timelines/store/timelinesSelectors.ts new file mode 100644 index 00000000000..fa9cf048eeb --- /dev/null +++ b/client/app/bundles/course/reference-timelines/store/timelinesSelectors.ts @@ -0,0 +1,17 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { TimelinesData } from 'types/course/referenceTimelines'; + +import { RootState } from './store'; + +const selectTimelinesStore = (state: RootState): TimelinesData => + state.timelines; + +export const selectTimelines = createSelector( + selectTimelinesStore, + (timelinesStore) => timelinesStore.timelines, +); + +export const selectItems = createSelector( + selectTimelinesStore, + (timelinesStore) => timelinesStore.items, +); diff --git a/client/app/bundles/course/reference-timelines/store/timelinesSlice.ts b/client/app/bundles/course/reference-timelines/store/timelinesSlice.ts new file mode 100644 index 00000000000..6fd4fdc86d9 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/store/timelinesSlice.ts @@ -0,0 +1,132 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { + ItemWithTimeData, + TimeData, + TimelineData, + TimelinesData, +} from 'types/course/referenceTimelines'; + +const initialState: TimelinesData = { + timelines: [], + items: [], + gamified: false, + defaultTimeline: 0, +}; + +export const timelinesSlice = createSlice({ + name: 'timelines', + initialState, + reducers: { + updateAll: (_, action: PayloadAction) => action.payload, + addEmptyTimeline: (state, action: PayloadAction) => { + const timeline = action.payload; + state.timelines.push({ + ...timeline, + timesCount: 0, + }); + }, + removeTimeline: (state, action: PayloadAction) => { + const id = action.payload; + const timelineIndex = state.timelines.findIndex( + (timeline) => timeline.id === id, + ); + if (timelineIndex === -1) return; + + state.timelines.splice(timelineIndex, 1); + state.items.forEach((item) => { + delete item.times[id]; + }); + }, + updateTimeline: ( + state, + action: PayloadAction<{ + id: TimelineData['id']; + title?: TimelineData['title']; + weight?: TimelineData['weight']; + }>, + ) => { + const { id, title, weight } = action.payload; + if (!title) return; + + const timelineToRename = state.timelines.find( + (timeline) => timeline.id === id, + ); + if (!timelineToRename) return; + + timelineToRename.title = title; + timelineToRename.weight = weight; + }, + addTimeToItem: ( + state, + action: PayloadAction<{ + timelineId: TimelineData['id']; + itemId: ItemWithTimeData['id']; + time: TimeData; + }>, + ) => { + const { timelineId, itemId, time } = action.payload; + const times = state.items.find((item) => item.id === itemId)?.times; + if (!times) return; + + times[timelineId] = time; + + const timelineToUpdate = state.timelines.find( + (timeline) => timeline.id === timelineId, + ); + if (!timelineToUpdate) return; + + timelineToUpdate.timesCount += 1; + }, + removeTimeFromItem: ( + state, + action: PayloadAction<{ + timelineId: TimelineData['id']; + itemId: ItemWithTimeData['id']; + }>, + ) => { + const { timelineId, itemId } = action.payload; + const times = state.items.find((item) => item.id === itemId)?.times; + if (!times) return; + + delete times[timelineId]; + + const timelineToUpdate = state.timelines.find( + (timeline) => timeline.id === timelineId, + ); + if (!timelineToUpdate) return; + + timelineToUpdate.timesCount -= 1; + }, + updateTimeInItem: ( + state, + action: PayloadAction<{ + timelineId: TimelineData['id']; + itemId: ItemWithTimeData['id']; + time: { + startAt?: TimeData['startAt']; + bonusEndAt?: TimeData['bonusEndAt'] | null; + endAt?: TimeData['endAt'] | null; + }; + }>, + ) => { + const { timelineId, itemId, time } = action.payload; + const times = state.items.find((item) => item.id === itemId)?.times; + if (!times) return; + + const oldTime = times[timelineId]; + times[timelineId] = { + id: oldTime.id, + startAt: time.startAt ?? oldTime.startAt, + bonusEndAt: + time.bonusEndAt === null + ? undefined + : time.bonusEndAt ?? oldTime.bonusEndAt, + endAt: time.endAt === null ? undefined : time.endAt ?? oldTime.endAt, + }; + }, + }, +}); + +export const timelinesActions = timelinesSlice.actions; + +export default timelinesSlice.reducer; diff --git a/client/app/bundles/course/reference-timelines/translations.ts b/client/app/bundles/course/reference-timelines/translations.ts new file mode 100644 index 00000000000..ccb06e2a695 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/translations.ts @@ -0,0 +1,219 @@ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + timelineDesigner: { + id: 'course.timelines.timelineDesigner', + defaultMessage: 'Timeline Designer', + }, + addTimeline: { + id: 'course.timelines.addTimeline', + defaultMessage: 'Timeline', + }, + errorUpdatingTime: { + id: 'course.timelines.errorUpdatingTime', + defaultMessage: 'An error occurred while updating this time.', + }, + errorCreatingTimeline: { + id: 'course.timelines.errorCreatingTimeline', + defaultMessage: 'An error occurred while creating timeline: {newTitle}.', + }, + errorRenamingTimeline: { + id: 'course.timelines.errorRenamingTimeline', + defaultMessage: 'An error occurred while renaming timeline: {newTitle}.', + }, + confirmCreateTimeline: { + id: 'course.timelines.confirmCreateTimeline', + defaultMessage: 'Create Timeline', + }, + confirmRenameTimeline: { + id: 'course.timelines.confirmRenameTimeline', + defaultMessage: 'Rename Timeline', + }, + timelineTitle: { + id: 'course.timelines.timelineTitle', + defaultMessage: 'Timeline title', + }, + renameTimelineTitle: { + id: 'course.timelines.renameTimelineTitle', + defaultMessage: 'Rename {title}', + }, + newTimeline: { + id: 'course.timelines.newTimeline', + defaultMessage: 'New Timeline', + }, + mustValidTimelineTitle: { + id: 'course.timelines.mustValidTimelineTitle', + defaultMessage: 'You must specify a valid title for a timeline.', + }, + canChangeTitleLater: { + id: 'course.timelines.canChangeTitleLater', + defaultMessage: 'You can change this title again later.', + }, + hintCanAddCustomTimes: { + id: 'course.timelines.hintCanAddCustomTimes', + defaultMessage: + 'Once you create this timeline, you can add times in this timeline that override those in the Default Timeline for some items.', + }, + hintAssignedStudentsSeeCustomTimes: { + id: 'course.timelines.hintAssignedStudentsSeeCustomTimes', + defaultMessage: + 'For these items, students assigned to this timeline will see these overridden times.', + }, + hintAssignedStudentsSeeDefaultTimes: { + id: 'course.timelines.hintAssignedStudentsSeeDefaultTimes', + defaultMessage: + "For items that are not overridden in this timeline, they will see the items' corresponding times in the Default Timeline.", + }, + confirmRevertAndDeleteTimeline: { + id: 'course.timelines.confirmRevertAndDeleteTimeline', + defaultMessage: 'Revert and delete timeline and its times', + }, + confirmDeleteTimeline: { + id: 'course.timelines.confirmDeleteTimeline', + defaultMessage: 'Delete timeline and its times', + }, + sureDeletingTimeline: { + id: 'course.timelines.sureDeletingTimeline', + defaultMessage: "Sure you're deleting {title}?", + }, + timelineHasNTimes: { + id: 'course.timelines.timelineHasNTimes', + defaultMessage: + 'This timeline assigns custom times in {n, plural, =1 {# item} other {# items}}.', + }, + timelineHasNStudents: { + id: 'course.timelines.timelineHasNStudents', + defaultMessage: + 'There are {n, plural, =1 {# student} other {# students}} assigned to this timeline.', + }, + hintDeletingTimelineWillRemoveTimes: { + id: 'course.timelines.hintDeletingTimelineWillRemoveTimes', + defaultMessage: 'Deleting this timeline will remove all its custom times.', + }, + hintDeletingTimelineWillNotAffectSubmissions: { + id: 'course.timelines.hintDeletingTimelineWillNotAffectSubmissions', + defaultMessage: + "Rest assured, there will be no changes to students' submissions data, though this action cannot be undone.", + }, + hintChooseAlternativeTimeline: { + id: 'course.timelines.hintChooseAlternativeTimeline', + defaultMessage: 'Please choose a timeline to revert the students to.', + }, + searchItems: { + id: 'course.timelines.searchItems', + defaultMessage: 'Search items', + }, + beta: { + id: 'course.timelines.beta', + defaultMessage: 'Beta', + }, + saving: { + id: 'course.timelines.saving', + defaultMessage: 'Saving...', + }, + lastSaved: { + id: 'course.timelines.lastSaved', + defaultMessage: 'Last saved {at}', + }, + unchangedSince: { + id: 'course.timelines.unchangedSince', + defaultMessage: 'Unchanged since {time}', + }, + saved: { + id: 'course.timelines.saved', + defaultMessage: 'Saved', + }, + error: { + id: 'course.timelines.error', + defaultMessage: 'Oops!', + }, + clickToAssignTime: { + id: 'course.timelines.clickToAssignTime', + defaultMessage: 'Click to assign a time here', + }, + hintNoTimeAssigned: { + id: 'course.timelines.hintNoTimeAssigned', + defaultMessage: + 'No custom time assigned. Students assigned to this timeline will follow the time in the Default Timeline.', + }, + errorDeletingTimeline: { + id: 'course.timelines.errorDeletingTimeline', + defaultMessage: 'An error occurred while deleting timeline: {title}.', + }, + errorDeletingTime: { + id: 'course.timelines.errorDeletingTime', + defaultMessage: 'An error occurred while deleting this time.', + }, + errorCreatingTime: { + id: 'course.timelines.errorCreatingTime', + defaultMessage: 'An error occurred while creating this time.', + }, + nAssigned: { + id: 'course.timelines.nAssigned', + defaultMessage: '{n} custom times', + }, + deleteTimeline: { + id: 'course.timelines.deleteTimeline', + defaultMessage: 'Delete', + }, + renameTimeline: { + id: 'course.timelines.renameTimeline', + defaultMessage: 'Rename', + }, + defaultTimeline: { + id: 'course.timelines.defaultTimeline', + defaultMessage: 'Default', + }, + deleteTime: { + id: 'course.timelines.deleteTime', + defaultMessage: 'Delete time', + }, + assigningToItem: { + id: 'course.timelines.assigningToItem', + defaultMessage: 'Assigning to item', + }, + assignedToItem: { + id: 'course.timelines.assignedToItem', + defaultMessage: 'Assigned to item', + }, + assignedInTimeline: { + id: 'course.timelines.assignedInTimeline', + defaultMessage: 'Assigned in timeline', + }, + assigningInTimeline: { + id: 'course.timelines.assigningInTimeline', + defaultMessage: 'Assigning in timeline', + }, + startsAt: { + id: 'course.timelines.startsAt', + defaultMessage: 'Starts at', + }, + bonusEndsAt: { + id: 'course.timelines.bonusEndsAt', + defaultMessage: 'Bonus ends at', + }, + endsAt: { + id: 'course.timelines.endsAt', + defaultMessage: 'Ends at', + }, + mustValidDateTimeFormat: { + id: 'course.timelines.mustValidDateTimeFormat', + defaultMessage: 'Please provide a valid date and time format.', + }, + mustSpecifyStartTime: { + id: 'course.timelines.mustSpecifyStartTime', + defaultMessage: 'You must specify a start time.', + }, + endTimeMustAfterStart: { + id: 'course.timelines.endTimeMustAfterStart', + defaultMessage: 'End time must be after the start time.', + }, + bonusEndTimeMustAfterStart: { + id: 'course.timelines.bonusEndTimeMustAfterStart', + defaultMessage: 'Bonus end time must be after the start time.', + }, + today: { + id: 'course.timelines.today', + defaultMessage: 'Today', + }, +}); diff --git a/client/app/bundles/course/reference-timelines/utils.ts b/client/app/bundles/course/reference-timelines/utils.ts new file mode 100644 index 00000000000..b10859b880e --- /dev/null +++ b/client/app/bundles/course/reference-timelines/utils.ts @@ -0,0 +1,37 @@ +import moment from 'moment'; +import { TimeData } from 'types/course/referenceTimelines'; + +const SECONDS_IN_A_DAY = 86_400 as const; + +export const DAY_WIDTH_PIXELS = 30 as const; + +interface DraftTimeData { + id?: TimeData['id']; + startAt?: moment.Moment; + bonusEndAt?: moment.Moment; + endAt?: moment.Moment; +} + +export type DraftableTimeData = TimeData | DraftTimeData; + +export const getDaysFromSeconds = (seconds: number): number => + Math.floor(seconds / SECONDS_IN_A_DAY); + +export const getSecondsFromDays = (days: number): number => + days * SECONDS_IN_A_DAY; + +export const getDurationDays = ( + start: moment.Moment, + end: moment.Moment, +): number => end.diff(start, 'days') + 1; + +export const getDaysFromWidth = (width: number): number => + Math.floor(width / DAY_WIDTH_PIXELS); + +export const isWeekend = (day: moment.Moment): boolean => { + const dayOfWeek = day.day(); + return dayOfWeek === 6 || dayOfWeek === 0; +}; + +export const isToday = (day: moment.Moment): boolean => + day.isSame(moment(), 'day'); diff --git a/client/app/bundles/course/reference-timelines/views/DayView/DayView.tsx b/client/app/bundles/course/reference-timelines/views/DayView/DayView.tsx new file mode 100644 index 00000000000..9250a70cb45 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/views/DayView/DayView.tsx @@ -0,0 +1,112 @@ +import { ComponentRef, useMemo, useRef, useState } from 'react'; +import { Chip, Typography } from '@mui/material'; +import { TimelineData } from 'types/course/referenceTimelines'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import DayCalendar from '../../components/DayCalendar'; +import SearchField from '../../components/SearchField'; +import SubmitIndicator from '../../components/SubmitIndicator'; +import TimelinesOverview from '../../components/TimelinesOverview'; +import TimelinesStack from '../../components/TimelinesStack'; +import { selectItems, selectTimelines, useAppSelector } from '../../store'; +import translations from '../../translations'; + +import ItemsSidebar from './ItemsSidebar'; + +const DayView = (): JSX.Element => { + const { t } = useTranslation(); + + const timelines = useAppSelector(selectTimelines); + const items = useAppSelector(selectItems); + + const calendarRef = useRef>(null); + const contentsRef = useRef(null); + + const [filterKeyword, setFilterKeyword] = useState(''); + + const [hiddenTimelineIds, setHiddenTimelineIds] = useState< + Set + >(new Set()); + + const visibleTimelines = useMemo(() => { + if (!hiddenTimelineIds.size) return timelines; + + return timelines.filter((timeline) => !hiddenTimelineIds.has(timeline.id)); + }, [hiddenTimelineIds, timelines]); + + const filteredItems = useMemo(() => { + if (!filterKeyword) return items; + + return items.filter((item) => + item.title.toLowerCase().includes(filterKeyword.toLowerCase().trim()), + ); + }, [filterKeyword, items]); + + return ( +
+ { + if (contentsRef.current) contentsRef.current.scrollLeft = offset; + }} + /> + + + +
+
+
+ + {t(translations.timelineDesigner)} + + + +
+ + +
+ +
+ + calendarRef.current?.scrollToItem(index) + } + within={visibleTimelines} + /> + + +
+
+ + + + +
+ ); +}; + +export default DayView; diff --git a/client/app/bundles/course/reference-timelines/views/DayView/ItemsSidebar.tsx b/client/app/bundles/course/reference-timelines/views/DayView/ItemsSidebar.tsx new file mode 100644 index 00000000000..59ed74613ba --- /dev/null +++ b/client/app/bundles/course/reference-timelines/views/DayView/ItemsSidebar.tsx @@ -0,0 +1,64 @@ +import { Typography } from '@mui/material'; +import moment from 'moment'; +import { + ItemWithTimeData, + TimelineData, +} from 'types/course/referenceTimelines'; + +import RowSpacer from '../../components/RowSpacer'; +import { getDaysFromSeconds } from '../../utils'; + +import TimelineSidebarItem from './TimelineSidebarItem'; + +interface ItemsSidebarProps { + for: ItemWithTimeData[]; + within: TimelineData[]; + className?: string; + onRequestFocus?: (index: number) => void; +} + +const ItemsSidebar = (props: ItemsSidebarProps): JSX.Element => { + const { for: items, within: timelines } = props; + + return ( + + ); +}; + +export default ItemsSidebar; diff --git a/client/app/bundles/course/reference-timelines/views/DayView/TimelineSidebarItem.tsx b/client/app/bundles/course/reference-timelines/views/DayView/TimelineSidebarItem.tsx new file mode 100644 index 00000000000..4b8dabd4239 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/views/DayView/TimelineSidebarItem.tsx @@ -0,0 +1,51 @@ +import { ArrowForward, InfoOutlined } from '@mui/icons-material'; +import { Tooltip, Typography } from '@mui/material'; +import { TimelineData } from 'types/course/referenceTimelines'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../translations'; + +interface TimelineSidebarItemProps { + for: TimelineData; + assigned?: boolean; + onClick?: () => void; +} + +const TimelineSidebarItem = (props: TimelineSidebarItemProps): JSX.Element => { + const { for: timeline, assigned } = props; + + const { t } = useTranslation(); + + return ( +
+ + {timeline.title} + + + {assigned ? ( + + ) : ( + + + + )} +
+ ); +}; + +export default TimelineSidebarItem; diff --git a/client/app/bundles/course/reference-timelines/views/DayView/index.ts b/client/app/bundles/course/reference-timelines/views/DayView/index.ts new file mode 100644 index 00000000000..36faa59283d --- /dev/null +++ b/client/app/bundles/course/reference-timelines/views/DayView/index.ts @@ -0,0 +1,3 @@ +import DayView from './DayView'; + +export default DayView; diff --git a/client/app/theme/index.css b/client/app/theme/index.css index 7d0c6c55e99..9985a2b1b6e 100644 --- a/client/app/theme/index.css +++ b/client/app/theme/index.css @@ -16,4 +16,16 @@ .key { @apply rounded-xl border border-solid py-0.5 px-2; } + + /* For Firefox 64+ and Firefox for Android 64+ */ + .scrollbar-hidden { + scrollbar-width: none; + scrollbar-color: transparent; + } + + /* For Blink- and WebKit-based browsers */ + .scrollbar-hidden::-webkit-scrollbar { + width: 0; + background: transparent; + } } diff --git a/client/app/types/course/referenceTimelines.ts b/client/app/types/course/referenceTimelines.ts new file mode 100644 index 00000000000..106026b5684 --- /dev/null +++ b/client/app/types/course/referenceTimelines.ts @@ -0,0 +1,44 @@ +export interface TimeData { + id: number; + startAt: string; + bonusEndAt?: string; + endAt?: string; +} + +export interface TimelineData { + id: number; + timesCount: number; + title: string; + default?: boolean; + weight?: number; + assignees?: number; +} + +export interface ItemWithTimeData { + id: number; + title: string; + times: Record; +} + +export interface TimelinesData { + timelines: TimelineData[]; + items: ItemWithTimeData[]; + gamified: boolean; + defaultTimeline: TimelineData['id']; +} + +export interface TimelinePostData { + reference_timeline: { + title?: TimelineData['title']; + weight?: TimelineData['weight']; + }; +} + +export interface TimePostData { + reference_time: { + lesson_plan_item_id?: number; + start_at?: string; + end_at?: string | null; + bonus_end_at?: string | null; + }; +} diff --git a/client/package.json b/client/package.json index 6faf89f503a..4e328784edc 100644 --- a/client/package.json +++ b/client/package.json @@ -74,6 +74,7 @@ "react-chartjs-2": "^4.3.1", "react-color": "^2.19.3", "react-dom": "^18.2.0", + "react-draggable": "^4.4.5", "react-dropzone": "^14.2.3", "react-emitter-factory": "^1.1.2", "react-hook-form": "^7.41.5", @@ -82,10 +83,13 @@ "react-intl": "^6.2.5", "react-player": "^2.11.0", "react-redux": "^8.0.4", + "react-resizable": "^3.0.4", "react-router-dom": "^6.3.0", "react-scroll": "^1.8.9", "react-toastify": "^9.1.1", "react-tooltip": "^4.5.1", + "react-virtualized-auto-sizer": "^1.0.7", + "react-window": "^1.8.8", "react-xarrows": "^2.0.2", "react-zoom-pan-pinch": "^2.1.3", "redux": "^4.1.2", @@ -118,6 +122,9 @@ "@types/react": "^18.0.26", "@types/react-beautiful-dnd": "^13.1.3", "@types/react-dom": "^18.0.10", + "@types/react-resizable": "^3.0.3", + "@types/react-virtualized-auto-sizer": "^1.0.1", + "@types/react-window": "^1.8.5", "@typescript-eslint/eslint-plugin": "^5.48.2", "@typescript-eslint/parser": "^5.48.1", "@wojtekmaj/enzyme-adapter-react-17": "^0.8.0", diff --git a/client/yarn.lock b/client/yarn.lock index 647d594a008..146cfa928b1 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -1027,7 +1027,7 @@ core-js-pure "^3.25.1" regenerator-runtime "^0.13.10" -"@babel/runtime@^7.10.1", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.18.3", "@babel/runtime@^7.18.9", "@babel/runtime@^7.20.6", "@babel/runtime@^7.20.7", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.10.1", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.18.3", "@babel/runtime@^7.18.9", "@babel/runtime@^7.20.6", "@babel/runtime@^7.20.7", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.20.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.7.tgz#fcb41a5a70550e04a7b708037c7c32f7f356d8fd" integrity sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ== @@ -2302,6 +2302,13 @@ hoist-non-react-statics "^3.3.0" redux "^4.0.0" +"@types/react-resizable@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/react-resizable/-/react-resizable-3.0.3.tgz#76952da74820fae0dbd9669861514c836d5f1e83" + integrity sha512-W/QsUOZoXBAIBQNhNm95A5ohoaiUA874lWQytO2UP9dOjp5JHO9+a0cwYNabea7sA12ZDJnGVUFZxcNaNksAWA== + dependencies: + "@types/react" "*" + "@types/react-transition-group@^4.4.5": version "4.4.5" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416" @@ -2309,6 +2316,20 @@ dependencies: "@types/react" "*" +"@types/react-virtualized-auto-sizer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.1.tgz#b3187dae1dfc4c15880c9cfc5b45f2719ea6ebd4" + integrity sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong== + dependencies: + "@types/react" "*" + +"@types/react-window@^1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1" + integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@16 || 17 || 18", "@types/react@^18.0.26": version "18.0.26" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.26.tgz#8ad59fc01fef8eaf5c74f4ea392621749f0b7917" @@ -6605,7 +6626,7 @@ memfs@^3.4.1, memfs@^3.4.3: dependencies: fs-monkey "^1.0.3" -memoize-one@^5.1.1: +"memoize-one@>=3.1.1 <6", memoize-one@^5.1.1: version "5.2.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== @@ -7584,7 +7605,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.5.10, prop-types@^15.5.9, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.0, prop-types@^15.7.2, prop-types@^15.8.0, prop-types@^15.8.1: +prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.9, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.0, prop-types@^15.7.2, prop-types@^15.8.0, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -7842,6 +7863,14 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-draggable@^4.0.3, react-draggable@^4.4.5: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.5.tgz#9e37fe7ce1a4cf843030f521a0a4cc41886d7e7c" + integrity sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g== + dependencies: + clsx "^1.1.1" + prop-types "^15.8.1" + react-dropzone@^14.2.3: version "14.2.3" resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.3.tgz#0acab68308fda2d54d1273a1e626264e13d4e84b" @@ -7952,6 +7981,14 @@ react-redux@^8.0.4: react-is "^18.0.0" use-sync-external-store "^1.0.0" +react-resizable@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-3.0.4.tgz#aa20108eff28c52c6fddaa49abfbef8abf5e581b" + integrity sha512-StnwmiESiamNzdRHbSSvA65b0ZQJ7eVQpPusrSmcpyGKzC0gojhtO62xxH6YOBmepk9dQTBi9yxidL3W4s3EBA== + dependencies: + prop-types "15.x" + react-draggable "^4.0.3" + react-router-dom@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d" @@ -8040,6 +8077,11 @@ react-transition-group@^4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" +react-virtualized-auto-sizer@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.7.tgz#bfb8414698ad1597912473de3e2e5f82180c1195" + integrity sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA== + react-virtualized@^9.21.2: version "9.22.3" resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.22.3.tgz#f430f16beb0a42db420dbd4d340403c0de334421" @@ -8052,6 +8094,14 @@ react-virtualized@^9.21.2: prop-types "^15.7.2" react-lifecycles-compat "^3.0.4" +react-window@^1.8.8: + version "1.8.8" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.8.tgz#1b52919f009ddf91970cbdb2050a6c7be44df243" + integrity sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react-xarrows@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/react-xarrows/-/react-xarrows-2.0.2.tgz#7555687612339eaefd4ed55fc5c63f2302726d9c" From 8233429a3da2d09e588c8e7272bcd98446563f77 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sun, 15 Jan 2023 13:05:27 +0800 Subject: [PATCH 13/22] fix(timelinedesigner): calendar scrolls to epoch when no timelines --- .../course/reference-timelines/views/DayView/DayView.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/app/bundles/course/reference-timelines/views/DayView/DayView.tsx b/client/app/bundles/course/reference-timelines/views/DayView/DayView.tsx index 9250a70cb45..d9939123664 100644 --- a/client/app/bundles/course/reference-timelines/views/DayView/DayView.tsx +++ b/client/app/bundles/course/reference-timelines/views/DayView/DayView.tsx @@ -89,9 +89,10 @@ const DayView = (): JSX.Element => { From a95ac78e42bfed578bcd0dec0213604b7c0e2b0e Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sun, 15 Jan 2023 13:16:01 +0800 Subject: [PATCH 14/22] feat(manageuserstable): add filter by group, bulk assign to timeline --- client/app/api/course/Users.ts | 14 + .../bundles/course/enrol-requests/reducers.ts | 1 + .../course/user-invitations/reducers.ts | 1 + client/app/bundles/course/users/actions.ts | 3 + .../buttons/UserManagementButtons.tsx | 10 +- .../components/tables/ManageUsersTable.tsx | 598 ++++++++++++------ client/app/bundles/course/users/operations.ts | 32 +- .../course/users/pages/ManageStaff/index.tsx | 4 +- .../users/pages/ManageStudents/index.tsx | 14 +- client/app/bundles/course/users/reducers.ts | 3 + client/app/bundles/course/users/selectors.ts | 4 + client/app/bundles/course/users/types.ts | 4 + .../lib/components/core/layouts/DataTable.jsx | 1 + .../DataTableInlineEditable/TextField.tsx | 26 +- client/app/lib/hooks/useTranslation.ts | 2 +- client/app/lib/translations/table.ts | 6 +- client/app/types/components/DataTable.ts | 14 + client/app/types/course/courseUsers.ts | 6 +- spec/features/course/staff_management_spec.rb | 2 +- 19 files changed, 525 insertions(+), 220 deletions(-) diff --git a/client/app/api/course/Users.ts b/client/app/api/course/Users.ts index 1bc74400fc2..00291830984 100644 --- a/client/app/api/course/Users.ts +++ b/client/app/api/course/Users.ts @@ -9,6 +9,7 @@ import { StaffRole, UpdateCourseUserPatchData, } from 'types/course/courseUsers'; +import { TimelineData } from 'types/course/referenceTimelines'; import BaseCourseAPI from './Base'; @@ -44,6 +45,7 @@ export default class UsersAPI extends BaseCourseAPI { users: CourseUserListData[]; permissions: ManageCourseUsersPermissions; manageCourseUsersData: ManageCourseUsersSharedData; + timelines?: Record; }> > { return this.getClient().get(`${this._baseUrlPrefix}/students`); @@ -133,4 +135,16 @@ export default class UsersAPI extends BaseCourseAPI { params, ); } + + assignToTimeline( + ids: CourseUserBasicMiniEntity['id'][], + timelineId: TimelineData['id'], + ): Promise { + const params = { course_users: { ids, reference_timeline_id: timelineId } }; + + return this.getClient().patch( + `${this._baseUrlPrefix}/users/assign_timeline`, + params, + ); + } } diff --git a/client/app/bundles/course/enrol-requests/reducers.ts b/client/app/bundles/course/enrol-requests/reducers.ts index eff9530a79a..5b9f276aa4c 100644 --- a/client/app/bundles/course/enrol-requests/reducers.ts +++ b/client/app/bundles/course/enrol-requests/reducers.ts @@ -17,6 +17,7 @@ const initialState: EnrolRequestsState = { permissions: { canManageCourseUsers: false, canManageEnrolRequests: false, + canManageReferenceTimelines: false, canManagePersonalTimes: false, canRegisterWithCode: false, }, diff --git a/client/app/bundles/course/user-invitations/reducers.ts b/client/app/bundles/course/user-invitations/reducers.ts index 679963e9d85..cf28f522664 100644 --- a/client/app/bundles/course/user-invitations/reducers.ts +++ b/client/app/bundles/course/user-invitations/reducers.ts @@ -24,6 +24,7 @@ const initialState: InvitationsState = { permissions: { canManageCourseUsers: false, canManageEnrolRequests: false, + canManageReferenceTimelines: false, canManagePersonalTimes: false, canRegisterWithCode: false, }, diff --git a/client/app/bundles/course/users/actions.ts b/client/app/bundles/course/users/actions.ts index ff012ca7b44..fdcdf185f6c 100644 --- a/client/app/bundles/course/users/actions.ts +++ b/client/app/bundles/course/users/actions.ts @@ -7,6 +7,7 @@ import { } from 'types/course/courseUsers'; import { ExperiencePointsRecordListData } from 'types/course/experiencePointsRecords'; import { PersonalTimeListData } from 'types/course/personalTimes'; +import { TimelineData } from 'types/course/referenceTimelines'; import { DELETE_EXPERIENCE_POINTS_RECORD, @@ -51,6 +52,7 @@ export function saveManageUserList( manageCourseUsersPermissions: ManageCourseUsersPermissions, manageCourseUsersData: ManageCourseUsersSharedData, userOptions: CourseUserBasicListData[] = [], + timelines?: Record, ): SaveManageUserListAction { return { type: SAVE_MANAGE_USER_LIST, @@ -58,6 +60,7 @@ export function saveManageUserList( manageCourseUsersPermissions, manageCourseUsersData, userOptions, + timelines, }; } diff --git a/client/app/bundles/course/users/components/buttons/UserManagementButtons.tsx b/client/app/bundles/course/users/components/buttons/UserManagementButtons.tsx index 6175426fbcc..58aee75dd65 100644 --- a/client/app/bundles/course/users/components/buttons/UserManagementButtons.tsx +++ b/client/app/bundles/course/users/components/buttons/UserManagementButtons.tsx @@ -13,6 +13,7 @@ import { deleteUser } from '../../operations'; interface Props extends WrappedComponentProps { user: CourseUserRowData; + disabled?: boolean; } const styles = { @@ -66,7 +67,7 @@ const UserManagementButtons: FC = (props) => { name: user.name, email: user.email, })} - disabled={isDeleting} + disabled={isDeleting || Boolean(props.disabled)} loading={isDeleting} onClick={onDelete} sx={styles.buttonStyle} @@ -76,9 +77,4 @@ const UserManagementButtons: FC = (props) => { ); }; -export default memo( - injectIntl(UserManagementButtons), - (prevProps, nextProps) => { - return equal(prevProps.user, nextProps.user); - }, -); +export default memo(injectIntl(UserManagementButtons), equal); diff --git a/client/app/bundles/course/users/components/tables/ManageUsersTable.tsx b/client/app/bundles/course/users/components/tables/ManageUsersTable.tsx index 9bb27f53a11..ddfa91a1f6a 100644 --- a/client/app/bundles/course/users/components/tables/ManageUsersTable.tsx +++ b/client/app/bundles/course/users/components/tables/ManageUsersTable.tsx @@ -1,13 +1,17 @@ -import { FC, memo, ReactElement } from 'react'; -import { - defineMessages, - FormattedMessage, - injectIntl, - WrappedComponentProps, -} from 'react-intl'; +import { memo, ReactElement, useMemo, useState } from 'react'; +import { defineMessages } from 'react-intl'; import { useDispatch, useSelector } from 'react-redux'; import { toast } from 'react-toastify'; -import { Checkbox, MenuItem, TextField, Typography } from '@mui/material'; +import { ExpandMore } from '@mui/icons-material'; +import { + Button, + Menu, + MenuItem, + Switch, + TextField, + Toolbar, + Typography, +} from '@mui/material'; import equal from 'fast-deep-equal'; import { TableColumns, @@ -20,7 +24,8 @@ import { CourseUserRowData, } from 'types/course/courseUsers'; import { TimelineAlgorithm } from 'types/course/personalTimes'; -import { AppDispatch, AppState } from 'types/store'; +import { TimelineData } from 'types/course/referenceTimelines'; +import { AppDispatch } from 'types/store'; import DataTable from 'lib/components/core/layouts/DataTable'; import Note from 'lib/components/core/Note'; @@ -31,17 +36,22 @@ import { TIMELINE_ALGORITHMS, } from 'lib/constants/sharedConstants'; import rebuildObjectFromRow from 'lib/helpers/mui-datatables-helpers'; +import useTranslation from 'lib/hooks/useTranslation'; import tableTranslations from 'lib/translations/table'; -import { updateUser } from '../../operations'; +import { assignToTimeline, updateUser } from '../../operations'; import { getManageCourseUserPermissions } from '../../selectors'; -interface Props extends WrappedComponentProps { +interface ManageUsersTableProps { title: string; users: CourseUserMiniEntity[]; manageStaff?: boolean; - renderRowActionComponent?: (user: CourseUserRowData) => ReactElement; + renderRowActionComponent?: ( + user: CourseUserRowData, + disabled: boolean, + ) => ReactElement; csvDownloadOptions: TableDownloadOptions; + timelinesMap?: Record; } const translations = defineMessages({ @@ -71,220 +81,359 @@ const translations = defineMessages({ }, changeRoleSuccess: { id: 'course.users.ManageUsersTable.changeRoleSuccess', - defaultMessage: "Successfully changed {name}'s role to {role}.", + defaultMessage: "Updated {name}'s role to {role}.", }, changeRoleFailure: { id: 'course.users.ManageUsersTable.changeRoleFailure', - defaultMessage: "Failed to change {name}'s role to {role}.", + defaultMessage: "Failed to update {name}'s role to {role}.", }, changeTimelineSuccess: { id: 'course.users.ManageUsersTable.changeTimelineSuccess', - defaultMessage: - "Successfully changed {name}'s timeline algorithm to {timeline}.", + defaultMessage: "Updated {name}'s reference timeline to {timeline}.", }, changeTimelineFailure: { id: 'course.users.ManageUsersTable.changeTimelineFailure', defaultMessage: - "Failed to change {name}'s timeline algorithm to {timeline}.", + "Failed to update {name}'s reference timeline to {timeline}.", + }, + bulkChangeTimelineSuccess: { + id: 'course.users.ManageUsersTable.bulkChangeTimelineSuccess', + defaultMessage: + "Updated {n, plural, =1 {# student''s} other {# students''}} reference timelines to {timeline}.", + }, + bulkChangeTimelineFailure: { + id: 'course.users.ManageUsersTable.bulkChangeTimelineFailure', + defaultMessage: + "Failed to update {n, plural, =1 {# student''s} other {# students''}} reference timelines to {timeline}.", + }, + changeAlgorithmSuccess: { + id: 'course.users.ManageUsersTable.changeAlgorithmSuccess', + defaultMessage: "Updated {name}'s timeline algorithm to {timeline}.", + }, + changeAlgorithmFailure: { + id: 'course.users.ManageUsersTable.changeAlgorithmFailure', + defaultMessage: + "Failed to update {name}'s timeline algorithm to {timeline}.", }, updateFailure: { id: 'course.users.ManageUsersTable.updateFailure', defaultMessage: 'Failed to update user - {error}', }, + defaultTimeline: { + id: 'course.users.ManageUsersTable.defaultTimeline', + defaultMessage: 'Default', + }, + group: { + id: 'course.users.ManageUsersTable.group', + defaultMessage: 'Group: {name}', + }, + selectedNStudents: { + id: 'course.users.ManageUsersTable.selectedNStudents', + defaultMessage: 'Selected {n, plural, =1 {# student} other {# students}}', + }, + assignToTimeline: { + id: 'course.users.ManageUsersTable.assignToTimeline', + defaultMessage: 'Assign to timeline', + }, }); -const styles = { - checkbox: { - margin: '0px 12px 0px 0px', - padding: 0, - }, -}; +const algorithms = TIMELINE_ALGORITHMS.map((option) => ( + + {option.label} + +)); -const ManageUsersTable: FC = (props) => { - const { - title, - users, - manageStaff = false, - renderRowActionComponent = null, - intl, - csvDownloadOptions, - } = props; - const permissions = useSelector((state: AppState) => - getManageCourseUserPermissions(state), - ); +const roles = Object.keys(COURSE_USER_ROLES).map((option) => ( + + {COURSE_USER_ROLES[option]} + +)); + +interface TableToolbarSelectProps { + selectedRows: { data: { index: number; dataIndex: number }[] }; +} + +const ManageUsersTable = (props: ManageUsersTableProps): JSX.Element => { + const { users, manageStaff, timelinesMap, renderRowActionComponent } = props; + + const { t } = useTranslation(); const dispatch = useDispatch(); - if (users && users.length === 0) { - return } />; - } + const permissions = useSelector(getManageCourseUserPermissions); + + const [submitting, setSubmitting] = useState(false); + const [filteringGroup, setFilteringGroup] = useState(false); + + const timelines = useMemo( + () => + timelinesMap && + Object.entries(timelinesMap).map(([id, timelineTitle]) => ( + + {timelineTitle ?? t(translations.defaultTimeline)} + + )), + [timelinesMap], + ); + + const handleAssignUsersToTimeline = ( + ids: CourseUserMiniEntity['id'][], + timelineId: TimelineData['id'], + timelineTitle: TimelineData['title'], + ): void => { + setSubmitting(true); + + dispatch(assignToTimeline(ids, timelineId)) + .then(() => { + toast.success( + t(translations.bulkChangeTimelineSuccess, { + n: ids.length, + timeline: timelineTitle ?? t(translations.defaultTimeline), + }), + ); + }) + .catch(() => { + toast.error( + t(translations.bulkChangeTimelineFailure, { + n: ids.length, + timeline: timelineTitle ?? t(translations.defaultTimeline), + }), + ); + }) + .finally(() => setSubmitting(false)); + }; + + const TableToolbarSelect = useMemo(() => { + const ManageUsersTableActionBar = ({ + selectedRows, + }: TableToolbarSelectProps): JSX.Element => { + const rows = selectedRows.data; + + const [timelinesMenu, setTimelinesMenu] = useState(); + + return ( + + + {t(translations.selectedNStudents, { n: rows.length })} + + + {timelinesMap && ( + <> + + + setTimelinesMenu(undefined)} + open={Boolean(timelinesMenu)} + > + {Object.entries(timelinesMap).map(([id, title]) => ( + { + const timelineId = parseInt(id, 10); + const ids = rows.map( + ({ dataIndex }) => users[dataIndex].id, + ); - const handleNameUpdate = (rowData, newName: string): Promise => { - const user = rebuildObjectFromRow( - columns, // eslint-disable-line @typescript-eslint/no-use-before-define - rowData, - ) as CourseUserMiniEntity; - const newUser = { - ...user, - name: newName, + handleAssignUsersToTimeline(ids, timelineId, title); + }} + > + {title ?? t(translations.defaultTimeline)} + + ))} + + + )} + + ); }; - return dispatch(updateUser(rowData[1], newUser)) + + ManageUsersTableActionBar.displayName = 'ManageUsersTableActionBar'; + + return ManageUsersTableActionBar; + }, [timelinesMap]); + + if (!users?.length) return ; + + const handleNameUpdate = ( + userId: CourseUserMiniEntity['id'], + userName: CourseUserMiniEntity['name'], + name: string, + ): Promise => { + setSubmitting(true); + + return dispatch(updateUser(userId, { name })) .then(() => { toast.success( - intl.formatMessage(translations.renameSuccess, { - oldName: user.name, - newName, + t(translations.renameSuccess, { + oldName: userName, + newName: name, }), ); }) .catch((error) => { - const errorMessage = error.response?.data?.errors - ? error.response.data.errors - : ''; toast.error( - intl.formatMessage(translations.renameFailure, { - oldName: user.name, - newName, - error: errorMessage, + t(translations.renameFailure, { + oldName: userName, + newName: name, + error: error.response?.data?.errors ?? '', }), ); - throw error; - }); + }) + .finally(() => setSubmitting(false)); }; const handlePhantomUpdate = ( user: CourseUserMiniEntity, - newValue: boolean, - ): Promise => { - const newUser = { - ...user, - phantom: newValue, - }; - return dispatch(updateUser(user.id, newUser)) + phantom: boolean, + ): void => { + setSubmitting(true); + + dispatch(updateUser(user.id, { phantom })) .then(() => { toast.success( - intl.formatMessage(translations.phantomSuccess, { + t(translations.phantomSuccess, { name: user.name, - isPhantom: newValue, + isPhantom: phantom, }), ); }) .catch((error) => { - const errorMessage = error.response?.data?.errors - ? error.response.data.errors - : ''; toast.error( - intl.formatMessage(translations.updateFailure, { - error: errorMessage, + t(translations.updateFailure, { + error: error.response?.data?.errors ?? '', }), ); - }); + }) + .finally(() => setSubmitting(false)); }; const handleRoleUpdate = ( - rowData, - newRole: string, - updateValue, - ): Promise => { - const user = rebuildObjectFromRow( - columns, // eslint-disable-line @typescript-eslint/no-use-before-define - rowData, - ) as CourseUserMiniEntity; - const newUser = { - ...user, - role: newRole as CourseUserRole, - }; - return dispatch(updateUser(user.id, newUser)) + userId: CourseUserMiniEntity['id'], + userName: CourseUserMiniEntity['name'], + role: CourseUserRole, + updateValue: (role: CourseUserRole) => void, + ): void => { + setSubmitting(true); + + dispatch(updateUser(userId, { role })) .then(() => { - updateValue(newRole); + updateValue(role); toast.success( - intl.formatMessage(translations.changeRoleSuccess, { - name: user.name, - role: COURSE_USER_ROLES[newRole], + t(translations.changeRoleSuccess, { + name: userName, + role: COURSE_USER_ROLES[role], }), ); }) .catch((error) => { - const errorMessage = error.response?.data?.errors - ? error.response.data.errors - : ''; toast.error( - intl.formatMessage(translations.changeRoleFailure, { - name: user.name, - role: COURSE_USER_ROLES[newRole], - error: errorMessage, + t(translations.changeRoleFailure, { + name: userName, + role: COURSE_USER_ROLES[role], + error: error.response?.data?.errors ?? '', }), ); - }); + }) + .finally(() => setSubmitting(false)); }; - const handleTimelineUpdate = ( - rowData, - newTimeline: string, - updateValue, - ): Promise => { - const user = rebuildObjectFromRow( - columns, // eslint-disable-line @typescript-eslint/no-use-before-define - rowData, - ) as CourseUserMiniEntity; - const newUser = { - ...user, - timelineAlgorithm: newTimeline as TimelineAlgorithm, - }; - return dispatch(updateUser(user.id, newUser)) + const handleAlgorithmUpdate = ( + userId: CourseUserMiniEntity['id'], + userName: CourseUserMiniEntity['name'], + timelineAlgorithm: TimelineAlgorithm, + updateValue: (algorithm: TimelineAlgorithm) => void, + ): void => { + setSubmitting(true); + + dispatch(updateUser(userId, { timelineAlgorithm })) .then(() => { - updateValue(newTimeline); + updateValue(timelineAlgorithm); toast.success( - intl.formatMessage(translations.changeTimelineSuccess, { - name: user.name, + t(translations.changeAlgorithmSuccess, { + name: userName, timeline: TIMELINE_ALGORITHMS.find( - (timeline) => timeline.value === newTimeline, + (timeline) => timeline.value === timelineAlgorithm, )?.label ?? 'Unknown', }), ); }) .catch((error) => { toast.error( - intl.formatMessage(translations.changeTimelineFailure, { - name: user.name, + t(translations.changeAlgorithmFailure, { + name: userName, timeline: TIMELINE_ALGORITHMS.find( - (timeline) => timeline.value === newTimeline, + (timeline) => timeline.value === timelineAlgorithm, )?.label ?? 'Unknown', error: error.response.data.errors, }), ); - }); + }) + .finally(() => setSubmitting(false)); + }; + + const handleTimelineUpdate = ( + userId: CourseUserMiniEntity['id'], + userName: CourseUserMiniEntity['name'], + referenceTimelineId: number, + timeline: string, + updateValue: (id: number) => void, + ): void => { + setSubmitting(true); + + dispatch(updateUser(userId, { referenceTimelineId })) + .then(() => { + updateValue(referenceTimelineId); + toast.success( + t(translations.changeTimelineSuccess, { name: userName, timeline }), + ); + }) + .catch(() => { + toast.error( + t(translations.changeTimelineFailure, { name: userName, timeline }), + ); + }) + .finally(() => setSubmitting(false)); }; const options: TableOptions = { download: true, - downloadOptions: csvDownloadOptions, - filter: false, + downloadOptions: props.csvDownloadOptions, + onFilterChange: (_, filterList: string[]) => + setFilteringGroup(Boolean(filterList[5].length)), pagination: true, print: false, rowsPerPage: DEFAULT_TABLE_ROWS_PER_PAGE, rowsPerPageOptions: [DEFAULT_TABLE_ROWS_PER_PAGE], search: true, - searchPlaceholder: intl.formatMessage(translations.searchText), - selectableRows: 'none', - setTableProps: (): object => { - return { size: 'small' }; - }, - setRowProps: (_row, dataIndex, _rowIndex): Record => { - return { - key: `user_${users[dataIndex].id}`, - userid: `user_${users[dataIndex].id}`, - className: `course_user course_user_${users[dataIndex].id}`, - }; - }, + // TODO: Remove `!permissions.canManageReferenceTimelines` when adding another + // action to `ManageUsersTableActionBar` + selectableRows: + manageStaff || !permissions.canManageReferenceTimelines + ? 'none' + : 'multiple', + searchPlaceholder: t(translations.searchText), + setTableProps: () => ({ size: 'small' }), + setRowProps: (_, dataIndex): Record => ({ + key: users[dataIndex].id, + userid: users[dataIndex].id, + className: `course_user course_user_${users[dataIndex].id}`, + }), viewColumns: false, }; const columns: TableColumns[] = [ { name: 'id', - label: intl.formatMessage(tableTranslations.id), + label: '', options: { display: false, filter: false, @@ -294,17 +443,21 @@ const ManageUsersTable: FC = (props) => { }, { name: 'name', - label: intl.formatMessage(tableTranslations.name), + label: t(tableTranslations.name), options: { alignCenter: false, + filter: false, customBodyRender: (value, tableMeta, updateValue): JSX.Element => { - const userId = tableMeta.rowData[0]; + const userId = tableMeta.rowData[1]; + const userName = tableMeta.rowData[2]; + return ( => - handleNameUpdate(tableMeta.rowData, newName) + handleNameUpdate(userId, userName, newName) } updateValue={updateValue} value={value} @@ -316,14 +469,16 @@ const ManageUsersTable: FC = (props) => { }, { name: 'email', - label: intl.formatMessage(tableTranslations.email), + label: t(tableTranslations.email), options: { alignCenter: false, + filter: false, customBodyRenderLite: (dataIndex): JSX.Element => { const user = users[dataIndex]; + return ( @@ -335,43 +490,120 @@ const ManageUsersTable: FC = (props) => { }, { name: 'phantom', - label: intl.formatMessage(tableTranslations.phantom), + label: t(tableTranslations.phantom), options: { + filter: false, customBodyRenderLite: (dataIndex): JSX.Element => { const user = users[dataIndex]; + return ( - => + onChange={(event): void => handlePhantomUpdate(user, event.target.checked) } - style={styles.checkbox} /> ); }, }, }, + { + name: 'groups', + label: t(tableTranslations.groups), + options: { + display: filteringGroup, + filterType: 'multiselect', + filterOptions: { + fullWidth: true, + logic: (groups: string[], filters: string[]): boolean => { + if (!filters.length) return false; + + const filterSet = new Set(filters); + return !groups.some((group) => filterSet.has(group)); + }, + }, + customFilterListOptions: { + render: (name: string) => t(translations.group, { name }), + }, + customBodyRenderLite: (dataIndex): JSX.Element => { + const user = users[dataIndex]; + + return ( +
    + {user.groups?.map((group) => ( + + {group} + + ))} +
+ ); + }, + }, + }, ]; + if (permissions?.canManageReferenceTimelines && timelines && timelinesMap) { + columns.push({ + name: 'referenceTimelineId', + label: t(tableTranslations.referenceTimeline), + options: { + alignCenter: false, + filter: false, + customBodyRender: (value, tableMeta, updateValue): JSX.Element => { + const userId = tableMeta.rowData[1]; + const userName = tableMeta.rowData[2]; + + return ( + { + const timelineId = parseInt(e.target.value, 10); + + handleTimelineUpdate( + userId, + userName, + timelineId, + timelinesMap[timelineId] || t(translations.defaultTimeline), + updateValue, + ); + }} + select + value={value} + variant="standard" + > + {timelines} + + ); + }, + }, + }); + } + if (permissions?.canManagePersonalTimes) { columns.push({ name: 'timelineAlgorithm', - label: intl.formatMessage(tableTranslations.timelineAlgorithm), + label: t(tableTranslations.timelineAlgorithm), options: { alignCenter: false, + filter: false, customBodyRender: (value, tableMeta, updateValue): JSX.Element => { - const user = users[tableMeta.rowIndex]; + const userId = tableMeta.rowData[1]; + const userName = tableMeta.rowData[2]; + return ( => - handleTimelineUpdate( - tableMeta.rowData, - e.target.value, + key={userId} + disabled={submitting} + onChange={(e): void => + handleAlgorithmUpdate( + userId, + userName, + e.target.value as TimelineAlgorithm, updateValue, ) } @@ -379,15 +611,7 @@ const ManageUsersTable: FC = (props) => { value={value} variant="standard" > - {TIMELINE_ALGORITHMS.map((option) => ( - - {option.label} - - ))} + {algorithms} ); }, @@ -398,32 +622,31 @@ const ManageUsersTable: FC = (props) => { if (manageStaff && permissions?.canManageCourseUsers) { columns.push({ name: 'role', - label: intl.formatMessage(tableTranslations.role), + label: t(tableTranslations.role), options: { alignCenter: false, customBodyRender: (value, tableMeta, updateValue): JSX.Element => { - const user = users[tableMeta.rowIndex]; + const userId = tableMeta.rowData[1]; + const userName = tableMeta.rowData[2]; + return ( => - handleRoleUpdate(tableMeta.rowData, e.target.value, updateValue) + disabled={submitting} + onChange={(e): void => + handleRoleUpdate( + userId, + userName, + e.target.value as CourseUserRole, + updateValue, + ) } select value={value} variant="standard" > - {Object.keys(COURSE_USER_ROLES).map((option) => ( - - {COURSE_USER_ROLES[option]} - - ))} + {roles} ); }, @@ -434,15 +657,15 @@ const ManageUsersTable: FC = (props) => { if (renderRowActionComponent) { columns.push({ name: 'actions', - label: intl.formatMessage(tableTranslations.actions), + label: t(tableTranslations.actions), options: { + filter: false, empty: true, sort: false, - alignCenter: true, - customBodyRender: (_value, tableMeta): JSX.Element => { + customBodyRender: (_, tableMeta): JSX.Element => { const rowData = tableMeta.rowData as CourseUserRowData; const user = rebuildObjectFromRow(columns, rowData); - return renderRowActionComponent(user); + return renderRowActionComponent(user, submitting); }, download: false, }, @@ -452,15 +675,16 @@ const ManageUsersTable: FC = (props) => { return ( ); }; -export default memo(injectIntl(ManageUsersTable), (prevProps, nextProps) => { - return equal(prevProps.users, nextProps.users); -}); +export default memo(ManageUsersTable, (prevProps, nextProps) => + equal(prevProps.users, nextProps.users), +); diff --git a/client/app/bundles/course/users/operations.ts b/client/app/bundles/course/users/operations.ts index a898a45ece8..7fc59af07b1 100644 --- a/client/app/bundles/course/users/operations.ts +++ b/client/app/bundles/course/users/operations.ts @@ -14,6 +14,7 @@ import { PersonalTimeFormData, PersonalTimePostData, } from 'types/course/personalTimes'; +import { TimelineData } from 'types/course/referenceTimelines'; import { Operation } from 'types/store'; import CourseAPI from 'api/course'; @@ -35,13 +36,14 @@ import { * } */ const formatUpdateUser = ( - data: CourseUserEntity | CourseUserMiniEntity, + data: CourseUserEntity | Partial, ): UpdateCourseUserPatchData => { return { course_user: { name: data.name, phantom: data.phantom, role: data.role, + reference_timeline_id: data.referenceTimelineId, timeline_algorithm: data.timelineAlgorithm, }, }; @@ -104,6 +106,8 @@ export function fetchStudents(): Operation { data.users, data.permissions, data.manageCourseUsersData, + [], + data.timelines, ), ); }); @@ -133,7 +137,7 @@ export function loadUser(userId: number): Operation { export function updateUser( userId: number, - data: CourseUserEntity | CourseUserMiniEntity, + data: CourseUserEntity | Partial, ): Operation { const attributes = formatUpdateUser(data); return async (dispatch) => @@ -146,6 +150,10 @@ export function updateUser( }; dispatch(actions.updateUserOption(userOption)); } + + // TODO: Fix `actions.saveUser`'s params to support handling `CourseUserMiniEntity`. + // This should trigger a TypeScript type mismatch because `response.data` could be + // of type `CourseUserMiniEntity`, but `actions.saveUser` only accepts `CourseUserData`. dispatch(actions.saveUser(response.data)); }); } @@ -163,6 +171,26 @@ export function upgradeToStaff( }); } +export function assignToTimeline( + ids: CourseUserBasicMiniEntity['id'][], + timelineId: TimelineData['id'], +): Operation { + return async (dispatch) => { + await CourseAPI.users.assignToTimeline(ids, timelineId); + ids.forEach((id) => { + // @ts-ignore: ignore type mismatch between this object and `CourseUserData` + // TODO: Fix `actions.saveUser`'s params to support handling `CourseUserMiniEntity`. + // The dispatch in `updateUser` above technically should also fire the same error. + // The only reason it does not is because the `response` is not typed, and thus + // its `response.data` is `any`, thus is assignable to `CourseUserData`. + // + // This line still technically works because `saveEntityToStore` thankfully + // intelligently merges the old and new entities. + dispatch(actions.saveUser({ id, referenceTimelineId: timelineId })); + }); + }; +} + export function deleteUser(userId: number): Operation { return async (dispatch) => CourseAPI.users.delete(userId).then(() => { diff --git a/client/app/bundles/course/users/pages/ManageStaff/index.tsx b/client/app/bundles/course/users/pages/ManageStaff/index.tsx index d83fba0df83..4aa0907f9a9 100644 --- a/client/app/bundles/course/users/pages/ManageStaff/index.tsx +++ b/client/app/bundles/course/users/pages/ManageStaff/index.tsx @@ -79,8 +79,8 @@ const ManageStaff: FC = (props) => { ( - + renderRowActionComponent={(user, disabled): JSX.Element => ( + )} title={intl.formatMessage(translations.manageStaffTitle)} users={staff} diff --git a/client/app/bundles/course/users/pages/ManageStudents/index.tsx b/client/app/bundles/course/users/pages/ManageStudents/index.tsx index 72150fed612..ef7b438a579 100644 --- a/client/app/bundles/course/users/pages/ManageStudents/index.tsx +++ b/client/app/bundles/course/users/pages/ManageStudents/index.tsx @@ -15,6 +15,7 @@ import ManageUsersTable from '../../components/tables/ManageUsersTable'; import { fetchStudents } from '../../operations'; import { getAllStudentMiniEntities, + getAssignableTimelines, getManageCourseUserPermissions, getManageCourseUsersSharedData, } from '../../selectors'; @@ -35,15 +36,23 @@ const translations = defineMessages({ const ManageStudents: FC = (props) => { const { intl } = props; const [isLoading, setIsLoading] = useState(true); + const students = useSelector((state: AppState) => getAllStudentMiniEntities(state), ); + const permissions = useSelector((state: AppState) => getManageCourseUserPermissions(state), ); + const sharedData = useSelector((state: AppState) => getManageCourseUsersSharedData(state), ); + + const timelines = useSelector((state: AppState) => + getAssignableTimelines(state), + ); + const dispatch = useDispatch(); useEffect(() => { @@ -78,9 +87,10 @@ const ManageStudents: FC = (props) => { {students.length > 0 ? ( ( - + renderRowActionComponent={(user, disabled): JSX.Element => ( + )} + timelinesMap={timelines} title={intl.formatMessage(translations.manageStudentsTitle)} users={students} /> diff --git a/client/app/bundles/course/users/reducers.ts b/client/app/bundles/course/users/reducers.ts index 9023c5034cf..45c2b5d19c8 100644 --- a/client/app/bundles/course/users/reducers.ts +++ b/client/app/bundles/course/users/reducers.ts @@ -30,6 +30,7 @@ const initialState: UsersState = { permissions: { canManageCourseUsers: false, canManageEnrolRequests: false, + canManageReferenceTimelines: false, canManagePersonalTimes: false, canRegisterWithCode: false, }, @@ -44,6 +45,7 @@ const initialState: UsersState = { courseUserName: '', rowCount: 0, }, + timelines: {}, }; const reducer = produce((draft: UsersState, action: UsersActionType) => { @@ -77,6 +79,7 @@ const reducer = produce((draft: UsersState, action: UsersActionType) => { } draft.permissions = action.manageCourseUsersPermissions; draft.manageCourseUsersData = action.manageCourseUsersData; + draft.timelines = action.timelines; break; } case DELETE_USER: { diff --git a/client/app/bundles/course/users/selectors.ts b/client/app/bundles/course/users/selectors.ts index d89d669285d..fe244334b45 100644 --- a/client/app/bundles/course/users/selectors.ts +++ b/client/app/bundles/course/users/selectors.ts @@ -48,6 +48,10 @@ export function getStudentOptionMiniEntities(state: AppState) { ).filter((entity) => entity.role === 'student'); } +export function getAssignableTimelines(state: AppState) { + return getLocalState(state).timelines; +} + export function getManageCourseUserPermissions(state: AppState) { return getLocalState(state).permissions; } diff --git a/client/app/bundles/course/users/types.ts b/client/app/bundles/course/users/types.ts index 9bc05a836e0..b4361a00bfd 100644 --- a/client/app/bundles/course/users/types.ts +++ b/client/app/bundles/course/users/types.ts @@ -17,6 +17,7 @@ import { PersonalTimeListData, PersonalTimeMiniEntity, } from 'types/course/personalTimes'; +import { TimelineData } from 'types/course/referenceTimelines'; import { EntityStore } from 'types/store'; // Action Names @@ -54,7 +55,9 @@ export interface SaveManageUserListAction { manageCourseUsersPermissions: ManageCourseUsersPermissions; manageCourseUsersData: ManageCourseUsersSharedData; userOptions: CourseUserBasicListData[]; + timelines?: Record; } + export interface DeleteUserAction { type: typeof DELETE_USER; userId: number; @@ -126,4 +129,5 @@ export interface UsersState { ExperiencePointsRecordMiniEntity >; experiencePointsRecordsSettings: ExperiencePointsRecordSettings; + timelines?: Record; } diff --git a/client/app/lib/components/core/layouts/DataTable.jsx b/client/app/lib/components/core/layouts/DataTable.jsx index 3d514a07e8d..e2aa0911fa4 100644 --- a/client/app/lib/components/core/layouts/DataTable.jsx +++ b/client/app/lib/components/core/layouts/DataTable.jsx @@ -45,6 +45,7 @@ const processTheme = (theme, newHeight, grid, alignCenter, newPadding) => ...theme.components.MuiTableCell?.styleOverrides.root, display: grid ? 'grid' : 'flex', alignContent: alignCenter ? 'center' : 'inherit', + width: '100%', }, }, }, diff --git a/client/app/lib/components/form/fields/DataTableInlineEditable/TextField.tsx b/client/app/lib/components/form/fields/DataTableInlineEditable/TextField.tsx index d62e8a866f1..f6df0f8f777 100644 --- a/client/app/lib/components/form/fields/DataTableInlineEditable/TextField.tsx +++ b/client/app/lib/components/form/fields/DataTableInlineEditable/TextField.tsx @@ -104,15 +104,15 @@ const InlineEditTextField: FC = (props): JSX.Element | null => { <> {link ? {controlledVal} : controlledVal} - {!disabled && ( - setIsEditing(true)} - sx={styles.buttonStyle} - > - - - )} + + setIsEditing(true)} + sx={styles.buttonStyle} + > + + ); @@ -156,10 +156,4 @@ const InlineEditTextField: FC = (props): JSX.Element | null => { return isEditing || alwaysEditable ? renderEditingField : renderDisplayField; }; -export default memo(InlineEditTextField, (prevProps, nextProps) => { - return ( - equal(prevProps.value, nextProps.value) && - equal(prevProps.link, nextProps.link) && - equal(prevProps.renderIf, nextProps.renderIf) - ); -}); +export default memo(InlineEditTextField, equal); diff --git a/client/app/lib/hooks/useTranslation.ts b/client/app/lib/hooks/useTranslation.ts index 29503b1ee39..e58dbd61517 100644 --- a/client/app/lib/hooks/useTranslation.ts +++ b/client/app/lib/hooks/useTranslation.ts @@ -5,7 +5,7 @@ export type Descriptor = MessageDescriptor; type TranslationHook = () => { t: ( descriptor: Descriptor, - values?: Record, + values?: Record, ) => string; }; diff --git a/client/app/lib/translations/table.ts b/client/app/lib/translations/table.ts index 26c0f05de9d..8861f1a80db 100644 --- a/client/app/lib/translations/table.ts +++ b/client/app/lib/translations/table.ts @@ -35,7 +35,7 @@ const translations = defineMessages({ }, timelineAlgorithm: { id: 'lib.translations.table.column.timelineAlgorithm', - defaultMessage: 'Timeline', + defaultMessage: 'Algorithm', }, invitationSentAt: { id: 'lib.translations.table.column.invitationSentAt', @@ -201,6 +201,10 @@ const translations = defineMessages({ id: 'lib.translations.table.column.videoName', defaultMessage: 'Video Name', }, + groups: { + id: 'lib.translations.table.column.groups', + defaultMessage: 'Group(s)', + }, }); export default translations; diff --git a/client/app/types/components/DataTable.ts b/client/app/types/components/DataTable.ts index 12da8ab6b66..b61f80d46a5 100644 --- a/client/app/types/components/DataTable.ts +++ b/client/app/types/components/DataTable.ts @@ -18,6 +18,19 @@ export interface TableColumns { empty?: boolean; filter?: boolean; filterList?: string[]; + filterType?: + | 'checkbox' + | 'dropdown' + | 'multiselect' + | 'textField' + | 'custom'; + filterOptions?: { + fullWidth?: boolean; + logic?: (cellValue, filters) => boolean; + }; + customFilterListOptions?: { + render?: (value: string) => string; + }; hideInSmallScreen?: boolean; justifyCenter?: boolean; justifyLeft?: boolean; @@ -51,6 +64,7 @@ export interface TableOptions { expandableRowsOnClick?: boolean; filter?: boolean; jumpToPage?: boolean; + onFilterChange?: (changedColumn: string, filterList) => void; onRowClick?: ( rowData: string[], rowMeta: { dataIndex: number; rowIndex: number }, diff --git a/client/app/types/course/courseUsers.ts b/client/app/types/course/courseUsers.ts index 69a876da070..10358902fa4 100644 --- a/client/app/types/course/courseUsers.ts +++ b/client/app/types/course/courseUsers.ts @@ -16,6 +16,7 @@ export type ManageCourseUsersPermissions = Permissions< | 'canManageCourseUsers' | 'canManageEnrolRequests' | 'canManagePersonalTimes' + | 'canManageReferenceTimelines' | 'canRegisterWithCode' >; @@ -57,7 +58,9 @@ export interface CourseUserMiniEntity extends CourseUserBasicMiniEntity { phantom?: boolean; email: string; role: CourseUserRole; + referenceTimelineId?: number | null; timelineAlgorithm?: TimelineAlgorithm; + groups?: string[]; } /** @@ -100,9 +103,10 @@ export interface CourseUserFormData { */ export interface UpdateCourseUserPatchData { course_user: { - name: string; + name?: string; phantom?: boolean; timeline_algorithm?: TimelineAlgorithm; + reference_timeline_id?: number | null; role?: CourseUserRole; }; } diff --git a/spec/features/course/staff_management_spec.rb b/spec/features/course/staff_management_spec.rb index 6cacbc09068..487cc5f452e 100644 --- a/spec/features/course/staff_management_spec.rb +++ b/spec/features/course/staff_management_spec.rb @@ -76,7 +76,7 @@ end page.all('li.MuiMenuItem-root')[3].click # option id "role-#{staff_to_change.id}-owner" can't be targeted... - expect_toastify("Successfully changed #{new_name}'s role to Owner.") + expect_toastify("Updated #{new_name}'s role to Owner.") expect(staff_to_change.reload).to be_owner expect(staff_to_change.name).to eq(new_name) From 96bf16d774c61b8254fcd6decdde36140afd9cb1 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 16 Jan 2023 11:30:09 +0800 Subject: [PATCH 15/22] chore(eslint): disable `no-restricted-exports` --- client/.eslintrc.js | 1 + .../course/group/pages/GroupShow/GroupManager/index.js | 1 - .../reference-timelines/components/DayCalendar/index.ts | 4 +--- .../course/reference-timelines/components/TimeBar/index.ts | 4 +--- .../course/reference-timelines/components/TimePopup/index.ts | 4 +--- .../reference-timelines/components/TimelinesOverview/index.ts | 4 +--- .../reference-timelines/components/TimelinesStack/index.ts | 4 +--- .../bundles/course/reference-timelines/views/DayView/index.ts | 4 +--- 8 files changed, 7 insertions(+), 19 deletions(-) diff --git a/client/.eslintrc.js b/client/.eslintrc.js index 756343437e2..6bc4cd8c771 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -145,6 +145,7 @@ module.exports = { 'no-underscore-dangle': 'off', 'object-curly-newline': ['error', { consistent: true }], 'prefer-destructuring': 'off', + 'no-restricted-exports': 'off', }, globals: { window: true, diff --git a/client/app/bundles/course/group/pages/GroupShow/GroupManager/index.js b/client/app/bundles/course/group/pages/GroupShow/GroupManager/index.js index 5ecebc5ecca..71c1664df11 100644 --- a/client/app/bundles/course/group/pages/GroupShow/GroupManager/index.js +++ b/client/app/bundles/course/group/pages/GroupShow/GroupManager/index.js @@ -1,2 +1 @@ -/* eslint-disable no-restricted-exports */ export { default } from './GroupManager'; diff --git a/client/app/bundles/course/reference-timelines/components/DayCalendar/index.ts b/client/app/bundles/course/reference-timelines/components/DayCalendar/index.ts index 15e2305ed42..c1cd5491d3d 100644 --- a/client/app/bundles/course/reference-timelines/components/DayCalendar/index.ts +++ b/client/app/bundles/course/reference-timelines/components/DayCalendar/index.ts @@ -1,3 +1 @@ -import DayCalendar from './DayCalendar'; - -export default DayCalendar; +export { default } from './DayCalendar'; diff --git a/client/app/bundles/course/reference-timelines/components/TimeBar/index.ts b/client/app/bundles/course/reference-timelines/components/TimeBar/index.ts index b7b00a3f8ac..3f3add3d3c5 100644 --- a/client/app/bundles/course/reference-timelines/components/TimeBar/index.ts +++ b/client/app/bundles/course/reference-timelines/components/TimeBar/index.ts @@ -1,3 +1 @@ -import TimeBar from './TimeBar'; - -export default TimeBar; +export { default } from './TimeBar'; diff --git a/client/app/bundles/course/reference-timelines/components/TimePopup/index.ts b/client/app/bundles/course/reference-timelines/components/TimePopup/index.ts index c24a0a2951f..7dedf2f2f7c 100644 --- a/client/app/bundles/course/reference-timelines/components/TimePopup/index.ts +++ b/client/app/bundles/course/reference-timelines/components/TimePopup/index.ts @@ -1,3 +1 @@ -import TimePopup from './TimePopup'; - -export default TimePopup; +export { default } from './TimePopup'; diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesOverview/index.ts b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/index.ts index df1314da154..2fa02a8e134 100644 --- a/client/app/bundles/course/reference-timelines/components/TimelinesOverview/index.ts +++ b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/index.ts @@ -1,3 +1 @@ -import TimelinesOverview from './TimelinesOverview'; - -export default TimelinesOverview; +export { default } from './TimelinesOverview'; diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesStack/index.ts b/client/app/bundles/course/reference-timelines/components/TimelinesStack/index.ts index d7bc08cfe8b..8ce68979c86 100644 --- a/client/app/bundles/course/reference-timelines/components/TimelinesStack/index.ts +++ b/client/app/bundles/course/reference-timelines/components/TimelinesStack/index.ts @@ -1,3 +1 @@ -import TimelinesStack from './TimelinesStack'; - -export default TimelinesStack; +export { default } from './TimelinesStack'; diff --git a/client/app/bundles/course/reference-timelines/views/DayView/index.ts b/client/app/bundles/course/reference-timelines/views/DayView/index.ts index 36faa59283d..0b4751a9e45 100644 --- a/client/app/bundles/course/reference-timelines/views/DayView/index.ts +++ b/client/app/bundles/course/reference-timelines/views/DayView/index.ts @@ -1,3 +1 @@ -import DayView from './DayView'; - -export default DayView; +export { default } from './DayView'; From 5578812b4627be54ccb3216a03b079c56daa2ca6 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Tue, 17 Jan 2023 12:10:35 +0800 Subject: [PATCH 16/22] style(operation): generic type defaults to `void` --- .../app/bundles/announcements/operations.ts | 2 +- .../bundles/course/achievement/operations.ts | 12 +++---- .../components/misc/AnnouncementCard.tsx | 4 +-- .../components/misc/AnnouncementsDisplay.tsx | 4 +-- .../course/announcements/operations.ts | 10 +++--- .../pages/AnnouncementEdit/index.tsx | 2 +- .../pages/AnnouncementNew/index.tsx | 2 +- .../course/assessment/skills/operations.ts | 6 ++-- .../assessment/submissions/operations.ts | 12 +++---- .../app/bundles/course/courses/operations.ts | 4 +-- .../course/discussion/topics/operations.ts | 10 +++--- .../course/enrol-requests/operations.ts | 6 ++-- client/app/bundles/course/forum/operations.ts | 32 +++++++---------- .../bundles/course/leaderboard/operations.ts | 2 +- .../course/material/folders/operations.ts | 14 ++++---- .../course/user-invitations/operations.ts | 14 ++++---- client/app/bundles/course/users/operations.ts | 20 +++++------ client/app/bundles/course/video/operations.ts | 15 ++++---- .../components/buttons/CoursesButtons.tsx | 2 +- .../admin/components/tables/CoursesTable.tsx | 2 +- .../bundles/system/admin/admin/operations.ts | 28 +++++++-------- .../admin/instance/instance/operations.ts | 34 +++++++++---------- client/app/bundles/users/operations.ts | 2 +- client/app/types/store.ts | 2 +- 24 files changed, 112 insertions(+), 129 deletions(-) diff --git a/client/app/bundles/announcements/operations.ts b/client/app/bundles/announcements/operations.ts index c547d677ee2..d0ff3b626da 100644 --- a/client/app/bundles/announcements/operations.ts +++ b/client/app/bundles/announcements/operations.ts @@ -5,7 +5,7 @@ import GlobalAnnouncementsAPI from 'api/Announcements'; import * as actions from './actions'; -export function indexAnnouncements(): Operation { +export function indexAnnouncements(): Operation { return async (dispatch) => GlobalAnnouncementsAPI.announcements.index().then((response) => { const data = response.data; diff --git a/client/app/bundles/course/achievement/operations.ts b/client/app/bundles/course/achievement/operations.ts index ab212b02e27..4ae0f0791cf 100644 --- a/client/app/bundles/course/achievement/operations.ts +++ b/client/app/bundles/course/achievement/operations.ts @@ -29,7 +29,7 @@ const formatAttributes = (data: AchievementFormData): FormData => { return payload; }; -export function fetchAchievements(): Operation { +export function fetchAchievements(): Operation { return async (dispatch) => CourseAPI.achievements.index().then((response) => { const data = response.data; @@ -51,9 +51,7 @@ export function loadAchievement( ); } -export function loadAchievementCourseUsers( - achievementId: number, -): Operation { +export function loadAchievementCourseUsers(achievementId: number): Operation { return async (dispatch) => CourseAPI.achievements .fetchAchievementCourseUsers(achievementId) @@ -84,7 +82,7 @@ export function updateAchievement( return async () => CourseAPI.achievements.update(achievementId, attributes); } -export function deleteAchievement(achievementId: number): Operation { +export function deleteAchievement(achievementId: number): Operation { return async (dispatch) => CourseAPI.achievements.delete(achievementId).then(() => { dispatch(actions.deleteAchievement(achievementId)); @@ -94,7 +92,7 @@ export function deleteAchievement(achievementId: number): Operation { export function awardAchievement( achievementId: number, data: number[], -): Operation { +): Operation { const attributes = { achievement: { course_user_ids: data } }; return async (dispatch) => CourseAPI.achievements @@ -107,7 +105,7 @@ export function awardAchievement( export function updatePublishedAchievement( achievementId: number, data: boolean, -): Operation { +): Operation { const attributes = { achievement: { published: data } }; return async (dispatch) => CourseAPI.achievements diff --git a/client/app/bundles/course/announcements/components/misc/AnnouncementCard.tsx b/client/app/bundles/course/announcements/components/misc/AnnouncementCard.tsx index 2b6ab7810a0..53012536b47 100644 --- a/client/app/bundles/course/announcements/components/misc/AnnouncementCard.tsx +++ b/client/app/bundles/course/announcements/components/misc/AnnouncementCard.tsx @@ -26,8 +26,8 @@ interface Props extends WrappedComponentProps { updateOperation?: ( announcementId: number, formData: AnnouncementFormData, - ) => Operation; - deleteOperation?: (announcementId: number) => Operation; + ) => Operation; + deleteOperation?: (announcementId: number) => Operation; canSticky?: boolean; } diff --git a/client/app/bundles/course/announcements/components/misc/AnnouncementsDisplay.tsx b/client/app/bundles/course/announcements/components/misc/AnnouncementsDisplay.tsx index 431d4ef12d9..e5e3b5439a9 100644 --- a/client/app/bundles/course/announcements/components/misc/AnnouncementsDisplay.tsx +++ b/client/app/bundles/course/announcements/components/misc/AnnouncementsDisplay.tsx @@ -20,8 +20,8 @@ interface Props extends WrappedComponentProps { updateOperation?: ( announcementId: number, formData: AnnouncementFormData, - ) => Operation; - deleteOperation?: (announcementId: number) => Operation; + ) => Operation; + deleteOperation?: (announcementId: number) => Operation; canSticky?: boolean; } diff --git a/client/app/bundles/course/announcements/operations.ts b/client/app/bundles/course/announcements/operations.ts index a502509ef5e..4daf6ed7170 100644 --- a/client/app/bundles/course/announcements/operations.ts +++ b/client/app/bundles/course/announcements/operations.ts @@ -29,7 +29,7 @@ const formatAttributes = (data: AnnouncementFormData): FormData => { return payload; }; -export function fetchAnnouncements(): Operation { +export function fetchAnnouncements(): Operation { return async (dispatch) => CourseAPI.announcements.index().then((response) => { const data = response.data; @@ -39,9 +39,7 @@ export function fetchAnnouncements(): Operation { }); } -export function createAnnouncement( - formData: AnnouncementFormData, -): Operation { +export function createAnnouncement(formData: AnnouncementFormData): Operation { const attributes = formatAttributes(formData); return async (dispatch) => CourseAPI.announcements.create(attributes).then((response) => { @@ -55,7 +53,7 @@ export function createAnnouncement( export function updateAnnouncement( announcementId: number, formData: AnnouncementFormData, -): Operation { +): Operation { const attributes = formatAttributes(formData); return async (dispatch) => CourseAPI.announcements @@ -65,7 +63,7 @@ export function updateAnnouncement( }); } -export function deleteAnnouncement(accouncementId: number): Operation { +export function deleteAnnouncement(accouncementId: number): Operation { return async (dispatch) => CourseAPI.announcements.delete(accouncementId).then(() => { dispatch(actions.deleteAnnouncement(accouncementId)); diff --git a/client/app/bundles/course/announcements/pages/AnnouncementEdit/index.tsx b/client/app/bundles/course/announcements/pages/AnnouncementEdit/index.tsx index 9d3a2d8237c..e7af1be3446 100644 --- a/client/app/bundles/course/announcements/pages/AnnouncementEdit/index.tsx +++ b/client/app/bundles/course/announcements/pages/AnnouncementEdit/index.tsx @@ -24,7 +24,7 @@ interface Props { updateOperation: ( announcementId: number, formData: AnnouncementFormData, - ) => Operation; + ) => Operation; canSticky: boolean; } diff --git a/client/app/bundles/course/announcements/pages/AnnouncementNew/index.tsx b/client/app/bundles/course/announcements/pages/AnnouncementNew/index.tsx index eba49279b57..157f1d65f54 100644 --- a/client/app/bundles/course/announcements/pages/AnnouncementNew/index.tsx +++ b/client/app/bundles/course/announcements/pages/AnnouncementNew/index.tsx @@ -13,7 +13,7 @@ import AnnouncementForm from '../../components/forms/AnnouncementForm'; interface Props { open: boolean; onClose: () => void; - createOperation: (formData: AnnouncementFormData) => Operation; + createOperation: (formData: AnnouncementFormData) => Operation; canSticky?: boolean; } diff --git a/client/app/bundles/course/assessment/skills/operations.ts b/client/app/bundles/course/assessment/skills/operations.ts index ce19b1cb648..d63ecce8ddf 100644 --- a/client/app/bundles/course/assessment/skills/operations.ts +++ b/client/app/bundles/course/assessment/skills/operations.ts @@ -51,7 +51,7 @@ const formatSkillBranchAttributes = (data: SkillFormData): FormData => { return payload; }; -export function fetchSkillBranches(): Operation { +export function fetchSkillBranches(): Operation { return async (dispatch) => CourseAPI.assessment.skills.index().then((response) => { const data = response.data; @@ -109,14 +109,14 @@ export function updateSkillBranch( }); } -export function deleteSkill(skillId: number): Operation { +export function deleteSkill(skillId: number): Operation { return async (dispatch) => CourseAPI.assessment.skills.delete(skillId).then(() => { dispatch(actions.deleteSkill(skillId)); }); } -export function deleteSkillBranch(branchId: number): Operation { +export function deleteSkillBranch(branchId: number): Operation { return async (dispatch) => CourseAPI.assessment.skills.deleteBranch(branchId).then(() => { dispatch(actions.deleteSkillBranch(branchId)); diff --git a/client/app/bundles/course/assessment/submissions/operations.ts b/client/app/bundles/course/assessment/submissions/operations.ts index 9d26d787b93..0f424608812 100644 --- a/client/app/bundles/course/assessment/submissions/operations.ts +++ b/client/app/bundles/course/assessment/submissions/operations.ts @@ -4,7 +4,7 @@ import CourseAPI from 'api/course'; import * as actions from './actions'; -export function fetchSubmissions(): Operation { +export function fetchSubmissions(): Operation { return async (dispatch) => CourseAPI.submissions.index().then((response) => { const data = response.data; @@ -19,7 +19,7 @@ export function fetchSubmissions(): Operation { }); } -export function fetchMyStudentsPendingSubmissions(): Operation { +export function fetchMyStudentsPendingSubmissions(): Operation { return async (dispatch) => CourseAPI.submissions.pending(true).then((response) => { const data = response.data; @@ -34,7 +34,7 @@ export function fetchMyStudentsPendingSubmissions(): Operation { }); } -export function fetchAllStudentsPendingSubmissions(): Operation { +export function fetchAllStudentsPendingSubmissions(): Operation { return async (dispatch) => CourseAPI.submissions.pending(false).then((response) => { const data = response.data; @@ -49,7 +49,7 @@ export function fetchAllStudentsPendingSubmissions(): Operation { }); } -export function fetchCategorySubmissions(categoryId: number): Operation { +export function fetchCategorySubmissions(categoryId: number): Operation { return async (dispatch) => CourseAPI.submissions.category(categoryId).then((response) => { const data = response.data; @@ -70,7 +70,7 @@ export function filterSubmissions( groupId: number | null, userId: number | null, pageNum: number | null, -): Operation { +): Operation { return async (dispatch) => CourseAPI.submissions .filter(categoryId, assessmentId, groupId, userId, pageNum) @@ -90,7 +90,7 @@ export function filterSubmissions( export function filterPendingSubmissions( myStudents: boolean, pageNum: number | null, -): Operation { +): Operation { return async (dispatch) => CourseAPI.submissions .filterPending(myStudents, pageNum) diff --git a/client/app/bundles/course/courses/operations.ts b/client/app/bundles/course/courses/operations.ts index 264c33a9b54..039828b9590 100644 --- a/client/app/bundles/course/courses/operations.ts +++ b/client/app/bundles/course/courses/operations.ts @@ -22,7 +22,7 @@ const formatAttributes = (data: NewCourseFormData): FormData => { return payload; }; -export function fetchCourses(): Operation { +export function fetchCourses(): Operation { return async (dispatch) => CourseAPI.courses.index().then((response) => { const data = response.data; @@ -59,7 +59,7 @@ export function removeTodo( courseId: number, todoType: 'assessments' | 'videos' | 'surveys', todoId: number, -): Operation { +): Operation { return async (dispatch) => CourseAPI.courses.removeTodo(ignoreLink).then(() => { dispatch(actions.removeTodo(courseId, todoType, todoId)); diff --git a/client/app/bundles/course/discussion/topics/operations.ts b/client/app/bundles/course/discussion/topics/operations.ts index 964952898bd..dd9b9fe4517 100644 --- a/client/app/bundles/course/discussion/topics/operations.ts +++ b/client/app/bundles/course/discussion/topics/operations.ts @@ -84,14 +84,14 @@ export function fetchCommentData( }); } -export function updatePending(topicId: number): Operation { +export function updatePending(topicId: number): Operation { return async (dispatch) => CourseAPI.comments.togglePending(topicId).then(() => { dispatch(actions.savePending(topicId)); }); } -export function updateRead(topicId: number): Operation { +export function updateRead(topicId: number): Operation { return async (dispatch) => CourseAPI.comments.markAsRead(topicId).then(() => { dispatch(actions.saveRead(topicId)); @@ -114,7 +114,7 @@ export function createPost( export function updatePost( post: CommentPostMiniEntity, text: string, -): Operation { +): Operation { return async (dispatch) => CourseAPI.comments .update( @@ -131,7 +131,7 @@ export function updatePostCodaveri( post: CommentPostMiniEntity, text: string, rating: number, -): Operation { +): Operation { return async (dispatch) => CourseAPI.comments .update( @@ -144,7 +144,7 @@ export function updatePostCodaveri( }); } -export function deletePost(post: CommentPostMiniEntity): Operation { +export function deletePost(post: CommentPostMiniEntity): Operation { return async (dispatch) => CourseAPI.comments .delete(post.topicId.toString(), post.id.toString()) diff --git a/client/app/bundles/course/enrol-requests/operations.ts b/client/app/bundles/course/enrol-requests/operations.ts index 68dfeeac849..8a8bb851eb8 100644 --- a/client/app/bundles/course/enrol-requests/operations.ts +++ b/client/app/bundles/course/enrol-requests/operations.ts @@ -21,7 +21,7 @@ const formatAttributes = ( }; }; -export function fetchEnrolRequests(): Operation { +export function fetchEnrolRequests(): Operation { return async (dispatch) => CourseAPI.enrolRequests.index().then((response) => { const data = response.data; @@ -37,7 +37,7 @@ export function fetchEnrolRequests(): Operation { export function approveEnrolRequest( enrolRequest: EnrolRequestMiniEntity, -): Operation { +): Operation { return async (dispatch) => CourseAPI.enrolRequests .approve(formatAttributes(enrolRequest), enrolRequest.id) @@ -47,7 +47,7 @@ export function approveEnrolRequest( }); } -export function rejectEnrolRequest(requestId: number): Operation { +export function rejectEnrolRequest(requestId: number): Operation { return async (dispatch) => CourseAPI.enrolRequests.reject(requestId).then((response) => { const enrolRequest = response.data; diff --git a/client/app/bundles/course/forum/operations.ts b/client/app/bundles/course/forum/operations.ts index 446f0099e2a..0436e29c8ab 100644 --- a/client/app/bundles/course/forum/operations.ts +++ b/client/app/bundles/course/forum/operations.ts @@ -32,7 +32,7 @@ import { // Forum -export function fetchForums(): Operation { +export function fetchForums(): Operation { return async (dispatch) => CourseAPI.forum.forums .index() @@ -57,7 +57,7 @@ export function fetchForum(forumId: string): Operation { }); } -export function createForum(forumFormData: ForumFormData): Operation { +export function createForum(forumFormData: ForumFormData): Operation { const forumPostData = { forum: { name: forumFormData.name, @@ -90,7 +90,7 @@ export function updateForum( }); } -export function deleteForum(forumId: number): Operation { +export function deleteForum(forumId: number): Operation { return async (dispatch) => CourseAPI.forum.forums.delete(forumId).then(() => { dispatch(removeForum({ forumId })); @@ -101,7 +101,7 @@ export function updateForumSubscription( forumId: number, entityUrl: string, isCurrentlySubscribed: boolean, -): Operation { +): Operation { return async (dispatch) => CourseAPI.forum.forums .updateSubscription(entityUrl, isCurrentlySubscribed) @@ -110,14 +110,14 @@ export function updateForumSubscription( }); } -export function markAllAsRead(): Operation { +export function markAllAsRead(): Operation { return async (dispatch) => CourseAPI.forum.forums.markAllAsRead().then(() => { dispatch(markAllPostsAsRead()); }); } -export function markAsRead(forumId: number): Operation { +export function markAsRead(forumId: number): Operation { return async (dispatch) => CourseAPI.forum.forums.markAsRead(forumId).then((response) => { dispatch( @@ -187,10 +187,7 @@ export function updateForumTopic( }); } -export function deleteForumTopic( - topicUrl: string, - topicId: number, -): Operation { +export function deleteForumTopic(topicUrl: string, topicId: number): Operation { return async (dispatch) => CourseAPI.forum.topics.delete(topicUrl).then(() => { dispatch(removeForumTopic({ topicId })); @@ -201,7 +198,7 @@ export function updateForumTopicSubscription( topicId: number, topicUrl: string, isCurrentlySubscribed: boolean, -): Operation { +): Operation { return async (dispatch) => CourseAPI.forum.topics .updateSubscription(topicUrl, isCurrentlySubscribed) @@ -214,7 +211,7 @@ export function updateForumTopicHidden( topicId: number, topicUrl: string, isCurrentlyHidden: boolean, -): Operation { +): Operation { return async (dispatch) => CourseAPI.forum.topics .updateHidden(topicUrl, isCurrentlyHidden) @@ -227,7 +224,7 @@ export function updateForumTopicLocked( topicId: number, topicUrl: string, isCurrentlyLocked: boolean, -): Operation { +): Operation { return async (dispatch) => CourseAPI.forum.topics .updateLocked(topicUrl, isCurrentlyLocked) @@ -269,7 +266,7 @@ export function createForumTopicPost( export function updateForumTopicPost( postUrl: string, postText: string, -): Operation { +): Operation { return async (dispatch) => CourseAPI.forum.posts.update(postUrl, postText).then((response) => { dispatch(updateForumTopicPostListData(response.data)); @@ -298,17 +295,14 @@ export function toggleForumTopicPostAnswer( postUrl: string, topicId: number, postId: number, -): Operation { +): Operation { return async (dispatch) => CourseAPI.forum.posts.toggleAnswer(postUrl).then(() => { dispatch(updatePostAsAnswer({ topicId, postId })); }); } -export function voteTopicPost( - postUrl: string, - vote: -1 | 0 | 1, -): Operation { +export function voteTopicPost(postUrl: string, vote: -1 | 0 | 1): Operation { return async (dispatch) => CourseAPI.forum.posts.vote(postUrl, vote).then((response) => { dispatch(updateForumTopicPostListData(response.data)); diff --git a/client/app/bundles/course/leaderboard/operations.ts b/client/app/bundles/course/leaderboard/operations.ts index d9d49a3b00b..65b6bb3ff4f 100644 --- a/client/app/bundles/course/leaderboard/operations.ts +++ b/client/app/bundles/course/leaderboard/operations.ts @@ -5,7 +5,7 @@ import CourseAPI from 'api/course'; import * as actions from './actions'; -const fetchLeaderboard = (): Operation => { +const fetchLeaderboard = (): Operation => { return async (dispatch) => CourseAPI.leaderboard.index().then((response) => { const data: LeaderboardData = response.data; diff --git a/client/app/bundles/course/material/folders/operations.ts b/client/app/bundles/course/material/folders/operations.ts index 201fa8ec711..b886666465a 100644 --- a/client/app/bundles/course/material/folders/operations.ts +++ b/client/app/bundles/course/material/folders/operations.ts @@ -79,7 +79,7 @@ export function loadFolder(folderId: number): Operation { export function createFolder( formData: FolderFormData, folderId: number, -): Operation { +): Operation { const attributes = formatFolderAttributes(formData); attributes.append('material_folder[parent_id]', `${folderId}`); return async (dispatch) => @@ -101,7 +101,7 @@ export function createFolder( export function updateFolder( formData: FolderFormData, folderId: number, -): Operation { +): Operation { const attributes = formatFolderAttributes(formData); return async (dispatch) => CourseAPI.folders.updateFolder(folderId, attributes).then((response) => { @@ -119,7 +119,7 @@ export function updateFolder( }); } -export function deleteFolder(folderId: number): Operation { +export function deleteFolder(folderId: number): Operation { return async (dispatch) => CourseAPI.folders.deleteFolder(folderId).then(() => { dispatch(actions.deleteFolderList(folderId)); @@ -152,7 +152,7 @@ function formatMaterialUploadAttributes( export function uploadMaterials( formData: MaterialUploadFormData, currFolderId: number, -): Operation { +): Operation { const attributes = formatMaterialUploadAttributes(formData); return async (dispatch) => CourseAPI.folders @@ -175,7 +175,7 @@ export function uploadMaterials( export function deleteMaterial( currFolderId: number, materialId: number, -): Operation { +): Operation { return async (dispatch) => CourseAPI.folders.deleteMaterial(currFolderId, materialId).then(() => { dispatch(actions.deleteMaterialList(materialId)); @@ -186,7 +186,7 @@ export function updateMaterial( formData: MaterialFormData, folderId: number, materialId: number, -): Operation { +): Operation { const attributes = formatMaterialAttributes(formData); return async (dispatch) => CourseAPI.folders @@ -201,7 +201,7 @@ export function downloadFolder( currFolderId: number, onSuccess: () => void, onFailure: () => void, -): Operation { +): Operation { return async (_) => CourseAPI.folders.downloadFolder(currFolderId, onSuccess, onFailure); } diff --git a/client/app/bundles/course/user-invitations/operations.ts b/client/app/bundles/course/user-invitations/operations.ts index 0d90c1a6854..9427687019f 100644 --- a/client/app/bundles/course/user-invitations/operations.ts +++ b/client/app/bundles/course/user-invitations/operations.ts @@ -42,7 +42,7 @@ const formatInvitations = (invitations: InvitationPostData[]): FormData => { return payload; }; -export function fetchInvitations(): Operation { +export function fetchInvitations(): Operation { return async (dispatch) => CourseAPI.userInvitations.index().then((response) => { const data = response.data; @@ -56,7 +56,7 @@ export function fetchInvitations(): Operation { }); } -export function fetchPermissionsAndSharedData(): Operation { +export function fetchPermissionsAndSharedData(): Operation { return async (dispatch) => CourseAPI.userInvitations.getPermissionsAndSharedData().then((response) => { dispatch(actions.savePermissions(response.data.permissions)); @@ -87,14 +87,14 @@ export function inviteUsersFromForm( }); } -export function resendAllInvitations(): Operation { +export function resendAllInvitations(): Operation { return async (dispatch) => CourseAPI.userInvitations.resendAllInvitations().then((response) => { dispatch(actions.updateInvitationList(response.data.invitations)); }); } -export function resendInvitationEmail(invitationId: number): Operation { +export function resendInvitationEmail(invitationId: number): Operation { return async (dispatch) => CourseAPI.userInvitations .resendInvitationEmail(invitationId) @@ -103,14 +103,14 @@ export function resendInvitationEmail(invitationId: number): Operation { }); } -export function deleteInvitation(invitationId: number): Operation { +export function deleteInvitation(invitationId: number): Operation { return async (dispatch) => CourseAPI.userInvitations.delete(invitationId).then(() => { dispatch(actions.deleteInvitation(invitationId)); }); } -export function fetchRegistrationCode(): Operation { +export function fetchRegistrationCode(): Operation { return async (dispatch) => CourseAPI.userInvitations.getCourseRegistrationKey().then((response) => { dispatch( @@ -119,7 +119,7 @@ export function fetchRegistrationCode(): Operation { }); } -export function toggleRegistrationCode(shouldEnable: boolean): Operation { +export function toggleRegistrationCode(shouldEnable: boolean): Operation { return async (dispatch) => CourseAPI.userInvitations .toggleCourseRegistrationKey(shouldEnable) diff --git a/client/app/bundles/course/users/operations.ts b/client/app/bundles/course/users/operations.ts index 7fc59af07b1..913f3fa3c67 100644 --- a/client/app/bundles/course/users/operations.ts +++ b/client/app/bundles/course/users/operations.ts @@ -78,7 +78,7 @@ const formatUpdateExperiencePointsRecord = ( }; }; -export function fetchUsers(asBasicData: boolean = false): Operation { +export function fetchUsers(asBasicData: boolean = false): Operation { return async (dispatch) => CourseAPI.users.index(asBasicData).then((response) => { const data = response.data; @@ -97,7 +97,7 @@ export function fetchUsers(asBasicData: boolean = false): Operation { }); } -export function fetchStudents(): Operation { +export function fetchStudents(): Operation { return async (dispatch) => CourseAPI.users.indexStudents().then((response) => { const data = response.data; @@ -113,7 +113,7 @@ export function fetchStudents(): Operation { }); } -export function fetchStaff(): Operation { +export function fetchStaff(): Operation { return async (dispatch) => CourseAPI.users.indexStaff().then((response) => { const data = response.data; @@ -138,7 +138,7 @@ export function loadUser(userId: number): Operation { export function updateUser( userId: number, data: CourseUserEntity | Partial, -): Operation { +): Operation { const attributes = formatUpdateUser(data); return async (dispatch) => CourseAPI.users.update(userId, attributes).then((response) => { @@ -161,7 +161,7 @@ export function updateUser( export function upgradeToStaff( users: CourseUserBasicMiniEntity[], role: StaffRole, -): Operation { +): Operation { return async (dispatch) => CourseAPI.users.upgradeToStaff(users, role).then((response) => { response.data.users.forEach((user) => { @@ -174,7 +174,7 @@ export function upgradeToStaff( export function assignToTimeline( ids: CourseUserBasicMiniEntity['id'][], timelineId: TimelineData['id'], -): Operation { +): Operation { return async (dispatch) => { await CourseAPI.users.assignToTimeline(ids, timelineId); ids.forEach((id) => { @@ -191,21 +191,21 @@ export function assignToTimeline( }; } -export function deleteUser(userId: number): Operation { +export function deleteUser(userId: number): Operation { return async (dispatch) => CourseAPI.users.delete(userId).then(() => { dispatch(actions.deleteUser(userId)); }); } -export function fetchPersonalTimes(userId: number): Operation { +export function fetchPersonalTimes(userId: number): Operation { return async (dispatch) => CourseAPI.personalTimes.index(userId).then((response) => { dispatch(actions.savePersonalTimeList(response.data.personalTimes)); }); } -export function recomputePersonalTimes(userId: number): Operation { +export function recomputePersonalTimes(userId: number): Operation { return async (dispatch) => CourseAPI.personalTimes.recompute(userId).then((response) => { dispatch(actions.savePersonalTimeList(response.data.personalTimes)); @@ -237,7 +237,7 @@ export function deletePersonalTime( export function fetchExperiencePointsRecord( userId: number, pageNum: number = 1, -): Operation { +): Operation { return async (dispatch) => CourseAPI.experiencePointsRecord.index(userId, pageNum).then((response) => { const data = response.data; diff --git a/client/app/bundles/course/video/operations.ts b/client/app/bundles/course/video/operations.ts index 21d42561069..8e1abb4563d 100644 --- a/client/app/bundles/course/video/operations.ts +++ b/client/app/bundles/course/video/operations.ts @@ -5,7 +5,7 @@ import CourseAPI from 'api/course'; import { removeVideo, saveVideo, saveVideoList } from './reducers'; -export function fetchVideos(currentTabId?: number): Operation { +export function fetchVideos(currentTabId?: number): Operation { return async (dispatch) => CourseAPI.video.videos.index(currentTabId).then((response) => { const data = response.data; @@ -13,14 +13,14 @@ export function fetchVideos(currentTabId?: number): Operation { }); } -export function loadVideo(videoId: number): Operation { +export function loadVideo(videoId: number): Operation { return async (dispatch) => CourseAPI.video.videos.fetch(videoId).then((response) => { dispatch(saveVideo(response.data)); }); } -export function createVideo(data: VideoFormData): Operation { +export function createVideo(data: VideoFormData): Operation { const videoPostData = { video: { title: data.title, @@ -39,10 +39,7 @@ export function createVideo(data: VideoFormData): Operation { }); } -export function updateVideo( - videoId: number, - data: VideoFormData, -): Operation { +export function updateVideo(videoId: number, data: VideoFormData): Operation { const videoPatchData = { video: { id: data.id, @@ -62,7 +59,7 @@ export function updateVideo( }); } -export function deleteVideo(videoId: number): Operation { +export function deleteVideo(videoId: number): Operation { return async (dispatch) => CourseAPI.video.videos.delete(videoId).then(() => { dispatch(removeVideo({ videoId })); @@ -72,7 +69,7 @@ export function deleteVideo(videoId: number): Operation { export function updatePublishedVideo( videoId: number, isPublished: boolean, -): Operation { +): Operation { const videoPatchPublishData = { video: { id: videoId, published: isPublished }, }; diff --git a/client/app/bundles/system/admin/admin/components/buttons/CoursesButtons.tsx b/client/app/bundles/system/admin/admin/components/buttons/CoursesButtons.tsx index 2b723eeef26..a3e6e855780 100644 --- a/client/app/bundles/system/admin/admin/components/buttons/CoursesButtons.tsx +++ b/client/app/bundles/system/admin/admin/components/buttons/CoursesButtons.tsx @@ -12,7 +12,7 @@ import DeleteCoursePrompt from 'bundles/course/admin/pages/CourseSettings/Delete interface Props extends WrappedComponentProps { course: CourseMiniEntity; - deleteOperation: (courseId: number) => Operation; + deleteOperation: (courseId: number) => Operation; } const translations = defineMessages({ diff --git a/client/app/bundles/system/admin/admin/components/tables/CoursesTable.tsx b/client/app/bundles/system/admin/admin/components/tables/CoursesTable.tsx index e40d8014acc..6b1c4f02169 100644 --- a/client/app/bundles/system/admin/admin/components/tables/CoursesTable.tsx +++ b/client/app/bundles/system/admin/admin/components/tables/CoursesTable.tsx @@ -28,7 +28,7 @@ interface Props extends WrappedComponentProps { courseCounts: AdminStats | InstanceAdminStats; title: string; renderRowActionComponent: (course: CourseMiniEntity) => ReactElement; - indexOperation: (params?) => Operation; + indexOperation: (params?) => Operation; } const translations = defineMessages({ diff --git a/client/app/bundles/system/admin/admin/operations.ts b/client/app/bundles/system/admin/admin/operations.ts index 68bbaabb684..07af8d20b43 100644 --- a/client/app/bundles/system/admin/admin/operations.ts +++ b/client/app/bundles/system/admin/admin/operations.ts @@ -78,7 +78,7 @@ const formatInstanceAttributes = ( return payload; }; -export function indexAnnouncements(): Operation { +export function indexAnnouncements(): Operation { return async (dispatch) => SystemAPI.admin.indexAnnouncements().then((response) => { const data = response.data; @@ -88,9 +88,7 @@ export function indexAnnouncements(): Operation { }); } -export function createAnnouncement( - formData: AnnouncementFormData, -): Operation { +export function createAnnouncement(formData: AnnouncementFormData): Operation { const attributes = formatAnnouncementAttributes(formData); return async (dispatch) => SystemAPI.admin.createAnnouncement(attributes).then((response) => { @@ -101,7 +99,7 @@ export function createAnnouncement( export function updateAnnouncement( announcementId: number, formData: AnnouncementFormData, -): Operation { +): Operation { const attributes = formatAnnouncementAttributes(formData); return async (dispatch) => SystemAPI.admin @@ -111,14 +109,14 @@ export function updateAnnouncement( }); } -export function deleteAnnouncement(announcementId: number): Operation { +export function deleteAnnouncement(announcementId: number): Operation { return async (dispatch) => SystemAPI.admin.deleteAnnouncement(announcementId).then(() => { dispatch(actions.deleteAnnouncement(announcementId)); }); } -export function indexUsers(params?): Operation { +export function indexUsers(params?): Operation { return async (dispatch) => SystemAPI.admin.indexUsers(params).then((response) => { const data = response.data; @@ -129,7 +127,7 @@ export function indexUsers(params?): Operation { export function updateUser( userId: number, userEntity: UserMiniEntity, -): Operation { +): Operation { const attributes = formatUserAttributes(userEntity); return async (dispatch) => SystemAPI.admin.updateUser(userId, attributes).then((response) => { @@ -137,14 +135,14 @@ export function updateUser( }); } -export function deleteUser(userId: number): Operation { +export function deleteUser(userId: number): Operation { return async (dispatch) => SystemAPI.admin.deleteUser(userId).then(() => { dispatch(actions.deleteUser(userId)); }); } -export function indexCourses(params?): Operation { +export function indexCourses(params?): Operation { return async (dispatch) => SystemAPI.admin.indexCourses(params).then((response) => { const data = response.data; @@ -157,14 +155,14 @@ export function indexCourses(params?): Operation { }); } -export function deleteCourse(courseId: number): Operation { +export function deleteCourse(courseId: number): Operation { return async (dispatch) => SystemAPI.admin.deleteCourse(courseId).then(() => { dispatch(actions.deleteCourse(courseId)); }); } -export function indexInstances(): Operation { +export function indexInstances(): Operation { return async (dispatch) => SystemAPI.admin.indexInstances().then((response) => { const data = response.data; @@ -174,7 +172,7 @@ export function indexInstances(): Operation { }); } -export function createInstance(formData: InstanceFormData): Operation { +export function createInstance(formData: InstanceFormData): Operation { const attributes = formatInstanceAttributes(formData); return async (dispatch) => SystemAPI.admin.createInstance(attributes).then((response) => { @@ -188,7 +186,7 @@ export function createInstance(formData: InstanceFormData): Operation { export function updateInstance( instanceId: number, instanceEntity: InstanceMiniEntity, -): Operation { +): Operation { const attributes = formatInstanceAttributes(instanceEntity); return async (dispatch) => SystemAPI.admin.updateInstance(instanceId, attributes).then((response) => { @@ -196,7 +194,7 @@ export function updateInstance( }); } -export function deleteInstance(instanceId: number): Operation { +export function deleteInstance(instanceId: number): Operation { return async (dispatch) => SystemAPI.admin.deleteInstance(instanceId).then(() => { dispatch(actions.deleteInstance(instanceId)); diff --git a/client/app/bundles/system/admin/instance/instance/operations.ts b/client/app/bundles/system/admin/instance/instance/operations.ts index cac1157bb44..5bcf0eda43d 100644 --- a/client/app/bundles/system/admin/instance/instance/operations.ts +++ b/client/app/bundles/system/admin/instance/instance/operations.ts @@ -140,7 +140,7 @@ export const fetchInstance = async (): Promise => { return response.data.instance; }; -export function indexAnnouncements(): Operation { +export function indexAnnouncements(): Operation { return async (dispatch) => SystemAPI.instance.indexAnnouncements().then((response) => { const data = response.data; @@ -150,9 +150,7 @@ export function indexAnnouncements(): Operation { }); } -export function createAnnouncement( - formData: AnnouncementFormData, -): Operation { +export function createAnnouncement(formData: AnnouncementFormData): Operation { const attributes = formatAnnouncementAttributes(formData); return async (dispatch) => SystemAPI.instance.createAnnouncement(attributes).then((response) => { @@ -163,7 +161,7 @@ export function createAnnouncement( export function updateAnnouncement( announcementId: number, formData: AnnouncementFormData, -): Operation { +): Operation { const attributes = formatAnnouncementAttributes(formData); return async (dispatch) => SystemAPI.instance @@ -173,14 +171,14 @@ export function updateAnnouncement( }); } -export function deleteAnnouncement(announcementId: number): Operation { +export function deleteAnnouncement(announcementId: number): Operation { return async (dispatch) => SystemAPI.instance.deleteAnnouncement(announcementId).then(() => { dispatch(actions.deleteAnnouncement(announcementId)); }); } -export function indexUsers(params?): Operation { +export function indexUsers(params?): Operation { return async (dispatch) => SystemAPI.instance.indexUsers(params).then((response) => { const data = response.data; @@ -191,7 +189,7 @@ export function indexUsers(params?): Operation { export function updateUser( userId: number, userEntity: InstanceUserMiniEntity, -): Operation { +): Operation { const attributes = formatUserAttributes(userEntity); return async (dispatch) => SystemAPI.instance.updateUser(userId, attributes).then((response) => { @@ -199,14 +197,14 @@ export function updateUser( }); } -export function deleteUser(userId: number): Operation { +export function deleteUser(userId: number): Operation { return async (dispatch) => SystemAPI.instance.deleteUser(userId).then(() => { dispatch(actions.deleteUser(userId)); }); } -export function indexCourses(params?): Operation { +export function indexCourses(params?): Operation { return async (dispatch) => SystemAPI.instance.indexCourses(params).then((response) => { const data = response.data; @@ -219,7 +217,7 @@ export function indexCourses(params?): Operation { }); } -export function deleteCourse(courseId: number): Operation { +export function deleteCourse(courseId: number): Operation { return async (dispatch) => SystemAPI.instance.deleteCourse(courseId).then(() => { dispatch(actions.deleteCourse(courseId)); @@ -243,7 +241,7 @@ export const updateComponents = async ( return response.data.components; }; -export function fetchInvitations(): Operation { +export function fetchInvitations(): Operation { return async (dispatch) => SystemAPI.instance.indexInvitations().then((response) => { const data = response.data; @@ -251,7 +249,7 @@ export function fetchInvitations(): Operation { }); } -export function deleteInvitation(invitationId: number): Operation { +export function deleteInvitation(invitationId: number): Operation { return async (dispatch) => SystemAPI.instance.deleteInvitation(invitationId).then(() => { dispatch(actions.deleteInvitation(invitationId)); @@ -269,21 +267,21 @@ export function inviteUsers( }); } -export function resendAllInvitations(): Operation { +export function resendAllInvitations(): Operation { return async (dispatch) => SystemAPI.instance.resendAllInvitations().then((response) => { dispatch(actions.saveInvitationList(response.data.invitations)); }); } -export function resendInvitationEmail(invitationId: number): Operation { +export function resendInvitationEmail(invitationId: number): Operation { return async (dispatch) => SystemAPI.instance.resendInvitationEmail(invitationId).then((response) => { dispatch(actions.saveInvitation(response.data)); }); } -export function fetchRoleRequests(): Operation { +export function fetchRoleRequests(): Operation { return async (dispatch) => SystemAPI.instance.indexRoleRequests().then((response) => { const data = response.data; @@ -313,7 +311,7 @@ export const updateRoleRequest = async ( export function approveRoleRequest( roleRequest: RoleRequestMiniEntity, -): Operation { +): Operation { return async (dispatch) => SystemAPI.instance .approveRoleRequest( @@ -329,7 +327,7 @@ export function approveRoleRequest( export function rejectRoleRequest( requestId: number, message?: string, -): Operation { +): Operation { return async (dispatch) => SystemAPI.instance .rejectRoleRequest(requestId, message) diff --git a/client/app/bundles/users/operations.ts b/client/app/bundles/users/operations.ts index 0de72325b67..9806976ad81 100644 --- a/client/app/bundles/users/operations.ts +++ b/client/app/bundles/users/operations.ts @@ -5,7 +5,7 @@ import GlobalUsersAPI from 'api/Users'; import * as actions from './actions'; // eslint-disable-next-line import/prefer-default-export -export function fetchUser(userId: number): Operation { +export function fetchUser(userId: number): Operation { return async (dispatch) => GlobalUsersAPI.users.fetch(userId).then((response) => { const data = response.data; diff --git a/client/app/types/store.ts b/client/app/types/store.ts index ecbb71ac904..364416d6c0b 100644 --- a/client/app/types/store.ts +++ b/client/app/types/store.ts @@ -43,7 +43,7 @@ export interface AppState { global: { user: GlobalUserState; announcements: GlobalAnnouncementState }; } -export type Operation = ThunkAction< +export type Operation = ThunkAction< Promise, AppState, Record, From 0dcb7b45ff6f7c81eb53785aee6f1517e3d3e044 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Tue, 17 Jan 2023 12:16:01 +0800 Subject: [PATCH 17/22] style(timelinedesigner): use global store conventions --- .../course/reference-timelines/operations.ts | 17 +++++++++-------- .../course/reference-timelines/store/hooks.ts | 5 ++--- .../course/reference-timelines/store/index.ts | 6 +++--- .../{timelinesSelectors.ts => selectors.ts} | 6 +++--- .../course/reference-timelines/store/store.ts | 16 ++++------------ .../{timelinesSlice.ts => timelinesReducer.ts} | 4 +++- .../bundles/course/reference-timelines/types.ts | 11 +++++++++++ client/app/types/store.ts | 2 ++ 8 files changed, 37 insertions(+), 30 deletions(-) rename client/app/bundles/course/reference-timelines/store/{timelinesSelectors.ts => selectors.ts} (65%) rename client/app/bundles/course/reference-timelines/store/{timelinesSlice.ts => timelinesReducer.ts} (97%) create mode 100644 client/app/bundles/course/reference-timelines/types.ts diff --git a/client/app/bundles/course/reference-timelines/operations.ts b/client/app/bundles/course/reference-timelines/operations.ts index 72a6fe5164b..513a224198d 100644 --- a/client/app/bundles/course/reference-timelines/operations.ts +++ b/client/app/bundles/course/reference-timelines/operations.ts @@ -6,18 +6,19 @@ import { TimelinePostData, TimePostData, } from 'types/course/referenceTimelines'; +import { Operation } from 'types/store'; import CourseAPI from 'api/course'; -import { actions, AppThunk } from './store'; +import { actions } from './store'; -export const fetchTimelines = (): AppThunk => async (dispatch) => { +export const fetchTimelines = (): Operation => async (dispatch) => { const response = await CourseAPI.referenceTimelines.index(); dispatch(actions.updateAll(response.data)); }; export const createTimeline = - (title: TimelineData['title']): AppThunk => + (title: TimelineData['title']): Operation => async (dispatch) => { const adaptedData: TimelinePostData = { reference_timeline: { title } }; @@ -34,7 +35,7 @@ export const deleteTimeline = ( id: TimelineData['id'], alternativeTimelineId?: TimelineData['id'], - ): AppThunk => + ): Operation => async (dispatch) => { try { await CourseAPI.referenceTimelines.delete(id, alternativeTimelineId); @@ -49,7 +50,7 @@ export const updateTimeline = ( id: TimelineData['id'], changes: Partial>, - ): AppThunk => + ): Operation => async (dispatch) => { const adaptedData: TimelinePostData = { reference_timeline: { title: changes.title, weight: changes.weight }, @@ -80,7 +81,7 @@ export const createTime = bonusEndAt?: string; endAt?: string; }, - ): AppThunk => + ): Operation => async (dispatch) => { const adaptedData: TimePostData = { reference_time: { @@ -120,7 +121,7 @@ export const deleteTime = timelineId: TimelineData['id'], itemId: ItemWithTimeData['id'], timeId: TimeData['id'], - ): AppThunk => + ): Operation => async (dispatch) => { try { await CourseAPI.referenceTimelines.deleteTime(timelineId, timeId); @@ -141,7 +142,7 @@ export const updateTime = bonusEndAt?: string | null; endAt?: string | null; }, - ): AppThunk => + ): Operation => async (dispatch) => { const adaptedData: TimePostData = { reference_time: { diff --git a/client/app/bundles/course/reference-timelines/store/hooks.ts b/client/app/bundles/course/reference-timelines/store/hooks.ts index 9eca6b76e4c..2e70bf2166d 100644 --- a/client/app/bundles/course/reference-timelines/store/hooks.ts +++ b/client/app/bundles/course/reference-timelines/store/hooks.ts @@ -1,7 +1,6 @@ import type { TypedUseSelectorHook } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux'; - -import type { AppDispatch, RootState } from './store'; +import { AppDispatch, AppState } from 'types/store'; export const useAppDispatch: () => AppDispatch = useDispatch; -export const useAppSelector: TypedUseSelectorHook = useSelector; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/client/app/bundles/course/reference-timelines/store/index.ts b/client/app/bundles/course/reference-timelines/store/index.ts index a51edf0713c..64023f0b1b2 100644 --- a/client/app/bundles/course/reference-timelines/store/index.ts +++ b/client/app/bundles/course/reference-timelines/store/index.ts @@ -1,4 +1,4 @@ export * from './hooks'; -export * from './store'; -export * from './timelinesSelectors'; -export { timelinesActions as actions } from './timelinesSlice'; +export { default as store } from './store'; +export * from './selectors'; +export { timelinesActions as actions } from './timelinesReducer'; diff --git a/client/app/bundles/course/reference-timelines/store/timelinesSelectors.ts b/client/app/bundles/course/reference-timelines/store/selectors.ts similarity index 65% rename from client/app/bundles/course/reference-timelines/store/timelinesSelectors.ts rename to client/app/bundles/course/reference-timelines/store/selectors.ts index fa9cf048eeb..dc6e18d8b9f 100644 --- a/client/app/bundles/course/reference-timelines/store/timelinesSelectors.ts +++ b/client/app/bundles/course/reference-timelines/store/selectors.ts @@ -1,9 +1,9 @@ import { createSelector } from '@reduxjs/toolkit'; -import { TimelinesData } from 'types/course/referenceTimelines'; +import { AppState } from 'types/store'; -import { RootState } from './store'; +import { TimelinesState } from '../types'; -const selectTimelinesStore = (state: RootState): TimelinesData => +const selectTimelinesStore = (state: AppState): TimelinesState => state.timelines; export const selectTimelines = createSelector( diff --git a/client/app/bundles/course/reference-timelines/store/store.ts b/client/app/bundles/course/reference-timelines/store/store.ts index 4987cbf8341..b715c5cf325 100644 --- a/client/app/bundles/course/reference-timelines/store/store.ts +++ b/client/app/bundles/course/reference-timelines/store/store.ts @@ -1,17 +1,9 @@ -import { AnyAction, configureStore, ThunkAction } from '@reduxjs/toolkit'; +import { configureStore } from '@reduxjs/toolkit'; -import timelinesReducer from './timelinesSlice'; +import timelinesReducer from './timelinesReducer'; -export const store = configureStore({ +const store = configureStore({ reducer: { timelines: timelinesReducer }, }); -export type RootState = ReturnType; -export type AppDispatch = typeof store.dispatch; - -export type AppThunk> = ThunkAction< - ReturnType, - RootState, - unknown, - AnyAction ->; +export default store; diff --git a/client/app/bundles/course/reference-timelines/store/timelinesSlice.ts b/client/app/bundles/course/reference-timelines/store/timelinesReducer.ts similarity index 97% rename from client/app/bundles/course/reference-timelines/store/timelinesSlice.ts rename to client/app/bundles/course/reference-timelines/store/timelinesReducer.ts index 6fd4fdc86d9..eba46ade62d 100644 --- a/client/app/bundles/course/reference-timelines/store/timelinesSlice.ts +++ b/client/app/bundles/course/reference-timelines/store/timelinesReducer.ts @@ -6,7 +6,9 @@ import { TimelinesData, } from 'types/course/referenceTimelines'; -const initialState: TimelinesData = { +import { TimelinesState } from '../types'; + +const initialState: TimelinesState = { timelines: [], items: [], gamified: false, diff --git a/client/app/bundles/course/reference-timelines/types.ts b/client/app/bundles/course/reference-timelines/types.ts new file mode 100644 index 00000000000..f5110bd3090 --- /dev/null +++ b/client/app/bundles/course/reference-timelines/types.ts @@ -0,0 +1,11 @@ +import { + ItemWithTimeData, + TimelineData, +} from 'types/course/referenceTimelines'; + +export interface TimelinesState { + timelines: TimelineData[]; + items: ItemWithTimeData[]; + gamified: boolean; + defaultTimeline: TimelineData['id']; +} diff --git a/client/app/types/store.ts b/client/app/types/store.ts index 364416d6c0b..afa57c59980 100644 --- a/client/app/types/store.ts +++ b/client/app/types/store.ts @@ -13,6 +13,7 @@ import { DisbursementState } from 'bundles/course/experience-points/disbursement import { ForumsState } from 'bundles/course/forum/types'; import { LeaderboardState } from 'bundles/course/leaderboard/types'; import { FoldersState } from 'bundles/course/material/folders/types'; +import { TimelinesState } from 'bundles/course/reference-timelines/types'; import { InvitationsState } from 'bundles/course/user-invitations/types'; import { UsersState } from 'bundles/course/users/types'; import { VideosState } from 'bundles/course/video/types'; @@ -41,6 +42,7 @@ export interface AppState { comments: CommentState; videos: VideosState; global: { user: GlobalUserState; announcements: GlobalAnnouncementState }; + timelines: TimelinesState; } export type Operation = ThunkAction< From eb7ccd23730be0fe000929d5431d682230eb5991 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Tue, 17 Jan 2023 12:22:04 +0800 Subject: [PATCH 18/22] refactor(store): add typed useappdispatch, useappselector --- .../bundles/course/reference-timelines/TimelineDesigner.tsx | 2 +- .../components/CreateRenameTimelinePrompt.tsx | 2 +- .../reference-timelines/components/DeleteTimelinePrompt.tsx | 3 ++- .../reference-timelines/components/TimePopup/TimePopup.tsx | 2 +- .../components/TimelinesOverview/TimelinesOverviewItem.tsx | 2 +- .../components/TimelinesStack/AssignedTimeline.tsx | 2 +- .../components/TimelinesStack/TimelinesStack.tsx | 3 ++- client/app/bundles/course/reference-timelines/store/index.ts | 3 +-- .../course/reference-timelines/views/DayView/DayView.tsx | 3 ++- .../reference-timelines/store/hooks.ts => lib/hooks/store.ts} | 0 10 files changed, 12 insertions(+), 10 deletions(-) rename client/app/{bundles/course/reference-timelines/store/hooks.ts => lib/hooks/store.ts} (100%) diff --git a/client/app/bundles/course/reference-timelines/TimelineDesigner.tsx b/client/app/bundles/course/reference-timelines/TimelineDesigner.tsx index b7089cd9ae5..951971d8a27 100644 --- a/client/app/bundles/course/reference-timelines/TimelineDesigner.tsx +++ b/client/app/bundles/course/reference-timelines/TimelineDesigner.tsx @@ -1,10 +1,10 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; +import { useAppDispatch } from 'lib/hooks/store'; import DayView from './views/DayView'; import { LastSavedProvider } from './contexts'; import { fetchTimelines } from './operations'; -import { useAppDispatch } from './store'; const TimelineDesigner = (): JSX.Element => { const dispatch = useAppDispatch(); diff --git a/client/app/bundles/course/reference-timelines/components/CreateRenameTimelinePrompt.tsx b/client/app/bundles/course/reference-timelines/components/CreateRenameTimelinePrompt.tsx index 941fb655593..1de6cef7981 100644 --- a/client/app/bundles/course/reference-timelines/components/CreateRenameTimelinePrompt.tsx +++ b/client/app/bundles/course/reference-timelines/components/CreateRenameTimelinePrompt.tsx @@ -5,12 +5,12 @@ import { TimelineData } from 'types/course/referenceTimelines'; import Prompt from 'lib/components/core/dialogs/Prompt'; import TextField from 'lib/components/core/fields/TextField'; +import { useAppDispatch } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import formTranslations from 'lib/translations/form'; import { useSetLastSaved } from '../contexts'; import { createTimeline, updateTimeline } from '../operations'; -import { useAppDispatch } from '../store'; import translations from '../translations'; interface CreateRenameTimelinePromptProps { diff --git a/client/app/bundles/course/reference-timelines/components/DeleteTimelinePrompt.tsx b/client/app/bundles/course/reference-timelines/components/DeleteTimelinePrompt.tsx index fc46f23b246..2a140a8d99b 100644 --- a/client/app/bundles/course/reference-timelines/components/DeleteTimelinePrompt.tsx +++ b/client/app/bundles/course/reference-timelines/components/DeleteTimelinePrompt.tsx @@ -3,9 +3,10 @@ import { Menu, MenuItem } from '@mui/material'; import { TimelineData } from 'types/course/referenceTimelines'; import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -import { selectTimelines, useAppSelector } from '../store'; +import { selectTimelines } from '../store'; import translations from '../translations'; interface DeleteTimelinePromptProps { diff --git a/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopup.tsx b/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopup.tsx index e93e31551e8..899b5653715 100644 --- a/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopup.tsx +++ b/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopup.tsx @@ -6,11 +6,11 @@ import { TimelineData, } from 'types/course/referenceTimelines'; +import { useAppDispatch } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import { useSetLastSaved } from '../../contexts'; import { createTime, deleteTime, updateTime } from '../../operations'; -import { useAppDispatch } from '../../store'; import translations from '../../translations'; import { DraftableTimeData } from '../../utils'; import SeriouslyAnchoredPopup from '../SeriouslyAnchoredPopup'; diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverviewItem.tsx b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverviewItem.tsx index 67252154d6e..90d83787efc 100644 --- a/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverviewItem.tsx +++ b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverviewItem.tsx @@ -5,11 +5,11 @@ import { Divider, IconButton, Menu, MenuItem } from '@mui/material'; import { TimelineData } from 'types/course/referenceTimelines'; import Checkbox from 'lib/components/core/buttons/Checkbox'; +import { useAppDispatch } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import { useLastSaved, useSetLastSaved } from '../../contexts'; import { deleteTimeline } from '../../operations'; -import { useAppDispatch } from '../../store'; import translations from '../../translations'; import CreateRenameTimelinePrompt from '../CreateRenameTimelinePrompt'; import DeleteTimelinePrompt from '../DeleteTimelinePrompt'; diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignedTimeline.tsx b/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignedTimeline.tsx index a380976bc0b..6557489fa69 100644 --- a/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignedTimeline.tsx +++ b/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignedTimeline.tsx @@ -6,11 +6,11 @@ import { TimelineData, } from 'types/course/referenceTimelines'; +import { useAppDispatch } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import { useLastSaved, useSetLastSaved } from '../../contexts'; import { updateTime } from '../../operations'; -import { useAppDispatch } from '../../store'; import translations from '../../translations'; import TimeBar from '../TimeBar'; import TimePopup from '../TimePopup'; diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesStack/TimelinesStack.tsx b/client/app/bundles/course/reference-timelines/components/TimelinesStack/TimelinesStack.tsx index e220a0791f4..ac5e4c867d7 100644 --- a/client/app/bundles/course/reference-timelines/components/TimelinesStack/TimelinesStack.tsx +++ b/client/app/bundles/course/reference-timelines/components/TimelinesStack/TimelinesStack.tsx @@ -4,7 +4,8 @@ import { TimelineData, } from 'types/course/referenceTimelines'; -import { useAppSelector } from '../../store'; +import { useAppSelector } from 'lib/hooks/store'; + import RowSpacer from '../RowSpacer'; import AssignableTimeline from './AssignableTimeline'; diff --git a/client/app/bundles/course/reference-timelines/store/index.ts b/client/app/bundles/course/reference-timelines/store/index.ts index 64023f0b1b2..b588da3fc09 100644 --- a/client/app/bundles/course/reference-timelines/store/index.ts +++ b/client/app/bundles/course/reference-timelines/store/index.ts @@ -1,4 +1,3 @@ -export * from './hooks'; -export { default as store } from './store'; export * from './selectors'; +export { default as store } from './store'; export { timelinesActions as actions } from './timelinesReducer'; diff --git a/client/app/bundles/course/reference-timelines/views/DayView/DayView.tsx b/client/app/bundles/course/reference-timelines/views/DayView/DayView.tsx index d9939123664..1c29961b47b 100644 --- a/client/app/bundles/course/reference-timelines/views/DayView/DayView.tsx +++ b/client/app/bundles/course/reference-timelines/views/DayView/DayView.tsx @@ -2,6 +2,7 @@ import { ComponentRef, useMemo, useRef, useState } from 'react'; import { Chip, Typography } from '@mui/material'; import { TimelineData } from 'types/course/referenceTimelines'; +import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import DayCalendar from '../../components/DayCalendar'; @@ -9,7 +10,7 @@ import SearchField from '../../components/SearchField'; import SubmitIndicator from '../../components/SubmitIndicator'; import TimelinesOverview from '../../components/TimelinesOverview'; import TimelinesStack from '../../components/TimelinesStack'; -import { selectItems, selectTimelines, useAppSelector } from '../../store'; +import { selectItems, selectTimelines } from '../../store'; import translations from '../../translations'; import ItemsSidebar from './ItemsSidebar'; diff --git a/client/app/bundles/course/reference-timelines/store/hooks.ts b/client/app/lib/hooks/store.ts similarity index 100% rename from client/app/bundles/course/reference-timelines/store/hooks.ts rename to client/app/lib/hooks/store.ts From d7344295eaedf8e9974706d73e6288057c375c60 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Tue, 17 Jan 2023 16:45:11 +0800 Subject: [PATCH 19/22] feat(components): searchbar -> searchfield --- .../components/misc/AnnouncementsDisplay.tsx | 24 ++++------ .../courses/components/misc/CourseDisplay.tsx | 28 +++++------ .../views/DayView/DayView.tsx | 2 +- .../lib/components/core/fields/SearchBar.tsx | 48 ------------------- .../components/core/fields}/SearchField.tsx | 2 + 5 files changed, 25 insertions(+), 79 deletions(-) delete mode 100644 client/app/lib/components/core/fields/SearchBar.tsx rename client/app/{bundles/course/reference-timelines/components => lib/components/core/fields}/SearchField.tsx (96%) diff --git a/client/app/bundles/course/announcements/components/misc/AnnouncementsDisplay.tsx b/client/app/bundles/course/announcements/components/misc/AnnouncementsDisplay.tsx index e5e3b5439a9..11dfcc24a5b 100644 --- a/client/app/bundles/course/announcements/components/misc/AnnouncementsDisplay.tsx +++ b/client/app/bundles/course/announcements/components/misc/AnnouncementsDisplay.tsx @@ -9,7 +9,7 @@ import { } from 'types/course/announcements'; import { Operation } from 'types/store'; -import SearchBar from 'lib/components/core/fields/SearchBar'; +import SearchField from 'lib/components/core/fields/SearchField'; import Pagination from 'lib/components/core/layouts/Pagination'; import AnnouncementCard from './AnnouncementCard'; @@ -56,21 +56,17 @@ const AnnouncementsDisplay: FC = (props) => { setShavedAnnouncements(announcements); }, [announcements]); - const handleSearchBarChange = ( - event: React.ChangeEvent, - ): void => { - if (event.target.value.trim() === '') { + const handleSearchBarChange = (rawKeyword: string): void => { + const keyword = rawKeyword.trim(); + + if (keyword === '') { setShavedAnnouncements(announcements); } else { setShavedAnnouncements( announcements.filter( (announcement: AnnouncementMiniEntity) => - announcement.title - .toLowerCase() - .includes(event.target.value.trim().toLowerCase()) || - announcement.content - .toLowerCase() - .includes(event.target.value.trim().toLowerCase()), + announcement.title.toLowerCase().includes(keyword.toLowerCase()) || + announcement.content.toLowerCase().includes(keyword.toLowerCase()), ), ); } @@ -88,12 +84,12 @@ const AnnouncementsDisplay: FC = (props) => { xs={1} >
-
diff --git a/client/app/bundles/course/courses/components/misc/CourseDisplay.tsx b/client/app/bundles/course/courses/components/misc/CourseDisplay.tsx index cf7c8c51933..710e193ebc9 100644 --- a/client/app/bundles/course/courses/components/misc/CourseDisplay.tsx +++ b/client/app/bundles/course/courses/components/misc/CourseDisplay.tsx @@ -3,7 +3,7 @@ import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; import { Grid } from '@mui/material'; import { CourseMiniEntity } from 'types/course/courses'; -import SearchBar from 'lib/components/core/fields/SearchBar'; +import SearchField from 'lib/components/core/fields/SearchField'; import Pagination from 'lib/components/core/layouts/Pagination'; import Note from 'lib/components/core/Note'; @@ -40,17 +40,15 @@ const CourseDisplay: FC = (props) => { return ; } - const handleSearchBarChange = ( - event: React.ChangeEvent, - ): void => { - if (event.target.value === '') { + const handleSearchBarChange = (rawKeyword: string): void => { + const keyword = rawKeyword.trim(); + + if (keyword === '') { setShavedCourses(courses); } else { setShavedCourses( courses.filter((course: CourseMiniEntity) => - course.title - .toLowerCase() - .includes(event.target.value.trim().toLowerCase()), + course.title.toLowerCase().includes(keyword.toLowerCase()), ), ); } @@ -64,17 +62,15 @@ const CourseDisplay: FC = (props) => { style={{ display: 'flex', justifyContent: 'left', + paddingTop: 16, + paddingBottom: 16, }} xs={1} > -
- -
+ , - ) => void; -} - -/* -Can refer to CourseDisplay.tsx on how to implement onChange. -Or refer to: -https://mui.com/material-ui/api/input-base/ - -This search bar will update the search everytime a change is detected -*/ - -const SearchBar: FC = (props) => { - const { placeholder, width, onChange } = props; - - return ( - - - - - - - ); -}; - -export default SearchBar; diff --git a/client/app/bundles/course/reference-timelines/components/SearchField.tsx b/client/app/lib/components/core/fields/SearchField.tsx similarity index 96% rename from client/app/bundles/course/reference-timelines/components/SearchField.tsx rename to client/app/lib/components/core/fields/SearchField.tsx index 7e627f58ee0..89ecfe51bf2 100644 --- a/client/app/bundles/course/reference-timelines/components/SearchField.tsx +++ b/client/app/lib/components/core/fields/SearchField.tsx @@ -8,6 +8,7 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator'; interface SearchFieldProps { onChangeKeyword?: (keyword: string) => void; placeholder?: string; + className?: string; } const SearchField = (props: SearchFieldProps): JSX.Element => { @@ -26,6 +27,7 @@ const SearchField = (props: SearchFieldProps): JSX.Element => { return ( Date: Wed, 18 Jan 2023 13:15:44 +0800 Subject: [PATCH 20/22] fix(timelinedesigner): remove unnecessary scrollbars --- .../components/TimelinesOverview/TimelinesOverview.tsx | 2 +- .../course/reference-timelines/views/DayView/DayView.tsx | 4 ++-- client/app/theme/index.css | 4 +--- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverview.tsx b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverview.tsx index dfe89969e55..44aa04e97b3 100644 --- a/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverview.tsx +++ b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverview.tsx @@ -30,7 +30,7 @@ const TimelinesOverview = (props: TimelinesOverviewProps): JSX.Element => { return (
-