From 5e29fe0ac431a290130ebda2c6288785828e7a0e Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 28 Nov 2024 12:21:37 -0800 Subject: [PATCH 1/2] refactor: convert most of 'AnswerWidget' to TypeScript --- .../{AnswerOption.jsx => AnswerOption.tsx} | 60 ++++++------------- ...wersContainer.jsx => AnswersContainer.tsx} | 49 +++++---------- .../AnswerWidget/components/Checker/index.jsx | 2 +- .../AnswerWidget/{index.jsx => index.tsx} | 27 +++------ .../AnswerWidget/{messages.js => messages.ts} | 0 .../components/EditProblemView/index.jsx | 2 +- src/editors/data/constants/problem.ts | 10 ++++ src/editors/data/redux/app/selectors.ts | 2 +- src/editors/data/redux/index.ts | 4 +- src/editors/data/services/cms/types.ts | 1 + 10 files changed, 58 insertions(+), 99 deletions(-) rename src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/{AnswerOption.jsx => AnswerOption.tsx} (73%) rename src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/{AnswersContainer.jsx => AnswersContainer.tsx} (64%) rename src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/{index.jsx => index.tsx} (52%) rename src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/{messages.js => messages.ts} (100%) diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.tsx similarity index 73% rename from src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx rename to src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.tsx index 57012064ac..1106e6bad1 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.tsx @@ -1,6 +1,5 @@ -import React, { memo } from 'react'; -import { connect, useDispatch } from 'react-redux'; -import PropTypes from 'prop-types'; +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { Collapsible, Icon, @@ -8,28 +7,31 @@ import { Form, } from '@openedx/paragon'; import { FeedbackOutline, DeleteOutline } from '@openedx/paragon/icons'; -import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import { selectors } from '../../../../../data/redux'; -import { answerOptionProps } from '../../../../../data/services/cms/types'; import Checker from './components/Checker'; import { FeedbackBox } from './components/Feedback'; import * as hooks from './hooks'; -import { ProblemTypeKeys } from '../../../../../data/constants/problem'; +import { AnswerData, ProblemTypeKeys } from '../../../../../data/constants/problem'; import ExpandableTextArea from '../../../../../sharedComponents/ExpandableTextArea'; +import { useEditorContext } from '../../../../../EditorContext'; -const AnswerOption = ({ +interface Props { + answer: AnswerData; + hasSingleAnswer: boolean; +} + +export const AnswerOption = ({ answer, hasSingleAnswer, - // injected - intl, - // redux - problemType, - images, - isLibrary, - learningContextId, -}) => { +}: Props) => { + const intl = useIntl(); const dispatch = useDispatch(); + const { learningContextId } = useEditorContext(); + const problemType = useSelector(selectors.problem.problemType); + const images = useSelector(selectors.app.images); + const isLibrary = useSelector(selectors.app.isLibrary); const removeAnswer = hooks.removeAnswer({ answer, dispatch }); const setAnswer = hooks.setAnswer({ answer, hasSingleAnswer, dispatch }); const setAnswerTitle = hooks.setAnswerTitle({ @@ -43,7 +45,7 @@ const AnswerOption = ({ const { isFeedbackVisible, toggleFeedback } = hooks.useFeedback(answer); const getInputArea = () => { - if ([ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType)) { + if ([ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType as any)) { return ( ); }; - -AnswerOption.propTypes = { - answer: answerOptionProps.isRequired, - hasSingleAnswer: PropTypes.bool.isRequired, - // injected - intl: intlShape.isRequired, - // redux - problemType: PropTypes.string.isRequired, - images: PropTypes.shape({}).isRequired, - learningContextId: PropTypes.string.isRequired, - isLibrary: PropTypes.bool.isRequired, -}; - -export const mapStateToProps = (state) => ({ - problemType: selectors.problem.problemType(state), - images: selectors.app.images(state), - isLibrary: selectors.app.isLibrary(state), - learningContextId: selectors.app.learningContextId(state), -}); - -export const mapDispatchToProps = {}; -export const AnswerOptionInternal = AnswerOption; // For testing only -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(memo(AnswerOption))); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.tsx similarity index 64% rename from src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.jsx rename to src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.tsx index 1a5d547cb4..8ed3ee471e 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Dropdown, Icon } from '@openedx/paragon'; @@ -8,22 +7,23 @@ import { Add } from '@openedx/paragon/icons'; import messages from './messages'; import { useAnswerContainer, isSingleAnswerProblem } from './hooks'; import { actions, selectors } from '../../../../../data/redux'; -import { answerOptionProps } from '../../../../../data/services/cms/types'; -import AnswerOption from './AnswerOption'; +import { AnswerOption } from './AnswerOption'; import Button from '../../../../../sharedComponents/Button'; -import { ProblemTypeKeys } from '../../../../../data/constants/problem'; +import { ProblemType, ProblemTypeKeys } from '../../../../../data/constants/problem'; -const AnswersContainer = ({ - problemType, - // Redux - answers, - addAnswer, - addAnswerRange, - updateField, -}) => { +interface Props { + problemType: ProblemType; +} + +export const AnswersContainer = ({ problemType }: Props) => { const hasSingleAnswer = isSingleAnswerProblem(problemType); + const answers = useSelector(selectors.problem.answers); + const dispatch = useDispatch(); + const addAnswer = React.useCallback(() => dispatch(actions.problem.addAnswer), [dispatch]); + const addAnswerRange = React.useCallback(() => dispatch(actions.problem.addAnswerRange), [dispatch]); + const updateField = React.useCallback(() => dispatch(actions.problem.updateField), [dispatch]); - useAnswerContainer({ answers, problemType, updateField }); + useAnswerContainer({ answers, updateField }); return (
@@ -76,24 +76,3 @@ const AnswersContainer = ({
); }; - -AnswersContainer.propTypes = { - problemType: PropTypes.string.isRequired, - answers: PropTypes.arrayOf(answerOptionProps).isRequired, - addAnswer: PropTypes.func.isRequired, - addAnswerRange: PropTypes.func.isRequired, - updateField: PropTypes.func.isRequired, -}; - -export const mapStateToProps = (state) => ({ - answers: selectors.problem.answers(state), -}); - -export const mapDispatchToProps = { - addAnswer: actions.problem.addAnswer, - addAnswerRange: actions.problem.addAnswerRange, - updateField: actions.problem.updateField, -}; - -export const AnswersContainerInternal = AnswersContainer; // For testing only -export default connect(mapStateToProps, mapDispatchToProps)(AnswersContainer); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Checker/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Checker/index.jsx index 308e175477..fad90cb819 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Checker/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Checker/index.jsx @@ -37,7 +37,7 @@ Checker.propTypes = { hasSingleAnswer: PropTypes.bool.isRequired, answer: PropTypes.shape({ correct: PropTypes.bool, - id: PropTypes.number, + id: PropTypes.string, }).isRequired, setAnswer: PropTypes.func.isRequired, disabled: PropTypes.bool, diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.tsx similarity index 52% rename from src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx rename to src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.tsx index c7e865212e..8c45fb0ed1 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.tsx @@ -1,18 +1,17 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; -import { ProblemTypes } from '../../../../../data/constants/problem'; -import AnswersContainer from './AnswersContainer'; +import { ProblemType, ProblemTypes } from '../../../../../data/constants/problem'; +import { AnswersContainer } from './AnswersContainer'; + +interface Props { + problemType: ProblemType; +} // This widget should be connected, grab all answers from store, update them as needed. -const AnswerWidget = ({ - // Redux - problemType, - // injected - intl, -}) => { +export const AnswerWidget = ({ problemType }: Props) => { + const intl = useIntl(); const problemStaticData = ProblemTypes[problemType]; return (
@@ -28,11 +27,3 @@ const AnswerWidget = ({
); }; - -AnswerWidget.propTypes = { - problemType: PropTypes.string.isRequired, - // injected - intl: intlShape.isRequired, -}; -export const AnswerWidgetInternal = AnswerWidget; // For testing only -export default injectIntl(AnswerWidget); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.ts similarity index 100% rename from src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js rename to src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.ts diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx index fbeb086b26..7a66b626bd 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx @@ -9,7 +9,7 @@ import { AlertModal, ActionRow, } from '@openedx/paragon'; -import AnswerWidget from './AnswerWidget'; +import { AnswerWidget } from './AnswerWidget'; import SettingsWidget from './SettingsWidget'; import QuestionWidget from './QuestionWidget'; import EditorContainer from '../../../EditorContainer'; diff --git a/src/editors/data/constants/problem.ts b/src/editors/data/constants/problem.ts index 0c26a45f04..6304d5855a 100644 --- a/src/editors/data/constants/problem.ts +++ b/src/editors/data/constants/problem.ts @@ -239,3 +239,13 @@ export const ignoredOlxAttributes = [ '@_url_name', '@_x-is-pointer-node', ] as const; + +export interface AnswerData { + id: string; + title?: string; + correct?: boolean; + isAnswerRange?: boolean; + feedback?: string; + selectedFeedback?: string; + unselectedFeedback?: string; +} diff --git a/src/editors/data/redux/app/selectors.ts b/src/editors/data/redux/app/selectors.ts index 3b7d151023..a2ed248757 100644 --- a/src/editors/data/redux/app/selectors.ts +++ b/src/editors/data/redux/app/selectors.ts @@ -15,7 +15,7 @@ export const simpleSelectors = { blockType: mkSimpleSelector(app => app.blockType), blockValue: mkSimpleSelector(app => app.blockValue), studioView: mkSimpleSelector(app => app.studioView), - /** @deprecated Get as `const { learningContextid } = useEditorContext()` instead */ + /** @deprecated Get as `const { learningContextId } = useEditorContext()` instead */ learningContextId: mkSimpleSelector(app => app.learningContextId), editorInitialized: mkSimpleSelector(app => app.editorInitialized), saveResponse: mkSimpleSelector(app => app.saveResponse), diff --git a/src/editors/data/redux/index.ts b/src/editors/data/redux/index.ts index 7758662965..77dcfa2711 100644 --- a/src/editors/data/redux/index.ts +++ b/src/editors/data/redux/index.ts @@ -8,7 +8,7 @@ import * as video from './video'; import * as problem from './problem'; import * as game from './game'; import type { RequestKeys, RequestStates } from '../constants/requests'; -import { AdvancedProblemType, ProblemType } from '../constants/problem'; +import { AdvancedProblemType, AnswerData, ProblemType } from '../constants/problem'; export { default as thunkActions } from './thunkActions'; @@ -148,7 +148,7 @@ export interface EditorState { rawOLX: string; problemType: null | ProblemType | AdvancedProblemType; question: string; - answers: any[]; + answers: AnswerData[]; correctAnswerCount: number; groupFeedbackList: any[]; generalFeedback: string; diff --git a/src/editors/data/services/cms/types.ts b/src/editors/data/services/cms/types.ts index f093b12359..5b483188c0 100644 --- a/src/editors/data/services/cms/types.ts +++ b/src/editors/data/services/cms/types.ts @@ -27,6 +27,7 @@ export const videoDataProps = { }; */ +/** @deprecated Use `AnswerData` TypeScript type instead (src/editors/data/constants/problem.ts) */ export const answerOptionProps = PropTypes.shape({ id: PropTypes.string, title: PropTypes.string, From d2e8c4ab01d7558c4f7bb86ac9dd69b0b26ee75f Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 28 Nov 2024 12:40:38 -0800 Subject: [PATCH 2/2] refactor: convert 'Button' and 'ExpandableTextArea' components to TS --- src/editors/sharedComponents/Button/hooks.js | 22 ----------- src/editors/sharedComponents/Button/hooks.ts | 26 +++++++++++++ src/editors/sharedComponents/Button/index.jsx | 32 --------------- src/editors/sharedComponents/Button/index.tsx | 22 +++++++++++ .../{index.jsx => index.tsx} | 39 ++++++++----------- 5 files changed, 64 insertions(+), 77 deletions(-) delete mode 100644 src/editors/sharedComponents/Button/hooks.js create mode 100644 src/editors/sharedComponents/Button/hooks.ts delete mode 100644 src/editors/sharedComponents/Button/index.jsx create mode 100644 src/editors/sharedComponents/Button/index.tsx rename src/editors/sharedComponents/ExpandableTextArea/{index.jsx => index.tsx} (59%) diff --git a/src/editors/sharedComponents/Button/hooks.js b/src/editors/sharedComponents/Button/hooks.js deleted file mode 100644 index e2a9d4d862..0000000000 --- a/src/editors/sharedComponents/Button/hooks.js +++ /dev/null @@ -1,22 +0,0 @@ -export const isVariantAdd = (variant) => variant === 'add'; - -export const getButtonProps = ({ variant, className, Add }) => { - const variantClasses = { - default: 'shared-button', - add: 'shared-button pl-0 text-primary-500 button-variant-add', - }; - const variantMap = { - add: 'tertiary', - }; - const classes = [variantClasses[variant]]; - if (className) { classes.push(className); } - - const iconProps = {}; - if (isVariantAdd(variant)) { iconProps.iconBefore = Add; } - - return { - className: classes.join(' '), - variant: variantMap[variant] || variant, - ...iconProps, - }; -}; diff --git a/src/editors/sharedComponents/Button/hooks.ts b/src/editors/sharedComponents/Button/hooks.ts new file mode 100644 index 0000000000..a1f47b4210 --- /dev/null +++ b/src/editors/sharedComponents/Button/hooks.ts @@ -0,0 +1,26 @@ +import React from 'react'; +import { Add } from '@openedx/paragon/icons'; +import { Button as ParagonButton } from '@openedx/paragon'; + +type ButtonProps = React.ComponentProps; + +export const getButtonProps = ({ variant, className }: { variant: string, className?: string }) => { + const variantClasses = { + default: 'shared-button', + add: 'shared-button pl-0 text-primary-500 button-variant-add', + }; + const variantMap = { + add: 'tertiary', + }; + const classes = [variantClasses[variant]]; + if (className) { classes.push(className); } + + const iconProps: Partial = {}; + if (variant === 'add') { iconProps.iconBefore = Add; } + + return { + className: classes.join(' '), + variant: variantMap[variant] || variant, + ...iconProps, + }; +}; diff --git a/src/editors/sharedComponents/Button/index.jsx b/src/editors/sharedComponents/Button/index.jsx deleted file mode 100644 index 29e28294bd..0000000000 --- a/src/editors/sharedComponents/Button/index.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { string, node, arrayOf } from 'prop-types'; -import { Button as ParagonButton } from '@openedx/paragon'; -import { Add } from '@openedx/paragon/icons'; - -import { getButtonProps } from './hooks'; -import './index.scss'; - -const Button = ({ - variant, className, text, children, ...props -}) => ( - - {children || text} - -); -Button.propTypes = { - variant: string, - className: string, - text: string, - children: node || arrayOf(node), -}; -Button.defaultProps = { - variant: 'default', - className: null, - text: null, - children: null, -}; - -export default Button; diff --git a/src/editors/sharedComponents/Button/index.tsx b/src/editors/sharedComponents/Button/index.tsx new file mode 100644 index 0000000000..f9759ae549 --- /dev/null +++ b/src/editors/sharedComponents/Button/index.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Button as ParagonButton } from '@openedx/paragon'; + +import { getButtonProps } from './hooks'; +import './index.scss'; + +interface Props extends React.ComponentProps { + text?: string; +} + +export const Button = ({ + variant = 'default', className, text, children, ...props +}: Props) => ( + + {children || text} + +); + +export default Button; diff --git a/src/editors/sharedComponents/ExpandableTextArea/index.jsx b/src/editors/sharedComponents/ExpandableTextArea/index.tsx similarity index 59% rename from src/editors/sharedComponents/ExpandableTextArea/index.jsx rename to src/editors/sharedComponents/ExpandableTextArea/index.tsx index a2f30d66d8..1f63ece3bf 100644 --- a/src/editors/sharedComponents/ExpandableTextArea/index.jsx +++ b/src/editors/sharedComponents/ExpandableTextArea/index.tsx @@ -1,52 +1,45 @@ import React from 'react'; -import PropTypes from 'prop-types'; import TinyMceWidget from '../TinyMceWidget'; import { prepareEditorRef } from '../TinyMceWidget/hooks'; import './index.scss'; -const ExpandableTextArea = ({ - value, +interface Props extends Partial> { + setContent: (content: string) => void; + value?: string | null; + placeholder?: string; + error?: boolean; + errorMessage?: string; +} + +export const ExpandableTextArea = ({ + value = null, // TODO: why not default to '' ? setContent, - error, - errorMessage, + error = false, + errorMessage = '', ...props -}) => { +}: Props) => { const { editorRef, setEditorRef } = prepareEditorRef(); return ( <>
+ {/* @ts-ignore TODO: Remove this 'ts-ignore' once TinyMceWidget is converted to TypeScript */}
{error && (
- {props.errorMessage} + {errorMessage}
)} ); }; -ExpandableTextArea.defaultProps = { - value: null, - placeholder: null, - error: false, - errorMessage: null, -}; - -ExpandableTextArea.propTypes = { - value: PropTypes.string, - setContent: PropTypes.func.isRequired, - placeholder: PropTypes.string, - error: PropTypes.bool, - errorMessage: PropTypes.string, -}; - export default ExpandableTextArea;