From c90911508755cbd5e368af29f3945a531e6b5e36 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Fri, 30 Oct 2026 09:23:29 +0530 Subject: [PATCH 01/14] OpenConceptLab/ocl_online#418 | Unified navigation: shared tray, + menu and Mapping Projects profile tab --- Dockerfile | 1 + docker-compose.yml | 1 + package.json | 4 +- src/common/navigation.js | 12 ++ src/common/plugins/loader.js | 4 + src/common/plugins/usage-dashboard.stub.js | 1 + src/common/utils.js | 100 ++----------- src/components/app/App.jsx | 6 +- .../app/ClassicTermBrowserButton.jsx | 44 ------ src/components/app/HeaderControls.jsx | 2 - src/components/app/LeftMenu.jsx | 131 +++++++----------- src/components/common/ClassicOCLLogo.jsx | 16 --- src/components/common/ProfileTabContent.jsx | 13 ++ src/components/map-projects/MapProjects.jsx | 87 ++++++++++++ src/components/orgs/OrgHome.jsx | 9 ++ src/components/users/AddMenuList.jsx | 42 +++--- src/components/users/UserHome.jsx | 9 ++ src/hooks/useProfileTabs.js | 34 +++++ src/i18n/locales/en/translations.json | 8 +- src/i18n/locales/es/translations.json | 8 +- src/i18n/locales/pt-BR/translations.json | 8 +- src/i18n/locales/zh/translations.json | 8 +- start-prod.sh | 3 + webpack.config.js | 1 + 24 files changed, 289 insertions(+), 263 deletions(-) create mode 100644 src/common/navigation.js delete mode 100644 src/components/app/ClassicTermBrowserButton.jsx delete mode 100644 src/components/common/ClassicOCLLogo.jsx create mode 100644 src/components/common/ProfileTabContent.jsx create mode 100644 src/components/map-projects/MapProjects.jsx create mode 100644 src/hooks/useProfileTabs.js diff --git a/Dockerfile b/Dockerfile index f0f21162d..777571351 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index c73aa5bc2..bcea4caf4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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-http://localhost:4004} - ERRBIT_URL - ERRBIT_KEY - HOTJAR_ID diff --git a/package.json b/package.json index fcc0b98fb..a30c780c0 100644 --- a/package.json +++ b/package.json @@ -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'" }, diff --git a/src/common/navigation.js b/src/common/navigation.js new file mode 100644 index 000000000..bf88e7bb8 --- /dev/null +++ b/src/common/navigation.js @@ -0,0 +1,12 @@ +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} +] diff --git a/src/common/plugins/loader.js b/src/common/plugins/loader.js index 94822e85e..3b81214f0 100644 --- a/src/common/plugins/loader.js +++ b/src/common/plugins/loader.js @@ -17,3 +17,7 @@ export function loadUsageDashboard() { export function loadUserProfileExtension() { return loadPrivateExport("UserProfileExtension"); } + +export function loadProfileTabs() { + return loadPrivateExport("ProfileTabs"); +} diff --git a/src/common/plugins/usage-dashboard.stub.js b/src/common/plugins/usage-dashboard.stub.js index 905fb6a44..0a4905bb6 100644 --- a/src/common/plugins/usage-dashboard.stub.js +++ b/src/common/plugins/usage-dashboard.stub.js @@ -1,3 +1,4 @@ export const UsageDashboard = null; export const UserProfileExtension = null; +export const ProfileTabs = null; export default null; diff --git a/src/common/utils.js b/src/common/utils.js index e294d18be..c76dcac02 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -11,8 +11,7 @@ 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'; @@ -1088,100 +1087,25 @@ 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}` +export const getMapperURL = () => { + const configured = (window.MAPPER_URL || process.env.MAPPER_URL || '').trim().replace(/\/+$/, '') + if(configured) + return configured + if(/^https:\/\/app\.(v3\.)?([a-z]+\.)*openconceptlab\.org$/.test(window.location.origin)) + return window.location.origin.replace(/\/\/app\.(v3\.)?/, '//map.') + return '' } -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 '/' - - const ownerHome = `/${ownerType}/${owner}` - - if(segments.length === 2) - return ownerHome +export const hasMapper = () => Boolean(getMapperURL()) - 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 => { diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx index 5e3f35559..c274f6d1d 100644 --- a/src/components/app/App.jsx +++ b/src/components/app/App.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { Route, Switch, withRouter } from 'react-router-dom'; import { - isLoggedIn, refreshCurrentUserCache, getCurrentUser, getLoginURL, isOtherOCLClientURL, isRedirectingToLoginViaReferrer + isLoggedIn, refreshCurrentUserCache, getCurrentUser, hasMapper, getLoginURL, isOtherOCLClientURL, isRedirectingToLoginViaReferrer } from '../../common/utils'; import GAService from '../../services/GAService'; import { silentSignIn, isSSOSessionEnabled, settleForcedSignOut } from '../../common/ssoSession'; @@ -39,6 +39,7 @@ import ConceptsComparison from '../concepts/ConceptsComparison' import MappingsComparison from '../mappings/MappingsComparison' import CheckAuth from './CheckAuth' import { loadUsageDashboard } from "../../common/plugins/loader"; +import { usePluginTabs } from "../../hooks/useProfileTabs"; import UserChip from '../users/UserChip' import UserTooltip from '../users/UserTooltip' @@ -186,7 +187,8 @@ const App = props => { }) }, []); - const orgTabs = ['repos'] + const pluginTabs = usePluginTabs() + const orgTabs = ['repos', ...(hasMapper() ? ['map-projects'] : []), ...pluginTabs.map(tab => tab.key)] const orgTabsStr = orgTabs.join('|') return ( diff --git a/src/components/app/ClassicTermBrowserButton.jsx b/src/components/app/ClassicTermBrowserButton.jsx deleted file mode 100644 index a9edccca7..000000000 --- a/src/components/app/ClassicTermBrowserButton.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { useLocation } from 'react-router-dom'; -import Chip from '@mui/material/Chip'; -import Tooltip from '@mui/material/Tooltip'; -import { toV2URL } from '../../common/utils'; -import ClassicOCLLogo from '../common/ClassicOCLLogo'; - -const V2_PRIMARY = 'rgb(51, 115, 170)'; - -const ClassicTermBrowserButton = () => { - const { t } = useTranslation(); - const location = useLocation(); - const href = toV2URL(location.pathname + (location.search || '')); - - return ( - - } - label={t('common.classic_termbrowser')} - sx={{ - marginRight: '8px', - paddingLeft: '8px', - verticalAlign: 'middle', - maxWidth: {xs: '150px', sm: 'none'}, - color: V2_PRIMARY, - borderColor: V2_PRIMARY, - '.MuiChip-label': { - paddingLeft: '6px', - paddingRight: '8px' - } - }} - /> - - ); -}; - -export default ClassicTermBrowserButton; diff --git a/src/components/app/HeaderControls.jsx b/src/components/app/HeaderControls.jsx index fe6f2e7c0..a1e00ab61 100644 --- a/src/components/app/HeaderControls.jsx +++ b/src/components/app/HeaderControls.jsx @@ -6,7 +6,6 @@ import ErrorBoundary from '../errors/ErrorBoundary'; import UserProfileButton from '../users/UserProfileButton'; import AddMenuList from '../users/AddMenuList' import { isLoggedIn } from '../../common/utils'; -import ClassicTermBrowserButton from './ClassicTermBrowserButton'; const HeaderControls = () => { @@ -27,7 +26,6 @@ const HeaderControls = () => { return (
- { authenticated && diff --git a/src/components/app/LeftMenu.jsx b/src/components/app/LeftMenu.jsx index fe6c8e410..abfe5cf3c 100644 --- a/src/components/app/LeftMenu.jsx +++ b/src/components/app/LeftMenu.jsx @@ -14,12 +14,20 @@ import DashboardIcon from '@mui/icons-material/Dashboard'; import FolderOpenIcon from '@mui/icons-material/FolderOutlined'; import Divider from '@mui/material/Divider'; import map from 'lodash/map' -import { getCurrentUser, refreshCurrentUserCache, getCurrentUserOrgs, toMapperURL, isInWaitlist} from '../../common/utils'; +import { getCurrentUser, refreshCurrentUserCache, getCurrentUserOrgs, hasMapper, isInWaitlist} from '../../common/utils'; +import { trayItems } from '../../common/navigation'; +import { PRIMARY_COLORS } from '../../common/colors'; import Drawer from '../common/Drawer'; import OrgIcon from '../orgs/OrgIcon'; import EntityIcon from '../common/EntityIcon' +const TRAY_ICONS = { + dashboard: selected => , + repos: selected => , + 'map-projects': selected => +} + const DrawerHeader = styled('div')(({ theme }) => ({ display: 'flex', alignItems: 'center', @@ -60,85 +68,46 @@ const LeftMenu = ({ isOpen, onClose }) => { - - - - - - - - - - - - - - - {((user?.collections || 0) + (user?.sources + 0)).toLocaleString()} - - - - - - - - - { - isInWaitlist() && - - } - - + { + trayItems(user, hasMapper()).map(item => { + const selected = location.pathname === item.path + return ( + + + + {TRAY_ICONS[item.key](selected)} + + + { + item.key === 'repos' && + {((user?.collections || 0) + (user?.sources + 0)).toLocaleString()} + } + { + item.key === 'map-projects' && isInWaitlist() && + + } + + + ) + }) + } @@ -146,7 +115,7 @@ const LeftMenu = ({ isOpen, onClose }) => { disablePadding sx={{ display: 'block', padding: 1, borderRadius: '100px' }} secondaryAction={ - } diff --git a/src/components/common/ClassicOCLLogo.jsx b/src/components/common/ClassicOCLLogo.jsx deleted file mode 100644 index 572ea1ee5..000000000 --- a/src/components/common/ClassicOCLLogo.jsx +++ /dev/null @@ -1,16 +0,0 @@ -/*eslint spellcheck/spell-checker: 0*/ -import React from 'react'; - -const CLASSIC_LOGO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAAHlUlEQVRYCa1Xa2xUxxX+Zu7dtddev41tDDEmAgsXNQ+3OA3GeF3sNk5bQ+IaRF9K0sRUoqmSP6jKL1SpSf9UVI3UBiq1SVAejcNDShtDw8MlDjEBg0NiiI1AUB42Btavtb2Pe+/0nFnWrJe1SUuOtL73zpyZ851zvnNmLPAVSl1zS4kTMZYqQ2YIYVwaCQW7u97bNjGbCTHb5J3mfE88kWqOpZYrGMsF7HoI40FA3SMMgz7VhBLoUlbkxf27tu6Zaa//GUB185NzUpz0CinUCkepOiFFuZRGliILSjlQjqNtCSEgDQOO4wQc5Ty9v/VPf08G4o4Ampub3X6roFRIe4WSRjUtqBZKLKDNTW2QjSo2n1wYBBxcsiIh34Hd284mapmJA/y96rGNeY6wlxgwaochqg0XKLSuOeSt9pA8h21Hki29bcyxbRima76pXGtp8qVEhWkRqF/7iyoytF4oVENgEYUxTYfWsWf1MnHTxG8KFqXC/sAMnPtBW1tbKH5eR8Dn22y6Cq5tkjB+DSkyOI8cXseZObTxm9zpnXhAKqIi6C0tpZfeeH3JH0b+YAtR5rdElgzbijDau/I43gC/szMUzVxDYXninPQ9/ux8AbGJ1LRiosJX9S2kJAxyJTZv1k7H9pVwLB/NLIiGKTYcfTK7bSJRsrmYJqdJ6yRUQmJlKIoqyYrqngt5sbX8JFhYTPmJH6NIAJGIBZfLRFFBPjIzvIhYFuybNc7KbJh1vOkezC2cA0+KG2H6ZtaQQ3C7XSCnp/ZlQDRekuKkVEwN0otJDeUKhz8m2msy9N3ah9HcuAq5OVmwLBsHOo5i+7vvIzgZJbHLNPHjpkfwaF2VBhoYn8CbO/diz/6PkZebiReeewqvv/NPnOw5o+d5X2mYbuGoGrK1N2bPpJruFMoZJnTZrBSORLD6kRps+FkTdre14/jJL1BclI8n1zciPzcHv/vj32BRWjb8aDVqq76J1975By5eGcCyB5bi+Zb1GBsL4FTfecybS1FJTZlGZt24hKh9uPl5z8etWyYZhDlo+k/PdfJ7hDSqLKqAgrxcrFtTj1fffg9v7NgDN6Whs8vCwKAfv9nUgoqvL8H1oRHU1VTixT/8FYePntQeHus+jTyK1poGH86c266jxg7FC5c3JaU807LKaPxTnjN7WlvDxU0b9xNLCYCFZQ+WU7cD/tXeqT0wDKkNHO0+hWOffoHllffh+o1hXO4fRBdFJ83jodxSKUuJ7a3vU5SywR0zmTAgakpZDrV1mtcAoiVhW4eUbYe5xRYV5OEGeRgYn5wiEZOKmd579gIWlsxDyfwi/OfyABybiRU1ZVDPHxi8gROf9WoSU2knwxAdk+rbxDutoAFMpsnj1PvO0alGrE6Hf3j0Zu5ubcIbTk4EyeMU/QsGw7TZ9BDHojWzZa4e3RUrGx7/1TzW0wA63vzzEOHpYNCTwRAyvek3PbtlgNoUMrO8GBoJYCwwiWx6TxQOcWLeb9fhriiLI9Ku5LloCuhFWOIg+8u5LZyTC2+aR9c6K/GmpmlgUel8XKLQn7/Yj2Kqfe4TMYPcE75WtlCXX5onFdTWeWlykUJS8igNcQAg7E7TMK9+cqJHL2qoW4FgKIRwOIKJySDuX1qG++jXfrgLnxz/nJpPPhF2KcYpLSHSYY58j9YUFeTSujB5KfRafo/9uJ9oiTa0lY2NT2XQbSEqOWkVgfQ86QuMhxaHw2E889M1FOYMYq1E1UP349mfr8OR459hF/WGoZFRFOTn4ic/bCBPle6Ga1fXo27lMmzZ+hYGr/uxuqEGqdQdS+YVEfDFeIDAc8SuXL1G5ObAq7SQkm1TAPr7u5x7yyuLXW6zvvfseQwNjeI7vm+hnup9yaKFuhP+Zftuqm9L9++Tp85ow9+vr0bN8gpq1+nY+tpOdBzpJpKm6oiV3bsA5WWltJ6fCzW5uz/v0+kkBCadDztu0ZxGaps2PkT3yX2UfC/3de7zOdmZGKeSZK+51qPoo2zmsyEnK1P3iyGqHCYwe8nC5wCn4ZYIzano0SyJO84I+eKbdiWbSJWnvUG7j2q6gs4ShCh/V4iUvBH3/nhhIPwbpdY7Mjqm32PGWc+2k5GQQTE4OoQdp9sTMk5PVQEvOvLGy6Nk/BB1Rf7UhrnBxLzWgwl/eI51pnsbXctj039s3GDvx6ktv9TW9nJoGgDe27ZD/6ZJi7ZIMPX/fzIIvh2bppu6ibpGPPrlvh2v6BNxelzJhls5xyzl9FM/v0fdxZ1QG2VvGbdSASqXPht2Ox14rx/ctVWfAzx1GwCvMdI/gsLDdDqui7+AsPKswl5yyNko3X4I+1WbnKHz/0Nb2AedSLCvfferw4l7JI1zfdOGR4Xh3knhSuEjdCaJ5ldSnplUdoj0z0slDilhfagc8dFQnn2xa9u2Wf+BuC0CbCxb+vf6nfzf09H5AnvEt+SYMEHZIAuB8xNdThFf9tF52WGItBN7393ij+l+mWfSCPDCb7S0uLL95jOk8BwZWERsFwSEW/8FOriOSaU+oFthp3vC08ts/jLGkunMCCCmvKrx6UL632oZhCp0LDUgUmT3gdZXLsfm7/b5X8C0XiX7z9h+AAAAAElFTkSuQmCC'; - -const ClassicOCLLogo = ({ size }) => ( - -); - -export default ClassicOCLLogo; diff --git a/src/components/common/ProfileTabContent.jsx b/src/components/common/ProfileTabContent.jsx new file mode 100644 index 000000000..1ad9b7b07 --- /dev/null +++ b/src/components/common/ProfileTabContent.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import APIService from '../../services/APIService'; +import { getCurrentUser } from '../../common/utils'; +import MapProjects from '../map-projects/MapProjects'; + +const ProfileTabContent = ({ tab, ownerType, owner, height }) => { + if(tab.key === 'map-projects') + return + const Component = tab.Component + return Component ? : null +} + +export default ProfileTabContent diff --git a/src/components/map-projects/MapProjects.jsx b/src/components/map-projects/MapProjects.jsx new file mode 100644 index 000000000..ab1a3434b --- /dev/null +++ b/src/components/map-projects/MapProjects.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import moment from 'moment'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import Skeleton from '@mui/material/Skeleton'; +import Link from '@mui/material/Link'; +import Typography from '@mui/material/Typography'; +import times from 'lodash/times'; +import APIService from '../../services/APIService'; +import { toMapperURL } from '../../common/utils'; + +const COLUMN_COUNT = 5 + +const MapProjects = ({ ownerType, owner, height }) => { + const { t } = useTranslation() + const [projects, setProjects] = React.useState(null) + + React.useEffect(() => { + let active = true + setProjects(null) + APIService[ownerType](owner).appendToUrl('map-projects/').get().then(response => { + if(active) + setProjects(Array.isArray(response?.data) ? response.data : []) + }) + return () => { active = false } + }, [ownerType, owner]) + + return ( + + + + + {t('common.id')} + {t('map_project.project_name')} + {t('common.owner')} + {t('common.created_by')} + {t('common.updated_by')} + + + + { + projects === null && + times(5, i => ( + + {times(COLUMN_COUNT, ci => )} + + )) + } + { + projects?.length === 0 && + + + {t('map_project.no_projects_found')} + + + } + { + projects?.map(project => ( + + {project.id} + + {project.name} + + {project.owner} + + {project.created_by} + {moment(project.created_at).fromNow()} + + + {project.updated_by} + {moment(project.updated_at).fromNow()} + + + )) + } + +
+
+ ) +} + +export default MapProjects diff --git a/src/components/orgs/OrgHome.jsx b/src/components/orgs/OrgHome.jsx index 751153019..534571df0 100644 --- a/src/components/orgs/OrgHome.jsx +++ b/src/components/orgs/OrgHome.jsx @@ -15,6 +15,8 @@ import Error40X from '../errors/Error40X'; import OrgOverview from './OrgOverview'; import OrgSummary from './OrgSummary'; import OrgHeader from './OrgHeader' +import useProfileTabs from '../../hooks/useProfileTabs' +import ProfileTabContent from '../common/ProfileTabContent' const OrgHome = () => { const { t } = useTranslation() @@ -30,9 +32,11 @@ const OrgHome = () => { const { setAlert } = React.useContext(OperationsContext); const { pins: bookmarks, canPin, deletePin } = usePins(params?.org ? {type: 'org', id: params.org} : false) + const extraTabs = useProfileTabs('orgs', params?.org) const TABS = [ {key: 'overview', label: t('common.overview')}, {key: 'repos', label: t('repo.repos')}, + ...extraTabs.map(({key, label}) => ({key, label})) ] const TAB_KEYS = TABS.map(tab => tab.key) const findTab = () => TAB_KEYS.includes(params?.tab) ? params.tab : 'overview' @@ -117,6 +121,11 @@ const OrgHome = () => { excludedColumns={['owner']} /> } + { + extraTabs.filter(extra => extra.key === tab).map(extra => ( + + )) + } { tab === 'overview' && org?.id && diff --git a/src/components/users/AddMenuList.jsx b/src/components/users/AddMenuList.jsx index 80d170d53..04f0c4cf8 100644 --- a/src/components/users/AddMenuList.jsx +++ b/src/components/users/AddMenuList.jsx @@ -2,10 +2,20 @@ import React from 'react'; import { useTranslation } from 'react-i18next' import { Menu, ListItemButton, ListItemText, ListItemIcon, Divider} from '@mui/material' import ImportIcon from '@mui/icons-material/Publish'; -import { getCurrentUser } from '../../common/utils'; +import { getCurrentUser, hasMapper, toMapperURL } from '../../common/utils'; +import { createItems } from '../../common/navigation'; import OrgIcon from '../orgs/OrgIcon' import RepoIcon from '../repos/RepoIcon' +const CREATE_ITEM_IDS = {org: 'addOrg', repo: 'addRepo', 'map-project': 'addMapProject', import: 'bulkImport'} +const CREATE_ITEM_LABELS = {org: 'org.org', repo: 'repo.repo', 'map-project': 'map_project.mapping_project', import: 'import.bulk_import'} +const CREATE_ITEM_ICONS = { + org: , + repo: , + 'map-project': , + import: +} + const ITEM_SX = {padding: '8px 12px', '&:hover': {color: 'inherit'}, '&:focus': {outline: 'none', textDecoration: 'none', color: 'inherit'}} const AddMenuList = ({ anchorEl, open, onClose }) => { @@ -19,25 +29,17 @@ const AddMenuList = ({ anchorEl, open, onClose }) => { onClose={onClose} sx={{'.MuiPaper-root': {backgroundColor: 'surface.n94', minWidth: '200px'}}} > - - - - - - - - - - - - - - - - - - - + { + createItems(user, hasMapper()).flatMap(item => [ + item.divider && , + + + {CREATE_ITEM_ICONS[item.key]} + + + + ]) + } ) } diff --git a/src/components/users/UserHome.jsx b/src/components/users/UserHome.jsx index 1b3e42e6c..cd6b48695 100644 --- a/src/components/users/UserHome.jsx +++ b/src/components/users/UserHome.jsx @@ -13,6 +13,8 @@ import Error40X from '../errors/Error40X'; import UserOverview from './UserOverview'; import UserSummary from './UserSummary' import UserProfile from './UserProfile' +import useProfileTabs from '../../hooks/useProfileTabs' +import ProfileTabContent from '../common/ProfileTabContent' const UserHome = () => { @@ -28,9 +30,11 @@ const UserHome = () => { const [eventsPage, setEventsPage] = React.useState(0) const [haveMoreEvents, setHaveMoreEvents] = React.useState(false) const height = 'calc(var(--app-height) - 95px)' + const extraTabs = useProfileTabs('users', params?.user) const TABS = [ {key: 'overview', label: t('common.overview')}, {key: 'repos', label: t('repo.repos')}, + ...extraTabs.map(({key, label}) => ({key, label})) ] const TAB_KEYS = TABS.map(tab => tab.key) const findTab = () => TAB_KEYS.includes(params?.tab) ? params.tab : 'overview' @@ -127,6 +131,11 @@ const UserHome = () => { excludedColumns={['owner']} /> } + { + extraTabs.filter(extra => extra.key === tab).map(extra => ( + + )) + } { tab === 'overview' && diff --git a/src/hooks/useProfileTabs.js b/src/hooks/useProfileTabs.js new file mode 100644 index 000000000..f7257d5cb --- /dev/null +++ b/src/hooks/useProfileTabs.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { loadProfileTabs } from '../common/plugins/loader'; +import { getCurrentUser, hasMapper } from '../common/utils'; + +let pluginTabsPromise + +const loadPluginTabs = () => { + if(!pluginTabsPromise) + pluginTabsPromise = loadProfileTabs().then(tabs => Array.isArray(tabs) ? tabs : []) + return pluginTabsPromise +} + +export const usePluginTabs = () => { + const [tabs, setTabs] = React.useState([]) + React.useEffect(() => { loadPluginTabs().then(setTabs) }, []) + return tabs +} + +const isOwnerTab = (tab, ownerType, owner) => ( + (!tab.ownerTypes || tab.ownerTypes.includes(ownerType)) && + (!tab.isVisible || tab.isVisible({ownerType, owner, currentUser: getCurrentUser()})) +) + +const useProfileTabs = (ownerType, owner) => { + const { t } = useTranslation() + const pluginTabs = usePluginTabs() + return React.useMemo(() => [ + ...(hasMapper() ? [{key: 'map-projects', label: t('map_project.mapping_projects')}] : []), + ...pluginTabs.filter(tab => isOwnerTab(tab, ownerType, owner)) + ], [pluginTabs, ownerType, owner, t]) +} + +export default useProfileTabs diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 2f868b611..44184b3e3 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -199,8 +199,6 @@ "suggested": "Suggested", "rest": "Rest", "click_here": "click here", - "classic_termbrowser": "View in TBv2", - "back_to_classic_termbrowser": "Back to the classic TermBrowser", "already_exists": "Already exists", "comment": "Comment", "update_comment": "Update comment", @@ -1198,5 +1196,11 @@ "default": "Clone a narrower concept or fewer cascade levels." } } + }, + "map_project": { + "mapping_project": "Mapping project", + "mapping_projects": "Mapping Projects", + "project_name": "Project Name", + "no_projects_found": "No mapping projects yet." } } diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index a616ae63f..790504be0 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -82,8 +82,6 @@ "location": "Ubicación", "generic_error": "Algo salió mal.", "click_here": "haz clic aquí", - "classic_termbrowser": "Ver en TBv2", - "back_to_classic_termbrowser": "Volver al TermBrowser clásico", "all": "Todos", "add": "Agregar", "already_exists": "Ya existe", @@ -724,5 +722,11 @@ "checksums": { "smart": "Checksum inteligente", "standard": "Checksum estándar" + }, + "map_project": { + "mapping_project": "Proyecto de mapeo", + "mapping_projects": "Proyectos de Mapeo", + "project_name": "Nombre del Proyecto", + "no_projects_found": "Aún no hay proyectos de mapeo." } } diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index 71490b2c9..04b9126e6 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -199,8 +199,6 @@ "suggested": "Sugeridos", "rest": "Restante", "click_here": "clique aqui", - "classic_termbrowser": "Ver no TBv2", - "back_to_classic_termbrowser": "Voltar para o TermBrowser clássico", "already_exists": "Já existe", "comment": "Comentário", "update_comment": "Comentário de atualização", @@ -1187,5 +1185,11 @@ "default": "Clone um conceito mais específico ou menos níveis de cascata." } } + }, + "map_project": { + "mapping_project": "Projeto de mapeamento", + "mapping_projects": "Projetos de Mapeamento", + "project_name": "Nome do Projeto", + "no_projects_found": "Ainda não há projetos de mapeamento." } } diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 5d2f6740f..3fea8ff98 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -121,8 +121,6 @@ "something_went_wrong": "出了些问题", "no_results": "无结果", "click_here": "点击此处", - "classic_termbrowser": "在 TBv2 中查看", - "back_to_classic_termbrowser": "返回经典版 TermBrowser", "already_exists": "已存在", "comment": "备注", "update_comment": "更新说明", @@ -791,5 +789,11 @@ }, "cloneToSource": { "clone_to_source": "克隆到来源" + }, + "map_project": { + "mapping_project": "映射项目", + "mapping_projects": "映射项目", + "project_name": "项目名称", + "no_projects_found": "还没有映射项目。" } } diff --git a/start-prod.sh b/start-prod.sh index cb2454f85..190a98afe 100755 --- a/start-prod.sh +++ b/start-prod.sh @@ -44,6 +44,9 @@ fi if [[ ! -z "${AI_ASSISTANT_API_URL}" ]]; then echo "var AI_ASSISTANT_API_URL = \"${AI_ASSISTANT_API_URL}\";" >> ${ENV_FILE} fi +if [[ ! -z "${MAPPER_URL}" ]]; then + echo "var MAPPER_URL = \"${MAPPER_URL}\";" >> ${ENV_FILE} +fi if [[ ! -z "${OCL_ONLINE_API_URL}" ]]; then echo "var OCL_ONLINE_API_URL = \"${OCL_ONLINE_API_URL}\";" >> ${ENV_FILE} fi diff --git a/webpack.config.js b/webpack.config.js index bb759ce19..c1d1aedd2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -147,6 +147,7 @@ module.exports = (env) => { 'process.env.OIDC_REALM': JSON.stringify(env.OIDC_REALM || ''), 'process.env.ANALYTICS_API': JSON.stringify(env.ANALYTICS_API || ''), 'process.env.AI_ASSISTANT_API_URL': JSON.stringify(env.AI_ASSISTANT_API_URL || ''), + 'process.env.MAPPER_URL': JSON.stringify(env.MAPPER_URL || ''), 'process.env.OCL_ONLINE_API_URL': JSON.stringify(env.OCL_ONLINE_API_URL || ''), }), new IgnorePlugin({ resourceRegExp: /moment\/locale\// }) From 53657dc3db9c5dc7e33fa7451c60f31f528b52de Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Fri, 30 Oct 2026 10:57:01 +0530 Subject: [PATCH 02/14] OpenConceptLab/ocl_online#418 | Review fixes: organization projects, counts, selection, configurable URLs, tests --- src/common/__tests__/appUrls.test.js | 22 +++++++++++ src/common/__tests__/mapProjects.test.js | 21 +++++++++++ src/common/__tests__/navigation.test.js | 41 +++++++++++++++++++++ src/common/appUrls.js | 12 ++++++ src/common/mapProjects.js | 13 +++++++ src/common/navigation.js | 9 +++++ src/common/utils.js | 10 +---- src/components/app/LeftMenu.jsx | 10 ++++- src/components/map-projects/MapProjects.jsx | 8 +++- src/hooks/useMapProjectCount.js | 23 ++++++++++++ 10 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 src/common/__tests__/appUrls.test.js create mode 100644 src/common/__tests__/mapProjects.test.js create mode 100644 src/common/__tests__/navigation.test.js create mode 100644 src/common/appUrls.js create mode 100644 src/common/mapProjects.js create mode 100644 src/hooks/useMapProjectCount.js diff --git a/src/common/__tests__/appUrls.test.js b/src/common/__tests__/appUrls.test.js new file mode 100644 index 000000000..992299251 --- /dev/null +++ b/src/common/__tests__/appUrls.test.js @@ -0,0 +1,22 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +const { resolveMapperURL, PRODUCTION_MAPPER_URL } = 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 and custom hosts without configuration point to production', () => { + assert.equal(resolveMapperURL('', 'http://localhost:4002'), PRODUCTION_MAPPER_URL) + assert.equal(resolveMapperURL('', 'https://terms.example.org'), PRODUCTION_MAPPER_URL) + assert.equal(resolveMapperURL('', undefined), PRODUCTION_MAPPER_URL) +}) diff --git a/src/common/__tests__/mapProjects.test.js b/src/common/__tests__/mapProjects.test.js new file mode 100644 index 000000000..4ad51a6e9 --- /dev/null +++ b/src/common/__tests__/mapProjects.test.js @@ -0,0 +1,21 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +const { mergeProjects, projectCount } = 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('mergeProjects ignores failed or non-list responses', () => { + assert.deepEqual(mergeProjects(undefined, null, {}, [{url: 'x'}]), [{url: 'x'}]) +}) + +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 000000000..7b5fd72e4 --- /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 000000000..cd0b17f1e --- /dev/null +++ b/src/common/appUrls.js @@ -0,0 +1,12 @@ +export const PRODUCTION_MAPPER_URL = 'https://map.openconceptlab.org' + +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 PRODUCTION_MAPPER_URL +} diff --git a/src/common/mapProjects.js b/src/common/mapProjects.js new file mode 100644 index 000000000..67cd49fec --- /dev/null +++ b/src/common/mapProjects.js @@ -0,0 +1,13 @@ +export const mergeProjects = (...lists) => { + const byUrl = new Map() + lists.forEach(list => { + if(Array.isArray(list)) + list.forEach(project => { if(!byUrl.has(project.url)) byUrl.set(project.url, project) }) + }) + return [...byUrl.values()] +} + +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 index bf88e7bb8..9eec4caca 100644 --- a/src/common/navigation.js +++ b/src/common/navigation.js @@ -10,3 +10,12 @@ export const createItems = (user, hasMapper) => [ ...(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 c76dcac02..508791b29 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -19,6 +19,7 @@ import { ensureIdToken, prepareSignOut, settleForcedSignOut, renewSessionSilentl import { getEnv } from './env'; import { SERVER_CONFIGS } from './serverConfigs'; import { legacyHashRoute, isSameSitePath } from './history'; +import { resolveMapperURL } from './appUrls'; export { dropVersion } from './uri'; @@ -1087,14 +1088,7 @@ export const handleLookupValuesResponse = (data, callback, attr) => { callback(orderBy(uniqBy(map(data, cc => ({id: get(cc, _attr), name: get(cc, _attr)})), 'name')), 'name'); } -export const getMapperURL = () => { - const configured = (window.MAPPER_URL || process.env.MAPPER_URL || '').trim().replace(/\/+$/, '') - if(configured) - return configured - if(/^https:\/\/app\.(v3\.)?([a-z]+\.)*openconceptlab\.org$/.test(window.location.origin)) - return window.location.origin.replace(/\/\/app\.(v3\.)?/, '//map.') - return '' -} +export const getMapperURL = () => resolveMapperURL(window.MAPPER_URL || process.env.MAPPER_URL, window.location.origin) export const hasMapper = () => Boolean(getMapperURL()) diff --git a/src/components/app/LeftMenu.jsx b/src/components/app/LeftMenu.jsx index abfe5cf3c..7dee61683 100644 --- a/src/components/app/LeftMenu.jsx +++ b/src/components/app/LeftMenu.jsx @@ -15,8 +15,9 @@ import FolderOpenIcon from '@mui/icons-material/FolderOutlined'; import Divider from '@mui/material/Divider'; import map from 'lodash/map' import { getCurrentUser, refreshCurrentUserCache, getCurrentUserOrgs, hasMapper, isInWaitlist} from '../../common/utils'; -import { trayItems } from '../../common/navigation'; +import { trayItems, isTrayItemSelected } from '../../common/navigation'; import { PRIMARY_COLORS } from '../../common/colors'; +import useMapProjectCount from '../../hooks/useMapProjectCount'; import Drawer from '../common/Drawer'; import OrgIcon from '../orgs/OrgIcon'; import EntityIcon from '../common/EntityIcon' @@ -44,6 +45,7 @@ const LeftMenu = ({ isOpen, onClose }) => { const formatFollowing = following => map(following, 'object') || [] + const mapProjectCount = useMapProjectCount(user?.username, isOpen && hasMapper()) const [orgs, setOrgs] = React.useState(getCurrentUserOrgs()) const [following, setFollowing] = React.useState(formatFollowing(user?.following)) @@ -70,7 +72,7 @@ const LeftMenu = ({ isOpen, onClose }) => { { trayItems(user, hasMapper()).map(item => { - const selected = location.pathname === item.path + const selected = isTrayItemSelected(item, location.pathname) return ( { item.key === 'repos' && {((user?.collections || 0) + (user?.sources + 0)).toLocaleString()} } + { + item.key === 'map-projects' && mapProjectCount !== null && + {mapProjectCount.toLocaleString()} + } { item.key === 'map-projects' && isInWaitlist() && diff --git a/src/components/map-projects/MapProjects.jsx b/src/components/map-projects/MapProjects.jsx index ab1a3434b..a2e248aab 100644 --- a/src/components/map-projects/MapProjects.jsx +++ b/src/components/map-projects/MapProjects.jsx @@ -13,6 +13,7 @@ import Typography from '@mui/material/Typography'; import times from 'lodash/times'; import APIService from '../../services/APIService'; import { toMapperURL } from '../../common/utils'; +import { mergeProjects } from '../../common/mapProjects'; const COLUMN_COUNT = 5 @@ -23,9 +24,12 @@ const MapProjects = ({ ownerType, owner, height }) => { React.useEffect(() => { let active = true setProjects(null) - APIService[ownerType](owner).appendToUrl('map-projects/').get().then(response => { + const requests = [APIService[ownerType](owner).appendToUrl('map-projects/').get()] + if(ownerType === 'users') + requests.push(APIService.users(owner).appendToUrl('orgs/map-projects/').get()) + Promise.all(requests).then(responses => { if(active) - setProjects(Array.isArray(response?.data) ? response.data : []) + setProjects(mergeProjects(...responses.map(response => response?.data))) }) return () => { active = false } }, [ownerType, owner]) diff --git a/src/hooks/useMapProjectCount.js b/src/hooks/useMapProjectCount.js new file mode 100644 index 000000000..efc50b38b --- /dev/null +++ b/src/hooks/useMapProjectCount.js @@ -0,0 +1,23 @@ +import React from 'react'; +import APIService from '../services/APIService'; +import { projectCount } from '../common/mapProjects'; + +const useMapProjectCount = (username, enabled) => { + const [count, setCount] = React.useState(null) + React.useEffect(() => { + if(!enabled || !username) + return + let active = true + Promise.all([ + APIService.users(username).appendToUrl('map-projects/').get(null, null, {limit: 1}, true), + APIService.users(username).appendToUrl('orgs/map-projects/').get(null, null, {limit: 1}, true), + ]).then(responses => { + if(active) + setCount(projectCount(...responses)) + }).catch(() => {}) + return () => { active = false } + }, [username, enabled]) + return count +} + +export default useMapProjectCount From e10a4e54b89b65e18a86b2079125ed9d1c626fd5 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Fri, 30 Oct 2026 11:12:34 +0530 Subject: [PATCH 03/14] OpenConceptLab/ocl_online#418 | Hide sibling-app navigation unless configured or on a recognized OCL host --- src/common/__tests__/appUrls.test.js | 11 ++++++----- src/common/appUrls.js | 4 +--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/common/__tests__/appUrls.test.js b/src/common/__tests__/appUrls.test.js index 992299251..e4e83cb7c 100644 --- a/src/common/__tests__/appUrls.test.js +++ b/src/common/__tests__/appUrls.test.js @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -const { resolveMapperURL, PRODUCTION_MAPPER_URL } = await import('../appUrls.js') +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') @@ -15,8 +15,9 @@ test('official hosts map to the matching Mapper environment', () => { assert.equal(resolveMapperURL(undefined, 'https://app.staging.openconceptlab.org'), 'https://map.staging.openconceptlab.org') }) -test('localhost and custom hosts without configuration point to production', () => { - assert.equal(resolveMapperURL('', 'http://localhost:4002'), PRODUCTION_MAPPER_URL) - assert.equal(resolveMapperURL('', 'https://terms.example.org'), PRODUCTION_MAPPER_URL) - assert.equal(resolveMapperURL('', undefined), PRODUCTION_MAPPER_URL) +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), '') }) diff --git a/src/common/appUrls.js b/src/common/appUrls.js index cd0b17f1e..409883f77 100644 --- a/src/common/appUrls.js +++ b/src/common/appUrls.js @@ -1,5 +1,3 @@ -export const PRODUCTION_MAPPER_URL = 'https://map.openconceptlab.org' - const OFFICIAL_ORIGIN = /^https:\/\/app\.(v3\.)?([a-z]+\.)*openconceptlab\.org$/ export const resolveMapperURL = (configured, origin) => { @@ -8,5 +6,5 @@ export const resolveMapperURL = (configured, origin) => { return url if(OFFICIAL_ORIGIN.test(origin || '')) return origin.replace(/\/\/app\.(v3\.)?/, '//map.') - return PRODUCTION_MAPPER_URL + return '' } From 294199f165c71ab6f4ce47db71daed57254632bc Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Fri, 30 Oct 2026 10:59:02 -0400 Subject: [PATCH 04/14] OpenConceptLab/ocl_online#418 | Mapping Projects tab shows only to the owner, the organization's members and staff Mapping projects are private. Someone else's profile, or an organization you don't belong to, no longer gets the tab; a direct link to it shows Overview. Staff see it on every profile. Co-Authored-By: Claude Opus 5.5 --- src/common/__tests__/mapProjects.test.js | 26 +++++++++++++++++++++++- src/common/mapProjects.js | 14 +++++++++++++ src/components/orgs/OrgHome.jsx | 3 ++- src/components/users/UserHome.jsx | 3 ++- src/hooks/useProfileTabs.js | 3 ++- 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/common/__tests__/mapProjects.test.js b/src/common/__tests__/mapProjects.test.js index 4ad51a6e9..044f08ff5 100644 --- a/src/common/__tests__/mapProjects.test.js +++ b/src/common/__tests__/mapProjects.test.js @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -const { mergeProjects, projectCount } = await import('../mapProjects.js') +const { mergeProjects, projectCount, canSeeMapProjects } = await import('../mapProjects.js') test('mergeProjects combines user and organization projects without duplicates', () => { const merged = mergeProjects( @@ -19,3 +19,27 @@ 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) +}) diff --git a/src/common/mapProjects.js b/src/common/mapProjects.js index 67cd49fec..6963415df 100644 --- a/src/common/mapProjects.js +++ b/src/common/mapProjects.js @@ -11,3 +11,17 @@ 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 +} diff --git a/src/components/orgs/OrgHome.jsx b/src/components/orgs/OrgHome.jsx index 534571df0..00bee8930 100644 --- a/src/components/orgs/OrgHome.jsx +++ b/src/components/orgs/OrgHome.jsx @@ -93,7 +93,8 @@ const OrgHome = () => { } React.useEffect(() => { fetchOrg() }, [params.org]) - React.useEffect(() => { setTab(params.tab || 'overview') }, [params.tab]) + // A tab this viewer doesn't get (someone else's Mapping Projects) shows Overview instead + React.useEffect(() => { setTab(findTab()) }, [params.tab, TAB_KEYS.join()]) const height = 'calc(var(--app-height) - 300px)' diff --git a/src/components/users/UserHome.jsx b/src/components/users/UserHome.jsx index cd6b48695..6a1d415c6 100644 --- a/src/components/users/UserHome.jsx +++ b/src/components/users/UserHome.jsx @@ -101,7 +101,8 @@ const UserHome = () => { reset() }, [params.user] ) - React.useEffect(() => { setTab(params.tab || 'overview') }, [params.tab]) + // A tab this viewer doesn't get (someone else's Mapping Projects) shows Overview instead + React.useEffect(() => { setTab(findTab()) }, [params.tab, TAB_KEYS.join()]) const baseHeightToDeduct = 150 return ( diff --git a/src/hooks/useProfileTabs.js b/src/hooks/useProfileTabs.js index f7257d5cb..193b052c4 100644 --- a/src/hooks/useProfileTabs.js +++ b/src/hooks/useProfileTabs.js @@ -2,6 +2,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { loadProfileTabs } from '../common/plugins/loader'; import { getCurrentUser, hasMapper } from '../common/utils'; +import { canSeeMapProjects } from '../common/mapProjects'; let pluginTabsPromise @@ -26,7 +27,7 @@ const useProfileTabs = (ownerType, owner) => { const { t } = useTranslation() const pluginTabs = usePluginTabs() return React.useMemo(() => [ - ...(hasMapper() ? [{key: 'map-projects', label: t('map_project.mapping_projects')}] : []), + ...(hasMapper() && canSeeMapProjects(getCurrentUser(), ownerType, owner) ? [{key: 'map-projects', label: t('map_project.mapping_projects')}] : []), ...pluginTabs.filter(tab => isOwnerTab(tab, ownerType, owner)) ], [pluginTabs, ownerType, owner, t]) } From eda2d42baf675dbd50a334be6b79acbfed1f1ea2 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Fri, 30 Oct 2026 11:00:39 -0400 Subject: [PATCH 05/14] OpenConceptLab/ocl_online#418 | Searching from the Mapping Projects tab filters its list The search drawer's owner option used to send the Mapping Projects tab to a broken address. It now stays on the tab, which loads every page of the owner's projects and filters them by name and ID; a chip shows the term and clears it. Co-Authored-By: Claude Opus 5.5 --- src/common/__tests__/mapProjects.test.js | 10 +- src/common/mapProjects.js | 9 ++ src/components/map-projects/MapProjects.jsx | 146 +++++++++++------- src/components/search/SearchInput.jsx | 4 + .../search/__tests__/ownerSearch.test.js | 23 +++ src/components/search/ownerSearch.js | 9 ++ 6 files changed, 146 insertions(+), 55 deletions(-) create mode 100644 src/components/search/__tests__/ownerSearch.test.js create mode 100644 src/components/search/ownerSearch.js diff --git a/src/common/__tests__/mapProjects.test.js b/src/common/__tests__/mapProjects.test.js index 044f08ff5..871db776f 100644 --- a/src/common/__tests__/mapProjects.test.js +++ b/src/common/__tests__/mapProjects.test.js @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -const { mergeProjects, projectCount, canSeeMapProjects } = await import('../mapProjects.js') +const { mergeProjects, projectCount, canSeeMapProjects, filterProjects } = await import('../mapProjects.js') test('mergeProjects combines user and organization projects without duplicates', () => { const merged = mergeProjects( @@ -43,3 +43,11 @@ 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) +}) diff --git a/src/common/mapProjects.js b/src/common/mapProjects.js index 6963415df..dddc4ba87 100644 --- a/src/common/mapProjects.js +++ b/src/common/mapProjects.js @@ -25,3 +25,12 @@ export const canSeeMapProjects = (currentUser, ownerType, owner) => { 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))) +} diff --git a/src/components/map-projects/MapProjects.jsx b/src/components/map-projects/MapProjects.jsx index a2e248aab..d4be03e6f 100644 --- a/src/components/map-projects/MapProjects.jsx +++ b/src/components/map-projects/MapProjects.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import { useHistory, useLocation } from 'react-router-dom'; import moment from 'moment'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; @@ -9,82 +10,119 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Skeleton from '@mui/material/Skeleton'; import Link from '@mui/material/Link'; +import Chip from '@mui/material/Chip'; import Typography from '@mui/material/Typography'; +import SearchIcon from '@mui/icons-material/Search'; import times from 'lodash/times'; import APIService from '../../services/APIService'; -import { toMapperURL } from '../../common/utils'; -import { mergeProjects } from '../../common/mapProjects'; +import { toMapperURL, pluralize } from '../../common/utils'; +import { mergeProjects, filterProjects } from '../../common/mapProjects'; const COLUMN_COUNT = 5 +const PAGE_SIZE = 100 +const MAX_PAGES = 20 + +// The API pages its lists, and the tab filters on the client, so it loads every page +const fetchAllPages = (getService, page = 1, loaded = []) => getService().get(null, null, {limit: PAGE_SIZE, page}).then(response => { + const all = [...loaded, ...(Array.isArray(response?.data) ? response.data : [])] + return response?.headers?.next && page < MAX_PAGES ? fetchAllPages(getService, page + 1, all) : all +}) const MapProjects = ({ ownerType, owner, height }) => { const { t } = useTranslation() + const history = useHistory() + const location = useLocation() const [projects, setProjects] = React.useState(null) + const query = new URLSearchParams(location.search).get('q') || '' + const shown = filterProjects(projects, query) React.useEffect(() => { let active = true setProjects(null) - const requests = [APIService[ownerType](owner).appendToUrl('map-projects/').get()] + const requests = [fetchAllPages(() => APIService[ownerType](owner).appendToUrl('map-projects/'))] if(ownerType === 'users') - requests.push(APIService.users(owner).appendToUrl('orgs/map-projects/').get()) - Promise.all(requests).then(responses => { + requests.push(fetchAllPages(() => APIService.users(owner).appendToUrl('orgs/map-projects/'))) + Promise.all(requests).then(lists => { if(active) - setProjects(mergeProjects(...responses.map(response => response?.data))) + setProjects(mergeProjects(...lists)) }) return () => { active = false } }, [ownerType, owner]) + const onClearQuery = () => history.push(location.pathname) + return ( - - - - - {t('common.id')} - {t('map_project.project_name')} - {t('common.owner')} - {t('common.created_by')} - {t('common.updated_by')} - - - - { - projects === null && - times(5, i => ( - - {times(COLUMN_COUNT, ci => )} +
+
+ { + shown === null ? + : + + {pluralize(shown.length, t('map_project.mapping_project').toLowerCase(), t('map_project.mapping_projects').toLowerCase())} + + } + { + query && + } label={query} onDelete={onClearQuery} title={t('common.clear')} /> + } +
+ +
+ + + {t('common.id')} + {t('map_project.project_name')} + {t('common.owner')} + {t('common.created_by')} + {t('common.updated_by')} + + + + { + shown === null && + times(5, i => ( + + {times(COLUMN_COUNT, ci => )} + + )) + } + { + shown?.length === 0 && + + + + { + query ? + {t('search.no_results')}{query}. : + t('map_project.no_projects_found') + } + + + + } + { + shown?.map(project => ( + + {project.id} + + {project.name} + + {project.owner} + + {project.created_by} + {moment(project.created_at).fromNow()} + + + {project.updated_by} + {moment(project.updated_at).fromNow()} + )) - } - { - projects?.length === 0 && - - - {t('map_project.no_projects_found')} - - - } - { - projects?.map(project => ( - - {project.id} - - {project.name} - - {project.owner} - - {project.created_by} - {moment(project.created_at).fromNow()} - - - {project.updated_by} - {moment(project.updated_at).fromNow()} - - - )) - } - -
-
+ } + + + +
) } diff --git a/src/components/search/SearchInput.jsx b/src/components/search/SearchInput.jsx index 162a66eda..1fb0b95f3 100644 --- a/src/components/search/SearchInput.jsx +++ b/src/components/search/SearchInput.jsx @@ -5,6 +5,7 @@ import SearchInputText from './SearchInputText'; import SearchInputDrawer from './SearchInputDrawer'; import { BROWSE_CONFIG, FACET_FILTER_CONFIG } from './browseConfig'; import { getURLWithoutFacetFilter } from './facetFilterURL'; +import { getOwnerSearchScope } from './ownerSearch'; const SearchInput = props => { @@ -120,6 +121,9 @@ const SearchInput = props => { } const applyURLRules = URL => { + // The Mapping Projects tab filters its own list (see ownerSearch.js) + if(getOwnerSearchScope(URL) === 'map-projects') + return URL if((URL.startsWith('/orgs/') || URL.startsWith('/users/')) && !URL.includes('/sources/') && !URL.includes('/collections/') && !URL.includes('/repos/') && !URL.endsWith('/repos')) { if(URL.includes('/edit')) URL = URL.replace('/edit', '/repos') diff --git a/src/components/search/__tests__/ownerSearch.test.js b/src/components/search/__tests__/ownerSearch.test.js new file mode 100644 index 000000000..139b01e78 --- /dev/null +++ b/src/components/search/__tests__/ownerSearch.test.js @@ -0,0 +1,23 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { getOwnerSearchScope } from '../ownerSearch.js' + +test('the Mapping Projects tab searches its own list', () => { + assert.equal(getOwnerSearchScope('/users/member/map-projects'), 'map-projects') + assert.equal(getOwnerSearchScope('/orgs/CIEL/map-projects/'), 'map-projects') +}) + +test('every other profile tab searches the owner\'s repositories', () => { + assert.equal(getOwnerSearchScope('/users/member/'), 'repos') + assert.equal(getOwnerSearchScope('/users/member'), 'repos') + assert.equal(getOwnerSearchScope('/orgs/CIEL/repos'), 'repos') + assert.equal(getOwnerSearchScope('/users/member/edit'), 'repos') +}) + +test('pages that aren\'t a profile have no owner scope', () => { + assert.equal(getOwnerSearchScope('/'), null) + assert.equal(getOwnerSearchScope('/search/'), null) + assert.equal(getOwnerSearchScope('/users/'), null) + assert.equal(getOwnerSearchScope(undefined), null) +}) diff --git a/src/components/search/ownerSearch.js b/src/components/search/ownerSearch.js new file mode 100644 index 000000000..68e3f09c1 --- /dev/null +++ b/src/components/search/ownerSearch.js @@ -0,0 +1,9 @@ +// On a user or organization profile, the search drawer's owner option searches +// what the open tab lists: the Mapping Projects tab filters its own list, and +// every other tab searches the owner's repositories. +export const getOwnerSearchScope = pathname => { + const [, ownerType, owner, tab] = (pathname || '').split('/') + if(!['users', 'orgs'].includes(ownerType) || !owner) + return null + return tab === 'map-projects' ? 'map-projects' : 'repos' +} From 771ab7f8b2bd7ce7e96fa108f75219849c2d9582 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Fri, 30 Oct 2026 11:02:04 -0400 Subject: [PATCH 06/14] OpenConceptLab/ocl_online#418 | Profile search says what it searches: the owner's repositories or mapping projects On a user or organization profile the drawer said "Search jon map-projects" or "Search jon repos", with the hint "Search this repository". It now reads "Search jon's repositories for ..." (Overview and Repositories) or "Search jon's mapping projects for ..." (Mapping Projects), with a matching hint. Repository pages keep "Search this repository". Co-Authored-By: Claude Opus 5.5 --- src/components/search/SearchInputDrawer.jsx | 16 ++++++++++++++-- src/i18n/locales/en/translations.json | 4 ++++ src/i18n/locales/es/translations.json | 4 ++++ src/i18n/locales/pt-BR/translations.json | 4 ++++ src/i18n/locales/zh/translations.json | 4 ++++ 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/components/search/SearchInputDrawer.jsx b/src/components/search/SearchInputDrawer.jsx index 582df8328..898327df5 100644 --- a/src/components/search/SearchInputDrawer.jsx +++ b/src/components/search/SearchInputDrawer.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { useHistory, useLocation } from 'react-router-dom'; -import { useTranslation } from 'react-i18next' +import { useTranslation, Trans } from 'react-i18next' import Drawer from '@mui/material/Drawer' import Divider from '@mui/material/Divider' import ListItemButton from '@mui/material/ListItemButton'; @@ -21,6 +21,7 @@ import FilterTypeChips from './FilterTypeChips' import EntityBrowseList from './EntityBrowseList' import { BROWSE_CONFIG, FACET_FILTER_CONFIG, getFacetFilterURL } from './browseConfig' import { getModeForFilterType } from './searchlightMode' +import { getOwnerSearchScope } from './ownerSearch' const Icon = ({ icon, style }) => ( @@ -83,6 +84,7 @@ const SearchInputDrawer = ({open, onClose, input, initiateSearch, inputProps, is const isURLRegistry = location?.pathname?.endsWith('/url-registry') || location?.pathname?.endsWith('/settings') const isNested = Boolean(location.pathname !== '/search/' && location.pathname !== '/' && owner && ownerType) || isURLRegistry const isRepoContext = Boolean(repoType && repo) + const isMapProjectsScope = getOwnerSearchScope(location.pathname) === 'map-projects' const [focus, setFocus] = React.useState(1); const [mode, setMode] = React.useState(activeFilterType ? 'typeBrowse' : 'query'); // 'query' | 'typeSelect' | 'typeBrowse' const chipsRef = React.useRef(); @@ -376,7 +378,17 @@ const SearchInputDrawer = ({open, onClose, input, initiateSearch, inputProps, is id='search_owner' nested index={1} - placeholder={{t('common.search')} {owner} {repoType} {t('common.for')} "{inputPlaceholder}"} + placeholder={ + + , ]} + shouldUnescape + /> + + } + text={t(isMapProjectsScope ? 'search.search_mapping_projects' : 'search.search_repositories')} icon selected={focus == 1} onClick={onClickOption} diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 44184b3e3..af8ccab77 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -1001,6 +1001,10 @@ "hierarchy": "Hierarchy", "search_all_site": "Search all of OCL for ", "search_this_repository": "Search this repository", + "search_owner_repos": "Search <0>{{owner}}'s repositories for \"<1>{{query}}\"", + "search_owner_map_projects": "Search <0>{{owner}}'s mapping projects for \"<1>{{query}}\"", + "search_repositories": "Search repositories", + "search_mapping_projects": "Search mapping projects", "search_this_url_registry": "Search this URL registry for ", "search_all_concepts": "Search all concepts", "url_registry": "entries", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 790504be0..23d24d476 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -606,6 +606,10 @@ "card": "Tarjeta", "search_all_site": "Buscar en todo OCL para ", "search_this_repository": "Buscar en este repositorio", + "search_owner_repos": "Buscar \"<1>{{query}}\" en los repositorios de <0>{{owner}}", + "search_owner_map_projects": "Buscar \"<1>{{query}}\" en los proyectos de mapeo de <0>{{owner}}", + "search_repositories": "Buscar repositorios", + "search_mapping_projects": "Buscar proyectos de mapeo", "search_all_concepts": "Buscar todos los conceptos", "save_default_filters": "Guardar como Filtros Predeterminados", "reset_default_filters": "Restablecer a Filtros Predeterminados", diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index 04b9126e6..dfd8b9105 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -991,6 +991,10 @@ "hierarchy": "Hierarquia", "search_all_site": "Buscar em todo o OCL por ", "search_this_repository": "Buscar neste repositório", + "search_owner_repos": "Buscar \"<1>{{query}}\" nos repositórios de <0>{{owner}}", + "search_owner_map_projects": "Buscar \"<1>{{query}}\" nos projetos de mapeamento de <0>{{owner}}", + "search_repositories": "Buscar repositórios", + "search_mapping_projects": "Buscar projetos de mapeamento", "search_this_url_registry": "Buscar neste registro de URL por ", "search_all_concepts": "Buscar todos os conceitos", "url_registry": "entradas", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 3fea8ff98..be51e4fea 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -713,6 +713,10 @@ "hierarchy": "层级", "search_all_site": "搜索全部符合搜索条件的 OCL 内容: ", "search_this_repository": "搜索该存储库", + "search_owner_repos": "在 <0>{{owner}} 的存储库中搜索“<1>{{query}}”", + "search_owner_map_projects": "在 <0>{{owner}} 的映射项目中搜索“<1>{{query}}”", + "search_repositories": "搜索存储库", + "search_mapping_projects": "搜索映射项目", "search_this_url_registry": "搜索该 URL 注册库: ", "search_all_concepts": "搜索全部的概念", "url_registry": "条目", From 7e30e79dece40dc2d75da0abd377501d87038a8f Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Fri, 30 Oct 2026 11:04:55 -0400 Subject: [PATCH 07/14] OpenConceptLab/ocl_online#418 | Profile summary counts mapping projects The Summary panel on user and organization profiles shows "N mapping projects", linking to the tab, wherever that tab shows. Co-Authored-By: Claude Opus 5.5 --- src/components/orgs/OrgStatistics.jsx | 18 +++++++++++++++++- src/components/users/UserStatistics.jsx | 17 +++++++++++++++++ src/hooks/useMapProjectCount.js | 18 +++++++++++------- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/components/orgs/OrgStatistics.jsx b/src/components/orgs/OrgStatistics.jsx index 7a69d42a5..200a89886 100644 --- a/src/components/orgs/OrgStatistics.jsx +++ b/src/components/orgs/OrgStatistics.jsx @@ -8,7 +8,9 @@ import ListItem from '@mui/material/ListItem'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import DateIcon from '@mui/icons-material/Today'; -import { formatDate, pluralize } from '../../common/utils' +import { formatDate, pluralize, getCurrentUser, hasMapper } from '../../common/utils' +import { canSeeMapProjects } from '../../common/mapProjects' +import useMapProjectCount from '../../hooks/useMapProjectCount' import RepoIcon from '../repos/RepoIcon'; import MemberList from './MemberList' @@ -18,6 +20,8 @@ const OrgStatistics = ({ org, members }) => { const repos = org.public_sources + org.public_collections const history = useHistory() const onRepoStatsClick = () => history.push(org.url + 'repos') + const showMapProjects = hasMapper() && canSeeMapProjects(getCurrentUser(), 'orgs', org.id) + const mapProjectCount = useMapProjectCount(org.id, showMapProjects, 'orgs') return (
@@ -34,6 +38,18 @@ const OrgStatistics = ({ org, members }) => { primary={pluralize(repos, t('common.public').toLowerCase() + ' ' + t('repo.repo').toLowerCase(), t('common.public').toLowerCase() + ' ' + t('repo.repos').toLowerCase())} /> + { + showMapProjects && mapProjectCount !== null && + history.push(org.url + 'map-projects')}> + + + + + + } setMembersOpen(true)}> diff --git a/src/components/users/UserStatistics.jsx b/src/components/users/UserStatistics.jsx index 5ca23b57a..8a8ef6458 100644 --- a/src/components/users/UserStatistics.jsx +++ b/src/components/users/UserStatistics.jsx @@ -9,12 +9,17 @@ import ListItemText from '@mui/material/ListItemText'; import RepoIcon from '../repos/RepoIcon'; import OrgIcon from '../orgs/OrgIcon' import About from '../common/About'; +import { getCurrentUser, hasMapper, pluralize } from '../../common/utils'; +import { canSeeMapProjects } from '../../common/mapProjects'; +import useMapProjectCount from '../../hooks/useMapProjectCount'; const UserStatistics = ({ user }) => { const { t } = useTranslation() const repos = user.public_sources + user.public_collections const history = useHistory() const onRepoStatsClick = () => history.push(user.url + 'repos') + const showMapProjects = hasMapper() && canSeeMapProjects(getCurrentUser(), 'users', user.username) + const mapProjectCount = useMapProjectCount(user.username, showMapProjects) return (
{ @@ -41,6 +46,18 @@ const UserStatistics = ({ user }) => { primary={`${repos} ${t('common.public').toLowerCase()} ${t('repo.repos').toLowerCase()}`} /> + { + showMapProjects && mapProjectCount !== null && + history.push(user.url + 'map-projects')}> + + + + + + } diff --git a/src/hooks/useMapProjectCount.js b/src/hooks/useMapProjectCount.js index efc50b38b..46be99efc 100644 --- a/src/hooks/useMapProjectCount.js +++ b/src/hooks/useMapProjectCount.js @@ -2,21 +2,25 @@ import React from 'react'; import APIService from '../services/APIService'; import { projectCount } from '../common/mapProjects'; -const useMapProjectCount = (username, enabled) => { +// A user's count includes their organizations' projects, as their Mapping Projects tab does +const useMapProjectCount = (owner, enabled, ownerType = 'users') => { const [count, setCount] = React.useState(null) React.useEffect(() => { - if(!enabled || !username) + if(!enabled || !owner) return let active = true - Promise.all([ - APIService.users(username).appendToUrl('map-projects/').get(null, null, {limit: 1}, true), - APIService.users(username).appendToUrl('orgs/map-projects/').get(null, null, {limit: 1}, true), - ]).then(responses => { + const requests = ownerType === 'orgs' ? + [APIService.orgs(owner).appendToUrl('map-projects/').get(null, null, {limit: 1}, true)] : + [ + APIService.users(owner).appendToUrl('map-projects/').get(null, null, {limit: 1}, true), + APIService.users(owner).appendToUrl('orgs/map-projects/').get(null, null, {limit: 1}, true), + ] + Promise.all(requests).then(responses => { if(active) setCount(projectCount(...responses)) }).catch(() => {}) return () => { active = false } - }, [username, enabled]) + }, [owner, enabled, ownerType]) return count } From 48a06cb3cd6c8ec999d275ec51908eb4afda6c10 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Fri, 30 Oct 2026 11:06:52 -0400 Subject: [PATCH 08/14] OpenConceptLab/ocl_online#418 | The tray and the footer link to the OCL Community website The community site was reachable only from the profile menu. The left tray now ends with an "OCL Community website" link, and the footer shows it beside Terms of Use and Privacy Policy. All three use one constant. Co-Authored-By: Claude Opus 5.5 --- src/common/constants.js | 1 + src/components/app/Footer.jsx | 4 +++- src/components/app/LeftMenu.jsx | 31 +++++++++++++++++++++++++++++++ src/components/users/UserMenu.jsx | 4 ++-- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/common/constants.js b/src/common/constants.js index 184147241..151653c45 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -108,5 +108,6 @@ export const URL_REGISTRY_DOC_LINK = 'https://docs.openconceptlab.org/en/latest/ export const GLOBAL_SEARCH_DOC_LINK = 'https://docs.openconceptlab.org/en/latest/oclweb/global-search.html' export const TBV3_ANNOUNCEMENT_LETTER_URL = 'https://openconceptlab.org/blog/letter-to-the-ocl-community?utm_source=tbv3&utm_medium=banner&utm_campaign=subscription_dates' export const TBV3_ANNOUNCEMENT_PRICING_URL = 'https://openconceptlab.org/pricing?utm_source=tbv3&utm_medium=banner&utm_campaign=subscription_dates' +export const COMMUNITY_SITE_URL = 'https://openconceptlab.org' export const TERMS_OF_USE_URL = 'https://openconceptlab.org/terms-of-use' export const PRIVACY_POLICY_URL = 'https://openconceptlab.org/privacy-policy' diff --git a/src/components/app/Footer.jsx b/src/components/app/Footer.jsx index 9a75cec1e..aecb44b33 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 = () => {