diff --git a/.gitignore b/.gitignore
index 167c486c..0661ceaf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ src/stories/
dist/
.idea/
.DS_Store
+CLAUDE.md
diff --git a/package-lock.json b/package-lock.json
index c51d6b92..8fe77533 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -512,13 +512,28 @@
"ms": "^2.1.3"
}
},
+ "function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true
+ },
+ "hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.2"
+ }
+ },
"is-core-module": {
- "version": "2.16.1",
- "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
- "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "version": "2.16.2",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"dev": true,
"requires": {
- "hasown": "^2.0.2"
+ "hasown": "^2.0.3"
}
},
"ms": {
@@ -2040,9 +2055,9 @@
}
},
"@babel/plugin-transform-modules-systemjs": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz",
- "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==",
+ "version": "7.29.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz",
+ "integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==",
"dev": true,
"requires": {
"@babel/helper-module-transforms": "^7.28.6",
@@ -2899,9 +2914,9 @@
}
},
"@babel/preset-env": {
- "version": "7.29.3",
- "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.3.tgz",
- "integrity": "sha512-ySZypNLAIH1ClygLDQzVMoGQRViATnkHkYYV6TcNDz+8+jwZCdsguGvsb3EY5d9wyWyhmF1iSuFM0Yh5XPnqSA==",
+ "version": "7.29.5",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.5.tgz",
+ "integrity": "sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA==",
"dev": true,
"requires": {
"@babel/compat-data": "^7.29.3",
@@ -2943,7 +2958,7 @@
"@babel/plugin-transform-member-expression-literals": "^7.27.1",
"@babel/plugin-transform-modules-amd": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
- "@babel/plugin-transform-modules-systemjs": "^7.29.0",
+ "@babel/plugin-transform-modules-systemjs": "^7.29.4",
"@babel/plugin-transform-modules-umd": "^7.27.1",
"@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0",
"@babel/plugin-transform-new-target": "^7.27.1",
@@ -7849,9 +7864,9 @@
},
"dependencies": {
"baseline-browser-mapping": {
- "version": "2.10.24",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz",
- "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==",
+ "version": "2.10.29",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
+ "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
"dev": true
},
"browserslist": {
@@ -7868,15 +7883,15 @@
}
},
"caniuse-lite": {
- "version": "1.0.30001791",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
- "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
+ "version": "1.0.30001792",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
+ "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
"dev": true
},
"electron-to-chromium": {
- "version": "1.5.347",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.347.tgz",
- "integrity": "sha512-BqbKWR67PjxFypgOFcDevD6j8N8GCPkSnQQRuqQIBh3GYCwr0xsLqw2EtSn83oq5iTqJ/wabM/YHV7KgvWGz7Q==",
+ "version": "1.5.353",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
+ "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
"dev": true
},
"escalade": {
@@ -7886,9 +7901,9 @@
"dev": true
},
"node-releases": {
- "version": "2.0.38",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
- "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+ "version": "2.0.44",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
+ "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
"dev": true
},
"picocolors": {
@@ -17769,9 +17784,9 @@
}
},
"react-virtuoso": {
- "version": "4.18.6",
- "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.6.tgz",
- "integrity": "sha512-CrT3P6HyjJMHZVWSste2bG2q5aWGlHfW2QuySZjiFwB2Qok/xsvgy+k8Z2jeDP8PP5KsBip7zNrl/F0QoxeyKw=="
+ "version": "4.18.7",
+ "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.7.tgz",
+ "integrity": "sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g=="
},
"react-window": {
"version": "1.8.11",
diff --git a/package.json b/package.json
index c3a4a5e0..3a02157b 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"react-native-rss-parser": "^1.5.1",
"react-quill": "^2.0.0",
"react-router-dom": "^5.3.4",
- "react-virtuoso": "^4.18.6",
+ "react-virtuoso": "^4.18.7",
"react-window": "^1.8.11",
"stacktrace-js": "^2.0.2",
"xlsx": "^0.18.5"
@@ -50,7 +50,7 @@
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
"@babel/polyfill": "^7.12.1",
- "@babel/preset-env": "^7.29.3",
+ "@babel/preset-env": "^7.29.5",
"@babel/preset-react": "^7.28.5",
"@babel/runtime": "^7.29.2",
"babel-loader": "^8.4.1",
diff --git a/src/components/collections/DeleteReferencesDialog.jsx b/src/components/collections/DeleteReferencesDialog.jsx
new file mode 100644
index 00000000..0718f8a9
--- /dev/null
+++ b/src/components/collections/DeleteReferencesDialog.jsx
@@ -0,0 +1,56 @@
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import Dialog from '@mui/material/Dialog'
+import DialogTitle from '@mui/material/DialogTitle'
+import DialogContent from '@mui/material/DialogContent'
+import DialogActions from '@mui/material/DialogActions'
+import Button from '@mui/material/Button'
+import Typography from '@mui/material/Typography'
+import CircularProgress from '@mui/material/CircularProgress'
+
+const DeleteReferencesDialog = ({ open, onClose, onConfirm, references, loading }) => {
+ const { t } = useTranslation()
+ const selectedReferences = references || []
+ const conceptsCount = selectedReferences.reduce((sum, r) => sum + (r.concepts || 0), 0)
+ const mappingsCount = selectedReferences.reduce((sum, r) => sum + (r.mappings || 0), 0)
+ const referenceIds = selectedReferences.map(r => r.id).filter(Boolean)
+ const getResourceCountLabel = (count, singular, plural) => `${count.toLocaleString()} ${count === 1 ? singular : plural}`
+ const resolvedCounts = [
+ conceptsCount > 0 ? getResourceCountLabel(conceptsCount, t('concept.concept').toLowerCase(), t('concept.concepts').toLowerCase()) : null,
+ mappingsCount > 0 ? getResourceCountLabel(mappingsCount, t('mapping.mapping').toLowerCase(), t('mapping.mappings').toLowerCase()) : null,
+ ].filter(Boolean)
+
+ return (
+
+ )
+}
+
+export default DeleteReferencesDialog
diff --git a/src/components/collections/RemoveFromCollectionDialog.jsx b/src/components/collections/RemoveFromCollectionDialog.jsx
new file mode 100644
index 00000000..2f1a2e1b
--- /dev/null
+++ b/src/components/collections/RemoveFromCollectionDialog.jsx
@@ -0,0 +1,256 @@
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import Dialog from '@mui/material/Dialog'
+import DialogTitle from '@mui/material/DialogTitle'
+import DialogContent from '@mui/material/DialogContent'
+import DialogActions from '@mui/material/DialogActions'
+import Button from '@mui/material/Button'
+import Box from '@mui/material/Box'
+import Typography from '@mui/material/Typography'
+import Checkbox from '@mui/material/Checkbox'
+import List from '@mui/material/List'
+import ListItem from '@mui/material/ListItem'
+import ListItemText from '@mui/material/ListItemText'
+import CircularProgress from '@mui/material/CircularProgress'
+import APIService from '../../services/APIService'
+import { dropVersion } from '../../common/utils'
+import RepoChip from '../repos/RepoChip'
+
+const getUrlPart = (url, part) => {
+ const parts = (url || '').replace(/\/$/, '').split('/')
+ const index = parts.lastIndexOf(part)
+ return index !== -1 ? parts[index + 1] : null
+}
+
+const getRepoFromConcept = concept => {
+ const sourceId = concept.source || getUrlPart(concept.source_url || concept.url, 'sources')
+ if(!sourceId) return null
+ const sourceURL = concept.source_url || (concept.owner_url ? `${concept.owner_url}sources/${sourceId}/` : undefined)
+
+ return {
+ id: sourceId,
+ short_code: sourceId,
+ type: 'Source',
+ url: sourceURL,
+ owner: concept.owner,
+ owner_type: concept.owner_type,
+ owner_url: concept.owner_url,
+ version: concept.latest_source_version,
+ version_url: concept.latest_source_version && sourceURL ? `${sourceURL}${concept.latest_source_version}/` : undefined,
+ }
+}
+
+const getVersionToken = version => {
+ if(!version) return null
+ if(String(version).toUpperCase() === 'HEAD') return version
+ return String(version).match(/^v/i) ? version : `v${version}`
+}
+
+const getMappingSourceToken = (mapping, direction) => {
+ const source = mapping[`${direction}_source`] ||
+ mapping[`${direction}_source_name`] ||
+ getUrlPart(mapping[`${direction}_source_url`] || mapping[`${direction}_concept_url`], 'sources') ||
+ (direction === 'from' ? mapping.source : null)
+ const version = getVersionToken(mapping[`${direction}_source_version`] || (source === mapping.source ? mapping.latest_source_version : null))
+
+ return source && version ? `${source}(${version})` : source
+}
+
+const getMappingConceptSyntax = (mapping, direction) => {
+ const source = getMappingSourceToken(mapping, direction)
+ const code = mapping[`${direction}_concept_code`] || mapping[`${direction}_concept`] || mapping.id
+ const name = mapping[`${direction}_concept_name_resolved`] || mapping[`${direction}_concept_name`]
+ const escapedName = name ? name.replace(/"/g, '\\"') : ''
+
+ return `${source ? `${source}:` : ''}${code || ''}${escapedName ? ` "${escapedName}"` : ''}`
+}
+
+const getMappingInlineSyntax = mapping => {
+ const mapType = mapping.map_type ? `[${mapping.map_type}]` : '[SAME-AS]'
+ return `${getMappingConceptSyntax(mapping, 'from')} ${mapType} ${getMappingConceptSyntax(mapping, 'to')}`
+}
+
+const getResourcePath = resource => resource.concept_class !== undefined ? 'concepts' : 'mappings'
+
+const getResourceUrl = (resource, collectionUrl) => {
+ if(collectionUrl)
+ return `${collectionUrl}${getResourcePath(resource)}/${encodeURIComponent(resource.id)}/`
+ if(resource.version_url || resource.url)
+ return resource.version_url || resource.url
+
+ return ''
+}
+
+const hasReferenceIds = references => Array.isArray(references) && references.some(ref => ref?.id)
+
+const getReferenceLabel = reference => {
+ if(typeof reference === 'string') return reference
+ return reference?.expression || reference?.url || reference?.uri || ''
+}
+
+const ResourceLabel = ({ resource }) => {
+ if(resource.concept_class !== undefined) {
+ const repo = getRepoFromConcept(resource)
+
+ return (
+
+
+ {resource.id} {resource.display_name || resource.name || ''}
+
+ {repo && (
+
+ ·
+
+
+ )}
+
+ )
+ }
+
+ return (
+
+ {getMappingInlineSyntax(resource)}
+
+ )
+}
+
+const RemoveFromCollectionDialog = ({ open, onClose, onConfirm, resources, collectionUrl, lookupCollectionUrl, loading }) => {
+ const { t } = useTranslation()
+ const [fetchingRefs, setFetchingRefs] = React.useState(false)
+ const [resourcesWithRefs, setResourcesWithRefs] = React.useState([])
+ const [checkedRefIds, setCheckedRefIds] = React.useState(new Set())
+ const baseCollectionUrl = dropVersion(collectionUrl)
+ const resourceLookupUrl = lookupCollectionUrl || baseCollectionUrl
+
+ React.useEffect(() => {
+ if(!open || !resources?.length || !resourceLookupUrl) {
+ setFetchingRefs(false)
+ setResourcesWithRefs([])
+ setCheckedRefIds(new Set())
+ return
+ }
+
+ let active = true
+ setFetchingRefs(true)
+ setResourcesWithRefs([])
+ setCheckedRefIds(new Set())
+
+ Promise.all(
+ resources.map(resource => {
+ if(hasReferenceIds(resource.references))
+ return Promise.resolve({ resource, references: resource.references })
+
+ return APIService.new()
+ .overrideURL(getResourceUrl(resource, resourceLookupUrl))
+ .get(null, null, { includeReferences: true })
+ .then(response => ({ resource, references: response?.data?.references || [] }))
+ .catch(() => ({ resource, references: [] }))
+ })
+ ).then(results => {
+ if(!active) return
+ setResourcesWithRefs(results)
+ const allIds = new Set()
+ results.forEach(({ references }) => references.forEach(ref => ref.id && allIds.add(ref.id)))
+ setCheckedRefIds(allIds)
+ setFetchingRefs(false)
+ })
+
+ return () => {
+ active = false
+ }
+ }, [open, resources, resourceLookupUrl])
+
+ const onToggleRef = refId => {
+ setCheckedRefIds(prev => {
+ const next = new Set(prev)
+ if(next.has(refId)) next.delete(refId)
+ else next.add(refId)
+ return next
+ })
+ }
+
+ const showGroupHeaders = resourcesWithRefs.length > 0
+ const checkedCount = checkedRefIds.size
+ const isDisabled = loading || fetchingRefs || checkedCount === 0
+
+ return (
+
+ )
+}
+
+export default RemoveFromCollectionDialog
diff --git a/src/components/common/ResourceReferences.jsx b/src/components/common/ResourceReferences.jsx
new file mode 100644
index 00000000..665ee890
--- /dev/null
+++ b/src/components/common/ResourceReferences.jsx
@@ -0,0 +1,39 @@
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import Paper from '@mui/material/Paper'
+import Typography from '@mui/material/Typography'
+import List from '@mui/material/List'
+import ListItem from '@mui/material/ListItem'
+import ListItemText from '@mui/material/ListItemText'
+import Tooltip from '@mui/material/Tooltip'
+import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
+import { map } from 'lodash'
+
+const borderColor = 'rgba(0, 0, 0, 0.12)'
+
+const ResourceReferences = ({ references, resourceType }) => {
+ const { t } = useTranslation()
+ if (!references?.length) return null
+ return (
+
+
+ {t('reference.references')} ({references.length})
+
+
+
+
+
+ {map(references, reference => (
+
+
+
+ ))}
+
+
+ )
+}
+
+export default ResourceReferences
diff --git a/src/components/concepts/ConceptDetails.jsx b/src/components/concepts/ConceptDetails.jsx
index 9951d5d7..5cdb2d3a 100644
--- a/src/components/concepts/ConceptDetails.jsx
+++ b/src/components/concepts/ConceptDetails.jsx
@@ -10,6 +10,7 @@ import Locales from './Locales'
import Associations from './Associations'
import ConceptProperties from './ConceptProperties'
import ExternalIdLabel from '../common/ExternalIdLabel'
+import ResourceReferences from '../common/ResourceReferences'
const borderColor = 'rgba(0, 0, 0, 0.12)'
@@ -48,6 +49,7 @@ const ConceptDetails = ({ concept, repo, mappings, reverseMappings, loading, loa
}
+
{
concept?.external_id &&
diff --git a/src/components/concepts/ConceptHome.jsx b/src/components/concepts/ConceptHome.jsx
index 460e4ed1..79b99e25 100644
--- a/src/components/concepts/ConceptHome.jsx
+++ b/src/components/concepts/ConceptHome.jsx
@@ -9,6 +9,7 @@ import { toParentURI, dropVersion } from '../../common/utils'
import { OperationsContext } from '../app/LayoutContext';
import RetireConfirmDialog from '../common/RetireConfirmDialog'
+import RemoveFromCollectionDialog from '../collections/RemoveFromCollectionDialog'
import ConceptHeader from './ConceptHeader';
import ConceptTabs from './ConceptTabs';
@@ -39,13 +40,18 @@ const ConceptHome = props => {
const [reverseOwnerMappings, setReverseOwnerMappings] = React.useState([])
const [retireDialog, setRetireDialog] = React.useState(false)
+ const [removeFromCollectionDialog, setRemoveFromCollectionDialog] = React.useState(false)
+ const [removingFromCollection, setRemovingFromCollection] = React.useState(false)
const { setAlert } = React.useContext(OperationsContext);
+ const isInCollection = Boolean(props.repo?.type?.includes('Collection') || props.url?.includes('/collections/'))
+
React.useEffect(() => {
setLoading(true)
setConcept(props.concept || {})
setVersions([])
- getService().get().then(response => {
+ const queryParams = isInCollection ? { includeReferences: true } : {}
+ getService().get(null, null, queryParams).then(response => {
const resource = response.data
setConcept(resource)
props.repo?.id ? setRepo(repo) : fetchRepo(resource)
@@ -215,6 +221,22 @@ const ConceptHome = props => {
})
}
+ const onRemoveFromCollection = deleteBody => {
+ const collectionUrl = dropVersion(props.repo?.version_url || props.repo?.url)
+ const body = deleteBody || { ids: (concept.references || []).map(r => r.id).filter(Boolean) }
+ setRemovingFromCollection(true)
+ APIService.new().overrideURL(collectionUrl).appendToUrl('references/').delete(body).then(response => {
+ setRemovingFromCollection(false)
+ if(response?.status === 204 || response?.status === 200) {
+ setRemoveFromCollectionDialog(false)
+ setAlert({ severity: 'success', message: t('reference.remove_success') })
+ props.onClose && props.onClose()
+ } else {
+ setAlert({ severity: 'error', message: response?.data?.detail || t('common.generic_error') })
+ }
+ })
+ }
+
return (concept?.id && repo?.id) ? (
<>
@@ -243,7 +265,7 @@ const ConceptHome = props => {
!edit &&
<>
- setEdit(true)} repo={repo} nested={props.nested} loading={loading} onRetire={() => setRetireDialog(true)} />
+ setEdit(true)} repo={repo} nested={props.nested} loading={loading} onRetire={() => setRetireDialog(true)} isInCollection={isInCollection} onRemoveFromCollection={() => setRemoveFromCollectionDialog(true)} />
onTabChange(newTab)} loading={loading} />
{
@@ -277,6 +299,15 @@ const ConceptHome = props => {
title={`${t('common.retire')} ${t('concept.concept')}`}
onSubmit={toggleRetire}
/>
+ setRemoveFromCollectionDialog(false)}
+ onConfirm={onRemoveFromCollection}
+ resources={[concept]}
+ collectionUrl={dropVersion(props.repo?.version_url || props.repo?.url)}
+ lookupCollectionUrl={props.repo?.version_url || props.repo?.url}
+ loading={removingFromCollection}
+ />
>
}
diff --git a/src/components/mappings/MappingDetails.jsx b/src/components/mappings/MappingDetails.jsx
index 911a6286..3f0b2959 100644
--- a/src/components/mappings/MappingDetails.jsx
+++ b/src/components/mappings/MappingDetails.jsx
@@ -12,6 +12,7 @@ import FromConceptCard from './FromConceptCard'
import ToConceptCard from './ToConceptCard'
import MappingIcon from './MappingIcon'
import MappingProperties from './MappingProperties'
+import ResourceReferences from '../common/ResourceReferences'
const borderColor = 'rgba(0, 0, 0, 0.12)'
@@ -37,6 +38,7 @@ const MappingDetails = ({ mapping }) => {
}
+
{t('common.last_updated')} {formatDateTime(mapping.versioned_updated_on || mapping.updated_on)} {t('common.by')}
diff --git a/src/components/mappings/MappingHome.jsx b/src/components/mappings/MappingHome.jsx
index de5980b9..aa9fc342 100644
--- a/src/components/mappings/MappingHome.jsx
+++ b/src/components/mappings/MappingHome.jsx
@@ -9,6 +9,7 @@ import { toParentURI, dropVersion } from '../../common/utils'
import { OperationsContext } from '../app/LayoutContext';
import RetireConfirmDialog from '../common/RetireConfirmDialog'
+import RemoveFromCollectionDialog from '../collections/RemoveFromCollectionDialog'
import MappingHeader from './MappingHeader';
import MappingTabs from './MappingTabs';
import MappingDetails from './MappingDetails'
@@ -31,13 +32,18 @@ const MappingHome = props => {
const [loading, setLoading] = React.useState(false)
const [retireDialog, setRetireDialog] = React.useState(false)
+ const [removeFromCollectionDialog, setRemoveFromCollectionDialog] = React.useState(false)
+ const [removingFromCollection, setRemovingFromCollection] = React.useState(false)
const { setAlert } = React.useContext(OperationsContext);
+ const isInCollection = Boolean(props.repo?.type?.includes('Collection') || props.url?.includes('/collections/'))
+
React.useEffect(() => {
setLoading(true)
setMapping(props.mapping || {})
setVersions([])
- getService().get().then(response => {
+ const queryParams = isInCollection ? { includeReferences: true } : {}
+ getService().get(null, null, queryParams).then(response => {
const resource = response.data
setMapping(resource)
props.repo?.id ? setRepo(props.repo) : fetchRepo(resource)
@@ -129,6 +135,22 @@ const MappingHome = props => {
})
}
+ const onRemoveFromCollection = deleteBody => {
+ const collectionUrl = dropVersion(props.repo?.version_url || props.repo?.url)
+ const body = deleteBody || { ids: (mapping.references || []).map(r => r.id).filter(Boolean) }
+ setRemovingFromCollection(true)
+ APIService.new().overrideURL(collectionUrl).appendToUrl('references/').delete(body).then(response => {
+ setRemovingFromCollection(false)
+ if(response?.status === 204 || response?.status === 200) {
+ setRemoveFromCollectionDialog(false)
+ setAlert({ severity: 'success', message: t('reference.remove_success') })
+ props.onClose && props.onClose()
+ } else {
+ setAlert({ severity: 'error', message: response?.data?.detail || t('common.generic_error') })
+ }
+ })
+ }
+
return (mapping?.id && repo?.id) ? (
<>
@@ -154,7 +176,7 @@ const MappingHome = props => {
- setEdit(true)} onRetire={() => setRetireDialog(true)} />
+ setEdit(true)} onRetire={() => setRetireDialog(true)} isInCollection={isInCollection} onRemoveFromCollection={() => setRemoveFromCollectionDialog(true)} />
onTabChange(newTab)} />
{
@@ -179,6 +201,15 @@ const MappingHome = props => {
title={`${t('common.retire')} ${t('mapping.mapping')}`}
onSubmit={toggleRetire}
/>
+ setRemoveFromCollectionDialog(false)}
+ onConfirm={onRemoveFromCollection}
+ resources={[mapping]}
+ collectionUrl={dropVersion(props.repo?.version_url || props.repo?.url)}
+ lookupCollectionUrl={props.repo?.version_url || props.repo?.url}
+ loading={removingFromCollection}
+ />
>
diff --git a/src/components/references/ReferenceHeader.jsx b/src/components/references/ReferenceHeader.jsx
index 47a590c3..b6f8a03b 100644
--- a/src/components/references/ReferenceHeader.jsx
+++ b/src/components/references/ReferenceHeader.jsx
@@ -11,7 +11,7 @@ import Breadcrumbs from '../common/Breadcrumbs'
import CloseIconButton from '../common/CloseIconButton';
import ReferenceManagementList from './ReferenceManagementList'
-const ReferenceHeader = ({ reference, onClose }) => {
+const ReferenceHeader = ({ reference, onClose, onDelete, canDelete, deleteDisabledReason }) => {
const { t } = useTranslation()
const [menu, setMenu] = React.useState(false)
const [menuAnchorEl, setMenuAnchorEl] = React.useState(false)
@@ -29,6 +29,8 @@ const ReferenceHeader = ({ reference, onClose }) => {
copyURL(toFullAPIURL(reference.expression))
else if(option === 'copyURL')
copyURL(toFullAPIURL(reference.uri))
+ else if(option === 'delete')
+ onDelete && onDelete()
onMenuClose()
}
@@ -53,7 +55,7 @@ const ReferenceHeader = ({ reference, onClose }) => {
} variant='text' sx={{textTransform: 'none', color: 'surface.contrastText'}} onClick={onMenuOpen} id='reference-actions'>
{t('common.actions')}
-
+
diff --git a/src/components/references/ReferenceHome.jsx b/src/components/references/ReferenceHome.jsx
index ae8e955f..83765155 100644
--- a/src/components/references/ReferenceHome.jsx
+++ b/src/components/references/ReferenceHome.jsx
@@ -1,23 +1,36 @@
import React from 'react'
+import { useTranslation } from 'react-i18next'
import APIService from '../../services/APIService';
+import { dropVersion } from '../../common/utils'
+import { OperationsContext } from '../app/LayoutContext';
import ReferenceHeader from './ReferenceHeader'
import ReferenceDetails from './ReferenceDetails'
import ReferenceTabs from './ReferenceTabs'
import ReferenceExpansionResults from './ReferenceExpansionResults'
+import DeleteReferencesDialog from '../collections/DeleteReferencesDialog'
const ReferenceHome = props => {
+ const { t } = useTranslation()
const { reference } = props
const [loading, setLoading] = React.useState(false)
+ const [deleteDialog, setDeleteDialog] = React.useState(false)
+ const [deleting, setDeleting] = React.useState(false)
const [tab, setTab] = React.useState('metadata')
const [concepts, setConcepts] = React.useState(false)
const [conceptHeaders, setConceptHeaders] = React.useState(false)
const [mappings, setMappings] = React.useState(false)
const [mappingHeaders, setMappingHeaders] = React.useState(false)
const activeReferenceIdRef = React.useRef(reference?.id)
+ const { setAlert } = React.useContext(OperationsContext);
const repoURL = props?.repo?.version_url || props?.repo?.url
+ const isHeadReferenceUrl = url => {
+ const normalizedUrl = (url || '').replace(/\/$/, '')
+ return normalizedUrl.includes('/HEAD/') || Boolean(normalizedUrl.match(/\/collections\/[^/]+\/references\/[^/]+$/))
+ }
+ const isHead = isHeadReferenceUrl(props.url) || isHeadReferenceUrl(repoURL)
const resetExpansionState = () => {
setLoading(false)
@@ -50,6 +63,7 @@ const ReferenceHome = props => {
}
}
const getRefService = () => APIService.new().overrideURL(repoURL).appendToUrl(`references/${reference.id}/`)
+ const getReferenceId = () => reference?.id || decodeURIComponent((props.url || '').replace(/\/$/, '').split('/').pop())
const fetchConcepts = ({ reset=false, currentReferenceId=reference?.id } = {}) => {
const { limit, page } = getLimits(reset ? false : conceptHeaders)
@@ -100,11 +114,27 @@ const ReferenceHome = props => {
fetchMappings()
}
+ const onDeleteReference = deleteBody => {
+ const body = deleteBody || { ids: [getReferenceId()].filter(Boolean) }
+ setDeleting(true)
+ APIService.new().overrideURL(dropVersion(repoURL)).appendToUrl('references/').delete(body).then(response => {
+ setDeleting(false)
+ if(response?.status === 204 || response?.status === 200) {
+ setDeleteDialog(false)
+ setAlert({ severity: 'success', message: t('reference.remove_success') })
+ props.onDelete && props.onDelete()
+ props.onClose && props.onClose()
+ } else {
+ setAlert({ severity: 'error', message: response?.data?.detail || t('common.generic_error') })
+ }
+ })
+ }
+
return (
-
+ setDeleteDialog(true)} canDelete={isHead} deleteDisabledReason={isHead ? '' : t('reference.not_available_in_version')} />
onTabChange(newTab)} loading={loading} />
{
tab === 'metadata' &&
@@ -114,6 +144,13 @@ const ReferenceHome = props => {
tab === 'expansion' &&
}
+ setDeleteDialog(false)}
+ onConfirm={onDeleteReference}
+ references={[{...reference, id: getReferenceId()}]}
+ loading={deleting}
+ />
)
}
diff --git a/src/components/references/ReferenceManagementList.jsx b/src/components/references/ReferenceManagementList.jsx
index ab61a8b2..54f2ed57 100644
--- a/src/components/references/ReferenceManagementList.jsx
+++ b/src/components/references/ReferenceManagementList.jsx
@@ -1,10 +1,10 @@
import React from 'react';
import { useTranslation } from 'react-i18next'
-import { Menu, ListItem, ListItemButton, ListItemText, ListItemIcon, Divider} from '@mui/material'
+import { Menu, ListItem, ListItemButton, ListItemText, ListItemIcon, Divider, Tooltip} from '@mui/material'
import CopyIcon from '@mui/icons-material/ContentCopy';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
-const ReferenceManagementList = ({ anchorEl, open, onClose, id, onClick }) => {
+const ReferenceManagementList = ({ anchorEl, open, onClose, id, onClick, canDelete=true, deleteDisabledReason='' }) => {
const { t } = useTranslation()
return (
)
diff --git a/src/components/repos/RepoHome.jsx b/src/components/repos/RepoHome.jsx
index 30a09465..06b027d3 100644
--- a/src/components/repos/RepoHome.jsx
+++ b/src/components/repos/RepoHome.jsx
@@ -53,6 +53,7 @@ const RepoHome = () => {
const [deleteTarget, setDeleteTarget] = React.useState(false)
const [releaseTarget, setReleaseTarget] = React.useState(false)
const [showSummary, setShowSummary] = React.useState(true)
+ const [searchReloadKey, setSearchReloadKey] = React.useState(0)
const TAB_KEYS = tabs.map(tab => tab.key)
const findTab = () => TAB_KEYS.includes(params?.tab || params?.repoVersion) ? params.tab || params.repoVersion : 'concepts'
@@ -275,12 +276,14 @@ const RepoHome = () => {
{
repo?.id && ['concepts', 'mappings', 'references'].includes(tab) &&
{
}
{
showReferenceURL &&
- setShowItem(false)} repoVersions={versions} nested />
+ setShowItem(false)} onDelete={() => setSearchReloadKey(key => key + 1)} repoVersions={versions} nested />
}
{
conceptForm &&
diff --git a/src/components/search/ResultConstants.js b/src/components/search/ResultConstants.js
index a0808381..c08f24ac 100644
--- a/src/components/search/ResultConstants.js
+++ b/src/components/search/ResultConstants.js
@@ -25,13 +25,8 @@ const getLocale = (concept, synonym) => {
const getReferenceSummary = (reference) => {
let label = '';
- if(reference.last_resolved_at && reference.concepts === 0 && reference.mappings === 0) {
- if(reference.reference_type === 'mappings')
- return '0 mappings'
- if(reference.reference_type === 'concepts')
- return '0 concepts'
- return '0 concepts, 0 mappings'
- }
+ if(reference.last_resolved_at && reference.concepts === 0 && reference.mappings === 0)
+ return '-'
if(isNumber(reference.concepts) && reference.concepts > 0)
label += `${reference.concepts.toLocaleString()} concepts`
if(isNumber(reference.mappings) && reference.mappings > 0) {
diff --git a/src/components/search/Search.jsx b/src/components/search/Search.jsx
index 6168732b..327d8797 100644
--- a/src/components/search/Search.jsx
+++ b/src/components/search/Search.jsx
@@ -4,11 +4,14 @@ import { useLocation, useHistory } from 'react-router-dom';
import { useTranslation } from 'react-i18next'
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
+import Button from '@mui/material/Button';
+import Tooltip from '@mui/material/Tooltip';
import OrgIcon from '@mui/icons-material/AccountBalance';
import UserIcon from '@mui/icons-material/Person';
+import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import { forEach, keys, pickBy, isEmpty, find, uniq, has, orderBy as sortBy, uniqBy, omit, max, isEqual, isBoolean } from 'lodash';
import { COLORS } from '../../common/colors';
-import { highlightTexts } from '../../common/utils';
+import { dropVersion, highlightTexts, isLoggedIn } from '../../common/utils';
import APIService from '../../services/APIService';
import RepoIcon from '../repos/RepoIcon';
import ConceptIcon from '../concepts/ConceptIcon';
@@ -17,11 +20,22 @@ import SearchResults from './SearchResults';
import SearchFilters from './SearchFilters'
import { OperationsContext } from '../app/LayoutContext';
import ReferenceFilters from '../repos/ReferenceFilters'
+import DeleteReferencesDialog from '../collections/DeleteReferencesDialog'
+import RemoveFromCollectionDialog from '../collections/RemoveFromCollectionDialog'
+import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'
const DEFAULT_LIMIT = 25;
const FILTERS_WIDTH = 250
const FILTERABLE_RESOURCES = ['concepts', 'mappings', 'repos', 'sources', 'collections', 'references']
+const getBaseCollectionUrl = url => {
+ const match = (url || '').match(/^(.*\/collections\/[^/]+\/)(?:[^/]+\/)?(?:concepts|mappings|references)\/?$/)
+ return match ? match[1] : dropVersion(url)
+}
+
+const getCollectionLookupUrl = url => (url || '').replace(/\/(concepts|mappings|references)\/?$/, '/')
+
+
const Search = props => {
const { setAlert, contextRepo } = React.useContext(OperationsContext);
const { t } = useTranslation()
@@ -41,6 +55,10 @@ const Search = props => {
const [order, setOrder] = React.useState('desc');
const [orderBy, setOrderBy] = React.useState('score');
const [isMatchOp, setIsMatchOp] = React.useState(false)
+ const [deleteReferencesOpen, setDeleteReferencesOpen] = React.useState(false)
+ const [deletingReferences, setDeletingReferences] = React.useState(false)
+ const [bulkRemoveOpen, setBulkRemoveOpen] = React.useState(false)
+ const [bulkRemoving, setBulkRemoving] = React.useState(false)
const didMount = React.useRef(false);
const isFilterable = _resource => FILTERABLE_RESOURCES.includes(_resource)
@@ -418,6 +436,80 @@ const Search = props => {
history.push(getCurrentLayoutURL(getQueryParams(input, page, pageSize, filters, newOrderByField, newOrder)))
}
+ const isHead = props.nested ? props.repo?.version === 'HEAD' : false
+ const isInCollection = props.url?.includes('/collections/')
+ const collectionUrl = isInCollection ? getBaseCollectionUrl(props.url) : null
+ const collectionLookupUrl = isInCollection ? getCollectionLookupUrl(props.url) : null
+
+ const selectedReferenceObjects = resource === 'references' && selected.length > 0
+ ? (result['references']?.results || []).filter(r => selected.includes(r.version_url || r.url || r.id))
+ : []
+
+ const onDeleteReferences = deleteBody => {
+ const body = deleteBody || { ids: selectedReferenceObjects.map(r => r.id).filter(Boolean) }
+ const deleteUrl = isInCollection ? `${collectionUrl}references/` : props.url
+ setDeletingReferences(true)
+ APIService.new().overrideURL(deleteUrl).delete(body).then(response => {
+ setDeletingReferences(false)
+ if(response?.status === 204 || response?.status === 200) {
+ setDeleteReferencesOpen(false)
+ setSelected([])
+ setAlert({ severity: 'success', message: t('reference.remove_success') })
+ fetchResults(getQueryParams(input, page, pageSize, filters, orderBy, order))
+ } else {
+ setAlert({ severity: 'error', message: response?.data?.detail || t('common.generic_error') })
+ }
+ })
+ }
+
+ const selectedRows = (result[resource]?.results || []).filter(r => selected.includes(r.version_url || r.url || r.id))
+
+ const onBulkRemoveFromCollection = deleteBody => {
+ const body = deleteBody || { ids: [] }
+ setBulkRemoving(true)
+ APIService.new().overrideURL(collectionUrl).appendToUrl('references/').delete(body).then(response => {
+ setBulkRemoving(false)
+ if(response?.status === 204 || response?.status === 200) {
+ setBulkRemoveOpen(false)
+ setSelected([])
+ setAlert({ severity: 'success', message: t('reference.remove_success') })
+ fetchResults(getQueryParams(input, page, pageSize, filters, orderBy, order))
+ } else {
+ setAlert({ severity: 'error', message: response?.data?.detail || t('common.generic_error') })
+ }
+ })
+ }
+
+ const bulkRemoveFromCollectionAction = isInCollection && isHead && ['concepts', 'mappings'].includes(resource) && isLoggedIn() && selected.length > 0 ? (
+ }
+ variant='contained'
+ size='small'
+ color='error'
+ sx={{textTransform: 'none', whiteSpace: 'nowrap', borderRadius: '8px', marginLeft: '8px'}}
+ onClick={() => setBulkRemoveOpen(true)}
+ >
+ {t('reference.remove_from_collection')}
+
+ ) : null
+
+ const deleteReferencesControl = resource === 'references' && isLoggedIn() && selected.length > 0 ? (
+
+
+ }
+ variant='contained'
+ size='small'
+ color='error'
+ disabled={!isHead}
+ sx={{textTransform: 'none', whiteSpace: 'nowrap', borderRadius: '8px', marginLeft: '8px'}}
+ onClick={() => setDeleteReferencesOpen(true)}
+ >
+ {t('reference.remove_selected')}
+
+
+
+ ) : null
React.useEffect(() => {
setShowItem(props.showItem || false)
@@ -497,6 +589,8 @@ const Search = props => {
properties={props.properties}
propertyFilters={props.propertyFilters}
isMatch={isMatchOp}
+ toolbarControl={deleteReferencesControl}
+ extraBulkActions={bulkRemoveFromCollectionAction}
/>
@@ -512,6 +606,22 @@ const Search = props => {
}
}
+ setDeleteReferencesOpen(false)}
+ onConfirm={onDeleteReferences}
+ references={selectedReferenceObjects}
+ loading={deletingReferences}
+ />
+ setBulkRemoveOpen(false)}
+ onConfirm={onBulkRemoveFromCollection}
+ resources={selectedRows}
+ collectionUrl={collectionUrl}
+ lookupCollectionUrl={collectionLookupUrl}
+ loading={bulkRemoving}
+ />
)
}
diff --git a/src/components/search/SearchResults.jsx b/src/components/search/SearchResults.jsx
index 8dcac5da..aff2969a 100644
--- a/src/components/search/SearchResults.jsx
+++ b/src/components/search/SearchResults.jsx
@@ -235,6 +235,9 @@ const SearchResults = props => {
) : null
+ const allBulkActions = [addToCollectionBulkAction, props.extraBulkActions].filter(Boolean)
+ const bulkActionsElement = allBulkActions.length > 0 ? <>{allBulkActions}> : null
+
React.useEffect(() => {
setSelected(props.selected || [])
}, [props.selected])
@@ -261,7 +264,7 @@ const SearchResults = props => {
noCardDisplay={noCardDisplay}
toolbarControl={props.toolbarControl}
appliedFilters={props.appliedFilters}
- bulkActions={addToCollectionBulkAction}
+ bulkActions={bulkActionsElement}
/>
}
{
diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json
index d3f5747b..2895cabd 100644
--- a/src/i18n/locales/en/translations.json
+++ b/src/i18n/locales/en/translations.json
@@ -278,7 +278,21 @@
"versioned_resource": "Versioned (Resource)",
"resolved_repo": "Resolved Repo",
"raw": "Raw",
- "translation": "Translation"
+ "translation": "Translation",
+ "remove_selected": "Remove Selected",
+ "remove_confirm_title": "Remove {{count}} {{reference}}?",
+ "remove_confirm_body": "This will remove {{summary}} from the collection expansion.",
+ "remove_confirm_body_selected": "This will remove the selected reference(s) from the collection expansion.",
+ "not_available_in_version": "Not available in saved versions. Switch to HEAD to edit.",
+ "remove_success": "References removed successfully.",
+ "brought_in_by": "Brought into collection by",
+ "brought_in_by_tooltip": "This {{resource}} appears in this collection expansion as a result of these references.",
+ "no_references_found": "No references found.",
+ "remove_from_collection": "Remove from collection",
+ "remove_concept_confirm_title": "Remove concept from collection?",
+ "remove_concept_confirm_body": "This concept is brought in by the following reference(s). Removing it will delete those references from the collection.",
+ "remove_mapping_confirm_title": "Remove mapping from collection?",
+ "remove_mapping_confirm_body": "This mapping is brought in by the following reference(s). Removing it will delete those references from the collection."
},
"checksums": {
"standard": "Standard Checksum",