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,