diff --git a/Dockerfile b/Dockerfile index ba941e20..cc9fddf4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,6 @@ FROM node:22 as build ARG NODE_ENV=production ARG NODE_OPTIONS=--max_old_space_size=700 -ENV OCL_ONLINE=${OCL_ONLINE} ENV BRIDGE_MATCH_URL=${BRIDGE_MATCH_URL:-''} ENV NPM_CONFIG_LOGLEVEL warn ENV WEB_PORT=${WEB_PORT:-4004} @@ -16,6 +15,7 @@ ENV OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} ENV OIDC_SERVER_URL=${OIDC_SERVER_URL} ENV OIDC_REALM=${OIDC_REALM} ENV OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL} +ENV TERMBROWSER_URL=${TERMBROWSER_URL} ENV SCISPACY_LOINC_API_URL=${SCISPACY_LOINC_API_URL} ENV REPO_SUGGESTIONS=${REPO_SUGGESTIONS} RUN mkdir /app diff --git a/docker-compose.yml b/docker-compose.yml index 514ea2ac..a6d01a7f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,9 +19,9 @@ services: - ERRBIT_URL - ERRBIT_KEY - HOTJAR_ID - - OCL_ONLINE=${OCL_ONLINE-false} - BRIDGE_MATCH_URL - AI_ASSISTANT_API_URL - SCISPACY_LOINC_API_URL - OCL_ONLINE_API_URL + - TERMBROWSER_URL=${TERMBROWSER_URL-} - REPO_SUGGESTIONS=${REPO_SUGGESTIONS} diff --git a/package.json b/package.json index 5ecc9de9..6e92156e 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,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 OCL_ONLINE=${OCL_ONLINE} --env BRIDGE_MATCH_URL=${BRIDGE_MATCH_URL} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --env SCISPACY_LOINC_API_URL=${SCISPACY_LOINC_API_URL} --env OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL} --env REPO_SUGGESTIONS=${REPO_SUGGESTIONS} --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 OCL_ONLINE=${OCL_ONLINE} --env BRIDGE_MATCH_URL=${BRIDGE_MATCH_URL} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --env SCISPACY_LOINC_API_URL=${SCISPACY_LOINC_API_URL} --env OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL} --env REPO_SUGGESTIONS=${REPO_SUGGESTIONS} --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 BRIDGE_MATCH_URL=${BRIDGE_MATCH_URL} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --env SCISPACY_LOINC_API_URL=${SCISPACY_LOINC_API_URL} --env OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL} --env TERMBROWSER_URL=${TERMBROWSER_URL} --env REPO_SUGGESTIONS=${REPO_SUGGESTIONS} --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 BRIDGE_MATCH_URL=${BRIDGE_MATCH_URL} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --env SCISPACY_LOINC_API_URL=${SCISPACY_LOINC_API_URL} --env OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL} --env TERMBROWSER_URL=${TERMBROWSER_URL} --env REPO_SUGGESTIONS=${REPO_SUGGESTIONS} --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'", "test:integration": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --import ./test-integration-setup.js --test 'src/**/__integration__/**/*.test.js'", diff --git a/src/common/__tests__/appUrls.test.js b/src/common/__tests__/appUrls.test.js new file mode 100644 index 00000000..ac921a00 --- /dev/null +++ b/src/common/__tests__/appUrls.test.js @@ -0,0 +1,32 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +const { resolveV3URL, resolveIntegratedV3URL, PRODUCTION_TERMBROWSER_URL } = await import('../appUrls.js') + +test('configured URL wins and loses its trailing slash', () => { + assert.equal(resolveV3URL('https://tb.example.org/', 'https://map.qa.openconceptlab.org', 'map.qa.openconceptlab.org'), 'https://tb.example.org') +}) + +test('localhost points to the local TermBrowser', () => { + assert.equal(resolveV3URL('', 'http://localhost:4004', 'localhost:4004'), 'http://localhost:4002') +}) + +test('official Mapper hosts map to the matching TermBrowser environment', () => { + assert.equal(resolveV3URL('', 'https://map.openconceptlab.org', 'map.openconceptlab.org'), 'https://app.v3.openconceptlab.org') + assert.equal(resolveV3URL('', 'https://map.qa.openconceptlab.org', 'map.qa.openconceptlab.org'), 'https://app.v3.qa.openconceptlab.org') + assert.equal(resolveV3URL(undefined, 'https://map.staging.who.openconceptlab.org', 'map.staging.who.openconceptlab.org'), 'https://app.v3.staging.who.openconceptlab.org') +}) + +test('custom hosts without configuration point to production', () => { + assert.equal(resolveV3URL('', 'https://mapper.example.org', 'mapper.example.org'), PRODUCTION_TERMBROWSER_URL) + assert.equal(resolveV3URL('', undefined, undefined), PRODUCTION_TERMBROWSER_URL) +}) + +test('an integrated TermBrowser needs configuration or an official Mapper host', () => { + assert.equal(resolveIntegratedV3URL('https://tb.example.org/', 'https://mapper.example.org', 'mapper.example.org'), 'https://tb.example.org') + assert.equal(resolveIntegratedV3URL('http://localhost:4002', 'http://localhost:4004', 'localhost:4004'), 'http://localhost:4002') + assert.equal(resolveIntegratedV3URL('', 'http://localhost:4004', 'localhost:4004'), '', 'a standalone local Mapper keeps its own pages') + assert.equal(resolveIntegratedV3URL('', 'https://map.qa.openconceptlab.org', 'map.qa.openconceptlab.org'), 'https://app.v3.qa.openconceptlab.org') + assert.equal(resolveIntegratedV3URL('', 'https://mapper.example.org', 'mapper.example.org'), '') + assert.equal(resolveIntegratedV3URL('', undefined, undefined), '') +}) diff --git a/src/common/__tests__/breadcrumb.test.js b/src/common/__tests__/breadcrumb.test.js new file mode 100644 index 00000000..714f9b89 --- /dev/null +++ b/src/common/__tests__/breadcrumb.test.js @@ -0,0 +1,20 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +const { projectCrumbs } = await import('../breadcrumb.js') + +const toV3URL = path => `https://tb.example.org/#${path}` + +test('integrated deployments link both crumbs to TermBrowser', () => { + assert.deepEqual(projectCrumbs('/users/test/', true, toV3URL).map(c => c.href), [ + 'https://tb.example.org/#/users/test/', + 'https://tb.example.org/#/users/test/map-projects' + ]) +}) + +test('standalone deployments keep the owner as text and the projects crumb local', () => { + assert.deepEqual(projectCrumbs('/users/test/', false, () => { throw new Error('no TermBrowser link') }).map(c => c.href), [ + null, + '#/map-projects' + ]) +}) diff --git a/src/common/__tests__/mapProjects.test.js b/src/common/__tests__/mapProjects.test.js new file mode 100644 index 00000000..f15897ff --- /dev/null +++ b/src/common/__tests__/mapProjects.test.js @@ -0,0 +1,9 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +const { projectCount } = await import('../mapProjects.js') + +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) +}) diff --git a/src/common/__tests__/navigation.test.js b/src/common/__tests__/navigation.test.js new file mode 100644 index 00000000..7b5fd72e --- /dev/null +++ b/src/common/__tests__/navigation.test.js @@ -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) +}) diff --git a/src/common/appUrls.js b/src/common/appUrls.js new file mode 100644 index 00000000..073cfeef --- /dev/null +++ b/src/common/appUrls.js @@ -0,0 +1,17 @@ +export const PRODUCTION_TERMBROWSER_URL = 'https://app.v3.openconceptlab.org' + +const OFFICIAL_MAPPER_ORIGIN = /^https:\/\/map\.([a-z]+\.)*openconceptlab\.org$/ + +// A TermBrowser counts as part of this installation only when it's configured +// or this is an official Mapper host; a standalone Mapper keeps its own pages. +export const resolveIntegratedV3URL = (configured, origin) => { + const url = (configured || '').trim().replace(/\/+$/, '') + if(url) + return url + if(OFFICIAL_MAPPER_ORIGIN.test(origin || '')) + return origin.replace('//map.', '//app.v3.') + return '' +} + +export const resolveV3URL = (configured, origin, host) => + resolveIntegratedV3URL(configured, origin) || (host?.includes('localhost') ? 'http://localhost:4002' : PRODUCTION_TERMBROWSER_URL) diff --git a/src/common/breadcrumb.js b/src/common/breadcrumb.js new file mode 100644 index 00000000..cd03ffb7 --- /dev/null +++ b/src/common/breadcrumb.js @@ -0,0 +1,4 @@ +export const projectCrumbs = (ownerURL, integrated, toV3URL) => [ + {key: 'owner', href: integrated ? toV3URL(ownerURL) : null}, + {key: 'projects', href: integrated ? toV3URL(`${ownerURL}map-projects`) : '#/map-projects'} +] diff --git a/src/common/constants.js b/src/common/constants.js index 62afee14..b364d120 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -78,3 +78,4 @@ export const MAPPER_ANNOUNCEMENT_LETTER_URL = 'https://openconceptlab.org/blog/l export const MAPPER_ANNOUNCEMENT_PRICING_URL = 'https://openconceptlab.org/pricing?utm_source=oclmap&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' diff --git a/src/common/mapProjects.js b/src/common/mapProjects.js new file mode 100644 index 00000000..822cd1ee --- /dev/null +++ b/src/common/mapProjects.js @@ -0,0 +1,4 @@ +export const projectCount = (...responses) => responses.reduce( + (total, response) => total + (parseInt(response?.headers?.num_found) || 0), + 0 +) diff --git a/src/common/navigation.js b/src/common/navigation.js new file mode 100644 index 00000000..9eec4cac --- /dev/null +++ b/src/common/navigation.js @@ -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}/`) +} diff --git a/src/common/utils.js b/src/common/utils.js index c4a72eab..85f38a7a 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -18,6 +18,7 @@ import GAService from '../services/GAService'; import { ensureIdToken, prepareSignOut, settleForcedSignOut, renewSessionSilently } from './ssoSession'; import { SERVER_CONFIGS } from './serverConfigs'; import { legacyHashRoute, isSameSitePath } from './history'; +import { resolveV3URL, resolveIntegratedV3URL } from './appUrls'; export const currentPath = () => window.location.pathname; @@ -245,7 +246,7 @@ export const arrayToCSV = objArray => { } export const refreshCurrentUserCache = (callback, onFailure) => { - APIService.user().get(null, null, {includeAuthGroups: true, includeSubscribedOrgs: true, includeCapabilities: true}).then(response => { + APIService.user().get(null, null, {includeAuthGroups: true, includeSubscribedOrgs: true, includeFollowing: true, includeCapabilities: true}).then(response => { if(response?.status === 200) { localStorage.setItem('user', JSON.stringify(response.data)); if(callback) callback(response); @@ -1093,20 +1094,16 @@ export const highlightTexts = (items, texts, unmark=false, additionalTexts) => { export const pluralize = (count, singular, plural) => `${count?.toLocaleString()} ${count === 1 ? singular : plural}`; -export const toV3URL = path => { - let url = 'https://app.v3.openconceptlab.org' - if(window.location.host?.includes('localhost')) - url = 'http://localhost:4002' - if(['map.dev.openconceptlab.org', 'map.demo.openconceptlab.org'].includes(window.location.host)) - url = 'https://app.v3.qa.openconceptlab.org' - if(window.location.host.match('map.*.openconceptlab.org')) - url = window.location.origin.replace('//map.', '//app.v3.') +export const getV3URL = () => resolveV3URL(window.TERMBROWSER_URL || process.env.TERMBROWSER_URL, window.location.origin, window.location.host) + +export const hasTermBrowser = () => Boolean(resolveIntegratedV3URL(window.TERMBROWSER_URL || process.env.TERMBROWSER_URL, window.location.origin, window.location.host)) +export const toV3URL = path => { let referrerParams = `referrer=${window.location.href}` if(isLoggedIn()) referrerParams += '?auth=true' - return `${url}/#${path || '/'}?${referrerParams}` + return `${getV3URL()}/#${path || '/'}?${referrerParams}` } export const isV3URL = url => { diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx index 977140ea..adc65901 100644 --- a/src/components/app/App.jsx +++ b/src/components/app/App.jsx @@ -26,7 +26,7 @@ import OIDLoginCallback from '../users/OIDLoginCallback'; import { OperationsContext } from './LayoutContext'; import Alert from '../common/Alert'; import MapProjectRoute from '../map-projects/MapProjectRoute' -import MapProjects from '../map-projects/MapProjects' +import MapProjectsRedirect from '../map-projects/MapProjectsRedirect' import { useTranslation } from 'react-i18next'; const AuthenticationRequiredRoute = ({component: Component, ...rest}) => { @@ -164,8 +164,8 @@ const App = props => { - - + + diff --git a/src/components/app/DocumentTitle.jsx b/src/components/app/DocumentTitle.jsx index 70ec0dd0..cd351987 100644 --- a/src/components/app/DocumentTitle.jsx +++ b/src/components/app/DocumentTitle.jsx @@ -5,7 +5,7 @@ import { getSiteTitle } from '../../common/utils'; const SITE_TITLE = getSiteTitle() -export const getAppTitle = () => `${getSiteTitle()} Mapper` +export const getAppTitle = () => getSiteTitle() /*eslint no-useless-escape: 0*/ const TITLES = { diff --git a/src/components/app/Footer.jsx b/src/components/app/Footer.jsx index bb59b7e8..1e2ef8cc 100644 --- a/src/components/app/Footer.jsx +++ b/src/components/app/Footer.jsx @@ -6,7 +6,7 @@ import { get } from 'lodash'; import { getAppliedServerConfig } from '../../common/utils'; -import { TERMS_OF_USE_URL, PRIVACY_POLICY_URL } from '../../common/constants'; +import { COMMUNITY_SITE_URL, TERMS_OF_USE_URL, PRIVACY_POLICY_URL } from '../../common/constants'; import AppVersions from './AppVersions'; import './Footer.scss'; @@ -44,6 +44,8 @@ const Footer = () => {