diff --git a/src/components/collections/TransformReferencesDialog.jsx b/src/components/collections/TransformReferencesDialog.jsx
new file mode 100644
index 00000000..8016bf4e
--- /dev/null
+++ b/src/components/collections/TransformReferencesDialog.jsx
@@ -0,0 +1,101 @@
+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'
+import Box from '@mui/material/Box'
+import Chip from '@mui/material/Chip'
+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 Alert from '@mui/material/Alert'
+import { getTransformPreviewItems } from './referenceTransformUtils'
+
+const expressionSx = {
+ fontFamily: 'monospace',
+ fontSize: '12px',
+ overflowWrap: 'anywhere',
+ maxWidth: '260px',
+}
+
+const TransformReferencesDialog = ({ open, onClose, onConfirm, references, loading }) => {
+ const { t } = useTranslation()
+ const previewItems = React.useMemo(() => getTransformPreviewItems(references), [references])
+ const eligibleItems = previewItems.filter(item => item.eligible)
+ const skippedItems = previewItems.filter(item => !item.eligible)
+ const selectedCount = references?.length || 0
+
+ return (
+
+ )
+}
+
+export default TransformReferencesDialog
diff --git a/src/components/collections/referenceTransformUtils.js b/src/components/collections/referenceTransformUtils.js
new file mode 100644
index 00000000..124982c2
--- /dev/null
+++ b/src/components/collections/referenceTransformUtils.js
@@ -0,0 +1,97 @@
+const RESOURCE_SEGMENTS = ['concepts', 'mappings']
+const REPO_SEGMENTS = ['sources', 'collections']
+
+const hasRepoPin = reference => Boolean(reference?.version && String(reference.version).toUpperCase() !== 'HEAD')
+
+const hasResourcePin = reference => Boolean(reference?.resource_version)
+
+const splitExpression = expression => {
+ const parts = String(expression || '').split('?')
+ return {
+ path: parts[0],
+ query: parts.length > 1 ? parts.slice(1).join('?') : '',
+ }
+}
+
+const getRelativePathParts = path => {
+ const match = String(path || '').match(/^(.*?)(\/(?:orgs|users)\/.*)$/)
+ if(!match)
+ return null
+
+ return {
+ prefix: match[1],
+ parts: match[2].split('/').filter(Boolean),
+ }
+}
+
+export const buildNonVersionedReferenceExpression = reference => {
+ const expression = reference?.expression
+ if(!expression)
+ return null
+
+ const { path, query } = splitExpression(expression)
+ const parsed = getRelativePathParts(path)
+ if(!parsed)
+ return null
+ if(parsed.prefix)
+ return null
+
+ const parts = [...parsed.parts]
+ const repoTypeIndex = parts.findIndex(part => REPO_SEGMENTS.includes(part))
+ if(repoTypeIndex === -1)
+ return null
+
+ const afterRepoIndex = repoTypeIndex + 2
+ if(hasRepoPin(reference) && parts[afterRepoIndex] && !RESOURCE_SEGMENTS.includes(parts[afterRepoIndex]))
+ parts.splice(afterRepoIndex, 1)
+
+ const resourceTypeIndex = parts.findIndex(part => RESOURCE_SEGMENTS.includes(part))
+ const resourceVersionIndex = resourceTypeIndex + 2
+ if(hasResourcePin(reference) && resourceTypeIndex !== -1 && parts[resourceVersionIndex])
+ parts.splice(resourceVersionIndex, 1)
+
+ const proposedPath = `${parsed.prefix}/${parts.join('/')}/`
+ return query ? `${proposedPath}?${query}` : proposedPath
+}
+
+export const getReferenceTransformPreview = reference => {
+ if(!reference?.id)
+ return { reference, eligible: false, reasonKey: 'reference.unsupported_transform' }
+
+ if(!hasRepoPin(reference) && !hasResourcePin(reference))
+ return { reference, eligible: false, reasonKey: 'reference.already_non_versioned' }
+
+ const proposedExpression = buildNonVersionedReferenceExpression(reference)
+ if(!proposedExpression || proposedExpression === reference.expression)
+ return { reference, eligible: false, reasonKey: 'reference.unsupported_transform' }
+
+ return {
+ reference,
+ eligible: true,
+ proposedExpression,
+ }
+}
+
+export const getTransformPreviewItems = references => (references || []).map(getReferenceTransformPreview)
+
+export const getTransformAddGroups = previewItems => {
+ const groups = {}
+ previewItems.filter(item => item.eligible).forEach(item => {
+ const reference = item.reference
+ const include = reference.include !== false
+ const key = JSON.stringify({
+ reference_type: reference.reference_type,
+ include,
+ cascade: reference.cascade || null,
+ })
+ if(!groups[key]) {
+ groups[key] = {
+ include,
+ cascade: reference.cascade || null,
+ items: [],
+ }
+ }
+ groups[key].items.push(item)
+ })
+ return Object.values(groups)
+}
diff --git a/src/components/search/Search.jsx b/src/components/search/Search.jsx
index 327d8797..25e44101 100644
--- a/src/components/search/Search.jsx
+++ b/src/components/search/Search.jsx
@@ -6,9 +6,15 @@ 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 Menu from '@mui/material/Menu';
+import MenuItem from '@mui/material/MenuItem';
+import ListItemIcon from '@mui/material/ListItemIcon';
+import ListItemText from '@mui/material/ListItemText';
import OrgIcon from '@mui/icons-material/AccountBalance';
import UserIcon from '@mui/icons-material/Person';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
+import TransformIcon from '@mui/icons-material/Transform';
+import DownIcon from '@mui/icons-material/KeyboardArrowDown';
import { forEach, keys, pickBy, isEmpty, find, uniq, has, orderBy as sortBy, uniqBy, omit, max, isEqual, isBoolean } from 'lodash';
import { COLORS } from '../../common/colors';
import { dropVersion, highlightTexts, isLoggedIn } from '../../common/utils';
@@ -22,6 +28,8 @@ import { OperationsContext } from '../app/LayoutContext';
import ReferenceFilters from '../repos/ReferenceFilters'
import DeleteReferencesDialog from '../collections/DeleteReferencesDialog'
import RemoveFromCollectionDialog from '../collections/RemoveFromCollectionDialog'
+import TransformReferencesDialog from '../collections/TransformReferencesDialog'
+import { getTransformAddGroups } from '../collections/referenceTransformUtils'
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'
const DEFAULT_LIMIT = 25;
@@ -57,6 +65,9 @@ const Search = props => {
const [isMatchOp, setIsMatchOp] = React.useState(false)
const [deleteReferencesOpen, setDeleteReferencesOpen] = React.useState(false)
const [deletingReferences, setDeletingReferences] = React.useState(false)
+ const [referenceActionsAnchor, setReferenceActionsAnchor] = React.useState(null)
+ const [transformReferencesOpen, setTransformReferencesOpen] = React.useState(false)
+ const [transformingReferences, setTransformingReferences] = React.useState(false)
const [bulkRemoveOpen, setBulkRemoveOpen] = React.useState(false)
const [bulkRemoving, setBulkRemoving] = React.useState(false)
@@ -462,6 +473,71 @@ const Search = props => {
})
}
+ const addTransformedReferences = group => {
+ const data = {
+ expressions: group.items.map(item => item.proposedExpression),
+ include: group.include,
+ }
+ const body = { data }
+ if(group.cascade) {
+ data.cascade = group.cascade
+ body.cascade = typeof group.cascade === 'string' ? group.cascade : group.cascade?.method || ''
+ }
+
+ return APIService.new().overrideURL(collectionUrl).appendToUrl('references/').put(body)
+ }
+
+ const getAddedTransformItems = (response, group) => {
+ if(![200, 201].includes(response?.status))
+ return []
+ if(!Array.isArray(response?.data))
+ return group.items
+
+ const addedExpressions = response.data.filter(item => item.added).map(item => item.expression)
+ return group.items.filter(item => addedExpressions.includes(item.proposedExpression))
+ }
+
+ const onTransformReferences = async transformItems => {
+ const groups = getTransformAddGroups(transformItems)
+ setTransformingReferences(true)
+
+ const addResults = await Promise.all(groups.map(group => addTransformedReferences(group).then(response => ({ group, response }))))
+ const addedItems = addResults.reduce((items, { group, response }) => [...items, ...getAddedTransformItems(response, group)], [])
+ const addFailures = addResults.reduce((count, { group, response }) => count + group.items.length - getAddedTransformItems(response, group).length, 0)
+
+ if(!addedItems.length) {
+ setTransformingReferences(false)
+ setAlert({ severity: 'error', message: t('reference.transform_no_references_changed') })
+ return
+ }
+
+ const deleteResponse = await APIService.new()
+ .overrideURL(collectionUrl)
+ .appendToUrl('references/')
+ .delete({ ids: addedItems.map(item => item.reference.id).filter(Boolean) })
+
+ setTransformingReferences(false)
+ const deleteSucceeded = [200, 204].includes(deleteResponse?.status)
+ if(deleteSucceeded) {
+ setTransformReferencesOpen(false)
+ setSelected([])
+ const transformedCount = addedItems.length
+ setAlert({
+ severity: addFailures ? 'warning' : 'success',
+ message: addFailures ?
+ t('reference.transform_partial_success', { transformed: transformedCount, failed: addFailures }) :
+ t('reference.transform_success', { count: transformedCount }),
+ })
+ fetchResults(getQueryParams(input, page, pageSize, filters, orderBy, order))
+ } else {
+ setAlert({
+ severity: 'warning',
+ message: t('reference.transform_delete_failed', { count: addedItems.length }),
+ })
+ fetchResults(getQueryParams(input, page, pageSize, filters, orderBy, order))
+ }
+ }
+
const selectedRows = (result[resource]?.results || []).filter(r => selected.includes(r.version_url || r.url || r.id))
const onBulkRemoveFromCollection = deleteBody => {
@@ -493,19 +569,20 @@ const Search = props => {
) : null
- const deleteReferencesControl = resource === 'references' && isLoggedIn() && selected.length > 0 ? (
+ const closeReferenceActions = () => setReferenceActionsAnchor(null)
+
+ const referenceActionsControl = resource === 'references' && isLoggedIn() && selected.length > 0 ? (