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

Add the popup for contextual help (#322)

Also:
- add modal component
- add CloseButton component
parent 16851516
Pipeline #93400 passed with stages
in 8 minutes and 22 seconds
/**
* 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.
*/
export default {
// Version numbers
app_version: '0.4.0',
......@@ -11,6 +27,7 @@ export default {
environment: process.env.ENVIRONMENT,
apiHost: process.env.API_HOST,
helpApiHost: 'http://hx-rke-wp-webadmin-14-worker-1.caas.ebi.ac.uk:30853',
// Keys for services
googleAnalyticsKey: process.env.GOOGLE_ANALYTICS_KEY,
......
......@@ -28,6 +28,7 @@ import * as urlFor from 'src/shared/helpers/urlHelper';
import AppBar from 'src/shared/components/app-bar/AppBar';
import SelectedSpecies from 'src/content/app/species-selector/components/selected-species/SelectedSpecies';
import SpeciesTabsWrapper from 'src/shared/components/species-tabs-wrapper/SpeciesTabsWrapper';
import { HelpPopupButton } from 'src/shared/components/help-popup';
import { RootState } from 'src/store';
import { CommittedItem } from 'src/content/app/species-selector/types/species-search';
......@@ -55,7 +56,13 @@ export const SpeciesSelectorAppBar = (props: Props) => {
<PlaceholderMessage />
);
return <AppBar appName="Species Selector" mainContent={mainContent} />;
return (
<AppBar
appName="Species Selector"
mainContent={mainContent}
aside={<HelpPopupButton slug="selecting-a-species" />}
/>
);
};
const SelectedSpeciesList = (props: Props) => {
......
......@@ -27,6 +27,8 @@
.appBarAside {
grid-area: right;
display: flex;
justify-content: flex-end;
}
.helpLink {
......
.icon {
font-size: 0; // to make the height of the svg independent on font-size settings
height: 15px;
width: 15px;
cursor: pointer;
}
/**
* 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 { ReactComponent as CloseIcon } from 'static/img/shared/close.svg';
import styles from './CloseButton.scss';
type Props = {
onClick: () => void;
};
const CloseButton = (props: Props) => {
return <CloseIcon className={styles.icon} onClick={props.onClick} />;
};
export default CloseButton;
@import 'src/styles/common';
$heading-outdent: 16px;
$aside-left-padding: 1.6rem;
.spinnerContainer {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.grid {
display: grid;
grid-template-columns: [article-text] 2fr [article-video] 2fr [aside] 1fr;
grid-column-gap: 1rem;
height: 100%;
padding-left: 52px;
overflow-y: auto;
}
.text {
grid-column: article-text;
padding-left: $heading-outdent;
h1 {
margin-left: -$heading-outdent;
font-weight: 300;
margin-top: 0;
}
img {
max-width: 100%;
}
}
.video {
grid-column: article-video;
}
.videoWrapper {
position: relative;
padding-top: 56.25%; // trick for enforcing the 16:9 aspect ratio in the container
iframe {
position: absolute;
top: 0;
width: 100%;
height: 100%;
}
}
.aside {
padding-left: $aside-left-padding;
h2 {
font-size: 13px;
font-weight: 300;
margin: 0 0 1rem;
}
}
.relatedArticlesContainer {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.relatedArticle,
.relatedVideo {
color: $blue;
cursor: pointer;
}
.relatedVideo {
position: relative;
}
.relatedVideoIcon {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
top: 50%;
transform: translateY(-50%);
background-color: $blue;
height: 10px;
width: 16px;
margin-left: -#{$aside-left-padding};
svg {
height: 80%;
fill: white;
}
}
/**
* 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 { CircleLoader } from 'src/shared/components/loader/Loader';
import { ReactComponent as VideoIcon } from 'static/img/shared/video.svg';
import styles from './HelpPopupBody.scss';
export type HelpVideo = {
title: string;
description: string;
youtube_id: string;
};
type ArticleSummary = {
title: string;
slug: string;
path: string;
};
export type HelpArticle = {
path: string;
slug: string;
body: string;
videos: HelpVideo[];
related_articles: ArticleSummary[];
};
type LoadingArticle =
| {
loading: true;
article: null;
}
| {
loading: false;
article: HelpArticle;
};
type Props = LoadingArticle & {
onArticleChange: (slug: string) => void;
onVideoChange: (youtubeId: string) => void;
};
const HelpPopupBody = (props: Props) => {
if (props.loading) {
// TODO: Ideally, we will want to avoid showing the spinner if the article is loaded
// nearly instantaneously. Perhaps revisit this when React gets Suspense.
return (
<div className={styles.spinnerContainer}>
<CircleLoader />
</div>
);
}
// have to destructure article from props after checking for props.loading;
// because only then typescript will be sure that article exists
const { article } = props;
/**
* TODO: we do not know yet:
* - how the interface will behave if there are multiple videos associated with a single article
* - whether videos, in their turn, will also have related videos
* - what should happen in the popup when a related video link is clicked
* Therefore, the current implementation is provisional, and expected to be changed
*/
const firstVideo = article.videos[0];
const relatedVideos = article.videos.slice(1);
const renderedVideo = firstVideo ? (
<div className={styles.videoWrapper}>
<iframe
src={`https://www.youtube.com/embed/${firstVideo.youtube_id}`}
allowFullScreen
frameBorder="0"
/>
</div>
) : null;
const relatedArticles = article.related_articles.map((relatedArticle) => (
<span
key={relatedArticle.slug}
className={styles.relatedArticle}
onClick={() => props.onArticleChange(relatedArticle.slug)}
>
{relatedArticle.title}
</span>
));
const relatedVideoElements = relatedVideos.map((video) => (
<span
key={video.youtube_id}
className={styles.relatedVideo}
onClick={() => props.onVideoChange(video.youtube_id)}
>
<span className={styles.relatedVideoIcon}>
<VideoIcon />
</span>
{video.title}
</span>
));
return (
<div className={styles.grid}>
<div className={styles.text}>
<div dangerouslySetInnerHTML={{ __html: article.body }} />
</div>
<div className={styles.video}>{renderedVideo}</div>
<div className={styles.aside}>
{Boolean(relatedArticles.length) && (
<>
<h2>Related...</h2>
<div className={styles.relatedArticlesContainer}>
{relatedArticles}
{relatedVideoElements}
</div>
</>
)}
</div>
</div>
);
};
export default HelpPopupBody;
@import 'src/styles/common';
.wrapper {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.label {
font-weight: 300;
}
.button {
display: inline-flex;
align-items: center;
padding: 0 3px;
height: 18px;
background: $blue;
font-size: 0;
border-radius: 5px;
margin-left: 0.6rem;
&_video {
padding: 0 6px;
}
}
.icon {
height: 90%;
fill: white;
}
/**
* 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, { useState, useEffect } from 'react';
import classNames from 'classnames';
import config from 'config';
import useApiService from 'src/shared/hooks/useApiService';
import { isEnvironment, Environment } from 'src/shared/helpers/environment';
import Modal from 'src/shared/components/modal/Modal';
import HelpPopupBody, { HelpArticle } from './HelpPopupBody';
import { HelpAndDocumentation } from 'src/shared/components/app-bar/AppBar';
import { ReactComponent as HelpIcon } from 'static/img/launchbar/help.svg';
import { ReactComponent as VideoIcon } from 'static/img/shared/video.svg';
import styles from './HelpPopupButton.scss';
type SlugReference = {
slug: string; // slug of the help article, e.g. "selecting-a-species"
};
type PathReference = {
path: string; // path to the article in the help&docs repo starting from the docs root folder, e.g. "ensembl-help/getting-started/about-the-site"
};
type ArticleReference = SlugReference | PathReference;
type Props = ArticleReference;
const getQuery = (params: SlugReference | PathReference) => {
if ('slug' in params) {
return `slug=${params.slug}`;
} else {
return `path=${encodeURIComponent(params.path)}`;
}
};
const HelpPopupButton = (props: Props) => {
const [slug, setSlug] = useState<string | null>(null);
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
const [shouldShowModal, setShouldShowModal] = useState(false);
const { helpApiHost } = config;
const query = slug ? getQuery({ slug }) : getQuery(props);
const url = `${helpApiHost}/api/article?${query}`;
// TODO: decide whether we want to show spinner while article content is loaded
// (it's gonna be fast, but we still might)
const { data: article } = useApiService<HelpArticle>({
endpoint: url,
skip: !shouldShowModal
});
useEffect(() => {
if (!shouldShowModal) {
setSlug(null);
}
}, [shouldShowModal]);
const handleArticleChange = (slug: string) => {
setSlug(slug);
};
// this is a provisional implementation and is likely to change
// as the design and the behaviour of the popup get more refined
const handleVideoChange = (youtubeId: string) => {
setSelectedVideoId(youtubeId);
};
const openModal = () => {
setShouldShowModal(true);
};
const closeModal = () => {
setShouldShowModal(false);
};
const videoButtonClasses = classNames(styles.button, styles.button_video);
if (isEnvironment([Environment.PRODUCTION])) {
return <HelpAndDocumentation />;
}
const sortedVideos = article?.videos.sort((a, b) => {
if (!selectedVideoId) {
return 0;
}
if (a.youtube_id === selectedVideoId) {
return -1;
} else if (b.youtube_id === selectedVideoId) {
return 1;
} else {
return 0;
}
});
const popupBodyProps = !article
? {
loading: true as const,
article: null
}
: {
loading: false as const,
article: { ...article, videos: sortedVideos } as HelpArticle
};
return (
<>
<div className={styles.wrapper} onClick={openModal}>
<span className={styles.label}>Help</span>
<div className={styles.button}>
<HelpIcon className={styles.icon} />
</div>
<div className={videoButtonClasses}>
<VideoIcon className={styles.icon} />
</div>
</div>
{shouldShowModal && (
<Modal onClose={closeModal}>
<HelpPopupBody
{...popupBodyProps}
onArticleChange={handleArticleChange}
onVideoChange={handleVideoChange}
/>
</Modal>
)}
</>
);
};
export default HelpPopupButton;
/**
* 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.
*/
export { default as HelpPopupButton } from './HelpPopupButton';
.background {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(black, 0.2);
z-index: 1000;
}
.body {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80vw;
height: 80vh;
background: white;
padding-top: 45px;
}
.close {
position: absolute;
right: 28px;
top: 26px;
}
/**
* 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 CloseButton from 'src/shared/components/close-button/CloseButton';
import styles from './Modal.scss';