diff --git a/public/index.html b/public/index.html index 21310d5..9523e18 100644 --- a/public/index.html +++ b/public/index.html @@ -8,7 +8,7 @@ manifest.json provides metadata used when your web app is added to the homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/ --> - + - + - + - + diff --git a/src/common/__tests__/history.test.js b/src/common/__tests__/history.test.js new file mode 100644 index 0000000..64ca5b8 --- /dev/null +++ b/src/common/__tests__/history.test.js @@ -0,0 +1,136 @@ +/* global globalThis */ +import test from 'node:test' +import assert from 'node:assert/strict' + +const location = { origin: 'http://localhost:4004', pathname: '/map-projects/', search: '', hash: '', href: 'http://localhost:4004/map-projects/' } +globalThis.window = { location } + +const { legacyHashRoute, toRoutePath, navigate, setAppHistory, handleLinkClick, keepLinkClickBubbling } = await import('../history.js') + +const loc = (hash, search='') => ({ hash, search }) + +test('legacyHashRoute turns a hash route into a path', () => { + assert.equal(legacyHashRoute(loc('#/map-projects/')), '/map-projects/') + assert.equal(legacyHashRoute(loc('#/orgs/CIEL/sources/CIEL/?q=malaria')), '/orgs/CIEL/sources/CIEL/?q=malaria') +}) + +test('legacyHashRoute keeps a nested referrer hash intact', () => { + assert.equal( + legacyHashRoute(loc('#/map-projects/1/?referrer=https://app.v3/#/orgs/X/?auth=true')), + '/map-projects/1/?referrer=https://app.v3/#/orgs/X/?auth=true' + ) +}) + +test('legacyHashRoute merges the outer query into the route', () => { + assert.equal(legacyHashRoute(loc('#/search/', '?foo=1')), '/search/?foo=1') + assert.equal(legacyHashRoute(loc('#/search/?q=1', '?foo=1')), '/search/?q=1&foo=1') +}) + +test('legacyHashRoute ignores in-page anchors, empty hashes and referrer hashes', () => { + assert.equal(legacyHashRoute(loc('#heading')), null) + assert.equal(legacyHashRoute(loc('')), null) + assert.equal(legacyHashRoute(loc('#/orgs/X/?auth=true', '?referrer=https://app.v3/')), null) +}) + +test('toRoutePath strips a legacy hash prefix only', () => { + assert.equal(toRoutePath('/#/map-projects/new'), '/map-projects/new') + assert.equal(toRoutePath('#/map-projects/new'), '/map-projects/new') + assert.equal(toRoutePath('/map-projects/new'), '/map-projects/new') +}) + +const pushed = [] +setAppHistory({ push: path => pushed.push(['push', path]), replace: path => pushed.push(['replace', path]) }) + +test('navigate pushes or replaces through the router history', () => { + pushed.length = 0 + navigate('/#/map-projects/new') + navigate('/search/', true) + assert.deepEqual(pushed, [['push', '/map-projects/new'], ['replace', '/search/']]) +}) + +const anchor = (href, attrs={}) => ({ + href: new URL(href, location.href).href, + target: attrs.target || '', + getAttribute: name => name === 'href' ? href : null, + hasAttribute: name => Boolean(attrs[name]), +}) + +const click = (a, extra={}) => { + const event = { + button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, defaultPrevented: false, + target: { closest: () => a }, + preventDefault() { this.defaultPrevented = true }, + stopPropagation() { this.stopped = true }, + ...extra, + } + return event +} + +test('handleLinkClick routes same-origin links in the app', () => { + pushed.length = 0 + const event = click(anchor('/map-projects/new')) + handleLinkClick(event) + assert.equal(event.defaultPrevented, true) + assert.deepEqual(pushed, [['push', '/map-projects/new']]) +}) + +test('handleLinkClick leaves new-tab, modified, external and prevented clicks alone', () => { + pushed.length = 0 + const cases = [ + click(anchor('/map-projects/new', { target: '_blank' })), + click(anchor('/map-projects/new'), { metaKey: true }), + click(anchor('/map-projects/new'), { button: 1 }), + click(anchor('https://app.v3.openconceptlab.org/#/orgs/CIEL/')), + click(anchor('#section')), + click(anchor('/map-projects/new'), { defaultPrevented: true }), + ] + cases.forEach(event => handleLinkClick(event)) + assert.deepEqual(pushed, []) +}) + +test('keepLinkClickBubbling neutralises stopPropagation only for app links', () => { + const appLink = click(anchor('/map-projects/new')) + keepLinkClickBubbling(appLink) + appLink.stopPropagation() + assert.equal(appLink.stopped, undefined) + + const external = click(anchor('https://openconceptlab.org/')) + keepLinkClickBubbling(external) + external.stopPropagation() + assert.equal(external.stopped, true) +}) + +test('legacyHashRoute rejects routes that would leave the site', () => { + assert.equal(legacyHashRoute(loc('#//example.com/')), null) + assert.equal(legacyHashRoute(loc('#/\\example.com/')), null) +}) + +test('navigate ignores anything but a single-slash path', () => { + pushed.length = 0 + navigate('//example.com/') + navigate('/\\example.com/') + navigate('/#//example.com/') + navigate('https://example.com/') + navigate(undefined) + assert.deepEqual(pushed, []) +}) + +test('a relative #/ link goes to that route without the current query', () => { + pushed.length = 0 + location.search = '?q=malaria&type=concepts' + const event = click(anchor('#/map-projects/new')) + handleLinkClick(event) + location.search = '' + assert.deepEqual(pushed, [['push', '/map-projects/new']]) +}) + +test('handleLinkClick leaves same-page fragments and non-http links to the browser', () => { + pushed.length = 0 + const sameFragment = click(anchor('/map-projects/#section')) + const blob = click(anchor('blob:http://localhost:4004/abc')) + handleLinkClick(sameFragment) + handleLinkClick(blob) + assert.equal(sameFragment.defaultPrevented, false) + assert.equal(blob.defaultPrevented, false) + assert.deepEqual(pushed, []) +}) diff --git a/src/common/history.js b/src/common/history.js new file mode 100644 index 0000000..38b08dc --- /dev/null +++ b/src/common/history.js @@ -0,0 +1,87 @@ +let appHistory = null + +export const setAppHistory = history => { + appHistory = history +} + +export const toRoutePath = path => { + const legacy = path?.match(/^\/?#(\/.*)$/) + return legacy ? legacy[1] : path +} + +export const isSameSitePath = path => /^\/(?![/\\])/.test(path || '') + +export const navigate = (path, replace=false) => { + const routePath = toRoutePath(path) + if(!isSameSitePath(routePath)) + return + if(!appHistory) { + if(replace) + window.location.replace(routePath) + else + window.location.assign(routePath) + return + } + if(replace) + appHistory.replace(routePath) + else + appHistory.push(routePath) +} + +export const legacyHashRoute = (location=window.location) => { + const { hash, search } = location + if(!hash.startsWith('#/') || /^#\/[/\\]/.test(hash) || /[?&]referrer=/.test(search)) + return null + let route = hash.slice(1) + const outer = search.replace(/^\?/, '') + if(outer) { + const hashAt = route.indexOf('#') + const tail = hashAt > -1 ? route.slice(hashAt) : '' + const head = hashAt > -1 ? route.slice(0, hashAt) : route + route = head + (head.includes('?') ? '&' : '?') + outer + tail + } + return route +} + +export const redirectLegacyHashRoute = () => { + const route = legacyHashRoute() + if(route) + navigate(route, true) +} + +const currentRoute = () => window.location.pathname + window.location.search + window.location.hash + +const isPlainLeftClick = event => event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey + +const appLinkRoute = event => { + const anchor = event.target?.closest?.('a[href]') + if(!anchor || (anchor.target && anchor.target !== '_self') || anchor.hasAttribute('download')) + return null + const href = anchor.getAttribute('href') + if(!href || (href.startsWith('#') && !href.startsWith('#/')) || /^(mailto|tel|javascript):/i.test(href)) + return null + if(href.startsWith('#/')) + return href.slice(1) + const url = new URL(anchor.href, window.location.href) + if(url.origin !== window.location.origin || !/^https?:$/.test(url.protocol)) + return null + if(url.hash && !url.hash.startsWith('#/') && url.pathname === window.location.pathname && url.search === window.location.search) + return null + return legacyHashRoute(url) || url.pathname + url.search + url.hash +} + +export const keepLinkClickBubbling = event => { + if(isPlainLeftClick(event) && appLinkRoute(event)) + event.stopPropagation = () => {} +} + +export const handleLinkClick = event => { + if(event.defaultPrevented || !isPlainLeftClick(event)) + return + const route = appLinkRoute(event) + if(!route) + return + event.preventDefault() + if(route !== currentRoute()) + navigate(route) +} diff --git a/src/common/utils.js b/src/common/utils.js index 9797bd7..528f0af 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -15,12 +15,13 @@ import { import APIService from '../services/APIService'; import GAService from '../services/GAService'; import { SERVER_CONFIGS } from './serverConfigs'; +import { legacyHashRoute, isSameSitePath } from './history'; -export const currentPath = () => window.location.hash.split('?')[0]; +export const currentPath = () => window.location.pathname; -export const isAtGlobalSearch = () => window.location.hash.includes('#/search') || isAtRoot(); +export const isAtGlobalSearch = () => currentPath().startsWith('/search') || isAtRoot(); -export const isAtRoot = () => currentPath() === '#/'; +export const isAtRoot = () => currentPath() === '/'; export const formatDate = date => moment(date).format(DATE_FORMAT); export const formatTime = date => moment(date).format(TIME_FORMAT); @@ -119,8 +120,6 @@ export const getAPIURL = () => { return get(savedConfigs, 'url') || window.API_URL || process.env.API_URL; } -export const toFullURL = uri => window.location.origin + '/#' + uri; - export const toFullAPIURL = uri => getAPIURL() + uri; export const toRelativeURL = url => url.replace(getAPIURL(), ''); @@ -210,7 +209,7 @@ export const arrayToObject = arr => { }, {}); } -export const currentUserHasAccess = () => hasAccessToURL(window.location.hash.replace('#/', '')) +export const currentUserHasAccess = () => hasAccessToURL(window.location.pathname) export const hasAccessToURL = url => { if(!isLoggedIn()) @@ -657,7 +656,7 @@ export const logoutUser = (redirectToLogin, forced) => { localStorage.removeItem('visits'); } - const returnTo = window.location.origin + '/' + window.location.hash + const returnTo = window.location.origin + window.location.pathname + window.location.search if(forced) sessionStorage.setItem('session_expired', 'true') @@ -671,10 +670,8 @@ export const logoutUser = (redirectToLogin, forced) => { window.location = logoutURL else if(redirectToLogin) getLoginURL(forced ? returnTo : undefined).then(url => { window.location.href = url }) - else { - window.location.hash = '#/'; - window.location.reload(); - } + else + window.location.assign('/'); } @@ -877,12 +874,25 @@ const routePath = route => { // Keycloak only redeems a code when the token request repeats the sign-in's redirect_uri exactly, and the // callback can't rebuild a page's query string (e.g. ?referrer= on links from openconceptlab.org). So sign-in -// always goes through LOGIN_REDIRECT_URL, and the page to come back to (its hash route) waits here, in this tab. +// always goes through LOGIN_REDIRECT_URL, and the page to come back to (its route) waits here, in this tab. +const returnToRoute = returnTo => { + if(!returnTo) + return null + try { + const url = new URL(returnTo, window.location.origin) + if(url.origin !== window.location.origin) + return null + return legacyHashRoute(url) || url.pathname + url.search + url.hash + } catch { + return null + } +} + const prepareOAuthReturnTo = returnTo => { - const route = returnTo?.includes('#') ? returnTo.slice(returnTo.indexOf('#') + 1) : null + const route = returnToRoute(returnTo) const path = route && routePath(route) // The router matches paths case-insensitively, so /SIGNUP would start a sign-up too. - if(path?.startsWith('/') && !/^\/(oidc\/login|signin|signup)(\/|$)/i.test(path)) + if(isSameSitePath(path) && !/^\/(oidc\/login|signin|signup)(\/|$)/i.test(path)) sessionStorage.setItem(OAUTH_RETURN_TO_KEY, route) else sessionStorage.removeItem(OAUTH_RETURN_TO_KEY) diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx index a7f24d0..1d917d8 100644 --- a/src/components/app/App.jsx +++ b/src/components/app/App.jsx @@ -87,14 +87,13 @@ const App = props => { const queryParams = new URLSearchParams(search) const referrer = queryParams.get('referrer') if(isLoggedIn()) { - // OIDLoginCallback navigates away itself; resetting the hash here would undo that. - if(!pathname.startsWith('/oidc/login')) - window.location.hash = '#' + pathname + if(hash?.startsWith('#/') && !pathname.startsWith('/oidc/login')) + props.history.replace(pathname + search) } else if(isOtherOCLClientURL(referrer) && !isLoggedIn()) { const parts = hash ? hash.split('?') : referrer.split('?') let params = new URLSearchParams(parts[1]) if(params.get('auth') === 'true') { - getLoginURL(window.location.origin + '/#' + pathname).then(url => { window.location.href = url }) + getLoginURL(window.location.origin + pathname).then(url => { window.location.href = url }) } } } diff --git a/src/components/app/LeftMenu.jsx b/src/components/app/LeftMenu.jsx index c4b32c4..56d0120 100644 --- a/src/components/app/LeftMenu.jsx +++ b/src/components/app/LeftMenu.jsx @@ -119,7 +119,7 @@ const LeftMenu = ({ isOpen, onClose }) => { px: 2, borderRadius: '100px' }} - href='/#/' + href='/' className='no-anchor-styles' selected={location.pathname === '/'} > diff --git a/src/components/common/BaseEntityChip.jsx b/src/components/common/BaseEntityChip.jsx index 5fcd325..83ce61b 100644 --- a/src/components/common/BaseEntityChip.jsx +++ b/src/components/common/BaseEntityChip.jsx @@ -172,7 +172,7 @@ const BaseEntityChip = ({ entity, icon, hideType, primary, size, sx, noLink, isV onClick={noLink ? undefined : event => { event.stopPropagation() }} - href={noLink ? undefined : '#' + (toV3URL(entity?.version_url || entity?.url))} + href={noLink ? undefined : toV3URL(entity?.version_url || entity?.url)} component='a' target='_blank' rel='noreferrer noopener' diff --git a/src/components/common/OCLLogo.jsx b/src/components/common/OCLLogo.jsx index 807956f..37d4368 100644 --- a/src/components/common/OCLLogo.jsx +++ b/src/components/common/OCLLogo.jsx @@ -4,7 +4,7 @@ import { PRIMARY_COLORS } from '../../common/colors' const OCLLogo = ({ color, width, height, }) => { return ( - + diff --git a/src/components/concepts/ConceptDetails.jsx b/src/components/concepts/ConceptDetails.jsx index 7b4d599..a42c30c 100644 --- a/src/components/concepts/ConceptDetails.jsx +++ b/src/components/concepts/ConceptDetails.jsx @@ -77,7 +77,7 @@ const ConceptDetails = ({ concept, repo, mappings, reverseMappings, loading, loa {t('common.last_updated')} { loading ? : - <>{formatDateTime(concept.versioned_updated_on || concept.updated_on)} {t('common.by')} + <>{formatDateTime(concept.versioned_updated_on || concept.updated_on)} {t('common.by')} } diff --git a/src/components/errors/Error403.jsx b/src/components/errors/Error403.jsx index 8bb4f27..f570f46 100644 --- a/src/components/errors/Error403.jsx +++ b/src/components/errors/Error403.jsx @@ -66,7 +66,7 @@ const Error403 = () => { i18nKey='common.go_back_or_to_your_dashboard' components={[ history.goBack()} />, - , + , ]} />

diff --git a/src/components/errors/ErrorBoundary.jsx b/src/components/errors/ErrorBoundary.jsx index 44c7419..b22284a 100644 --- a/src/components/errors/ErrorBoundary.jsx +++ b/src/components/errors/ErrorBoundary.jsx @@ -61,7 +61,7 @@ class ErrorBoundary extends React.Component { getErrorUIProps() { const props = {header: 'Error', message: isString(this.state.error) ? this.state.error : 'Something went wrong.'} - if(window.location.hash.match(/debug=true/)) + if(window.location.search.match(/debug=true/)) return {...props, error: this.state.error, errorInfo: this.state.errorInfo} return props diff --git a/src/components/errors/WaitListing.jsx b/src/components/errors/WaitListing.jsx index 0dfd7dc..fcc9b4b 100644 --- a/src/components/errors/WaitListing.jsx +++ b/src/components/errors/WaitListing.jsx @@ -65,7 +65,7 @@ const WaitListing = () => { @@ -145,7 +145,7 @@ const WaitListing = () => { { return } if(project?.url) { - window.open(`/#/map-projects/new?templateFrom=${encodeURIComponent(project.url)}`, '_blank', 'noopener,noreferrer') + window.open(`/map-projects/new?templateFrom=${encodeURIComponent(project.url)}`, '_blank', 'noopener,noreferrer') } } diff --git a/src/components/map-projects/MapProjects.jsx b/src/components/map-projects/MapProjects.jsx index 6c23292..85599a4 100644 --- a/src/components/map-projects/MapProjects.jsx +++ b/src/components/map-projects/MapProjects.jsx @@ -161,7 +161,7 @@ const MapProjects = () => { variant='contained' color='primary' startIcon={} - href='#/map-projects/new' + href='/map-projects/new' onClick={onNewProjectClick} sx={{textTransform: 'none'}} > diff --git a/src/components/users/OIDLoginCallback.jsx b/src/components/users/OIDLoginCallback.jsx index 36b98cf..980a4a2 100644 --- a/src/components/users/OIDLoginCallback.jsx +++ b/src/components/users/OIDLoginCallback.jsx @@ -6,6 +6,7 @@ import { refreshCurrentUserCache, consumeStoredPKCECodeVerifier, consumeAndValidateOAuthState, consumeOAuthReturnTo, isSignupOAuthState, isLoggedIn, getLoginURL } from '../../common/utils'; +import { navigate } from '../../common/history'; import APIService from '../../services/APIService' import GAService from '../../services/GAService' import { OperationsContext } from '../app/LayoutContext'; @@ -84,15 +85,15 @@ class OIDLoginCallback extends React.Component { ) }) - window.location.hash = '#' + (next || '/') + navigate(next || '/', true) } cacheUserData() { refreshCurrentUserCache(() => { if(this.state.returnTo) - window.location.hash = '#' + this.state.returnTo + navigate(this.state.returnTo, true) else if(this.state.next) - window.location.hash = '#' + this.state.next + navigate(this.state.next, true) else { let returnToURL = '/' if(this.props?.location?.search) { @@ -100,7 +101,7 @@ class OIDLoginCallback extends React.Component { if(queryParams && queryParams.get('returnTo')) returnToURL = queryParams.get('returnTo') } - window.location.hash = '#' + returnToURL + navigate(returnToURL, true) } }) } diff --git a/src/index.jsx b/src/index.jsx index e3ffe63..c7b8cca 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,13 +1,14 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import Fade from '@mui/material/Fade'; -import { HashRouter } from 'react-router-dom'; +import { BrowserRouter, useHistory } from 'react-router-dom'; import { ThemeProvider, StyledEngineProvider, createTheme, alpha } from '@mui/material/styles'; import App from './components/app/App'; import LayoutContext from './components/app/LayoutContext'; import './index.scss'; import { COLORS } from './common/colors'; import './i18n/config'; +import { setAppHistory, handleLinkClick, keepLinkClickBubbling, legacyHashRoute, redirectLegacyHashRoute } from './common/history'; const theme = createTheme(); const v5Theme = createTheme(theme, { @@ -121,14 +122,28 @@ const v5Theme = createTheme(theme, { }) +const HistoryBridge = () => { + setAppHistory(useHistory()) + return null +} + +const legacyRoute = legacyHashRoute() +if(legacyRoute) + window.history.replaceState(null, '', legacyRoute) + +window.addEventListener('click', keepLinkClickBubbling, true) +document.addEventListener('click', handleLinkClick) +window.addEventListener('hashchange', redirectLegacyHashRoute) + const container = document.getElementById('root'); const root = createRoot(container); root.render( - + + )} /> - + ); diff --git a/src/services/GAService.js b/src/services/GAService.js index 21525a1..c0da9a6 100644 --- a/src/services/GAService.js +++ b/src/services/GAService.js @@ -25,7 +25,7 @@ const GAService = { if(!enabled()) return; - ReactGA.send({ hitType: 'pageview', page: window.location.pathname + window.location.hash.split('?')[0] }); + ReactGA.send({ hitType: 'pageview', page: window.location.pathname }); }, recordUpsertEvent(category, edit, resource) { diff --git a/webpack.config.js b/webpack.config.js index 80bddab..4bfbc61 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -9,6 +9,7 @@ module.exports = (env) => { return { mode: env.NODE_ENV, output: { + publicPath: '/', // Production names carry a content hash (OpenConceptLab/ocl_issues#2824), // so a changed bundle gets a new URL. nginx caches these files for a year // and revalidates index.html on every load, so a returning browser never @@ -101,7 +102,8 @@ module.exports = (env) => { directory: path.join(__dirname, 'public') }, historyApiFallback: { - index: 'index.html', + index: '/index.html', + disableDotRule: true, } }, devtool: env.NODE_ENV == 'production' ? "source-map" : undefined,