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
101 changes: 101 additions & 0 deletions src/components/collections/TransformReferencesDialog.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onClose={loading ? undefined : onClose} maxWidth='md' fullWidth>
<DialogTitle>
{t('reference.transform_confirm_title', { count: selectedCount })}
</DialogTitle>
<DialogContent sx={{display: 'flex', flexDirection: 'column', gap: 1.5}}>
<Typography variant='body2' color='text.secondary'>
{t('reference.transform_confirm_body')}
</Typography>

<Box sx={{display: 'flex', gap: 1, flexWrap: 'wrap'}}>
<Chip size='small' color='primary' label={`${selectedCount} ${t('common.selected')}`} />
<Chip size='small' color='success' label={`${eligibleItems.length} ${t('common.eligible')}`} />
<Chip size='small' color={skippedItems.length ? 'warning' : 'default'} label={`${skippedItems.length} ${t('common.skipped')}`} />
</Box>

{eligibleItems.length === 0 && (
<Alert severity='warning'>
{t('reference.transform_no_eligible')}
</Alert>
)}

<TableContainer sx={{border: '1px solid rgba(0,0,0,0.12)', borderRadius: '4px', maxHeight: '420px'}}>
<Table stickyHeader size='small'>
<TableHead>
<TableRow>
<TableCell>{t('reference.expression')}</TableCell>
<TableCell>{t('reference.new_expression')}</TableCell>
<TableCell>{t('common.status')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{previewItems.map(item => (
<TableRow key={item.reference?.id || item.reference?.expression}>
<TableCell sx={expressionSx}>{item.reference?.expression}</TableCell>
<TableCell sx={expressionSx}>{item.proposedExpression || '-'}</TableCell>
<TableCell>
{item.eligible ? (
<Chip size='small' color='success' label={t('common.eligible')} />
) : (
<Chip size='small' color='warning' label={t(item.reasonKey)} />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant='text' disabled={loading}>
{t('common.cancel')}
</Button>
<Button
onClick={() => onConfirm(eligibleItems)}
variant='contained'
disabled={loading || eligibleItems.length === 0}
startIcon={loading ? <CircularProgress size={16} color='inherit' /> : null}
>
{t('reference.transform_references')}
</Button>
</DialogActions>
</Dialog>
)
}

export default TransformReferencesDialog
97 changes: 97 additions & 0 deletions src/components/collections/referenceTransformUtils.js
Original file line number Diff line number Diff line change
@@ -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)
}
124 changes: 118 additions & 6 deletions src/components/search/Search.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -493,19 +569,20 @@ const Search = props => {
</Button>
) : null

const deleteReferencesControl = resource === 'references' && isLoggedIn() && selected.length > 0 ? (
const closeReferenceActions = () => setReferenceActionsAnchor(null)

const referenceActionsControl = resource === 'references' && isLoggedIn() && selected.length > 0 ? (
<Tooltip title={!isHead ? t('reference.not_available_in_version') : ''}>
<span>
<Button
startIcon={<DeleteForeverIcon fontSize='inherit' />}
endIcon={<DownIcon fontSize='inherit' />}
variant='contained'
size='small'
color='error'
disabled={!isHead}
sx={{textTransform: 'none', whiteSpace: 'nowrap', borderRadius: '8px', marginLeft: '8px'}}
onClick={() => setDeleteReferencesOpen(true)}
onClick={event => setReferenceActionsAnchor(event.currentTarget)}
>
{t('reference.remove_selected')}
{t('reference.actions')}
</Button>
</span>
</Tooltip>
Expand Down Expand Up @@ -589,7 +666,7 @@ const Search = props => {
properties={props.properties}
propertyFilters={props.propertyFilters}
isMatch={isMatchOp}
toolbarControl={deleteReferencesControl}
toolbarControl={referenceActionsControl}
extraBulkActions={bulkRemoveFromCollectionAction}
/>
</div>
Expand All @@ -606,6 +683,41 @@ const Search = props => {
}
</div>
}
<Menu
anchorEl={referenceActionsAnchor}
open={Boolean(referenceActionsAnchor)}
onClose={closeReferenceActions}
>
<MenuItem
onClick={() => {
closeReferenceActions()
setTransformReferencesOpen(true)
}}
>
<ListItemIcon>
<TransformIcon fontSize='small' />
</ListItemIcon>
<ListItemText>{t('reference.transform_to_non_versioned')}</ListItemText>
</MenuItem>
<MenuItem
onClick={() => {
closeReferenceActions()
setDeleteReferencesOpen(true)
}}
>
<ListItemIcon>
<DeleteForeverIcon fontSize='small' color='error' />
</ListItemIcon>
<ListItemText>{t('common.remove')}</ListItemText>
</MenuItem>
</Menu>
<TransformReferencesDialog
open={transformReferencesOpen}
onClose={() => setTransformReferencesOpen(false)}
onConfirm={onTransformReferences}
references={selectedReferenceObjects}
loading={transformingReferences}
/>
<DeleteReferencesDialog
open={deleteReferencesOpen}
onClose={() => setDeleteReferencesOpen(false)}
Expand Down
Loading