public/js/sidebar.js
import React, { Component } from 'react';
import _ from 'underscore';
import downloadFASTA from './download_fasta';
import asMailtoHref from './mailto';
import CloudShareModal from './cloud_share_modal';
import DownloadLinks from 'download_links';
/**
* checks whether code is being run by jest
*/
// eslint-disable-next-line no-undef
const isTestMode = () => process.env.JEST_WORKER_ID !== undefined || process.env.NODE_ENV === 'test';
/**
* Renders links for downloading hit information in different formats.
* Renders links for navigating to each query.
*/
export default class extends Component {
constructor(props) {
super(props);
this.downloadFastaOfAll = this.downloadFastaOfAll.bind(this);
this.downloadFastaOfSelected = this.downloadFastaOfSelected.bind(this);
this.topPanelJSX = this.topPanelJSX.bind(this);
this.summaryString = this.summaryString.bind(this);
this.indexJSX = this.indexJSX.bind(this);
this.downloadsPanelJSX = this.downloadsPanelJSX.bind(this);
this.handleQueryIndexChange = this.handleQueryIndexChange.bind(this);
this.isElementInViewPort = this.isElementInViewPort.bind(this);
this.setVisibleQueryIndex = this.setVisibleQueryIndex.bind(this);
this.debounceScrolling = this.debounceScrolling.bind(this);
this.scrollListener = this.scrollListener.bind(this);
this.copyURL = this.copyURL.bind(this);
this.shareCloudInit = this.shareCloudInit.bind(this);
this.sharingPanelJSX = this.sharingPanelJSX.bind(this);
this.cloudShareModal = React.createRef();
this.timeout = null;
this.queryElems = [];
this.state = {
queryIndex: 1
};
}
componentDidMount() {
//keep track of the current queryIndex so it doesn't get lost on page reload
const urlMatch = window.location.href.match(/#Query_(\d+)/);
if (urlMatch && urlMatch.length > 1) {
const queryNumber = +urlMatch[1];
const index = this.props.data.queries.findIndex(query => query.number === queryNumber);
this.setState({ queryIndex: index + 1 });
}
window.addEventListener('scroll', this.scrollListener);
$('a[href^="#Query_"]').on('click', this.animateAnchorElements);
}
componentWillUnmount() {
window.removeEventListener('scroll', this.scrollListener);
}
componentDidUpdate(prevProps) {
if (this.props.allQueriesLoaded && !prevProps.allQueriesLoaded) {
/**
* storing all query elements in this variable once they all become available so we don't have to fetch them all over again
*/
this.queryElems = Array.from(document.querySelectorAll('.resultn'));
}
}
/**
* to avoid unnecessary computations, we debounce the scroll listener so it only fires after user has stopped scrolling for some milliseconds
*/
scrollListener() {
this.debounceScrolling(this.setVisibleQueryIndex, 500);
}
debounceScrolling(callback, timer) {
if (this.timeout) {
clearTimeout(this.timeout);
}
this.timeout = setTimeout(callback, timer);
}
/**
* This method makes the page aware of what query is visible so that clicking previous / next button at any point
* navigates to the proper query
*/
setVisibleQueryIndex() {
const queryElems = this.queryElems.length ? this.queryElems : Array.from(document.querySelectorAll('.resultn'));
const hits = Array.from(document.querySelectorAll('.hit[id^=Query_]'));
// get the first visible element and marks it as the current query
const topmostEl = queryElems.find(this.isElementInViewPort) || hits.find(this.isElementInViewPort);
if (topmostEl) {
const queryIndex = Number(topmostEl.id.match(/Query_(\d+)/)[1]);
let hash = `#Query_${queryIndex}`;
// if we can guarantee that the browser can handle change in url hash without the page jumping,
// then we update the url hash after scroll. else, hash is only updated on click of next or prev button
if (window.history.pushState) {
window.history.pushState(null, null, hash);
}
this.setState({ queryIndex });
}
}
animateAnchorElements(e) {
// allow normal behavior in test mode to prevent warnings or errors from jquery
if (isTestMode()) return;
e.preventDefault();
$('html, body').animate({
scrollTop: $(this.hash).offset().top
}, 300);
if (window.history.pushState) {
window.history.pushState(null, null, this.hash);
} else {
window.location.hash = this.hash;
}
}
isElementInViewPort(elem) {
const { top, left, right, bottom } = elem.getBoundingClientRect();
return (
top >= 0 &&
left >= 0 &&
bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
/**
* Clear sessionStorage - useful to initiate a new search in the same tab.
* Passing sessionStorage.clear directly as onclick callback didn't work
* (on macOS Chrome).
*/
clearSession() {
sessionStorage.clear();
}
/**
*
* handle next and previous query button clicks
*/
handleQueryIndexChange(nextQuery) {
if (nextQuery < 1 || nextQuery > this.props.data.queries.length) return;
const anchorEl = document.createElement('a');
//indexing at [nextQuery - 1] because array is 0-indexed
anchorEl.setAttribute('href', '#Query_' + this.props.data.queries[nextQuery - 1].number);
anchorEl.setAttribute('hidden', true);
document.body.appendChild(anchorEl);
// add smooth scrolling animation with jquery
$(anchorEl).on('click', this.animateAnchorElements);
anchorEl.click();
document.body.removeChild(anchorEl);
this.setState({ queryIndex: nextQuery });
}
/**
* Event-handler for downloading fasta of all hits.
*/
downloadFastaOfAll() {
var sequence_ids = [];
this.props.data.queries.forEach(
(query) => query.hits.forEach(
(hit) => sequence_ids.push(hit.id)));
var database_ids = this.props.data.querydb.map((querydb) => querydb.id);
downloadFASTA(sequence_ids, database_ids);
return false;
}
/**
* Handles downloading fasta of selected hits.
*/
downloadFastaOfSelected() {
var sequence_ids = $('.hit-links :checkbox:checked').map(function () {
return this.value;
}).get();
if (sequence_ids.length === 0) {
return false;
}
var database_ids = _.map(this.props.data.querydb, _.iteratee('id'));
downloadFASTA(sequence_ids, database_ids);
return false;
}
/**
* Handles copying the URL into the user's clipboard. Modified from: https://stackoverflow.com/a/49618964/18117380
* Hides the 'Copied!' tooltip after 3 seconds
*/
copyURL() {
const element = document.createElement('input');
const url = window.location.href;
document.body.appendChild(element);
element.value = url;
element.select();
document.execCommand('copy');
document.body.removeChild(element);
const tooltip = document.getElementById('tooltip');
tooltip.classList.remove('hidden');
setTimeout(() => {
tooltip.classList.add('hidden');
}, 3000);
}
shareCloudInit() {
this.cloudShareModal.current.show();
}
topPanelJSX() {
var path = location.pathname.split('/');
// Get job id.
var job_id = path.pop();
// Deriving rootURL this way is required for subURI deployments
// - we cannot just send to '/'.
var rootURL = path.join('/');
return (
<div className="sidebar-top-panel">
<div className="pl-px table mb-0 w-full">
<h4 className="text-sm font-bold mb-0 mt-0.5">
{this.summaryString()}
</h4>
</div>
{this.props.data.queries.length > 12 && this.queryIndexButtons()}
<div>
<a href={`${rootURL}/?job_id=${job_id}`} className="text-sm text-seqblue hover:text-seqorange cursor-pointer">
<i className="fa fa-pencil"></i> Edit search
</a>
<span className="text-seqorange px-1">|</span>
<a href={`${rootURL}/`}
onClick={this.clearSession} className="text-sm text-seqblue hover:text-seqorange cursor-pointer">
<i className="fa-regular fa-file"></i> New search
</a>
</div>
{this.props.shouldShowIndex && this.indexJSX()}
</div>
);
}
summaryString() {
var program = this.props.data.program;
var numqueries = this.props.data.queries.length;
var numquerydb = this.props.data.querydb.length;
return (
program.toUpperCase() + ': ' +
numqueries + ' ' + (numqueries > 1 ? 'queries' : 'query') + ', ' +
numquerydb + ' ' + (numquerydb > 1 ? 'databases' : 'database')
);
}
queryIndexButtons() {
const buttonStyle = {
outline: 'none', border: 'none', background: 'none'
};
const buttonClasses = 'text-sm text-seqblue hover:text-seqorange hover:bg-gray-200';
const handlePreviousBtnClick = () => this.handleQueryIndexChange(this.state.queryIndex - 1);
const handleNextBtnClick = () => this.handleQueryIndexChange(this.state.queryIndex + 1);
// eslint-disable-next-line no-unused-vars
const NavButton = ({ text, onClick }) => (
<button className={buttonClasses} onClick={onClick} style={buttonStyle}>{text}</button>
);
return <div style={{ display: 'flex', width: '100%', margin: '7px 0' }}>
{this.state.queryIndex > 1 && <NavButton text="Previous Query" onClick={handlePreviousBtnClick} />}
{this.state.queryIndex > 1 && this.state.queryIndex < this.props.data.queries.length && <span className="text-seqorange px-1">|</span>}
{this.state.queryIndex < this.props.data.queries.length && <NavButton onClick={handleNextBtnClick} text="Next Query" />}
</div>;
}
indexJSX() {
return <ul className="w-full"> {
_.map(this.props.data.queries, (query) => {
return <li key={'Side_bar_' + query.id}>
<a className="side-nav text-sm text-seqblue hover:text-seqorange focus:text-seqorange active:text-seqorange cursor-pointer hover-bold line-clamp-1 mb-1.5"
title={'Query= ' + query.id + ' ' + query.title}
href={'#Query_' + query.number}>
{'Query= ' + query.id}
</a>
</li>;
})
}
</ul>;
}
downloadsPanelJSX() {
return (
<div className="downloads">
<div className="pl-px table mb-0 w-full">
<h4 className="text-sm font-bold mb-0 mt-2.5">
Download FASTA, XML, TSV
</h4>
</div>
<ul>
{
!(this.props.data.imported_xml || this.props.data.non_parse_seqids) && <li className="hover:bg-gray-200 mb-1">
<a
href="#"
className={`text-sm text-seqblue download-fasta-of-all hover:text-seqorange cursor-pointer py-0.5 px-0.5 ${!this.props.atLeastOneHit && 'disabled'}`}
onClick={this.props.atLeastOneHit ? this.downloadFastaOfAll : (e) => e.preventDefault()}>
FASTA of all hits
</a>
</li>
}
{
!(this.props.data.imported_xml || this.props.data.non_parse_seqids) && <li className="mb-1">
<a
href="#"
className="flex text-sm download-fasta-of-selected text-seqblue disabled py-0.5 px-0.5"
onClick={this.downloadFastaOfSelected}>
FASTA of <span className="font-bold px-0.5"></span> selected hit(s)
</a>
</li>
}
<li className="hover:bg-gray-200 mb-1">
<a href="#" className={`text-sm text-seqblue download-alignment-of-all hover:text-seqorange cursor-pointer py-0.5 px-0.5 ${!this.props.atLeastOneHit && 'disabled'}`}>
Alignment of all hits
</a>
</li>
<li className="mb-1">
<a href="#" className="flex text-sm download-alignment-of-selected text-seqblue disabled py-0.5 px-0.5">
Alignment of <span className="font-bold px-0.5"></span> selected hit(s)
</a>
</li>
{
!this.props.data.imported_xml && <li className="hover:bg-gray-200 mb-1">
<a href={'download/' + this.props.data.search_id + '.std_tsv'}>
<div className="relative flex flex-col items-center group">
<div className="flex items-center w-full">
<span className="w-full text-sm text-seqblue hover:text-seqorange download cursor-pointer py-0.5 px-0.5">Standard tabular report</span>
<div className="absolute hidden left-full ml-2 items-center group-hover:flex tooltip-wrap">
<div className="w-0 h-0 border-y-8 border-r-8 border-t-transparent border-b-transparent border-r-black -mr-px"></div>
<span className="relative z-10 p-2 side-tooltip-text leading-4 text-center text-white whitespace-no-wrap bg-black shadow-lg rounded-md">
15 columns: query and subject ID; scientific
name, alignment length, mismatches, gaps, identity,
start and end coordinates, e value, bitscore, query
coverage per subject and per HSP.
</span>
</div>
</div>
</div>
</a>
</li>
}
{
!this.props.data.imported_xml && <li className="hover:bg-gray-200 mb-1">
<a href={'download/' + this.props.data.search_id + '.full_tsv'}>
<div className="relative flex flex-col items-center group">
<div className="flex items-center w-full">
<span className="w-full text-sm text-seqblue hover:text-seqorange download cursor-pointer py-0.5 px-0.5">Full tabular report</span>
<div className="absolute hidden left-full ml-2 items-center group-hover:flex tooltip-wrap">
<div className="w-0 h-0 border-y-8 border-r-8 border-t-transparent border-b-transparent border-r-black -mr-px"></div>
<span className="relative z-10 p-2 side-tooltip-text leading-4 text-center text-white whitespace-no-wrap bg-black shadow-lg rounded-md">
44 columns: query and subject ID, GI,
accessions, and length; alignment details;
taxonomy details of subject sequence(s) and
query coverage per subject and per HSP.
</span>
</div>
</div>
</div>
</a>
</li>
}
{
!this.props.data.imported_xml && <li className="hover:bg-gray-200 mb-1">
<a href={'download/' + this.props.data.search_id + '.xml'}>
<div className="relative flex flex-col items-center group">
<div className="flex items-center w-full">
<span className="w-full text-sm text-seqblue hover:text-seqorange download cursor-pointer py-0.5 px-0.5">Full XML report</span>
<div className="absolute hidden left-full ml-2 items-center group-hover:flex tooltip-wrap">
<div className="w-0 h-0 border-y-8 border-r-8 border-t-transparent border-b-transparent border-r-black -mr-px"></div>
<span className="relative z-10 p-2 side-tooltip-text leading-4 text-center text-white whitespace-no-wrap bg-black shadow-lg rounded-md">
Results in XML format.
</span>
</div>
</div>
</div>
</a>
</li>
}
{
!this.props.data.imported_xml && <li className="hover:bg-gray-200 mb-1">
<a href={'download/' + this.props.data.search_id + '.pairwise'}>
<div className="relative flex flex-col items-center group">
<div className="flex items-center w-full">
<span className="w-full text-sm text-seqblue hover:text-seqorange download cursor-pointer py-0.5 px-0.5">Full Text report</span>
<div className="absolute hidden left-full ml-2 items-center group-hover:flex tooltip-wrap">
<div className="w-0 h-0 border-y-8 border-r-8 border-t-transparent border-b-transparent border-r-black -mr-px"></div>
<span className="relative z-10 p-2 side-tooltip-text leading-4 text-center text-white whitespace-no-wrap bg-black shadow-lg rounded-md">
Results in text format.
</span>
</div>
</div>
</div>
</a>
</li>
}
<DownloadLinks imported_xml={this.props.data.imported_xml} search_id={this.props.data.search_id} />
</ul>
</div>
);
}
sharingPanelJSX() {
return (
<div className="sharing-panel">
<div className="pl-px table mb-0 w-full">
<h4 className="text-sm font-bold mb-0 mt-2.5">
Share results
</h4>
</div>
<ul>
{!this.props.cloudSharingEnabled &&
<li className="hover:text-seqorange hover:bg-gray-200">
<a id="copyURL" className="flex text-sm text-seqblue hover:text-seqorange copy-URL cursor-pointer py-0.5 px-0.5 w-full" onClick={this.copyURL}>
<div className="relative flex gap-2 items-center group w-full">
<i className="fa fa-copy"></i>
<div className="flex items-center">
<span className="w-full">Copy URL to clipboard</span>
<div id="tooltip" className="absolute hidden left-full ml-2 items-center">
<div className="flex items-center">
<div className="w-0 h-0 border-y-8 border-r-8 border-t-transparent border-b-transparent border-r-black -mr-px"></div>
<span className="relative z-10 p-2 side-tooltip-text leading-4 text-center text-white whitespace-no-wrap bg-black shadow-lg rounded-md">
Copied!
</span>
</div>
</div>
</div>
</div>
</a>
</li>
}
{!this.props.cloudSharingEnabled &&
<li className="hover:text-seqorange hover:bg-gray-200">
<a id="sendEmail" className="flex text-sm text-seqblue hover:text-seqorange email-URL cursor-pointer py-0.5 px-0.5 w-full"
href={asMailtoHref(this.props.data.querydb, this.props.data.program, this.props.data.queries.length, window.location.href)}
target="_blank" rel="noopener noreferrer">
<div className="relative flex gap-2 items-center group w-full">
<i className="fa fa-envelope"></i>
<div className="flex items-center w-full">
<span className="w-full">Send by email</span>
<div className="absolute hidden left-full ml-2 items-center group-hover:flex tooltip-wrap">
<div className="w-0 h-0 border-y-8 border-r-8 border-t-transparent border-b-transparent border-r-black -mr-px"></div>
<span className="relative z-10 p-2 side-tooltip-text leading-4 text-center text-white whitespace-no-wrap bg-black shadow-lg rounded-md">
Send by email
</span>
</div>
</div>
</div>
</a>
</li>
}
{this.props.cloudSharingEnabled &&
<li className="hover:text-seqorange hover:bg-gray-200">
<button className="flex text-sm text-seqblue hover:text-seqorange cloud-Post cursor-pointer py-0.5 px-0.5 w-full" onClick={this.shareCloudInit}>
<div className="relative flex gap-2 items-center group w-full">
<i className="fa fa-cloud"></i>
<div className="flex items-center">
<span className="w-full">Share to cloud</span>
<div className="absolute hidden left-full ml-2 items-center group-hover:flex tooltip-wrap">
<div className="w-0 h-0 border-y-8 border-r-8 border-t-transparent border-b-transparent border-r-black -mr-px"></div>
<span className="relative z-10 p-2 side-tooltip-text leading-4 text-center text-white whitespace-no-wrap bg-black shadow-lg rounded-md">
Results in pairwise format
Upload results to SequenceServer Cloud where it will become accessable
to everyone who has a link.
</span>
</div>
</div>
</div>
</button>
</li>
}
</ul>
{
<CloudShareModal
ref={this.cloudShareModal}
querydb={this.props.data.querydb}
program={this.props.data.program}
queryLength={this.props.data.queries.length}
/>
}
</div>
);
}
render() {
return (
<div className="sidebar sticky top-0 print:hidden">
{this.topPanelJSX()}
{this.downloadsPanelJSX()}
{this.sharingPanelJSX()}
<div className="referral-panel">
<div className="pl-px table mb-0 w-full text-sm">
<h4 className="font-bold mb-0 mt-2.5">Recommend SequenceServer</h4>
<p><a href="https://sequenceserver.com/referral-program" target="_blank" className="text-seqblue hover:text-seqorange">Earn up to $400 per signup</a></p>
</div>
</div>
</div>
);
}
}