Skip to content

Commit

Permalink
feat: search modal refinements (#959)
Browse files Browse the repository at this point in the history
* feat: More spacing between search bar and selectmenu
* feat: Autofocus search field when modal opens
* feat: Fix issues with scroll to search result
This includes the following:
  - The target search element is aligned to the top of the page when scrolling to it
  - Makes sure the section/subsection is expanded in order to scroll to result
* fix: Match focus border radius with button's
* fix: Only expand (sub)section with search result
  • Loading branch information
yusuf-musleh authored May 3, 2024
1 parent 65f45f7 commit 6d9a8a1
Show file tree
Hide file tree
Showing 9 changed files with 243 additions and 45 deletions.
52 changes: 50 additions & 2 deletions src/course-outline/section-card/SectionCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,36 @@ const SectionCard = ({
const intl = useIntl();
const dispatch = useDispatch();
const { activeId, overId } = useContext(DragContext);
const [isExpanded, setIsExpanded] = useState(isSectionsExpanded);
const [searchParams] = useSearchParams();
const locatorId = searchParams.get('show');
const isScrolledToElement = locatorId === section.id;

// Expand the section if a search result should be shown/scrolled to
const containsSearchResult = () => {
if (locatorId) {
const subsections = section.childInfo?.children;
if (subsections) {
for (let i = 0; i < subsections.length; i++) {
const subsection = subsections[i];

// Check if the search result is one of the subsections
const matchedSubsection = subsection.id === locatorId;
if (matchedSubsection) {
return true;
}

// Check if the search result is one of the units
const matchedUnit = !!subsection.childInfo?.children?.filter((child) => child.id === locatorId).length;
if (matchedUnit) {
return true;
}
}
}
}

return false;
};
const [isExpanded, setIsExpanded] = useState(containsSearchResult() || isSectionsExpanded);
const [isFormOpen, openForm, closeForm] = useToggle(false);
const namePrefix = 'section';

Expand Down Expand Up @@ -75,10 +101,18 @@ const SectionCard = ({

useEffect(() => {
if (currentRef.current && (section.shouldScroll || isScrolledToElement)) {
scrollToElement(currentRef.current);
// Align element closer to the top of the screen if scrolling for search result
const alignWithTop = !!isScrolledToElement;
scrollToElement(currentRef.current, alignWithTop);
}
}, [isScrolledToElement]);

useEffect(() => {
// If the locatorId is set/changed, we need to make sure that the section is expanded
// if it contains the result, in order to scroll to it
setIsExpanded((prevState) => containsSearchResult() || prevState);
}, [locatorId, setIsExpanded]);

// re-create actions object for customizations
const actions = { ...sectionActions };
// add actions to control display of move up & down menu buton.
Expand Down Expand Up @@ -253,6 +287,20 @@ SectionCard.propTypes = {
duplicable: PropTypes.bool.isRequired,
}).isRequired,
isHeaderVisible: PropTypes.bool,
childInfo: PropTypes.shape({
children: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
childInfo: PropTypes.shape({
children: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
}),
).isRequired,
}).isRequired,
}),
).isRequired,
}).isRequired,
}).isRequired,
isSelfPaced: PropTypes.bool.isRequired,
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
Expand Down
129 changes: 105 additions & 24 deletions src/course-outline/section-card/SectionCard.test.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import {
act, render, fireEvent, within,
} from '@testing-library/react';
Expand All @@ -15,6 +16,40 @@ import SectionCard from './SectionCard';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
const mockPathname = '/foo-bar';

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));

const unit = {
id: 'unit-1',
};

const subsection = {
id: '123',
displayName: 'Subsection Name',
category: 'sequential',
published: true,
visibilityState: 'live',
hasChanges: false,
actions: {
draggable: true,
childAddable: true,
deletable: true,
duplicable: true,
},
isHeaderVisible: true,
releasedToStudents: true,
childInfo: {
children: [{
id: unit.id,
}],
},
};

const section = {
id: '123',
Expand All @@ -31,37 +66,49 @@ const section = {
duplicable: true,
},
isHeaderVisible: true,
childInfo: {
children: [{
id: subsection.id,
childInfo: {
children: [{
id: unit.id,
}],
},
}],
},
};

const onEditSectionSubmit = jest.fn();

const queryClient = new QueryClient();

const renderComponent = (props) => render(
<AppProvider store={store}>
const renderComponent = (props, entry = '/') => render(
<AppProvider store={store} wrapWithRouter={false}>
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<SectionCard
section={section}
index={1}
canMoveItem={jest.fn()}
onOrderChange={jest.fn()}
onOpenPublishModal={jest.fn()}
onOpenHighlightsModal={jest.fn()}
onOpenDeleteModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
savingStatus=""
onEditSectionSubmit={onEditSectionSubmit}
onDuplicateSubmit={jest.fn()}
isSectionsExpanded
onNewSubsectionSubmit={jest.fn()}
isSelfPaced={false}
isCustomRelativeDatesActive={false}
{...props}
>
<span>children</span>
</SectionCard>
</IntlProvider>
<MemoryRouter initialEntries={[entry]}>
<IntlProvider locale="en">
<SectionCard
section={section}
index={1}
canMoveItem={jest.fn()}
onOrderChange={jest.fn()}
onOpenPublishModal={jest.fn()}
onOpenHighlightsModal={jest.fn()}
onOpenDeleteModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
savingStatus=""
onEditSectionSubmit={onEditSectionSubmit}
onDuplicateSubmit={jest.fn()}
isSectionsExpanded
onNewSubsectionSubmit={jest.fn()}
isSelfPaced={false}
isCustomRelativeDatesActive={false}
{...props}
>
<span>children</span>
</SectionCard>
</IntlProvider>
</MemoryRouter>
</QueryClientProvider>
</AppProvider>,
);
Expand Down Expand Up @@ -148,4 +195,38 @@ describe('<SectionCard />', () => {
expect(within(element).queryByTestId('section-card-header__menu-delete-button')).not.toBeInTheDocument();
expect(queryByTestId('new-subsection-button')).not.toBeInTheDocument();
});

it('check extended section when URL "show" param in subsection under section', async () => {
const collapsedSections = { ...section };
collapsedSections.isSectionsExpanded = false;
const { findByTestId } = renderComponent(collapsedSections, `?show=${subsection.id}`);

const cardSubsections = await findByTestId('section-card__subsections');
const newSubsectionButton = await findByTestId('new-subsection-button');
expect(cardSubsections).toBeInTheDocument();
expect(newSubsectionButton).toBeInTheDocument();
});

it('check extended section when URL "show" param in unit under section', async () => {
const collapsedSections = { ...section };
collapsedSections.isSectionsExpanded = false;
const { findByTestId } = renderComponent(collapsedSections, `?show=${unit.id}`);

const cardSubsections = await findByTestId('section-card__subsections');
const newSubsectionButton = await findByTestId('new-subsection-button');
expect(cardSubsections).toBeInTheDocument();
expect(newSubsectionButton).toBeInTheDocument();
});

it('check not extended section when URL "show" param not in section', async () => {
const randomId = 'random-id';
const collapsedSections = { ...section };
collapsedSections.isSectionsExpanded = false;
const { queryByTestId } = renderComponent(collapsedSections, `?show=${randomId}`);

const cardSubsections = await queryByTestId('section-card__subsections');
const newSubsectionButton = await queryByTestId('new-subsection-button');
expect(cardSubsections).toBeNull();
expect(newSubsectionButton).toBeNull();
});
});
27 changes: 25 additions & 2 deletions src/course-outline/subsection-card/SubsectionCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,15 @@ const SubsectionCard = ({
actions.allowMoveUp = !isEmpty(moveUpDetails);
actions.allowMoveDown = !isEmpty(moveDownDetails);

const [isExpanded, setIsExpanded] = useState(locatorId ? isScrolledToElement : !isHeaderVisible);
// Expand the subsection if a search result should be shown/scrolled to
const containsSearchResult = () => {
if (locatorId) {
return !!subsection.childInfo?.children?.filter((child) => child.id === locatorId).length;
}

return false;
};
const [isExpanded, setIsExpanded] = useState(containsSearchResult() || !isHeaderVisible);
const subsectionStatus = getItemStatus({
published,
visibilityState,
Expand Down Expand Up @@ -132,10 +140,18 @@ const SubsectionCard = ({
// 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 || isScrolledToElement)) {
scrollToElement(currentRef.current);
// Align element closer to the top of the screen if scrolling for search result
const alignWithTop = !!isScrolledToElement;
scrollToElement(currentRef.current, alignWithTop);
}
}, [isScrolledToElement]);

useEffect(() => {
// If the locatorId is set/changed, we need to make sure that the subsection is expanded
// if it contains the result, in order to scroll to it
setIsExpanded((prevState) => (containsSearchResult() || prevState));
}, [locatorId, setIsExpanded]);

useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
closeForm();
Expand Down Expand Up @@ -264,6 +280,13 @@ SubsectionCard.propTypes = {
duplicable: PropTypes.bool.isRequired,
}).isRequired,
isHeaderVisible: PropTypes.bool,
childInfo: PropTypes.shape({
children: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
}),
).isRequired,
}).isRequired,
}).isRequired,
children: PropTypes.node,
isSelfPaced: PropTypes.bool.isRequired,
Expand Down
42 changes: 33 additions & 9 deletions src/course-outline/subsection-card/SubsectionCard.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,8 @@ const clipboardBroadcastChannelMock = {

global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);

const section = {
id: '123',
displayName: 'Section Name',
published: true,
visibilityState: 'live',
hasChanges: false,
highlights: ['highlight 1', 'highlight 2'],
const unit = {
id: 'unit-1',
};

const subsection = {
Expand All @@ -56,6 +51,25 @@ const subsection = {
},
isHeaderVisible: true,
releasedToStudents: true,
childInfo: {
children: [{
id: unit.id,
}],
},
};

const section = {
id: '123',
displayName: 'Section Name',
published: true,
visibilityState: 'live',
hasChanges: false,
highlights: ['highlight 1', 'highlight 2'],
childInfo: {
children: [{
id: subsection.id,
}],
},
};

const onEditSubectionSubmit = jest.fn();
Expand Down Expand Up @@ -227,12 +241,22 @@ describe('<SubsectionCard />', () => {
expect(await findByText(cardHeaderMessages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
});

it('check extended section when URL has a "show" param', async () => {
const { findByTestId } = renderComponent(null, `?show=${section.id}`);
it('check extended subsection when URL "show" param in subsection', async () => {
const { findByTestId } = renderComponent(null, `?show=${unit.id}`);

const cardUnits = await findByTestId('subsection-card__units');
const newUnitButton = await findByTestId('new-unit-button');
expect(cardUnits).toBeInTheDocument();
expect(newUnitButton).toBeInTheDocument();
});

it('check not extended subsection when URL "show" param not in subsection', async () => {
const randomId = 'random-id';
const { queryByTestId } = renderComponent(null, `?show=${randomId}`);

const cardUnits = await queryByTestId('subsection-card__units');
const newUnitButton = await queryByTestId('new-unit-button');
expect(cardUnits).toBeNull();
expect(newUnitButton).toBeNull();
});
});
4 changes: 3 additions & 1 deletion src/course-outline/unit-card/UnitCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ const UnitCard = ({
// we need to check section.shouldScroll as whole section is fetched when a
// unit is duplicated under it.
if (currentRef.current && (section.shouldScroll || unit.shouldScroll || isScrolledToElement)) {
scrollToElement(currentRef.current);
// Align element closer to the top of the screen if scrolling for search result
const alignWithTop = !!isScrolledToElement;
scrollToElement(currentRef.current, alignWithTop);
}
}, [isScrolledToElement]);

Expand Down
14 changes: 11 additions & 3 deletions src/course-outline/utils.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,20 @@ const getHighlightsFormValues = (currentHighlights) => {
* Method to scroll into view port, if it's outside the viewport
*
* @param {Object} target - DOM Element
* @param {boolean} alignWithTop (optional) - Whether top of the target will be aligned to
* the top of viewpoint. (default: false)
* @returns {undefined}
*/
const scrollToElement = target => {
const scrollToElement = (target, alignWithTop = false) => {
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' });
// if alignWithTop is set, the top of the target will be aligned to the top of visible area
// of the scrollable ancestor, Otherwise, the bottom of the target will be aligned to the
// bottom of the visible area of the scrollable ancestor.
target.scrollIntoView({
behavior: 'smooth',
block: alignWithTop ? 'start' : 'end',
inline: 'nearest',
});
}

// Target is outside the view from the top
Expand Down
Loading

0 comments on commit 6d9a8a1

Please sign in to comment.