Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ ENV OIDC_REALM=${OIDC_REALM}
ENV ANALYTICS_API=${ANALYTICS_API}
ENV AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL}
ENV OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL}
ENV MAPPER_URL=${MAPPER_URL}
RUN mkdir /app
WORKDIR /app
ENV PATH /app/node_modules/.bin:$PATH
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ services:
- ANALYTICS_API=${ANALYTICS_API-}
- AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL-}
- OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL-}
- MAPPER_URL=${MAPPER_URL-}
- ERRBIT_URL
- ERRBIT_KEY
- HOTJAR_ID
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@
"react-is": "^18.3.1"
},
"scripts": {
"start": "./node_modules/webpack-dev-server/bin/webpack-dev-server.js --progress --host 0.0.0.0 --port ${WEB_PORT} --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env ANALYTICS_API=${ANALYTICS_API} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --env OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL} --mode ${NODE_ENV} --hot",
"build": "node --max-old-space-size=1536 ./node_modules/webpack/bin/webpack.js --progress --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env ANALYTICS_API=${ANALYTICS_API} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --env OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL} --mode ${NODE_ENV}",
"start": "./node_modules/webpack-dev-server/bin/webpack-dev-server.js --progress --host 0.0.0.0 --port ${WEB_PORT} --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env ANALYTICS_API=${ANALYTICS_API} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --env OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL} --env MAPPER_URL=${MAPPER_URL} --mode ${NODE_ENV} --hot",
"build": "node --max-old-space-size=1536 ./node_modules/webpack/bin/webpack.js --progress --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env ANALYTICS_API=${ANALYTICS_API} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --env OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL} --env MAPPER_URL=${MAPPER_URL} --mode ${NODE_ENV}",
"eslint": "./node_modules/.bin/eslint ./src --ext .jsx,.js",
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test 'src/**/__tests__/**/*.test.js'"
},
Expand Down
23 changes: 23 additions & 0 deletions src/common/__tests__/appUrls.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import test from 'node:test'
import assert from 'node:assert/strict'

const { resolveMapperURL } = await import('../appUrls.js')

test('configured URL wins and loses its trailing slash', () => {
assert.equal(resolveMapperURL('http://localhost:4004/', 'https://app.v3.qa.openconceptlab.org'), 'http://localhost:4004')
assert.equal(resolveMapperURL(' https://mapper.example.org ', 'https://tb.example.org'), 'https://mapper.example.org')
})

test('official hosts map to the matching Mapper environment', () => {
assert.equal(resolveMapperURL('', 'https://app.v3.openconceptlab.org'), 'https://map.openconceptlab.org')
assert.equal(resolveMapperURL('', 'https://app.openconceptlab.org'), 'https://map.openconceptlab.org')
assert.equal(resolveMapperURL('', 'https://app.v3.qa.openconceptlab.org'), 'https://map.qa.openconceptlab.org')
assert.equal(resolveMapperURL(undefined, 'https://app.staging.openconceptlab.org'), 'https://map.staging.openconceptlab.org')
})

test('localhost, custom hosts and a missing origin have no Mapper without configuration', () => {
assert.equal(resolveMapperURL('', 'http://localhost:4002'), '')
assert.equal(resolveMapperURL('', 'https://terms.example.org'), '')
assert.equal(resolveMapperURL('', 'https://app.example.org'), '')
assert.equal(resolveMapperURL('', undefined), '')
})
71 changes: 71 additions & 0 deletions src/common/__tests__/mapProjects.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import test from 'node:test'
import assert from 'node:assert/strict'

const { mergeProjects, projectCount, canSeeMapProjects, filterProjects, fetchAllPages } = await import('../mapProjects.js')

test('mergeProjects combines user and organization projects without duplicates', () => {
const merged = mergeProjects(
[{url: '/users/a/map-projects/1/'}, {url: '/users/a/map-projects/2/'}],
[{url: '/orgs/o/map-projects/3/'}, {url: '/users/a/map-projects/2/'}]
)
assert.deepEqual(merged.map(p => p.url), ['/users/a/map-projects/1/', '/users/a/map-projects/2/', '/orgs/o/map-projects/3/'])
})

test('projectCount sums num_found across user and organization lists', () => {
assert.equal(projectCount({headers: {num_found: '4'}}, {headers: {num_found: '3'}}), 7)
assert.equal(projectCount({headers: {}}, undefined), 0)
})

test('canSeeMapProjects shows a user their own projects, not anyone else\'s', () => {
const member = {username: 'member', subscribed_orgs: [{id: 'CIEL'}]}
assert.equal(canSeeMapProjects(member, 'users', 'member'), true)
assert.equal(canSeeMapProjects(member, 'users', 'stranger'), false)
})

test('canSeeMapProjects shows an organization\'s projects to its members only', () => {
const member = {username: 'member', subscribed_orgs: [{id: 'CIEL'}]}
assert.equal(canSeeMapProjects(member, 'orgs', 'CIEL'), true)
assert.equal(canSeeMapProjects(member, 'orgs', 'WHO'), false)
assert.equal(canSeeMapProjects({username: 'member'}, 'orgs', 'CIEL'), false)
})

test('canSeeMapProjects shows staff every profile\'s projects', () => {
const staff = {username: 'admin', is_staff: true}
assert.equal(canSeeMapProjects(staff, 'users', 'stranger'), true)
assert.equal(canSeeMapProjects(staff, 'orgs', 'WHO'), true)
})

test('canSeeMapProjects hides projects when signed out', () => {
assert.equal(canSeeMapProjects(null, 'users', 'member'), false)
assert.equal(canSeeMapProjects({}, 'orgs', 'CIEL'), false)
})

test('filterProjects matches name or ID, ignoring case and spaces around the term', () => {
const projects = [{id: 181, name: 'Malaria labs'}, {id: 182, name: 'Kenya HIV'}, {id: 1818, name: 'Other'}]
assert.deepEqual(filterProjects(projects, ' malaria ').map(p => p.id), [181])
assert.deepEqual(filterProjects(projects, '181').map(p => p.id), [181, 1818])
assert.equal(filterProjects(projects, ''), projects)
assert.equal(filterProjects(null, 'x'), null)
})

const page = (data, next) => ({status: 200, data, headers: next ? {next: 'yes'} : {}})

test('fetchAllPages loads every page until there is no next one', async () => {
const pages = {1: page([{url: 'a'}], true), 2: page([{url: 'b'}], true), 3: page([{url: 'c'}])}
const all = await fetchAllPages(n => Promise.resolve(pages[n]), 20)
assert.deepEqual(all.map(p => p.url), ['a', 'b', 'c'])
})

test('fetchAllPages rejects when any page fails, so a partial list never looks complete', async () => {
// APIService resolves a failed request with the error body, not a response
const pages = {1: page([{url: 'a'}], true), 2: {detail: 'Server error'}}
await assert.rejects(fetchAllPages(n => Promise.resolve(pages[n]), 20))
await assert.rejects(fetchAllPages(() => Promise.resolve(undefined), 20))
})

test('fetchAllPages stops at maxPages', async () => {
const requested = []
const all = await fetchAllPages(n => { requested.push(n); return Promise.resolve(page([{url: String(n)}], true)) }, 2)
assert.deepEqual(requested, [1, 2])
assert.equal(all.length, 2)
})
41 changes: 41 additions & 0 deletions src/common/__tests__/navigation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import test from 'node:test'
import assert from 'node:assert/strict'

const { trayItems, createItems } = await import('../navigation.js')

const user = {url: '/users/test/'}

test('tray lists dashboard, repositories and mapping projects', () => {
assert.deepEqual(trayItems(user, true).map(i => [i.key, i.path]), [
['dashboard', '/'],
['repos', '/users/test/repos'],
['map-projects', '/users/test/map-projects'],
])
})

test('tray omits mapping projects without a mapper', () => {
assert.deepEqual(trayItems(user, false).map(i => i.key), ['dashboard', 'repos'])
})

test('create menu sends the mapping project to the mapper app', () => {
const items = createItems(user, true)
assert.equal(items.find(i => i.key === 'map-project').app, 'mapper')
assert.equal(createItems(user, false).some(i => i.key === 'map-project'), false)
assert.equal(items.find(i => i.key === 'import').divider, true)
})

const { isTrayItemSelected } = await import('../navigation.js')

test('mapping projects stays selected on a project page', () => {
const item = trayItems(user, true).find(i => i.key === 'map-projects')
assert.equal(isTrayItemSelected(item, '/users/test/map-projects'), true)
assert.equal(isTrayItemSelected(item, '/users/test/map-projects/'), true)
assert.equal(isTrayItemSelected(item, '/users/test/map-projects/123/'), true)
assert.equal(isTrayItemSelected(item, '/users/test/repos'), false)
})

test('dashboard is selected only on the root path', () => {
const item = trayItems(user, true).find(i => i.key === 'dashboard')
assert.equal(isTrayItemSelected(item, '/'), true)
assert.equal(isTrayItemSelected(item, '/users/test/map-projects'), false)
})
10 changes: 10 additions & 0 deletions src/common/appUrls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const OFFICIAL_ORIGIN = /^https:\/\/app\.(v3\.)?([a-z]+\.)*openconceptlab\.org$/

export const resolveMapperURL = (configured, origin) => {
const url = (configured || '').trim().replace(/\/+$/, '')
if(url)
return url
if(OFFICIAL_ORIGIN.test(origin || ''))
return origin.replace(/\/\/app\.(v3\.)?/, '//map.')
return ''
}
1 change: 1 addition & 0 deletions src/common/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,4 @@ export const TBV3_ANNOUNCEMENT_LETTER_URL = 'https://openconceptlab.org/blog/let
export const TBV3_ANNOUNCEMENT_PRICING_URL = 'https://openconceptlab.org/pricing?utm_source=tbv3&utm_medium=banner&utm_campaign=subscription_dates'
export const TERMS_OF_USE_URL = 'https://openconceptlab.org/terms-of-use'
export const PRIVACY_POLICY_URL = 'https://openconceptlab.org/privacy-policy'
export const COMMUNITY_SITE_URL = 'https://openconceptlab.org'
41 changes: 41 additions & 0 deletions src/common/mapProjects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export const mergeProjects = (...lists) => {
const seen = new Set()
return lists.flat().filter(project => !seen.has(project.url) && seen.add(project.url))
}

export const projectCount = (...responses) => responses.reduce(
(total, response) => total + (parseInt(response?.headers?.num_found) || 0),
0
)

// Mapping projects are private: only their owner, members of the owning
// organization and staff can see them, so only they get the profile tab.
export const canSeeMapProjects = (currentUser, ownerType, owner) => {
if(!currentUser?.username || !owner)
return false
if(currentUser.is_staff)
return true
if(ownerType === 'users')
return currentUser.username === owner
if(ownerType === 'orgs')
return Boolean(currentUser.subscribed_orgs?.some(org => org?.id === owner))
return false
}

// The API can't search mapping projects, so the profile tab filters the
// list it loaded, by name and ID.
export const filterProjects = (projects, query) => {
const term = (query || '').trim().toLowerCase()
if(!term || !Array.isArray(projects))
return projects
return projects.filter(project => [project.name, project.id].some(value => String(value ?? '').toLowerCase().includes(term)))
}

// Loads every page of a mapping-project list (up to maxPages). A page that fails
// rejects the whole load, so a partial list never shows as if it were complete.
export const fetchAllPages = (getPage, maxPages, page = 1, loaded = []) => getPage(page).then(response => {
if(!(response?.status >= 200 && response.status < 300))
throw new Error(`Mapping projects page ${page} could not be loaded`)
const all = [...loaded, ...(Array.isArray(response.data) ? response.data : [])]
return response.headers?.next && page < maxPages ? fetchAllPages(getPage, maxPages, page + 1, all) : all
})
21 changes: 21 additions & 0 deletions src/common/navigation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const trayItems = (user, hasMapper) => [
{key: 'dashboard', app: 'v3', path: '/', labelKey: 'dashboard.my'},
{key: 'repos', app: 'v3', path: `${user?.url}repos`, labelKey: 'user.my_repositories'},
...(hasMapper ? [{key: 'map-projects', app: 'v3', path: `${user?.url}map-projects`, labelKey: 'user.my_mapping_projects'}] : [])
]

export const createItems = (user, hasMapper) => [
{key: 'org', app: 'v3', path: '/orgs/new'},
{key: 'repo', app: 'v3', path: `${user?.url}repos/new`},
...(hasMapper ? [{key: 'map-project', app: 'mapper', path: '/map-projects/new'}] : []),
{key: 'import', app: 'v3', path: '/imports', divider: true}
]

export const isTrayItemSelected = (item, pathname) => {
if(!pathname)
return false
if(item.key === 'dashboard')
return pathname === item.path
const base = item.path.replace(/\/+$/, '')
return pathname === base || pathname === `${base}/` || pathname.startsWith(`${base}/`)
}
4 changes: 4 additions & 0 deletions src/common/plugins/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ export function loadUsageDashboard() {
export function loadUserProfileExtension() {
return loadPrivateExport("UserProfileExtension");
}

export function loadProfileTabs() {
return loadPrivateExport("ProfileTabs");
}
1 change: 1 addition & 0 deletions src/common/plugins/usage-dashboard.stub.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const UsageDashboard = null;
export const UserProfileExtension = null;
export const ProfileTabs = null;
export default null;
96 changes: 7 additions & 89 deletions src/common/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ import {
} from 'lodash';
import {
DATE_FORMAT, TIME_FORMAT, DATETIME_FORMAT, OCL_SERVERS_GROUP, OCL_FHIR_SERVERS_GROUP, HAPI_FHIR_SERVERS_GROUP,
OPENMRS_URL, DEFAULT_FHIR_SERVER_FOR_LOCAL_ID, OPERATIONS_PANEL_GROUP, ID_REGEX,
RESERVED_ROUTE_KEYWORDS, OCL_CLIENT_HEADERS
OPENMRS_URL, DEFAULT_FHIR_SERVER_FOR_LOCAL_ID, OPERATIONS_PANEL_GROUP, OCL_CLIENT_HEADERS
} from './constants';
import APIService from '../services/APIService';
import GAService from '../services/GAService';
import { ensureIdToken, prepareSignOut, settleForcedSignOut, renewSessionSilently } from './ssoSession';
import { getEnv } from './env';
import { SERVER_CONFIGS } from './serverConfigs';
import { legacyHashRoute, isSameSitePath } from './history';
import { resolveMapperURL } from './appUrls';

export { dropVersion } from './uri';

Expand Down Expand Up @@ -1088,100 +1088,18 @@ export const handleLookupValuesResponse = (data, callback, attr) => {
callback(orderBy(uniqBy(map(data, cc => ({id: get(cc, _attr), name: get(cc, _attr)})), 'name')), 'name');
}

export const toMapperURL = path => {
let url = 'https://map.openconceptlab.org'
if(window.location.host?.includes('localhost'))
url = 'http://localhost:4004'
if(['app.v3.qa.openconceptlab.org', 'app.v3.demo.openconceptlab.org'].includes(window.location.host))
url = 'https://map.qa.openconceptlab.org'
if(window.location.host.match('app.v3.*.openconceptlab.org'))
url = window.location.origin.replace('//app.v3.', '//map.')

let referrerParams = `referrer=${window.location.href}`
if(isLoggedIn())
referrerParams += '?auth=true'


return `${url}/#${path || '/'}?${referrerParams}`
}

const SHARED_REPO_TABS = ['concepts', 'mappings', 'references', 'versions', 'about']
const EXPANSION_SCOPED_TABS = ['concepts', 'mappings']
const RESERVED_REPO_SEGMENTS = [...SHARED_REPO_TABS, ...RESERVED_ROUTE_KEYWORDS, 'edit', 'compare-versions']
const SHARED_ROOT_PATHS = ['/search', '/imports', '/concepts/compare', '/mappings/compare']
const SHARED_OWNER_PATHS = {users: ['settings'], orgs: ['edit']}

const isRouteId = segment => Boolean(segment) && ID_REGEX.test(segment)

export const toV2Path = path => {
const segments = (path || '').split('?')[0].split('/').filter(Boolean)

if(segments.length === 0)
return '/'

const fullPath = '/' + segments.join('/')
if(SHARED_ROOT_PATHS.includes(fullPath))
return fullPath

const [ownerType, owner, repoType, repo] = segments

if(!['users', 'orgs'].includes(ownerType) || !isRouteId(owner))
return '/'
export const getMapperURL = () => resolveMapperURL(window.MAPPER_URL || process.env.MAPPER_URL, window.location.origin)

const ownerHome = `/${ownerType}/${owner}`
export const hasMapper = () => Boolean(getMapperURL())

if(segments.length === 2)
return ownerHome

if(segments.length === 3 && SHARED_OWNER_PATHS[ownerType].includes(segments[2]))
return fullPath

if(!['sources', 'collections'].includes(repoType) || !isRouteId(repo))
return ownerHome

let repoHome = `${ownerHome}/${repoType}/${repo}`
let rest = segments.slice(4)
let hasVersion = false

if(rest.length > 0 && !RESERVED_REPO_SEGMENTS.includes(rest[0])) {
if(!isRouteId(rest[0]))
return repoHome
repoHome = `${repoHome}/${rest[0]}`
rest = rest.slice(1)
hasVersion = true
}

let expansion = ''
if(rest[0] === 'expansions' && isRouteId(rest[1])) {
expansion = rest[1]
rest = rest.slice(2)
}

const expansionBase = expansion ? `${repoHome}${hasVersion ? '' : '/HEAD'}/expansions/${expansion}` : repoHome

if(rest.length === 0)
return expansionBase

if(!SHARED_REPO_TABS.includes(rest[0]))
return repoHome

const tabPath = `${EXPANSION_SCOPED_TABS.includes(rest[0]) ? expansionBase : repoHome}/${rest[0]}`

return isRouteId(rest[1]) ? `${tabPath}/${rest[1]}` : tabPath
}

export const toV2URL = path => {
let url = 'https://app.openconceptlab.org'
if(window.location.host?.includes('localhost'))
url = 'http://localhost:4000'
if(window.location.host.match('app.v3.*.openconceptlab.org'))
url = window.location.origin.replace('//app.v3.', '//app.')
export const toMapperURL = path => {
const url = getMapperURL()

let referrerParams = `referrer=${window.location.href}`
if(isLoggedIn())
referrerParams += '?auth=true'

return `${url}/#${toV2Path(path)}?${referrerParams}`
return `${url}/#${path || '/'}?${referrerParams}`
}

export const isMapperURL = url => {
Expand Down
Loading
Loading