diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..8c09d78a9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,48 @@ +# dependencies +node_modules + +# testing +coverage + +# next.js +.next/ +out/ +**/build +**/dist +**/dist-platform +**/dist-genetics +**/bundle-platform +**/bundle-genetics + +# misc +.DS_Store +*.pem +**/.env.local +**/.env.development.local +**/.env.test.local +**/.env.production.local +**/*.swp + +# debug +**/npm-debug.log* +**/yarn-debug.log* +**/yarn-error.log* +.pnpm-debug.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# turbo +.turbo +# Local Netlify folder +.netlify + +**/src/icons/sections + +**/tmp + +.git/ +Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..f20d6920b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:16-bookworm as build +# make sure app variable is set and valid +ARG app="" +RUN : "${app:?Missing --build-arg app}" +RUN case "$app" in platform) true;; genetics) true;; *) echo "variable 'app' must be set to either 'platform' or 'genetics'"; false;; esac +# assert that a compatible yarn version is installed or fail +RUN case `yarn --version` in 1.22*) true;; *) false;; esac +# COPY package.json yarn.lock /tmp/$app-app/ +WORKDIR /tmp/app/ +COPY . ./ +RUN yarn --network-timeout 100000 +RUN yarn build:$app +RUN mv ./apps/$app/bundle-$app/ ./bundle/ + +FROM node:16-bookworm +RUN npm install --location=global serve +COPY --from=build /tmp/app/bundle/ /var/www/app/ +WORKDIR /var/www/app/ +EXPOSE 80 +CMD ["serve", "--no-clipboard", "--single", "-l", "tcp://0.0.0.0:80"] diff --git a/README.md b/README.md index 6c5cb83cf..9f0d73ee0 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,24 @@ From the root directory run: `yarn build`. This will generate on each applicatio As same as development, you can run `yarn build:platform` or `yarn build:genetics` to scope the build only for one of the applications. +## Building and using a Docker image + +A Docker image hosting either the Open Targets Platform or Open Targets Genetics web application can be built using the following command: + +```sh +docker build --tag {name:tag} --build-arg app={platform,genetics} +``` + +The `app` variable can be set to either `platform` or `genetics`, depending on which application should be built. + +Once the image is built, the application can be hosted as such: + +```sh +docker run -it --rm -p 80:80 {name:tag} +``` + +The application is then locally accessible in the browser at: + ## Copyright Copyright 2014-2018 Biogen, Celgene Corporation, EMBL - European Bioinformatics Institute, GlaxoSmithKline, Takeda Pharmaceutical Company and Wellcome Sanger Institute diff --git a/apps/genetics/index.html b/apps/genetics/index.html index 0e5539149..ef751f3be 100644 --- a/apps/genetics/index.html +++ b/apps/genetics/index.html @@ -23,7 +23,8 @@ - + + Open Targets Genetics diff --git a/apps/genetics/package.json b/apps/genetics/package.json index 8789b5082..16efc514d 100644 --- a/apps/genetics/package.json +++ b/apps/genetics/package.json @@ -43,7 +43,7 @@ "react-helmet": "^6.0.0", "react-measure": "^2.1.2", "react-router-dom": "5.1.2", - "react-scripts": "4.0.3", + "react-scripts": "5.0.0", "react-scroll": "^1.7.16", "react-select": "^5.3.2", "react-sizeme": "^3.0.2", diff --git a/apps/genetics/public/assets/img/logo-org.png b/apps/genetics/public/assets/img/logo-org.png new file mode 100644 index 000000000..76e8189a4 Binary files /dev/null and b/apps/genetics/public/assets/img/logo-org.png differ diff --git a/apps/genetics/public/matomo/get_logged_in_user.js b/apps/genetics/public/matomo/get_logged_in_user.js new file mode 100644 index 000000000..162e8e72e --- /dev/null +++ b/apps/genetics/public/matomo/get_logged_in_user.js @@ -0,0 +1,24 @@ +/** +Returns a promise of logged-in user id. +This function assumes response header contains 'user' entry. +*/ +function getLoggedInUser() { + return new Promise(function(resolve, reject) { + var request = new XMLHttpRequest(); + request.onreadystatechange = function() { + if (this.readyState === this.HEADERS_RECEIVED) { + var user = request.getResponseHeader('user'); + if (user != null) { + resolve(user); + } else { + reject(); + } + } + }; + request.onerror = function() { + reject(); + }; + request.open('HEAD', document.location, true); + request.send(null); + }); +} \ No newline at end of file diff --git a/apps/genetics/public/matomo/init_matomo.js b/apps/genetics/public/matomo/init_matomo.js new file mode 100644 index 000000000..fc4786055 --- /dev/null +++ b/apps/genetics/public/matomo/init_matomo.js @@ -0,0 +1,40 @@ +var _paq = window._paq || []; +/* tracker methods like "setCustomDimension" should be called before "trackPageView" */ + +(function() { + function registerUserVisit() { + getLoggedInUser() + .then(function(user) { + _paq.push(['setUserId', user]); + }) + .finally(function() { + _paq.push(['setCustomUrl', window.location.href]); + _paq.push(['setDocumentTitle', window.document.title]); + _paq.push(['trackPageView']); + _paq.push(['enableLinkTracking']); + }); + } + var pushState = history.pushState; + history.pushState = function() { + pushState.apply(history, arguments); + registerUserVisit(); + }; + registerUserVisit(); +})(); + +(function() { + var u = 'DISABLED'; + if (u === 'DISABLED') { + return + } + _paq.push(['setTrackerUrl', u + 'matomo.php']); + _paq.push(['setSiteId', '1']); + var d = document, + g = d.createElement('script'), + s = d.getElementsByTagName('script')[0]; + g.type = 'text/javascript'; + g.async = true; + g.defer = true; + g.src = u + 'matomo.js'; + s.parentNode.insertBefore(g, s); +})(); \ No newline at end of file diff --git a/apps/genetics/src/components/NavBar/NavBar.jsx b/apps/genetics/src/components/NavBar/NavBar.jsx index 0ee12eed3..7b2550798 100644 --- a/apps/genetics/src/components/NavBar/NavBar.jsx +++ b/apps/genetics/src/components/NavBar/NavBar.jsx @@ -11,6 +11,8 @@ import classNames from 'classnames'; import Link from '../Link'; import OpenTargetsTitle from './OpenTargetsTitle'; import HeaderMenu from './HeaderMenu'; +import config from '../../config'; +import { TopBar } from 'ui'; const styles = theme => ({ navbar: { @@ -88,7 +90,18 @@ const MenuExternalLink = ({ classes, href, children }) => ( ); -const NavBar = ({ +const NavBar = props => ( + <> + {/* + * Outside of the NavBar AppBar to mirror + * apps/platform/src/components/NavBar.jsx. + */} + {config.showTopBar && } + + +) + +const NavBarContent = ({ classes, name, search, @@ -111,6 +124,9 @@ const NavBar = ({ color="primary" elevation={0} > + {/* push the content down so it isn't hidden behind the logo bar */} + {config.showTopBar && +
}
{homepage ? null : ( diff --git a/apps/genetics/src/config.js b/apps/genetics/src/config.js index 748660a49..00f8b4812 100644 --- a/apps/genetics/src/config.js +++ b/apps/genetics/src/config.js @@ -8,6 +8,7 @@ const config = { platformUrl: window.configPlatformUrl ? window.configPlatformUrl.replace(/\/$/, '') : 'https://platform.opentargets.org', + showTopBar: window.configShowTopBar ?? false, }; export default config; diff --git a/apps/platform/public/assets/img/logo-org.png b/apps/platform/public/assets/img/logo-org.png new file mode 100644 index 000000000..76e8189a4 Binary files /dev/null and b/apps/platform/public/assets/img/logo-org.png differ diff --git a/apps/platform/src/components/NavBar.jsx b/apps/platform/src/components/NavBar.jsx index 9963ea73f..eb18fd7f6 100644 --- a/apps/platform/src/components/NavBar.jsx +++ b/apps/platform/src/components/NavBar.jsx @@ -12,6 +12,8 @@ import Link from './Link'; import OpenTargetsTitle from './OpenTargetsTitle'; import HeaderMenu from './HeaderMenu'; import PrivateWrapper from './PrivateWrapper'; +import config from '../config'; +import { TopBar } from 'ui'; const styles = theme => ({ navbar: { @@ -84,7 +86,26 @@ const MenuExternalLink = ({ classes, href, children }) => ( ); -const NavBar = ({ +const NavBar = props => ( + <> + {/* + * Keep the TopBar outside of the NavBar's AppBar component, as nesting it + * renders the top bar behind the ProtVista protein structure viewer when + * scrolling down the target profile page. That's probably because the + * NavBar's AppBar has its own z-index lower than 40001, which creates a + * local stacking context outside of which the z-indices of descendants + * are not compared. + * + * This still leaves the issue that the bar also overlays the 3d structure + * viewer when it's expanded to fill the viewport, blocking some of the + * buttons of the viewer. + */} + {config.showTopBar && } + + +) + +const NavBarContent = ({ classes, name, search, @@ -108,6 +129,9 @@ const NavBar = ({ color="primary" elevation={0} > + {/* push the content down so it isn't hidden behind the logo bar */} + {config.showTopBar && +
}
{homepage ? null : ( diff --git a/apps/platform/src/components/NavPanel/NavPanel.jsx b/apps/platform/src/components/NavPanel/NavPanel.jsx index fc262b929..05c2aa594 100644 --- a/apps/platform/src/components/NavPanel/NavPanel.jsx +++ b/apps/platform/src/components/NavPanel/NavPanel.jsx @@ -4,6 +4,7 @@ import { Drawer } from '@material-ui/core'; import GoBackButton from './GoBackButton'; import navPanelStyles from './navPanelStyles'; import SectionMenu from './SectionMenu'; +import config from '../../config'; function NavPanel({ ...props }) { const classes = navPanelStyles(); @@ -13,6 +14,9 @@ function NavPanel({ ...props }) { variant="permanent" classes={{ root: classes.drawer, paper: classes.paper }} > + {/* leave the space that will be hidden behind the logo bar unused */} + {config.showTopBar && +
} diff --git a/apps/platform/src/config.js b/apps/platform/src/config.js index f4248423e..464b3caa9 100644 --- a/apps/platform/src/config.js +++ b/apps/platform/src/config.js @@ -10,6 +10,7 @@ const config = { downloadsURL: window.configDownloadsURL ?? '/data/downloads.json', geneticsPortalUrl: window.configGeneticsPortalUrl ?? 'https://genetics.opentargets.org', + showTopBar: window.configShowTopBar ?? false, }; export default config; diff --git a/packages/ui/index.js b/packages/ui/index.js index e93c2df10..32e04086d 100644 --- a/packages/ui/index.js +++ b/packages/ui/index.js @@ -4,3 +4,4 @@ export { default as LoadingBackdrop } from "./src/LoadingBackdrop"; export { default as GlobalSearch } from "./src/GlobalSearch"; export { default as AutocompleteSearch } from "./src/AutocompleteSearch"; export { default as SearchProvider } from "./src/Search/SearchContext"; +export { default as TopBar } from "./src/TopBar"; diff --git a/packages/ui/src/GlobalSearch.jsx b/packages/ui/src/GlobalSearch.jsx index bef0acf21..85d0b5f8d 100644 --- a/packages/ui/src/GlobalSearch.jsx +++ b/packages/ui/src/GlobalSearch.jsx @@ -49,6 +49,8 @@ const useStyles = makeStyles((theme) => ({ right: "10px", }, modal: { + // leave the space that will be hidden behind the logo bar unused + paddingTop: (window.configShowTopBar ?? false) ? "50px" : "", "& .MuiDialog-scrollPaper": { alignItems: "start", "& .MuiDialog-paperWidthSm": { diff --git a/packages/ui/src/TopBar.jsx b/packages/ui/src/TopBar.jsx new file mode 100644 index 000000000..2bb726f3e --- /dev/null +++ b/packages/ui/src/TopBar.jsx @@ -0,0 +1,89 @@ +import React, { useState, useEffect } from 'react'; + +import AppBar from '@material-ui/core/AppBar'; +import useScrollTrigger from '@material-ui/core/useScrollTrigger'; +import { Typography } from '@material-ui/core'; + + +/** + * Shadows the AppBar to hint at content scrolling up. + * + * @see {@link https://mui.com/material-ui/react-app-bar/#elevate-app-bar} + */ +function ElevationScroll({children}) { + const trigger = useScrollTrigger({ + disableHysteresis: true, + threshold: 0, + }); + return React.cloneElement(children, { + elevation: trigger ? 4 : 0, + }); +} + +export default function TopBar() { + const [username, setUsername] = useState(''); + useEffect(() => { + let isCurrent = true; + + async function displayLoggedInUser() { + const userFunction = window.getLoggedInUser || (() => Promise.resolve('')); + const username = await resolveWithNameOrErrorMessage(userFunction()); + if (isCurrent) { + setUsername(username) + } + } + async function resolveWithNameOrErrorMessage(userPromise) { + try { + return await userPromise; + } catch { + return 'Unidentified user'; + } + } + displayLoggedInUser(); + + return () => { + isCurrent = false; + }; + }, []); + + return ( + + + Logo + + {username} + + + + ); +}