-
Notifications
You must be signed in to change notification settings - Fork 9
OpenConceptLab/ocl_online#374 | using path routing in place of hash routing #91
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| /* 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) | ||
| }) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,81 @@ | ||||||||||||||||
| let appHistory = null | ||||||||||||||||
|
|
||||||||||||||||
| export const setAppHistory = history => { | ||||||||||||||||
| appHistory = history | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| export const toRoutePath = path => { | ||||||||||||||||
| const legacy = path?.match(/^\/?#(\/.*)$/) | ||||||||||||||||
| return legacy ? legacy[1] : path | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| export const navigate = (path, replace=false) => { | ||||||||||||||||
| if(!path) | ||||||||||||||||
| return | ||||||||||||||||
| const routePath = toRoutePath(path) | ||||||||||||||||
|
Comment on lines
+13
to
+15
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same-site paths only. Details in OpenConceptLab/ocl_online#374.
Suggested change
|
||||||||||||||||
| 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('#/') || /[?&]referrer=/.test(search)) | ||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Skip
Suggested change
|
||||||||||||||||
| 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 | ||||||||||||||||
| const url = new URL(anchor.href, window.location.href) | ||||||||||||||||
| if(url.origin !== window.location.origin) | ||||||||||||||||
|
Comment on lines
+61
to
+62
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A relative
Suggested change
|
||||||||||||||||
| 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) | ||||||||||||||||
| } | ||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,12 +15,13 @@ import { | |||||||||||||||
| import APIService from '../services/APIService'; | ||||||||||||||||
| import GAService from '../services/GAService'; | ||||||||||||||||
| import { SERVER_CONFIGS } from './serverConfigs'; | ||||||||||||||||
| import { legacyHashRoute } 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); | ||||||||||||||||
|
|
@@ -210,7 +211,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,12 +658,12 @@ 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') | ||||||||||||||||
|
|
||||||||||||||||
| const redirectURL = forced ? | ||||||||||||||||
| window.location.origin + '/#/signin?returnTo=' + encodeURIComponent(returnTo) : | ||||||||||||||||
| window.location.origin + '/signin?returnTo=' + encodeURIComponent(returnTo) : | ||||||||||||||||
|
Comment on lines
665
to
+666
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oclapi2 decodes
Suggested change
|
||||||||||||||||
| undefined | ||||||||||||||||
| const logoutURL = getSSOLogoutURL(redirectURL) | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -671,10 +672,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,9 +876,22 @@ 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)) | ||||||||||||||||
|
|
||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Mapper has no
/searchroute, so this branch only ever sentorigin=openmrsvisitors to a missing page, and since this PR it fires on every path.