Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/components/map-projects/ConfigurationForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import isEmpty from 'lodash/isEmpty'
import omit from 'lodash/omit'
import filter from 'lodash/filter'

import { getCurrentUser, toV3URL } from '../../common/utils'
import { formatDate, getCurrentUser, toV3URL } from '../../common/utils'
import { MATCHING_ALGORITHMS_DOC_LINK } from '../../common/constants'
import { getProjectConfigErrors } from './algorithms'
import NamespaceDropdown from '../common/NamespaceDropdown'
Expand All @@ -43,7 +43,7 @@ import LookupConfig from './LookupConfig'
import AdvancedSettings from './AdvancedSettings'
import RerankerConfig from './RerankerConfig'
import AIAssistantSelectorPanel from './AIAssistantSelectorPanel'
import { hasSelectedTargetRepoVersion } from './projectTargetRepo'
import { getTargetRepoVersionBadges, getTargetRepoVersionReleaseDate, hasSelectedTargetRepoVersion } from './projectTargetRepo'
import { getPreviewAlgorithmOptions, getPreviewConfigAccessErrors } from './previewConfig'

const VisuallyHiddenInput = styled('input')({
Expand All @@ -61,7 +61,7 @@ const VisuallyHiddenInput = styled('input')({

const deriveCanonicalUrl = relativeUrl => relativeUrl ? `https://ns.openconceptlab.org${relativeUrl}` : ''

const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, name, setName, description, setDescription, repo, onRepoChange, repoVersion, setRepoVersion, versions, mappedSources, targetSourcesFromRows, algosSelected, setAlgosSelected, sx, algos, validColumns, columns, isValidColumnValue, updateColumn, configure, setConfigure, columnVisibilityModel, setColumnVisibilityModel, onSave, isSaving, candidatesScore, onScoreChange, includeDefaultFilter, setIncludeDefaultFilter, filters, setFilters, locales, isLoadingLocales, setAIAssistantColumns, AIAssistantColumns, inAIAssistantGroup, lookupConfig, setLookupConfig, encoderModel, setEncoderModel, isCoreUser, requestLimits, canSelectAIModel, canSetAIOutputLocale, canBridge, canScispacy, canUseOrgProjects=true, canUseCustomAlgorithms=true, promptTemplates, promptTemplate, onPromptTemplateChange, AIModels, AIModel, setAIModel, namespace, setNamespace, promptOutputLocale, setPromptOutputLocale, inputLocale, setInputLocale, oclLocales, useLexicalVariants, setUseLexicalVariants }) => {
const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, name, setName, description, setDescription, repo, onRepoChange, repoVersion, setRepoVersion, showTargetRepoVersionError, versions, mappedSources, targetSourcesFromRows, algosSelected, setAlgosSelected, sx, algos, validColumns, columns, isValidColumnValue, updateColumn, configure, setConfigure, columnVisibilityModel, setColumnVisibilityModel, onSave, isSaving, candidatesScore, onScoreChange, includeDefaultFilter, setIncludeDefaultFilter, filters, setFilters, locales, isLoadingLocales, setAIAssistantColumns, AIAssistantColumns, inAIAssistantGroup, lookupConfig, setLookupConfig, encoderModel, setEncoderModel, isCoreUser, requestLimits, canSelectAIModel, canSetAIOutputLocale, canBridge, canScispacy, canUseOrgProjects=true, canUseCustomAlgorithms=true, promptTemplates, promptTemplate, onPromptTemplateChange, AIModels, AIModel, setAIModel, namespace, setNamespace, promptOutputLocale, setPromptOutputLocale, inputLocale, setInputLocale, oclLocales, useLexicalVariants, setUseLexicalVariants }) => {
const { t } = useTranslation();
const user = getCurrentUser()
const isLLMAlgoNotAllowed = !repoVersion?.match_algorithms?.includes('llm')
Expand Down Expand Up @@ -112,6 +112,7 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n
const configErrors = getProjectConfigErrors(algosSelected)
const hasConfigErrors = configErrors.length > 0
const hasTargetRepoVersion = hasSelectedTargetRepoVersion(repoVersion)
const isTargetRepoVersionInError = Boolean(showTargetRepoVersionError && !hasTargetRepoVersion)
const getAlgos = () => getPreviewAlgorithmOptions({
algos,
algosSelected,
Expand Down Expand Up @@ -242,11 +243,16 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n
onChange={(id, item) => setRepoVersion(item)}
value={repoVersion}
sx={{marginTop: '12px'}}
error={Boolean(repo?.url && !hasTargetRepoVersion)}
helperText={repo?.url && !hasTargetRepoVersion ? t(
error={isTargetRepoVersionInError}
helperText={isTargetRepoVersionInError ? t(
'map_project.target_repo_version_required',
'Select a target repository version before saving.'
) : ''}
getOptionBadges={option => getTargetRepoVersionBadges(option, versions).map(key => t(key))}
getOptionRightText={option => {
const releaseDate = getTargetRepoVersionReleaseDate(option)
return releaseDate ? t('map_project.version_released_on', {date: formatDate(releaseDate)}) : ''
}}
/>
{
effectiveTargetCanonical &&
Expand Down Expand Up @@ -537,7 +543,7 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n
sx={{textTransform: 'none', margin: '20px 5px 5px 0px'}}
startIcon={<SaveIcon />}
onClick={onSave}
disabled={!name || !file?.name || !owner || hasConfigErrors || hasAccessErrors || !hasTargetRepoVersion}
disabled={!name || !file?.name || !owner || hasConfigErrors || hasAccessErrors}
loading={isSaving}
loadingPosition="start"
>
Expand Down
26 changes: 18 additions & 8 deletions src/components/map-projects/MapProject.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ import { getAIAssistantChoices, getModelUsed, getPromptTemplateKey, createLatest
import { DEFAULT_ENCODER_MODEL } from './rerankerModels'
import { normalizeAlgorithmInvocation, hasSuccessfulAlgorithmResponse, getAlgorithmStagesFromResponses, lookupStatusRank, buildRecommendableConceptEntry, stripConstantClassAndDatatype, buildLookupConceptUrl, serializeCandidates, toStoredRowMatchState } from './normalizers'
import { parseConceptKey } from './conceptKey'
import { getDefaultTargetRepoVersion, getProjectTargetRepoVersion, getTargetRepoVersionFromUrl, getTargetRepoVersionId } from './projectTargetRepo'
import { getDefaultTargetRepoVersion, getProjectTargetRepoVersion, getSavedTargetRepoVersion, getTargetRepoVersionFromUrl, getTargetRepoVersionId } from './projectTargetRepo'
import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs } from './viewBuilders.js'
import { formatClockTime, getCapacityWaitLabel, mergeCapacityWaits } from './rowProgress.js'

Expand Down Expand Up @@ -388,6 +388,8 @@ const MapProject = () => {
// repo state
const [repo, setRepo] = React.useState(false)
const [repoVersion, setRepoVersion] = React.useState(false)
// Version shows its error only after a save without one.
const [showTargetRepoVersionError, setShowTargetRepoVersionError] = React.useState(false)
const [mappedSources, setMappedSources] = React.useState([])
const [bridgeMappedSources, setBridgeMappedSources] = React.useState({})
const [locales, setLocales] = React.useState([])
Expand Down Expand Up @@ -1584,6 +1586,7 @@ const MapProject = () => {
autosaveSchedulerRef.current.cancel()
if(!repoVersion?.version_url || !selectedTargetRepoVersion) {
setConfigure(true)
setShowTargetRepoVersionError(true)
setAlert({
message: t(
'map_project.target_repo_version_required',
Expand Down Expand Up @@ -2029,6 +2032,7 @@ const MapProject = () => {

const onRepoVersionChange = version => {
setRepoVersion(version || false)
setShowTargetRepoVersionError(false)
if(version?.version_url) {
fetchLocaleDistribution(version.version_url)
fetchMappedSources(version.version_url, setMappedSources)
Expand Down Expand Up @@ -2826,16 +2830,20 @@ const MapProject = () => {
const isRunningBulkBridgeCandidates = bridgeCandidatesEndedAt ? moment().isBetween(bridgeCandidatesStartedAt, bridgeCandidatesEndedAt) : Boolean(bridgeCandidatesStartedAt)
const isRunningBulkScispacyCandidates = scispacyCandidatesStartedAt ? moment().isBetween(scispacyCandidatesStartedAt, scispacyCandidatesEndedAt) : Boolean(scispacyCandidatesStartedAt)

const fetchVersions = (url, _selectedVersion) => {
// Only the latest request may set the version: a slower response is for a
// repository no longer chosen.
const versionsRequestRef = React.useRef(0)

// A newly chosen repository gets the default version. A saved project
// passes its own version and keeps it (OpenConceptLab/ocl_issues#2906).
const fetchVersions = (url, savedVersion) => {
const request = ++versionsRequestRef.current
APIService.new().overrideURL(dropVersion(url)).appendToUrl('versions/?verbose=true').get().then(response => {
if(request !== versionsRequestRef.current)
return
let _versions = response.data
setVersions(_versions)
if(_selectedVersion) {
const _version = find(_versions, {id: _selectedVersion})
onRepoVersionChange(_version || false)
} else {
onRepoVersionChange(getDefaultTargetRepoVersion(_versions))
}
onRepoVersionChange(savedVersion === undefined ? getDefaultTargetRepoVersion(_versions) : getSavedTargetRepoVersion(_versions, savedVersion))
})
}

Expand All @@ -2845,6 +2853,7 @@ const MapProject = () => {
if(newRepo?.url) {
fetchVersions(newRepo.url)
} else {
versionsRequestRef.current += 1
setVersions([])
}
}
Expand Down Expand Up @@ -5940,6 +5949,7 @@ const MapProject = () => {
onRepoChange={onRepoChange}
repoVersion={repoVersion}
setRepoVersion={onRepoVersionChange}
showTargetRepoVersionError={showTargetRepoVersionError}
mappedSources={mappedSources}
targetSourcesFromRows={targetSourcesFromRows}
versions={versions}
Expand Down
103 changes: 86 additions & 17 deletions src/components/map-projects/__tests__/projectTargetRepo.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,25 @@ import {
getDefaultBridgeRepoVersion,
getDefaultTargetRepoVersion,
getLatestReleasedBridgeVersion,
getLatestReleasedTargetRepoVersion,
getProjectTargetRepoVersion,
getSavedTargetRepoVersion,
getTargetRepoVersionBadges,
getTargetRepoVersionFromUrl,
getTargetRepoVersionId,
getTargetRepoVersionReleaseDate,
hasSelectedTargetRepoVersion
} from '../projectTargetRepo.js'

// LOINC's versions as the API lists them, newest first: only 2.81 supports
// the OCL Semantic Algorithm.
const LOINC_VERSIONS = [
{ id: '2.81', released: true, match_algorithms: ['es', 'llm'], revision_date: '2025-08-12T00:00:00Z', created_on: '2025-09-01T10:00:00Z' },
{ id: '2.71.21AA', released: true, match_algorithms: ['es'], created_on: '2021-11-03T10:00:00Z' },
{ id: '2.70.21AA', released: true, match_algorithms: ['es'], created_on: '2021-06-30T10:00:00Z' },
{ id: 'HEAD', released: false, match_algorithms: ['es'], created_on: '2021-01-15T10:00:00Z' }
]

test('getTargetRepoVersionId prefers the selected version id', () => {
assert.equal(getTargetRepoVersionId({ id: '2.81', version_url: '/orgs/OCL/sources/Test/2.81/' }), '2.81')
})
Expand Down Expand Up @@ -43,32 +56,88 @@ test('getProjectTargetRepoVersion returns empty when the project has no pinned v
)
})

test('getDefaultTargetRepoVersion selects the only version transparently', () => {
const onlyVersion = { id: '2.81', released: false }
assert.deepEqual(getDefaultTargetRepoVersion([onlyVersion]), onlyVersion)
test('getDefaultTargetRepoVersion skips a newer release without semantic search', () => {
const versions = [
{ id: '2.82', released: true, match_algorithms: ['es'] },
{ id: '2.81', released: true, match_algorithms: ['es', 'llm'] },
{ id: 'HEAD', released: false, match_algorithms: ['es', 'llm'] }
]
assert.equal(getDefaultTargetRepoVersion(versions), versions[1])
})

test('getDefaultTargetRepoVersion falls back to the latest release', () => {
const versions = [
{ id: 'HEAD', released: false, match_algorithms: ['llm'] },
{ id: '2.81', released: false, match_algorithms: ['llm'] },
{ id: '2.80', released: true, match_algorithms: ['es'] },
{ id: '2.79', released: true, match_algorithms: null }
]
assert.equal(getDefaultTargetRepoVersion(versions), versions[2])
})

test('getDefaultTargetRepoVersion falls back to HEAD when nothing is released', () => {
const versions = [
{ id: '2.81', released: false, match_algorithms: ['llm'] },
{ id: 'HEAD', released: false }
]
assert.equal(getDefaultTargetRepoVersion(versions), versions[1])
})

test('getDefaultTargetRepoVersion stays unset without a release or HEAD', () => {
assert.equal(getDefaultTargetRepoVersion([]), false)
assert.equal(getDefaultTargetRepoVersion(undefined), false)
assert.equal(getDefaultTargetRepoVersion({ detail: 'Not found.' }), false)
assert.equal(getDefaultTargetRepoVersion([{ id: '2.81', released: false }]), false)
})

test('getSavedTargetRepoVersion keeps the saved version over a newer default', () => {
assert.equal(getSavedTargetRepoVersion(LOINC_VERSIONS, '2.70.21AA'), LOINC_VERSIONS[2])
})

test('getSavedTargetRepoVersion reads a blank saved version as HEAD', () => {
// HEAD's version_url carries no version, so a project saved on HEAD
// stores a target_repo_url without one.
assert.equal(getSavedTargetRepoVersion(LOINC_VERSIONS, ''), LOINC_VERSIONS[3])
})

test('getDefaultTargetRepoVersion prefers the only released version', () => {
const releasedVersion = { id: '2.81', released: true }
test('getSavedTargetRepoVersion stays unset when the saved version is gone', () => {
assert.equal(getSavedTargetRepoVersion(LOINC_VERSIONS, '2.69'), false)
assert.equal(getSavedTargetRepoVersion(undefined, '2.81'), false)
})

test('getLatestReleasedTargetRepoVersion is null without a versions list', () => {
assert.equal(getLatestReleasedTargetRepoVersion(undefined), null)
})

test('getTargetRepoVersionBadges marks the latest release and semantic search', () => {
assert.deepEqual(
getDefaultTargetRepoVersion([
{ id: 'HEAD', released: false },
releasedVersion
]),
releasedVersion
LOINC_VERSIONS.map(version => getTargetRepoVersionBadges(version, LOINC_VERSIONS)),
[['common.latest', 'map_project.semantic_search'], [], [], []]
)
})

test('getDefaultTargetRepoVersion stays unset when multiple choices remain', () => {
assert.equal(
getDefaultTargetRepoVersion([
{ id: 'HEAD', released: false },
{ id: '2.81', released: false }
]),
false
test('getTargetRepoVersionBadges marks no version latest when nothing is released', () => {
const versions = [
{ id: '2.81', released: false },
{ id: 'HEAD', released: false, match_algorithms: ['llm'] }
]
assert.deepEqual(
versions.map(version => getTargetRepoVersionBadges(version, versions)),
[[], ['map_project.semantic_search']]
)
})

test('getTargetRepoVersionReleaseDate dates a release by when OCL created it', () => {
// Not revision_date: OCL copies it from HEAD into each new version.
assert.equal(getTargetRepoVersionReleaseDate(LOINC_VERSIONS[0]), '2025-09-01T10:00:00Z')
assert.equal(getTargetRepoVersionReleaseDate(LOINC_VERSIONS[1]), '2021-11-03T10:00:00Z')
})

test('getTargetRepoVersionReleaseDate leaves HEAD and unreleased versions undated', () => {
assert.equal(getTargetRepoVersionReleaseDate(LOINC_VERSIONS[3]), null)
assert.equal(getTargetRepoVersionReleaseDate({ id: '2.81', released: false, created_on: '2025-09-01T10:00:00Z' }), null)
})

test('hasSelectedTargetRepoVersion treats blank selections as invalid', () => {
assert.equal(hasSelectedTargetRepoVersion(false), false)
assert.equal(hasSelectedTargetRepoVersion({ id: 'HEAD' }), true)
Expand Down
39 changes: 32 additions & 7 deletions src/components/map-projects/projectTargetRepo.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,46 @@ export const getTargetRepoVersionFromUrl = url => {
return parts[4] || ''
}

// The OCL Semantic Algorithm runs only on versions vectorized for it.
export const isLLMBridgeRepoVersion = version => version?.match_algorithms?.includes('llm')

// The API lists versions newest first.
export const getLatestReleasedTargetRepoVersion = versions =>
(Array.isArray(versions) && versions.find(version => version?.released)) || null

// A newly chosen repository starts on its latest release that supports
// semantic search, else its latest release, else HEAD.
export const getDefaultTargetRepoVersion = versions => {
if(!Array.isArray(versions) || versions.length === 0)
if(!Array.isArray(versions))
return false
if(versions.length === 1)
return versions[0]

const releasedVersions = versions.filter(version => version?.released)
if(releasedVersions.length === 1)
return releasedVersions[0]
return versions.find(version => version?.released && isLLMBridgeRepoVersion(version)) ||
getLatestReleasedTargetRepoVersion(versions) ||
versions.find(version => version?.id === 'HEAD') ||
false
}

return false
// A saved project keeps its version, never the default. HEAD's version_url
// carries no version, so a project saved on HEAD has none in its URL.
export const getSavedTargetRepoVersion = (versions, savedVersion) =>
(Array.isArray(versions) && versions.find(version => version?.id === (savedVersion || 'HEAD'))) || false

export const getTargetRepoVersionBadges = (version, versions) => {
const latestReleasedVersion = getLatestReleasedTargetRepoVersion(versions)
const badges = []
if(latestReleasedVersion && version?.id === latestReleasedVersion.id)
badges.push('common.latest')
if(isLLMBridgeRepoVersion(version))
badges.push('map_project.semantic_search')
return badges
}

// OCL keeps no release timestamp: revision_date is copied from HEAD when a
// version is created, and updated_on moves with later edits. created_on is
// what OCL orders versions by, so the latest release shows the newest date.
export const getTargetRepoVersionReleaseDate = version =>
(version?.released && version.created_on) || null

export const getDefaultBridgeRepoVersion = versions => {
if(!Array.isArray(versions) || versions.length === 0)
return false
Expand Down
12 changes: 10 additions & 2 deletions src/components/repos/RepoVersionSearchAutocomplete.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import TextField from '@mui/material/TextField';
import Autocomplete from '@mui/material/Autocomplete';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';


const RepoVersionSearchAutocomplete = ({
Expand All @@ -16,7 +17,8 @@ const RepoVersionSearchAutocomplete = ({
error,
helperText,
getOptionDisabled,
getOptionRightText
getOptionRightText,
getOptionBadges
}) => {
const [open, setOpen] = React.useState(false)

Expand Down Expand Up @@ -51,14 +53,20 @@ const RepoVersionSearchAutocomplete = ({
renderOption={(props, option) => {
const labelContent = getLabel(option)
const rightText = getOptionRightText ? getOptionRightText(option) : ''
const badges = getOptionBadges ? getOptionBadges(option) : []

return (
<li {...props} key={option?.version_url || option?.id || getLabel(option)}>
<Box sx={{display: 'flex', alignItems: 'center', width: '100%', gap: 1, minWidth: 0}}>
<Box sx={{display: 'flex', alignItems: 'center', minWidth: 0, flex: 1, gap: 1}}>
<Box sx={{display: 'flex', alignItems: 'center', flexWrap: 'wrap', minWidth: 0, flex: 1, gap: 1}}>
<Box sx={{minWidth: 0}}>
{labelContent}
</Box>
{
badges.map(badge => (
<Chip key={badge} label={badge} size='small' variant='outlined' color='primary' sx={{height: '20px', fontSize: '11px'}} />
))
}
</Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0}}>
{
Expand Down
Loading
Loading