Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(client): support node switching #15

Merged
merged 39 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7f8c69a
chore(client): support node switching in client w/ state resets
davidlougheed Jan 17, 2024
e7844f3
chore(client): prevent (some) race conditions when switching dataset
davidlougheed Jan 17, 2024
a564d17
chore(prod): re-enable hg38 [no ci]
davidlougheed Jan 18, 2024
636342d
chore(prod): set images to pr-15 [no ci]
davidlougheed Jan 18, 2024
c99f3cd
cleanup
davidlougheed Jan 18, 2024
45e1057
fix(client): add values to dataset options
davidlougheed Jan 18, 2024
6e835ca
fix(client): reset more state on dataset switch
davidlougheed Jan 18, 2024
9e2beeb
fix(client): move node-first-set dispatches to useEffect in App
davidlougheed Jan 18, 2024
467350b
chore(prod): re-disable hg38 [no ci]
davidlougheed Jan 18, 2024
d0e6eb3
chore(prod): link to main about page from node about pages [no ci]
davidlougheed Jan 18, 2024
c880fc2
feat(client): include node in page urls
davidlougheed Jan 18, 2024
dcf4b7d
chore(prod): link to about page from dataset abouts [no ci]
davidlougheed Jan 18, 2024
07810ce
fix(client): try to fix protected page container
davidlougheed Jan 18, 2024
fe879ee
fix(client): multi-node get/set logged in
davidlougheed Jan 18, 2024
f4f9947
fix(client): pass forward outlet context
davidlougheed Jan 18, 2024
e265c93
fix(client): handle undefined dataset on load
davidlougheed Jan 18, 2024
07569e7
fix(client): active navigation items + navigate explore
davidlougheed Jan 18, 2024
848cb0b
chore(client): rm debug log
davidlougheed Jan 18, 2024
0c98f22
chore(client): work on logging
davidlougheed Jan 18, 2024
873a15d
fix(client): don't set node again if already set
davidlougheed Jan 18, 2024
a8dedff
fix(client): move some effects to root app component
davidlougheed Jan 18, 2024
bf00516
chore(client): debug logging
davidlougheed Jan 18, 2024
8489f4e
chore(client): explore not found component
davidlougheed Jan 18, 2024
f71bc70
fix(client): use url-encoded node for peak results navigation
davidlougheed Jan 18, 2024
e5a893a
fix(prod): static track hub spaces [no ci]
davidlougheed Jan 18, 2024
696cd9a
chore(prod): re-add hg38 node [no ci]
davidlougheed Jan 18, 2024
a91f0a7
chore: log error if we don't have any vcf lines to normalize
davidlougheed Jan 18, 2024
cc5248f
fix(client): post-auth redirect
davidlougheed Jan 18, 2024
bbc8f9e
chore: log vcf queries
davidlougheed Jan 18, 2024
807b70d
chore(prod): try to fix vcf chr transform for node2
davidlougheed Jan 18, 2024
6f196bf
fix(client): dataset ethnicities access
davidlougheed Jan 18, 2024
e015cc1
chore(client): navigate to dataset about page on node change
davidlougheed Jan 18, 2024
1a0d9cb
chore(client): reset some UI state when node changes
davidlougheed Jan 18, 2024
b813e6a
fix(client): don't override node if we load into a setter url
davidlougheed Jan 18, 2024
dd2ecb5
chore(client): log when setting to first node in the list
davidlougheed Jan 18, 2024
e7cc4f0
chore(client): triggerLogIn logging
davidlougheed Jan 18, 2024
73c9db4
chore: log login api redirects
davidlougheed Jan 18, 2024
e4f4a72
chore: don't double-respond for login redirection
davidlougheed Jan 18, 2024
e9ac9d5
fix(client): try encoding redirect uri
davidlougheed Jan 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 87 additions & 42 deletions client/src/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ import ProtectedPageContainer from "./pages/ProtectedPageContainer";
import DatasetAboutPage from "./pages/DatasetAboutPage";
import OverviewPage from "./pages/OverviewPage";
import ExplorePage from "./pages/ExplorePage";
import DatasetsPage from "./pages/DatasetsPage";
// import DatasetsPage from "./pages/DatasetsPage";
import FAQPage from "./pages/FAQPage";

import {setDevMode, saveUser} from "../actions";
import {setDevMode, saveUser, fetchDatasets, setNode, fetchUser, fetchMessages, fetchAssays} from "../actions";
import {SITE_SUBTITLE, SITE_TITLE} from "../constants/app";
import {EPIVAR_NODES} from "../config";
import {useNode, useUrlEncodedNode} from "../hooks";
import DatasetPage from "./pages/DatasetPage";
import NotFound from "./NotFound";


const RoutedApp = () => {
Expand All @@ -32,6 +36,8 @@ const RoutedApp = () => {
const [contactModal, setContactModal] = useState(false);
const [termsModal, setTermsModal] = useState(false);

const urlEncodedNode = useUrlEncodedNode();

const chrom = useSelector(state => state.ui.chrom);
const position = useSelector(state => state.ui.position);

Expand All @@ -42,28 +48,27 @@ const RoutedApp = () => {
const navigateAbout = useCallback(() => navigate("/about"), [navigate]);
const navigateDatasets = useCallback(() => navigate("/datasets"), [navigate]);
// TODO: remember chrom and assay:
const navigateDatasetAbout = useCallback(() => navigate("/dataset/about"), [navigate]);
const navigateOverview = useCallback(() => navigate("/dataset/overview"), [navigate]);
const navigateDatasetAbout = useCallback(() => navigate(`/datasets/${urlEncodedNode}/about`),
[navigate, urlEncodedNode]);
const navigateOverview = useCallback(() => navigate(`/datasets/${urlEncodedNode}/overview`),
[navigate, urlEncodedNode]);
const navigateExplore = useCallback(() => {
if (location.pathname.startsWith("/dataset/explore")) return;
if (location.pathname.startsWith(`/datasets/${urlEncodedNode}/explore`)) {
console.debug("navigate explore - already on explore URL:", location.pathname);
return;
}
if (chrom && position) {
navigate(`/dataset/explore/locus/${chrom}/${position}`);
const url = `/datasets/${urlEncodedNode}/explore/locus/${chrom}/${position}`;
console.debug("navigate explore - have URL-encoded node, chrom, and position", url);
navigate(url);
} else {
navigate("/dataset/explore");
const url = `/datasets/${urlEncodedNode}/explore`;
console.debug("navigate explore - have URL-encoded node only", url);
navigate(url);
}
}, [location.pathname, chrom, position, navigate]);
}, [location.pathname, urlEncodedNode, chrom, position, navigate]);
const navigateFAQ = () => navigate("/faq");

useEffect(() => {
document.title = `${SITE_TITLE} | ${SITE_SUBTITLE}`;

document.addEventListener("keydown", (e) => {
if (e.key === "~") {
dispatch(setDevMode(true));
}
});
}, []);

useEffect(() => {
if (userData.isLoaded && userData.data && !userData.data.consentedToTerms) {
// If the user has signed in and has not yet consented to the current terms version,
Expand Down Expand Up @@ -110,31 +115,71 @@ const RoutedApp = () => {
};


const App = () => (
<div className='App'>
<Routes>
<Route path="/" element={<RoutedApp />}>
<Route index={true} element={<Navigate to="/about" replace={true} />} />
<Route path="about" element={<AboutPage />} />
<Route path="datasets" element={<DatasetsPage />} />
<Route path="dataset/about" element={<DatasetAboutPage />} />
<Route path="dataset/overview" element={<ProtectedPageContainer>
<OverviewPage />
</ProtectedPageContainer>} />
<Route path="dataset/explore" element={<ProtectedPageContainer>
<ExplorePage />
</ProtectedPageContainer>}>
<Route index={true} element={<PeakResults />} />
<Route path="locus/:chrom/:position/:assay" element={<PeakResults />} />
<Route path="locus/:chrom/:position" element={<PeakResults />} />
const App = () => {
const dispatch = useDispatch();
const node = useNode();

const datasetsByNode = useSelector((state) => state.datasets.datasetsByNode);

useEffect(() => {
document.title = `${SITE_TITLE} | ${SITE_SUBTITLE}`;

document.addEventListener("keydown", (e) => {
if (e.key === "~") {
dispatch(setDevMode(true));
}
});

// On first initialization, load datasets:
dispatch(fetchDatasets());
}, [dispatch]);

useEffect(() => {
const firstNode = EPIVAR_NODES[0];
if (!window.location.pathname.match(/^\/datasets\/.+/) && !node && firstNode && datasetsByNode[firstNode]) {
// Select first node if we haven't already done so, and we're not on a URL which will set a node for us via the
// DatasetPage component effect.
console.info(
`setting node to the first one in the list (pathname=${window.location.pathname}; firstNode=${firstNode})`);
dispatch(setNode(firstNode));
}
}, [node, datasetsByNode]);

useEffect(() => {
if (node) {
// When the node is set / changed, load relevant data:
console.info("node changed to: ", node, "re-fetching user/messages/assays");
dispatch(fetchUser());
dispatch(fetchMessages()); // Server-side messages, e.g. auth errors
dispatch(fetchAssays());
}
}, [dispatch, node]);

return (
<div className='App'>
<Routes>
<Route path="/" element={<RoutedApp />}>
<Route index={true} element={<Navigate to="/about" replace={true} />} />
<Route path="about" element={<AboutPage />} />
{/*<Route path="datasets" element={<DatasetsPage />} />*/}
<Route path="datasets/:node" element={<DatasetPage />}>
<Route path="about" element={<DatasetAboutPage />} />
<Route path="overview" element={<ProtectedPageContainer><OverviewPage /></ProtectedPageContainer>} />
<Route path="explore" element={<ProtectedPageContainer><ExplorePage /></ProtectedPageContainer>}>
<Route index={true} element={<PeakResults />} />
<Route path="locus/:chrom/:position/:assay" element={<PeakResults />} />
<Route path="locus/:chrom/:position" element={<PeakResults />} />
<Route path="*" element={<NotFound context="no explore route" />} />
</Route>
</Route>
<Route path="faq" element={<FAQPage />} />
<Route path="auth-failure" element={<div />} />
</Route>
<Route path="faq" element={<FAQPage />} />
<Route path="auth-failure" element={<div />} />
</Route>
<Route path="*" element={<Navigate to="/" />}/>
</Routes>
</div>
);
<Route path="*" element={<Navigate to="/" />}/>
</Routes>
</div>
);
}


export default App;
10 changes: 6 additions & 4 deletions client/src/components/Controls.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
fetchPositions,
} from '../actions.js'
import {useNavigate, useParams} from "react-router-dom";
import {useUrlEncodedNode} from "../hooks";

const defaultChrom = "rsID";

Expand All @@ -47,6 +48,7 @@ const Controls = ({toggleHelp}) => {
const params = useParams();
const navigate = useNavigate();

const urlEncodedNode = useUrlEncodedNode();
const chroms = useSelector(state => state.chroms);
const {chrom, position} = useSelector(state => state.ui);
const positions = useSelector(state => state.positions);
Expand Down Expand Up @@ -141,11 +143,11 @@ const Controls = ({toggleHelp}) => {
// The item assay is the tab with the most significant result - which will be
// selected first by nature of ordering, thus leading the user to the most interesting
// detail from the autocomplete.
navigate(`/dataset/explore/locus/${chrom}/${position}/${item.assay}`, {replace: true});
navigate(`/datasets/${urlEncodedNode}/explore/locus/${chrom}/${position}/${item.assay}`, {replace: true});
changePosition(position);
dispatch(doSearch());
setDidFirstSearch(true);
}, [list, dispatch, navigate, changePosition]);
}, [urlEncodedNode, list, dispatch, navigate, changePosition]);

const moveSelection = useCallback(n => {
const {length} = list;
Expand Down Expand Up @@ -192,10 +194,10 @@ const Controls = ({toggleHelp}) => {

const onClickSearch = useCallback(() => {
if (!chrom || !position) return;
navigate(`/dataset/explore/locus/${chrom}/${position}`, {replace: true});
navigate(`/datasets/${urlEncodedNode}/explore/locus/${chrom}/${position}`, {replace: true});
dispatch(doSearch());
setDidFirstSearch(true);
}, [navigate, chrom, position]);
}, [navigate, urlEncodedNode, chrom, position]);

return <div className={cx('Controls', { didFirstSearch })}>
<div className='Controls__content'>
Expand Down
58 changes: 31 additions & 27 deletions client/src/components/Footer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,41 @@ import {Container} from "reactstrap";
import {Link} from "react-router-dom";

import packageJson from "../../package.json";
import {useUrlEncodedNode} from "../hooks";

const Footer = ({/*onContact, */onTerms}) => (
<Container>
<div className="Footer">
<div className="Footer__text">
<div className="Footer__logo">
<a href="https://computationalgenomics.ca" target="_blank" rel="nofollow">
<img src="/c3g_logo_small.png" alt="Canadian Centre for Computational Genomics" />
</a>
<div>
const Footer = ({/*onContact, */onTerms}) => {
const urlEncodedNode = useUrlEncodedNode();
return (
<Container>
<div className="Footer">
<div className="Footer__text">
<div className="Footer__logo">
<a href="https://computationalgenomics.ca" target="_blank" rel="nofollow">
<img src="/c3g_logo_small.png" alt="Canadian Centre for Computational Genomics" />
</a>
<div>
<span>Developed by <a href="https://computationalgenomics.ca" target="_blank" rel="nofollow">
C3G</a> at McGill University &copy; 2017-2023</span><br />
<em>
Version {packageJson.version} &bull;{" "}
<a href="https://github.com/c3g/epivar-browser" target="_blank" rel="nofollow">source code</a>
</em><br />
C3G</a> at McGill University &copy; 2017-2024</span><br />
<em>
Version {packageJson.version} &bull;{" "}
<a href="https://github.com/c3g/epivar-browser" target="_blank" rel="nofollow">source code</a>
</em><br />
</div>
</div>
</div>
<nav className="Footer__nav">
<ul>
{/*<li><Link to="/datasets">Datasets</Link></li>*/}
<li><Link to={`/datasets/${urlEncodedNode}/about`}>About Dataset</Link></li>
<li><Link to={`/datasets/${urlEncodedNode}/overview`}>Overview</Link></li>
<li><Link to={`/datasets/${urlEncodedNode}/explore`}>Explore</Link></li>
<li><Link to="/about">About EpiVar</Link></li>
<li><a href="#" onClick={onTerms}>Terms of Use</a></li>
</ul>
</nav>
</div>
<nav className="Footer__nav">
<ul>
{/*<li><Link to="/datasets">Datasets</Link></li>*/}
<li><Link to="/dataset/about">About Dataset</Link></li>
<li><Link to="/dataset/overview">Overview</Link></li>
<li><Link to="/dataset/explore">Explore</Link></li>
<li><Link to="/about">About EpiVar</Link></li>
<li><a href="#" onClick={onTerms}>Terms of Use</a></li>
</ul>
</nav>
</div>
</Container>
);
</Container>
);
}

export default Footer;
38 changes: 28 additions & 10 deletions client/src/components/Header.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react'
import {useSelector} from "react-redux";
import React, {useCallback} from 'react'
import {useDispatch, useSelector} from "react-redux";
import {Alert, Button, Container, Input} from 'reactstrap'
import {Link, useLocation, useNavigate} from "react-router-dom";

Expand All @@ -8,9 +8,11 @@ import Icon from "./Icon";
import {EPIVAR_NODES} from "../config";
import {SITE_SUBTITLE, SITE_TITLE} from "../constants/app";
import {useCurrentDataset, useDatasetsByNode, useDevMode, useNode} from "../hooks";
import {setNode} from "../actions";

export default function Header({children, onAbout, /*onDatasets, */onDatasetAbout, onOverview, onExplore, onFAQ,
/*, onContact*/}) {
const dispatch = useDispatch();
const location = useLocation();
const navigate = useNavigate();

Expand All @@ -22,7 +24,24 @@ export default function Header({children, onAbout, /*onDatasets, */onDatasetAbou

const datasetsByNode = useDatasetsByNode();

console.debug("Datasets by node:", datasetsByNode);
const isLoadingData = useSelector((state) =>
state.assays.isLoading ||
state.samples.isLoading ||
state.peaks.isLoading ||
state.positions.isLoading ||
state.overview.isLoading ||
state.user.isLoading);

const onDatasetChange = useCallback((e) => {
if (isLoadingData) return;

const newNode = e.target.value;
if (newNode !== node) {
console.info("selecting node", newNode);
dispatch(setNode(newNode));
navigate(`/datasets/${encodeURIComponent(newNode)}/about`);
}
}, [dispatch, isLoadingData, navigate]);

return <div>
<div className='Header'>
Expand All @@ -43,14 +62,13 @@ export default function Header({children, onAbout, /*onDatasets, */onDatasetAbou
<div className="Header__dataset">
<div>
<label htmlFor="dataset-selector">Dataset:</label>
<Input type="select" id="dataset-selector" value={node ?? undefined}>
<Input type="select" id="dataset-selector" value={node ?? undefined} onChange={onDatasetChange}>
{EPIVAR_NODES.map((n) => {
if (n in datasetsByNode) {
const d = datasetsByNode[n];
console.debug("Adding option for dataset", n, d);
return <option key={n} >{d?.title ?? ""} ({d?.assembly ?? ""})</option>;
return <option key={n} value={n}>{d?.title ?? ""} ({d?.assembly ?? ""})</option>;
} else {
return <option key={n} disabled={true}>{n} (unreachable)</option>;
return <option key={n} value={n} disabled={true}>{n} (unreachable)</option>;
}
})}
</Input>
Expand All @@ -66,16 +84,16 @@ export default function Header({children, onAbout, /*onDatasets, */onDatasetAbou
<div className="Header__highlight_group">
<Button color="link"
disabled={!dataset}
className={location.pathname.startsWith("/dataset/about") ? "active" : ""}
className={location.pathname.match(/^\/datasets\/.*\/about/) ? "active" : ""}
onClick={onDatasetAbout}>
<Icon name="info-circle" bootstrap={true}/>About Dataset</Button>
<Button color="link"
disabled={!dataset}
className={location.pathname.startsWith("/dataset/overview") ? "active" : ""}
className={location.pathname.match(/^\/datasets\/.*\/overview/) ? "active" : ""}
onClick={onOverview}><Icon name="graph-up" bootstrap={true} />Overview</Button>
<Button color="link"
disabled={!dataset}
className={"highlight" + (location.pathname.startsWith("/dataset/explore") ? " active" : "")}
className={"highlight" + (location.pathname.match(/^\/datasets\/.*\/explore/) ? " active" : "")}
onClick={onExplore}><Icon name="search" bootstrap={true} />Explore</Button>
</div>
<Button color="link"
Expand Down
18 changes: 18 additions & 0 deletions client/src/components/NotFound.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React, {useEffect} from "react";
import {useLocation} from "react-router-dom";

const NotFound = ({context}) => {
const location = useLocation();

useEffect(() => {
console.debug("not found location", location);
}, [location]);

return (
<div>
<h2>Not Found{context ? `: ${context}` : null}</h2>
</div>
);
};

export default NotFound;
2 changes: 1 addition & 1 deletion client/src/components/PeakBoxplot.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {useNode, useCurrentDataset} from "../hooks";
function PeakBoxplot({ title, peak, /*values = defaultValues*/ }) {
const dataset = useCurrentDataset();

const ethnicities = useMemo(() => dataset.data?.ethnicities ?? [], [dataset]);
const ethnicities = useMemo(() => dataset?.ethnicities ?? [], [dataset]);
const node = useNode();
const usePrecomputed = useSelector(state => state.ui.usePrecomputed);

Expand Down
Loading