Unverified Commit a8ac5046 authored by Andrey Azov's avatar Andrey Azov Committed by GitHub
Browse files

Add the Help app (#492)

Plus some refactoring to reuse the same components across Help app, contextual help, and About Ensembl.
parent 3da4c438
Pipeline #152094 passed with stages
in 4 minutes and 3 seconds
......@@ -34,6 +34,7 @@ const CustomDownload = lazy(() => import('./custom-download/CustomDownload'));
const Browser = lazy(() => import('./browser/Browser'));
const EntityViewer = lazy(() => import('./entity-viewer/EntityViewer'));
const About = lazy(() => import('./about/About'));
const Help = lazy(() => import('./help/Help'));
type AppProps = {
changeCurrentApp: (name: string) => void;
......@@ -69,6 +70,7 @@ const App = (props: AppProps) => {
/>
<Route path={`/genome-browser/:genomeId?`} component={Browser} />
<Route path={`/about`} component={About} />
<Route path={`/help`} component={Help} />
<Route>
<Redirect to={{ ...location, state: { is404: true } }} />
</Route>
......
......@@ -19,25 +19,21 @@ $article-right-padding: 1.5rem;
box-shadow: 0 2px 3px $grey;
}
.main {
.grid {
height: 100%;
display: grid;
grid-template-columns: [article] calc(440px + #{$article-right-padding}) [aside] minmax(200px, 300px);
grid-column-gap: 10rem;
padding-left: 80px;
padding-top: 44px;
}
article {
grid-column: article;
}
aside {
display: flex;
flex-direction: column;
grid-column: aside;
}
.aside {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
align-self: start;
}
.asideTitle {
margin-bottom: $aside-title-margin-bottom;
}
.asideTitle {
font-weight: $light;
margin-bottom: 1rem;
}
......@@ -19,14 +19,17 @@ import { useLocation } from 'react-router';
import useApiService from 'src/shared/hooks/useApiService';
import { TextArticle } from 'src/shared/components/help-article';
import {
TextArticle,
HelpArticleGrid
} from 'src/shared/components/help-article';
import {
TopMenu,
SideMenu
} from 'src/content/app/about/components/about-menu/AboutMenu';
import { Menu as MenuType } from 'src/shared/types/help-and-docs/menu';
import { TextArticle as TextArticleType } from 'src/shared/types/help-and-docs/article';
import { TextArticleData } from 'src/shared/types/help-and-docs/article';
import styles from './About.scss';
......@@ -35,7 +38,7 @@ const About = () => {
const { data: menu } = useApiService<MenuType>({
endpoint: `/api/docs/menus?name=about`
});
const { data: article } = useApiService<TextArticleType>({
const { data: article } = useApiService<TextArticleData>({
endpoint: `/api/docs/article?url=${encodeURIComponent(location.pathname)}`
});
......@@ -47,9 +50,9 @@ const About = () => {
{menu && <TopMenu menu={menu} currentUrl={location.pathname} />}
</TopMenuBar>
</div>
<Main>
<HelpArticleGrid className={styles.grid}>
{article && <TextArticle article={article} />}
<aside>
<aside className={styles.aside}>
{menu && (
<>
<div className={styles.asideTitle}>More about...</div>
......@@ -57,7 +60,7 @@ const About = () => {
</>
)}
</aside>
</Main>
</HelpArticleGrid>
</>
);
};
......@@ -70,8 +73,4 @@ const TopMenuBar = (props: { children: ReactNode }) => {
return <div className={styles.topMenuBar}>{props.children}</div>;
};
const Main = (props: { children: ReactNode }) => {
return <main className={styles.main}>{props.children}</main>;
};
export default About;
......@@ -6,7 +6,7 @@
.sideMenuLink {
display: inline-block;
padding: 0.3rem;
padding: 0.3rem 0;
}
.activeLink {
......
.help {
display: grid;
grid-template-rows: [menu] auto [article-grid] minmax(0, 1fr);
height: 100%;
}
.appBar {
display: flex;
align-items: center;
height: 80px;
padding: 0 44px;
}
.main {
height: 100%;
padding-left: 80px;
padding-top: 44px;
}
.articleGrid {
grid-row: article-grid;
}
/**
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useLocation } from 'react-router';
import useApiService from 'src/shared/hooks/useApiService';
import HelpMenu from './help-menu/HelpMenu';
import {
IndexArticle,
TextArticle,
RelatedArticles,
HelpArticleGrid,
VideoArticle
} from 'src/shared/components/help-article';
import { Menu as MenuType } from 'src/shared/types/help-and-docs/menu';
import {
TextArticleData,
VideoArticleData,
IndexArticleData
} from 'src/shared/types/help-and-docs/article';
import styles from './Help.scss';
type ArticleData = TextArticleData | VideoArticleData | IndexArticleData;
const Help = () => {
const location = useLocation();
const { data: menu } = useApiService<MenuType>({
endpoint: `/api/docs/menus?name=help`
});
const { data: article } = useApiService<any>({
endpoint: `/api/docs/article?url=${encodeURIComponent(location.pathname)}`
});
return (
<>
<AppBar />
<div className={styles.help}>
{menu && <HelpMenu menu={menu} currentUrl={location.pathname} />}
{article && <MainContent article={article} />}
</div>
</>
);
};
const AppBar = () => {
return <div className={styles.appBar}>Help</div>;
};
const MainContent = (props: { article: ArticleData }) => {
const { article } = props;
let content;
if (article.type === 'index') {
content = <IndexArticle article={article} />;
} else if (['article', 'video'].includes(article.type)) {
const renderedArticle =
article.type === 'article' ? (
<TextArticle article={article} />
) : (
<VideoArticle video={article} />
);
content = (
<HelpArticleGrid className={styles.articleGrid}>
{renderedArticle}
{!!article.related_articles.length && (
<RelatedArticles articles={article.related_articles} />
)}
</HelpArticleGrid>
);
}
return <main className={styles.main}>{content}</main>;
};
export default Help;
@import 'src/styles/common';
$submenuSidePadding: 30px;
.helpMenu {
grid-row: menu;
position: relative;
}
.menuBar {
position: relative;
display: flex;
align-items: center;
height: 40px;
padding: 0 44px;
background: $light-grey;
box-shadow: 0 2px 3px $grey;
z-index: 5;
}
.expandedMenuPanel {
display: flex;
position: absolute;
background-color: $off-white;
top: 100%;
height: 360px;
max-height: 50vh;
width: 100%;
z-index: 4;
padding: 0 calc(44px - #{$submenuSidePadding}); // FIXME: 44px is a magic number
}
.backdrop {
position: fixed;
top: 0;
width: 100vw;
height: 100vh;
z-index: 1;
background-color: rgba($color: #000000, $alpha: 0.3);
}
.topMenuItem, .submenuItem {
color: $blue;
cursor: pointer;
}
.topMenuItem {
& + & {
margin-left: 46px;
}
}
.submenu {
border-right: 2px solid $light-grey;
padding: 18px 10px;
min-width: 326px;
flex-grow: 0;
}
.submenuItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4em $submenuSidePadding;
}
.submenuItem:hover {
background: $ice-blue;
}
.chevron {
height: 14px;
fill: $blue;
}
/**
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ReactNode } from 'react';
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import userEvent from '@testing-library/user-event';
import HelpMenu, { Props as HelpMenuProps } from './HelpMenu';
import menuData from './helpMenuFixture';
jest.mock('react-router-dom', () => ({
Link: (props: { children: ReactNode }) => (
<a className="topMenuItem" data-test-id="react-router-link">
{props.children}
</a>
)
}));
const reduxStore = configureMockStore()({});
const defaultProps: HelpMenuProps = {
menu: menuData,
currentUrl: '/help'
};
const renderMenu = (props: Partial<HelpMenuProps> = {}) =>
render(
<Provider store={reduxStore}>
<HelpMenu {...defaultProps} {...props} />
</Provider>
);
describe('<HelpMenu>', () => {
describe('collapsed menu', () => {
it('renders top-level menu items in the menu bar', () => {
const { container } = renderMenu();
const menuBar = container.querySelector('.menuBar') as HTMLElement;
const topMenuItems = menuData.items.map((item) => item.name);
const renderedMenuItems = [...menuBar.querySelectorAll('.topMenuItem')];
expect(
topMenuItems.every((item) =>
renderedMenuItems.find((element) => element.textContent === item)
)
).toBe(true);
});
it('opens the megamenu when clicked', () => {
const { container, getByText } = renderMenu();
expect(container.querySelector('.expandedMenuPanel')).toBeFalsy(); // start with a closed megamenu
expect(() => getByText('Viewing Ensembl data')).toThrow(); // getByText will throw if it can't find an element
const itemWithSubmenu = getByText('Using Ensembl');
userEvent.click(itemWithSubmenu);
const expandedMenuPanel = container.querySelector('.expandedMenuPanel');
const submenuItem = getByText('Viewing Ensembl data');
expect(expandedMenuPanel).toBeTruthy();
expect(expandedMenuPanel?.contains(submenuItem)).toBe(true);
});
});
describe('expanded menu', () => {
it('contains expandable submenus', () => {
const { container, getByText } = renderMenu();
const itemWithSubmenu = getByText('Using Ensembl');
userEvent.click(itemWithSubmenu);
const expandedMenuPanel = container.querySelector(
'.expandedMenuPanel'
) as HTMLElement;
expect(expandedMenuPanel.querySelectorAll('.submenu').length).toBe(1);
const submenuItem = getByText('Viewing Ensembl data'); // this item corresponds to a collection of other menu items
userEvent.hover(submenuItem);
// hovering over a submenu collection item should expand another submenu
expect(expandedMenuPanel.querySelectorAll('.submenu').length).toBe(2);
});
});
});
/**
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useState, useRef } from 'react';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import { push } from 'connected-react-router';
import { Link } from 'react-router-dom';
import { ReactComponent as Chevron } from 'static/img/shared/chevron-right.svg';
import {
Menu as MenuType,
MenuItem
} from 'src/shared/types/help-and-docs/menu';
import styles from './HelpMenu.scss';
export type Props = {
menu: MenuType;
currentUrl: string;
};
const HelpMenu = (props: Props) => {
const [submenuItems, setSubmenuItems] = useState<MenuItem[] | null>(null);
const clickedMenuRef = useRef<number | null>(null);
const toggleMegaMenu = (items: MenuItem[], menuIndex: number) => {
let nextValue = null;
if (
clickedMenuRef.current === null ||
clickedMenuRef.current !== menuIndex
) {
// clicking on a menu item for the first time
clickedMenuRef.current = menuIndex;
nextValue = items;
} else {
// this means a repeated click on the same menu iteem
clickedMenuRef.current = null;
}
setSubmenuItems(nextValue);
};
const closeMegaMenu = () => {
setSubmenuItems(null);
clickedMenuRef.current = null;
};
const topLevelItems = props.menu.items.map((item, index) => {
const className = classNames(styles.topMenuItem);
const commonProps = {
key: index,
className
};
return item.type === 'collection' ? (
<span {...commonProps} onClick={() => toggleMegaMenu(item.items, index)}>
{item.name}
</span>
) : (
<Link {...commonProps} to={item.url} onClick={closeMegaMenu}>
{item.name}
</Link>
);
});
return (
<div className={styles.helpMenu}>
<div className={styles.menuBar}>{topLevelItems}</div>
{submenuItems && (
<>
<div className={styles.expandedMenuPanel}>
<Submenu items={submenuItems} onLinkClick={closeMegaMenu} />
</div>
<div
className={styles.backdrop}
onMouseEnter={closeMegaMenu}
onClick={closeMegaMenu}
/>
</>
)}
</div>
);
};
type SubmenuProps = {
items: MenuItem[];
onLinkClick: () => void;
};
const Submenu = (props: SubmenuProps) => {
const [childItems, setChildItems] = useState<MenuItem[] | null>(null);
const dispatch = useDispatch();
useEffect(() => {
setChildItems(null);
}, [props.items]);
const onLinkClick = (url: string) => {
// hopefully, the url is an internal one;
// might need extra logic if we can have external urls in the menu
props.onLinkClick();
dispatch(push(url));
};
const renderedMenuItems = props.items.map((item, index) => {
const className = classNames(styles.submenuItem);
const props: Record<string, unknown> = {};
if (item.type === 'collection') {
props.onMouseOver = () => setChildItems(item.items);
} else {
props.onMouseOver = () => setChildItems(null);
props.onClick = () => onLinkClick(item.url);
}
return (
<li key={index} {...props} className={className}>
{item.type === 'collection' ? (
<>
{item.name}
<Chevron className={styles.chevron} />
</>
) : (
item.name
)}
</li>
);
});
const renderedSubmenu = (
<ul className={styles.submenu}>{renderedMenuItems}</ul>
);
return childItems ? (
<>
{renderedSubmenu}
<Submenu items={childItems} onLinkClick={props.onLinkClick} />
</>
) : (
renderedSubmenu
);
};
export default HelpMenu;
/**
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/