diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index c6de9b0a9..71157d11c 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -62,7 +62,7 @@ import { * @param {string} courseId - ID of the course * @returns {Object} - Object containing fetch course outline index query success or failure status */ -export function fetchCourseOutlineIndexQuery(courseId: string): object { +export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) => Promise { return async (dispatch) => { dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS })); diff --git a/src/library-authoring/components/BaseCard.tsx b/src/library-authoring/components/BaseCard.tsx index ef7dc5828..f7d9ea32b 100644 --- a/src/library-authoring/components/BaseCard.tsx +++ b/src/library-authoring/components/BaseCard.tsx @@ -7,11 +7,12 @@ import { Stack, } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { getItemIcon, getComponentStyleColor } from '@src/generic/block-type-utils'; +import ComponentCount from '@src/generic/component-count'; +import TagCount from '@src/generic/tag-count'; +import { BlockTypeLabel, type ContentHitTags, Highlight } from '@src/search-manager'; +import { skipIfUnwantedTarget } from '@src/utils'; import messages from './messages'; -import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils'; -import ComponentCount from '../../generic/component-count'; -import TagCount from '../../generic/tag-count'; -import { BlockTypeLabel, type ContentHitTags, Highlight } from '../../search-manager'; type BaseCardProps = { itemType: string; @@ -52,7 +53,7 @@ const BaseCard = ({ skipIfUnwantedTarget(e, onSelect)} onKeyDown={(e: React.KeyboardEvent) => { if (['Enter', ' '].includes(e.key)) { onSelect(); @@ -65,12 +66,13 @@ const BaseCard = ({ title={ } - actions={ - // Wrap the actions in a div to prevent the card from being clicked when the actions are clicked - /* eslint-disable-next-line jsx-a11y/click-events-have-key-events, - jsx-a11y/no-static-element-interactions */ -
e.stopPropagation()}>{actions}
- } + actions={( +
{actions} +
+ )} /> diff --git a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx index 0ddda577c..9a315a4aa 100644 --- a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx +++ b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx @@ -24,7 +24,7 @@ import { ToastContext } from '../../generic/toast-context'; import TagCount from '../../generic/tag-count'; import { useLibraryRoutes } from '../routes'; import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext'; -import { useRunOnNextRender } from '../../utils'; +import { skipIfUnwantedTarget, useRunOnNextRender } from '../../utils'; import { ContainerMenu } from '../containers/ContainerCard'; interface LibraryContainerChildrenProps { @@ -76,7 +76,7 @@ const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps) {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
e.stopPropagation()} + className="stop-event-propagation" > e.stopPropagation()} + className="stop-event-propagation" > {!showOnlyPublished && container.hasUnpublishedChanges && ( handleChildClick(child, e.detail)} + onClick={(e) => skipIfUnwantedTarget(e, (event) => handleChildClick(child, event.detail))} onKeyDown={(e) => { if (e.key === 'Enter') { handleChildClick(child, 1); diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index 12e249777..2381cbc5a 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -7,16 +7,18 @@ import classNames from 'classnames'; import { useCallback, useContext, useEffect, useState, } from 'react'; -import { blockTypes } from '../../editors/data/constants/app'; -import DraggableList, { SortableItem } from '../../generic/DraggableList'; +import { blockTypes } from '@src/editors/data/constants/app'; +import DraggableList, { SortableItem } from '@src/generic/DraggableList'; -import ErrorAlert from '../../generic/alert-error'; -import { getItemIcon } from '../../generic/block-type-utils'; -import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; -import { IframeProvider } from '../../generic/hooks/context/iFrameContext'; -import { InplaceTextEditor } from '../../generic/inplace-text-editor'; -import Loading from '../../generic/Loading'; -import TagCount from '../../generic/tag-count'; +import ErrorAlert from '@src/generic/alert-error'; +import { getItemIcon } from '@src/generic/block-type-utils'; +import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; +import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; +import { InplaceTextEditor } from '@src/generic/inplace-text-editor'; +import Loading from '@src/generic/Loading'; +import TagCount from '@src/generic/tag-count'; +import { ToastContext } from '@src/generic/toast-context'; +import { skipIfUnwantedTarget, useRunOnNextRender } from '@src/utils'; import { useLibraryContext } from '../common/context/LibraryContext'; import ComponentMenu from '../components'; import { LibraryBlockMetadata } from '../data/api'; @@ -28,9 +30,7 @@ import { import { LibraryBlock } from '../LibraryBlock'; import messages from './messages'; import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext'; -import { ToastContext } from '../../generic/toast-context'; import { canEditComponent } from '../components/ComponentEditorModal'; -import { useRunOnNextRender } from '../../utils'; /** Components that need large min height in preview */ const LARGE_COMPONENTS = [ @@ -91,9 +91,7 @@ const BlockHeader = ({ block, readOnly }: ComponentBlockProps) => { e.stopPropagation()} + className="font-weight-bold stop-event-propagation" > { e.stopPropagation()} + className="stop-event-propagation" > {!showOnlyPublished && block.hasUnpublishedChanges && ( borderBottom: 'solid 1px #E1DDDB', }} isClickable={!readOnly} - onClick={(e) => handleComponentSelection(e.detail)} + onClick={(e) => skipIfUnwantedTarget(e, (event) => handleComponentSelection(event.detail))} onKeyDown={(e) => { if (e.key === 'Enter') { handleComponentSelection(e.detail); diff --git a/src/pages-and-resources/PagesAndResourcesProvider.jsx b/src/pages-and-resources/PagesAndResourcesProvider.jsx deleted file mode 100644 index 9a066f8d0..000000000 --- a/src/pages-and-resources/PagesAndResourcesProvider.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import React, { useMemo } from 'react'; -import PropTypes from 'prop-types'; - -export const PagesAndResourcesContext = React.createContext({}); - -const PagesAndResourcesProvider = ({ courseId, children }) => { - const contextValue = useMemo(() => ({ - courseId, - path: `/course/${courseId}/pages-and-resources`, - }), []); - return ( - - {children} - - ); -}; - -PagesAndResourcesProvider.propTypes = { - courseId: PropTypes.string.isRequired, - children: PropTypes.node.isRequired, -}; - -export default PagesAndResourcesProvider; diff --git a/src/pages-and-resources/PagesAndResourcesProvider.tsx b/src/pages-and-resources/PagesAndResourcesProvider.tsx new file mode 100644 index 000000000..ae7e2d0ef --- /dev/null +++ b/src/pages-and-resources/PagesAndResourcesProvider.tsx @@ -0,0 +1,28 @@ +import React, { useMemo } from 'react'; + +interface PagesAndResourcesContextData { + courseId?: string; + path?: string; +} +export const PagesAndResourcesContext = React.createContext({}); + +interface PagesAndResourcesProviderProps { + courseId: string; + children: React.ReactNode, +} + +const PagesAndResourcesProvider = ({ courseId, children }: PagesAndResourcesProviderProps) => { + const contextValue = useMemo(() => ({ + courseId, + path: `/course/${courseId}/pages-and-resources`, + }), []); + return ( + + {children} + + ); +}; + +export default PagesAndResourcesProvider; diff --git a/src/utils.js b/src/utils.ts similarity index 73% rename from src/utils.js rename to src/utils.ts index d763a6246..7e68e996c 100644 --- a/src/utils.js +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import { useState, useContext, useEffect } from 'react'; +import React, { useState, useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useMediaQuery } from 'react-responsive'; import * as Yup from 'yup'; @@ -6,6 +6,8 @@ import { snakeCase } from 'lodash/string'; import moment from 'moment'; import { getConfig, getPath } from '@edx/frontend-platform'; +import type { Dispatch, AnyAction } from 'redux'; +import type { TypeOfShape } from 'yup/lib/object'; import { RequestStatus } from './data/constants'; import { getCourseAppSettingValue, getLoadingStatus } from './pages-and-resources/data/selectors'; import { fetchCourseAppSettings, updateCourseAppSetting } from './pages-and-resources/data/thunks'; @@ -15,7 +17,11 @@ import { } from './pages-and-resources/discussions/app-config-form/utils'; import { DATE_TIME_FORMAT } from './constants'; -export const executeThunk = async (thunk, dispatch, getState) => { +export const executeThunk = async ( + thunk: (dispatch: any, state?: any) => Promise, + dispatch: Dispatch, + getState?: any, +) => { await thunk(dispatch, getState); await new Promise(setImmediate); }; @@ -28,7 +34,7 @@ export function useIsDesktop() { return useMediaQuery({ query: '(min-width: 992px)' }); } -export function convertObjectToSnakeCase(obj, unpacked = false) { +export function convertObjectToSnakeCase(obj: Object, unpacked = false) { return Object.keys(obj).reduce((snakeCaseObj, key) => { const snakeCaseKey = snakeCase(key); const value = unpacked ? obj[key] : { value: obj[key] }; @@ -39,7 +45,7 @@ export function convertObjectToSnakeCase(obj, unpacked = false) { }, {}); } -export function deepConvertingKeysToCamelCase(obj) { +export function deepConvertingKeysToCamelCase(obj: any[] | Object | null) { if (typeof obj !== 'object' || obj === null) { return obj; } @@ -50,13 +56,13 @@ export function deepConvertingKeysToCamelCase(obj) { const camelCaseObj = {}; Object.keys(obj).forEach((key) => { - const camelCaseKey = key.replace(/_([a-z])/g, (match, p1) => p1.toUpperCase()); + const camelCaseKey = key.replace(/_([a-z])/g, (_, p1) => p1.toUpperCase()); camelCaseObj[camelCaseKey] = deepConvertingKeysToCamelCase(obj[key]); }); return camelCaseObj; } -export function deepConvertingKeysToSnakeCase(obj) { +export function deepConvertingKeysToSnakeCase(obj: any[] | Object | null) { if (typeof obj !== 'object' || obj === null) { return obj; } @@ -73,11 +79,11 @@ export function deepConvertingKeysToSnakeCase(obj) { return snakeCaseObj; } -export function transformKeysToCamelCase(obj) { - return obj.key.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase()); +export function transformKeysToCamelCase(obj: { key: any; }) { + return obj.key.replace(/_([a-z])/g, (_: any, letter: string) => letter.toUpperCase()); } -export function parseArrayOrObjectValues(obj) { +export function parseArrayOrObjectValues(obj: { [s: string]: string; } | ArrayLike) { const result = {}; Object.entries(obj).forEach(([key, value]) => { @@ -97,10 +103,8 @@ export function parseArrayOrObjectValues(obj) { /** * Create a correct inner path depend on config PUBLIC_PATH. - * @param {string} checkPath - the internal route path that is validated - * @returns {string} - the correct internal route path */ -export const createCorrectInternalRoute = (checkPath) => { +export const createCorrectInternalRoute = (checkPath: string): string => { let basePath = getPath(getConfig().PUBLIC_PATH); if (basePath.endsWith('/')) { @@ -114,7 +118,7 @@ export const createCorrectInternalRoute = (checkPath) => { return checkPath; }; -export function getPagePath(courseId, isMfePageEnabled, urlParameter) { +export function getPagePath(courseId: string | undefined, isMfePageEnabled: string, urlParameter: string) { if (isMfePageEnabled === 'true') { if (urlParameter === 'tabs') { return `/course/${courseId}/pages-and-resources`; @@ -124,7 +128,7 @@ export function getPagePath(courseId, isMfePageEnabled, urlParameter) { return `${getConfig().STUDIO_BASE_URL}/${urlParameter}/${courseId}`; } -export function useAppSetting(settingName) { +export function useAppSetting(settingName: string) { const dispatch = useDispatch(); const { courseId } = useContext(PagesAndResourcesContext); const settingValue = useSelector(getCourseAppSettingValue(settingName)); @@ -138,15 +142,19 @@ export function useAppSetting(settingName) { } }, [courseId]); - const saveSetting = async (value) => dispatch(updateCourseAppSetting(courseId, settingName, value)); + const saveSetting = async (value: any) => dispatch(updateCourseAppSetting(courseId, settingName, value)); return [settingValue, saveSetting]; } -export const getLabelById = (options, id) => { +export const getLabelById = (options: any[], id: any) => { const foundOption = options.find((option) => option.id === id); return foundOption ? foundOption.label : ''; }; +interface YupTestContextExtended { + originalValue?: unknown; +} + /** * Adds additional validation methods to Yup. */ @@ -156,9 +164,9 @@ export function setupYupExtensions() { // Credit: https://github.com/jquense/yup/issues/345#issuecomment-717400071 Yup.addMethod(Yup.array, 'uniqueProperty', function uniqueProperty(property, message) { return this.test('unique', '', function testUniqueness(list) { - const errors = []; + const errors: Yup.ValidationError[] = []; - list.forEach((item, index) => { + list?.forEach((item, index) => { const propertyValue = item[property]; if (propertyValue && list.filter(entry => entry[property] === propertyValue).length > 1) { @@ -184,13 +192,15 @@ export function setupYupExtensions() { if (!discussionTopic || !discussionTopic[propertyName]) { return true; } - const isDuplicate = this.parent.filter(topic => topic !== discussionTopic) - .some(topic => topic[propertyName]?.toLowerCase() === discussionTopic[propertyName].toLowerCase()); + const isDuplicate = this.parent.filter((topic: TypeOfShape) => topic !== discussionTopic) + .some(( + topic: { [x: string]: string; }, + ) => topic[propertyName]?.toLowerCase() === discussionTopic[propertyName].toLowerCase()); if (isDuplicate) { throw this.createError({ path: `${this.path}.${propertyName}`, - error: message, + message, }); } return true; @@ -212,7 +222,7 @@ export function setupYupExtensions() { const startDateTime = decodeDateTime(this.parent.startDate, startOfDayTime(this.parent.startTime)); const endDateTime = decodeDateTime(this.parent.endDate, endOfDayTime(this.parent.endTime)); - let isInvalidStartDateTime; + let isInvalidStartDateTime: boolean = false; if (type === 'date') { isInvalidStartDateTime = startDateTime.isAfter(endDateTime); @@ -223,7 +233,7 @@ export function setupYupExtensions() { if (isInvalidStartDateTime) { throw this.createError({ path: `${this.path}`, - error: message, + message, }); } return true; @@ -232,21 +242,22 @@ export function setupYupExtensions() { Yup.addMethod(Yup.string, 'checkFormat', function checkFormat(message, type) { return this.test('isValidFormat', message, function isValidFormat() { - if (!this.originalValue) { + const { originalValue } = this as Yup.TestContext & YupTestContextExtended; + if (!originalValue) { return true; } - let isValid; + let isValid: boolean = false; if (type === 'date') { - isValid = hasValidDateFormat(this.originalValue); + isValid = hasValidDateFormat(originalValue); } else if (type === 'time') { - isValid = hasValidTimeFormat(this.originalValue); + isValid = hasValidTimeFormat(originalValue); } if (!isValid) { throw this.createError({ path: `${this.path}`, - error: message, + message, }); } return true; @@ -254,7 +265,7 @@ export function setupYupExtensions() { }); } -export const convertToDateFromString = (dateStr) => { +export const convertToDateFromString = (dateStr: string) => { /** * Convert UTC to local time for react-datepicker * Note: react-datepicker has a bug where it only interacts with local time @@ -265,12 +276,12 @@ export const convertToDateFromString = (dateStr) => { return ''; } - const stripTimeZone = (stringValue) => stringValue.substring(0, 19); + const stripTimeZone = (stringValue: string) => stringValue.substring(0, 19); return moment(stripTimeZone(String(dateStr))).toDate(); }; -export const convertToStringFromDate = (date) => { +export const convertToStringFromDate = (date: moment.MomentInput) => { /** * Convert local time to UTC from react-datepicker * Note: react-datepicker has a bug where it only interacts with local time @@ -284,13 +295,13 @@ export const convertToStringFromDate = (date) => { return moment(date).format(DATE_TIME_FORMAT); }; -export const isValidDate = (date) => { +export const isValidDate = (date: moment.MomentInput) => { const formattedValue = convertToStringFromDate(date).split('T')[0]; return Boolean(formattedValue.length <= 10); }; -export const getFileSizeToClosestByte = (fileSize) => { +export const getFileSizeToClosestByte = (fileSize: any) => { let divides = 0; let size = fileSize; while (size > 1000 && divides < 4) { @@ -306,7 +317,7 @@ export const getFileSizeToClosestByte = (fileSize) => { * A generic hook to run callback on next render cycle. * @param {} callback - Callback function that needs to be run later */ -export const useRunOnNextRender = (callback) => { +export const useRunOnNextRender = (callback: () => void) => { const [scheduled, setScheduled] = useState(false); useEffect(() => { @@ -320,3 +331,19 @@ export const useRunOnNextRender = (callback) => { return () => setScheduled(true); }; + +/** + * Checks if the click event originated from an element with the stop-event-propagation class. + * If so, return without further processing. + */ +export const skipIfUnwantedTarget = ( + e: React.MouseEvent, + onClick: (e: React.MouseEvent) => void, + selector?: string, +) => { + const target = e.target as HTMLElement; + if (target && target.closest(selector || '.stop-event-propagation')) { + return; + } + onClick(e); +};