fix: handle scrolling with drag-n-drop
test: update tests fix: scroll to element only when required test: fix subsection component render refactor: use textarea for highlights
This commit is contained in:
committed by
Kristin Aoki
parent
48ab324100
commit
580b8cbdb4
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
React, useState, useCallback, useEffect, useRef,
|
||||
React, useState, useCallback, useEffect,
|
||||
} from 'react';
|
||||
import update from 'immutability-helper';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
@@ -40,10 +40,8 @@ import ConfigureModal from './configure-modal/ConfigureModal';
|
||||
import DeleteModal from './delete-modal/DeleteModal';
|
||||
import { useCourseOutline } from './hooks';
|
||||
import messages from './messages';
|
||||
import { scrollToElement } from './utils';
|
||||
|
||||
const CourseOutline = ({ courseId }) => {
|
||||
const listRef = useRef(null);
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
@@ -117,14 +115,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionsList) {
|
||||
setSections((prevSections) => {
|
||||
if (prevSections.length < sectionsList.length) {
|
||||
scrollToElement(listRef);
|
||||
}
|
||||
return sectionsList;
|
||||
});
|
||||
}
|
||||
setSections(sectionsList);
|
||||
}, [sectionsList]);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -207,7 +198,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
onNewSubsectionSubmit={handleNewSubsectionSubmit}
|
||||
moveSection={moveSection}
|
||||
finalizeSectionOrder={finalizeSectionOrder}
|
||||
ref={listRef}
|
||||
>
|
||||
{section.childInfo.children.map((subsection) => (
|
||||
<SubsectionCard
|
||||
@@ -219,7 +209,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||
ref={listRef}
|
||||
/>
|
||||
))}
|
||||
</SectionCard>
|
||||
|
||||
@@ -124,6 +124,10 @@ describe('<CourseOutline />', () => {
|
||||
it('adds new section correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
let element = await findAllByTestId('section-card');
|
||||
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
top: 0,
|
||||
bottom: 4000,
|
||||
}));
|
||||
expect(element.length).toBe(4);
|
||||
|
||||
axiosMock
|
||||
@@ -147,6 +151,10 @@ describe('<CourseOutline />', () => {
|
||||
const [section] = await findAllByTestId('section-card');
|
||||
let subsections = await within(section).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(1);
|
||||
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
top: 0,
|
||||
bottom: 4000,
|
||||
}));
|
||||
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl())
|
||||
@@ -160,6 +168,7 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
subsections = await within(section).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(2);
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
|
||||
});
|
||||
|
||||
it('render error alert after failed reindex correctly', async () => {
|
||||
@@ -490,7 +499,7 @@ describe('<CourseOutline />', () => {
|
||||
expect(getByRole('button', { name: '5 Section highlights' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check section order list when set section order query is successful', async () => {
|
||||
it('check section list is ordered successfully', async () => {
|
||||
const { getAllByTestId } = render(<RootWrapper />);
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
let { children } = courseOutlineIndexMock.courseStructure.childInfo;
|
||||
@@ -511,7 +520,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('check section order list when set section order query is unsuccessful', async () => {
|
||||
it('check section list is restored to original order when API call fails', async () => {
|
||||
const { getAllByTestId } = render(<RootWrapper />);
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
const { children } = courseOutlineIndexMock.courseStructure.childInfo;
|
||||
|
||||
@@ -129,12 +129,13 @@ export function fetchCourseReindexQuery(courseId, reindexLink) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseSectionQuery(sectionId) {
|
||||
export function fetchCourseSectionQuery(sectionId, shouldScroll = false) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const data = await getCourseItem(sectionId);
|
||||
data.shouldScroll = shouldScroll;
|
||||
dispatch(updateSectionList(data));
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
@@ -307,6 +308,8 @@ export function duplicateSectionQuery(sectionId, courseBlockId) {
|
||||
courseBlockId,
|
||||
async (locator) => {
|
||||
const duplicatedItem = await getCourseItem(locator);
|
||||
// Page should scroll to newly duplicated item.
|
||||
duplicatedItem.shouldScroll = true;
|
||||
dispatch(duplicateSection({ id: sectionId, duplicatedItem }));
|
||||
},
|
||||
));
|
||||
@@ -318,7 +321,7 @@ export function duplicateSubsectionQuery(subsectionId, sectionId) {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
subsectionId,
|
||||
sectionId,
|
||||
async () => dispatch(fetchCourseSectionQuery(sectionId)),
|
||||
async () => dispatch(fetchCourseSectionQuery(sectionId, true)),
|
||||
));
|
||||
};
|
||||
}
|
||||
@@ -344,6 +347,8 @@ function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn)
|
||||
).then(async (result) => {
|
||||
if (result) {
|
||||
const data = await getCourseItem(result.locator);
|
||||
// Page should scroll to newly created item.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addItemFn(data));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
|
||||
@@ -65,6 +65,7 @@ const HighlightsModal = ({
|
||||
value={values[key]}
|
||||
floatingLabel={intl.formatMessage(messages.highlight, { index: index + 1 })}
|
||||
maxLength={HIGHLIGHTS_FIELD_MAX_LENGTH}
|
||||
as="textarea"
|
||||
/>
|
||||
))}
|
||||
</ModalDialog.Body>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {
|
||||
forwardRef, useEffect, useState, useRef,
|
||||
useEffect, useState, useRef,
|
||||
} from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import PropTypes from 'prop-types';
|
||||
@@ -11,11 +11,11 @@ import { Add as IconAdd } from '@edx/paragon/icons';
|
||||
import { setCurrentItem, setCurrentSection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import { getItemStatus } from '../utils';
|
||||
import { getItemStatus, scrollToElement } from '../utils';
|
||||
import messages from './messages';
|
||||
import ItemTypes from './itemTypes';
|
||||
|
||||
const SectionCard = forwardRef(({
|
||||
const SectionCard = ({
|
||||
section,
|
||||
index,
|
||||
children,
|
||||
@@ -30,7 +30,8 @@ const SectionCard = forwardRef(({
|
||||
onNewSubsectionSubmit,
|
||||
moveSection,
|
||||
finalizeSectionOrder,
|
||||
}, lastItemRef) => {
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [isExpanded, setIsExpanded] = useState(isSectionsExpanded);
|
||||
@@ -40,6 +41,13 @@ const SectionCard = forwardRef(({
|
||||
setIsExpanded(isSectionsExpanded);
|
||||
}, [isSectionsExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
// if this items has been newly added, scroll to it.
|
||||
if (currentRef.current && section.shouldScroll) {
|
||||
scrollToElement(currentRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const {
|
||||
id,
|
||||
displayName,
|
||||
@@ -52,8 +60,6 @@ const SectionCard = forwardRef(({
|
||||
highlights,
|
||||
} = section;
|
||||
|
||||
const moveRef = useRef(null);
|
||||
|
||||
const [{ handlerId }, drop] = useDrop({
|
||||
accept: ItemTypes.SECTION,
|
||||
collect(monitor) {
|
||||
@@ -62,7 +68,7 @@ const SectionCard = forwardRef(({
|
||||
};
|
||||
},
|
||||
hover(item, monitor) {
|
||||
if (!moveRef.current) {
|
||||
if (!currentRef.current) {
|
||||
return;
|
||||
}
|
||||
const dragIndex = item.index;
|
||||
@@ -72,7 +78,7 @@ const SectionCard = forwardRef(({
|
||||
return;
|
||||
}
|
||||
// Determine rectangle on screen
|
||||
const hoverBoundingRect = moveRef.current?.getBoundingClientRect();
|
||||
const hoverBoundingRect = currentRef.current?.getBoundingClientRect();
|
||||
// Get vertical middle
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
// Determine mouse position
|
||||
@@ -117,7 +123,7 @@ const SectionCard = forwardRef(({
|
||||
},
|
||||
});
|
||||
const opacity = isDragging ? 0 : 1;
|
||||
drag(drop(moveRef));
|
||||
drag(drop(currentRef));
|
||||
|
||||
const sectionStatus = getItemStatus({
|
||||
published,
|
||||
@@ -166,9 +172,9 @@ const SectionCard = forwardRef(({
|
||||
data-testid="section-card"
|
||||
data-handler-id={handlerId}
|
||||
style={{ opacity }}
|
||||
ref={moveRef}
|
||||
ref={currentRef}
|
||||
>
|
||||
<div ref={lastItemRef}>
|
||||
<div>
|
||||
<CardHeader
|
||||
sectionId={id}
|
||||
title={displayName}
|
||||
@@ -222,7 +228,7 @@ const SectionCard = forwardRef(({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
SectionCard.defaultProps = {
|
||||
children: null,
|
||||
@@ -239,6 +245,7 @@ SectionCard.propTypes = {
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
staffOnlyMessage: PropTypes.bool.isRequired,
|
||||
highlights: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
}).isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
children: PropTypes.node,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
@@ -8,10 +8,10 @@ import { Add as IconAdd } from '@edx/paragon/icons';
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import { getItemStatus } from '../utils';
|
||||
import { getItemStatus, scrollToElement } from '../utils';
|
||||
import messages from './messages';
|
||||
|
||||
const SubsectionCard = forwardRef(({
|
||||
const SubsectionCard = ({
|
||||
section,
|
||||
subsection,
|
||||
children,
|
||||
@@ -20,7 +20,8 @@ const SubsectionCard = forwardRef(({
|
||||
savingStatus,
|
||||
onOpenDeleteModal,
|
||||
onDuplicateSubmit,
|
||||
}, lastItemRef) => {
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
@@ -64,6 +65,15 @@ const SubsectionCard = forwardRef(({
|
||||
closeForm();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// if this items has been newly added, scroll to it.
|
||||
// we need to check section.shouldScroll as whole section is fetched when a
|
||||
// subsection is duplicated under it.
|
||||
if (currentRef.current && (section.shouldScroll || subsection.shouldScroll)) {
|
||||
scrollToElement(currentRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
closeForm();
|
||||
@@ -71,7 +81,7 @@ const SubsectionCard = forwardRef(({
|
||||
}, [savingStatus]);
|
||||
|
||||
return (
|
||||
<div className="subsection-card" data-testid="subsection-card" ref={lastItemRef}>
|
||||
<div className="subsection-card" data-testid="subsection-card" ref={currentRef}>
|
||||
<CardHeader
|
||||
title={displayName}
|
||||
status={subsectionStatus}
|
||||
@@ -107,7 +117,7 @@ const SubsectionCard = forwardRef(({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
SubsectionCard.defaultProps = {
|
||||
children: null,
|
||||
@@ -123,6 +133,7 @@ SubsectionCard.propTypes = {
|
||||
visibleToStaffOnly: PropTypes.bool,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
staffOnlyMessage: PropTypes.bool.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
}).isRequired,
|
||||
subsection: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
@@ -133,6 +144,7 @@ SubsectionCard.propTypes = {
|
||||
visibleToStaffOnly: PropTypes.bool,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
staffOnlyMessage: PropTypes.bool.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
}).isRequired,
|
||||
children: PropTypes.node,
|
||||
onOpenPublishModal: PropTypes.func.isRequired,
|
||||
|
||||
@@ -13,6 +13,18 @@ import SubsectionCard from './SubsectionCard';
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const section = {
|
||||
id: '123',
|
||||
displayName: 'Section Name',
|
||||
published: true,
|
||||
releasedToStudents: true,
|
||||
visibleToStaffOnly: false,
|
||||
visibilityState: 'visible',
|
||||
staffOnlyMessage: false,
|
||||
hasChanges: false,
|
||||
highlights: ['highlight 1', 'highlight 2'],
|
||||
};
|
||||
|
||||
const subsection = {
|
||||
id: '123',
|
||||
displayName: 'Subsection Name',
|
||||
@@ -28,6 +40,7 @@ const renderComponent = (props) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<SubsectionCard
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
|
||||
@@ -109,12 +109,23 @@ const getHighlightsFormValues = (currentHighlights) => {
|
||||
return formValues;
|
||||
};
|
||||
|
||||
const scrollToElement = (ref) => {
|
||||
ref.current?.scrollIntoView({
|
||||
block: 'end',
|
||||
inline: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
/**
|
||||
* Method to scroll into view port, if it's outside the viewport
|
||||
*
|
||||
* @param {Object} target - DOM Element
|
||||
* @returns {undefined}
|
||||
*/
|
||||
const scrollToElement = target => {
|
||||
if (target.getBoundingClientRect().bottom > window.innerHeight) {
|
||||
// The bottom of the target will be aligned to the bottom of the visible area of the scrollable ancestor.
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
|
||||
}
|
||||
|
||||
// Target is outside the view from the top
|
||||
if (target.getBoundingClientRect().top < 0) {
|
||||
// The top of the target will be aligned to the top of the visible area of the scrollable ancestor
|
||||
target.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user