diff --git a/src/components/map-projects/ConfigurationForm.jsx b/src/components/map-projects/ConfigurationForm.jsx index 94de2a20..d0457a8f 100644 --- a/src/components/map-projects/ConfigurationForm.jsx +++ b/src/components/map-projects/ConfigurationForm.jsx @@ -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' @@ -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')({ @@ -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') @@ -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, @@ -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 && @@ -537,7 +543,7 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n sx={{textTransform: 'none', margin: '20px 5px 5px 0px'}} startIcon={} onClick={onSave} - disabled={!name || !file?.name || !owner || hasConfigErrors || hasAccessErrors || !hasTargetRepoVersion} + disabled={!name || !file?.name || !owner || hasConfigErrors || hasAccessErrors} loading={isSaving} loadingPosition="start" > diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 7882629e..248dd197 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -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' @@ -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([]) @@ -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', @@ -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) @@ -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)) }) } @@ -2845,6 +2853,7 @@ const MapProject = () => { if(newRepo?.url) { fetchVersions(newRepo.url) } else { + versionsRequestRef.current += 1 setVersions([]) } } @@ -5940,6 +5949,7 @@ const MapProject = () => { onRepoChange={onRepoChange} repoVersion={repoVersion} setRepoVersion={onRepoVersionChange} + showTargetRepoVersionError={showTargetRepoVersionError} mappedSources={mappedSources} targetSourcesFromRows={targetSourcesFromRows} versions={versions} diff --git a/src/components/map-projects/__tests__/projectTargetRepo.test.js b/src/components/map-projects/__tests__/projectTargetRepo.test.js index c35c99b3..20549109 100644 --- a/src/components/map-projects/__tests__/projectTargetRepo.test.js +++ b/src/components/map-projects/__tests__/projectTargetRepo.test.js @@ -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') }) @@ -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) diff --git a/src/components/map-projects/projectTargetRepo.js b/src/components/map-projects/projectTargetRepo.js index d654ddb3..5ed190d2 100644 --- a/src/components/map-projects/projectTargetRepo.js +++ b/src/components/map-projects/projectTargetRepo.js @@ -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 diff --git a/src/components/repos/RepoVersionSearchAutocomplete.jsx b/src/components/repos/RepoVersionSearchAutocomplete.jsx index e7118260..9ae471f4 100644 --- a/src/components/repos/RepoVersionSearchAutocomplete.jsx +++ b/src/components/repos/RepoVersionSearchAutocomplete.jsx @@ -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 = ({ @@ -16,7 +17,8 @@ const RepoVersionSearchAutocomplete = ({ error, helperText, getOptionDisabled, - getOptionRightText + getOptionRightText, + getOptionBadges }) => { const [open, setOpen] = React.useState(false) @@ -51,14 +53,20 @@ const RepoVersionSearchAutocomplete = ({ renderOption={(props, option) => { const labelContent = getLabel(option) const rightText = getOptionRightText ? getOptionRightText(option) : '' + const badges = getOptionBadges ? getOptionBadges(option) : [] return (
  • - + {labelContent} + { + badges.map(badge => ( + + )) + } { diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 1367cf2e..534b4dcb 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -768,6 +768,9 @@ "bridge_version_source_required": "Set a bridge source URL before choosing a version.", "bridge_version_reset": "Bridge repo version changed to {{version}} because the previously selected version ({{previous}}) is no longer available.", "not_vectorised": "Not vectorised", + "semantic_search": "Semantic search", + "version_released_on": "Released {{date}}", + "target_repo_version_required": "Select a target repository version before saving.", "advanced_settings": "Advanced settings", "resolution_namespace": "Resolution Namespace", "resolution_namespace_description": "Namespace passed to $resolveReference. When blank, defaults to {{owner}}. Drives which URL Registry entries apply when resolving canonical URLs.", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 41180134..75f09c51 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -747,6 +747,9 @@ "bridge_version_source_required": "Defina una URL de fuente de Bridge antes de elegir una versión.", "bridge_version_reset": "La versión del repositorio de Bridge se cambió a {{version}} porque la versión seleccionada anteriormente ({{previous}}) ya no está disponible.", "not_vectorised": "No vectorizado", + "semantic_search": "Búsqueda semántica", + "version_released_on": "Publicada el {{date}}", + "target_repo_version_required": "Seleccione una versión del repositorio objetivo antes de guardar.", "advanced_settings": "Configuración avanzada", "resolution_namespace": "Espacio de nombres de resolución", "resolution_namespace_description": "Espacio de nombres que se pasa a $resolveReference. Si se deja en blanco, usa {{owner}} por defecto. Esto determina qué entradas del Registro de URL se aplican al resolver URL canónicas.", diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index 303b8574..5fc3f368 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -768,6 +768,9 @@ "bridge_version_source_required": "Defina uma URL de Source Bridge antes de escolher uma versão.", "bridge_version_reset": "A versão do repositório Bridge foi alterada para {{version}} porque a versão selecionada anteriormente ({{previous}}) não está mais disponível.", "not_vectorised": "Não vetorizado", + "semantic_search": "Busca semântica", + "version_released_on": "Publicada em {{date}}", + "target_repo_version_required": "Selecione uma versão do repositório de destino antes de salvar.", "advanced_settings": "Configurações avançadas", "resolution_namespace": "Namespace de Resolução", "resolution_namespace_description": "Namespace passado para $resolveReference. Quando em branco, usa {{owner}} como padrão. Determina quais entradas do Registro de URL se aplicam ao resolver URLs canônicas.", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 9953349a..5f585f68 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -772,6 +772,9 @@ "bridge_version_source_required": "请选择版本前先设置桥接源 URL。", "bridge_version_reset": "桥接仓库版本已更改为 {{version}},因为先前选择的版本({{previous}})已不可用。", "not_vectorised": "未向量化", + "semantic_search": "语义搜索", + "version_released_on": "发布于 {{date}}", + "target_repo_version_required": "保存前请选择目标存储库版本。", "advanced_settings": "高级设置", "resolution_namespace": "解析命名空间", "resolution_namespace_description": "传递给 $resolveReference 的命名空间。留空时默认使用 {{owner}}。它决定解析规范 URL 时适用哪些 URL 注册表条目。",