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 ( + + + {t('reference.transform_confirm_title', { count: selectedCount })} + + + + {t('reference.transform_confirm_body')} + + + + + + + + + {eligibleItems.length === 0 && ( + + {t('reference.transform_no_eligible')} + + )} + + + + + + {t('reference.expression')} + {t('reference.new_expression')} + {t('common.status')} + + + + {previewItems.map(item => ( + + {item.reference?.expression} + {item.proposedExpression || '-'} + + {item.eligible ? ( + + ) : ( + + )} + + + ))} + +
+
+
+ + + + +
+ ) +} + +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 ? ( @@ -589,7 +666,7 @@ const Search = props => { properties={props.properties} propertyFilters={props.propertyFilters} isMatch={isMatchOp} - toolbarControl={deleteReferencesControl} + toolbarControl={referenceActionsControl} extraBulkActions={bulkRemoveFromCollectionAction} /> @@ -606,6 +683,41 @@ const Search = props => { } } + + { + closeReferenceActions() + setTransformReferencesOpen(true) + }} + > + + + + {t('reference.transform_to_non_versioned')} + + { + closeReferenceActions() + setDeleteReferencesOpen(true) + }} + > + + + + {t('common.remove')} + + + setTransformReferencesOpen(false)} + onConfirm={onTransformReferences} + references={selectedReferenceObjects} + loading={transformingReferences} + /> setDeleteReferencesOpen(false)} diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index da6e2220..9e6a5299 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -52,6 +52,10 @@ "view_all_attributes": "View all attributes", "all": "All", "actions": "Actions", + "selected": "selected", + "eligible": "eligible", + "skipped": "skipped", + "status": "Status", "metadata": "Metadata", "details": "Details", "copied_to_clipboard": "Copied to clipboard", @@ -286,6 +290,19 @@ "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.", + "actions": "Actions", + "transform_to_non_versioned": "Transform to non-versioned reference", + "transform_confirm_title": "Transform {{count}} reference(s)?", + "transform_confirm_body": "This will add non-versioned replacement references first, then remove the old pinned references after the add succeeds.", + "transform_references": "Transform references", + "transform_success": "{{count}} reference(s) transformed successfully.", + "transform_partial_success": "{{transformed}} reference(s) transformed. {{failed}} reference(s) could not be transformed and were left unchanged.", + "transform_no_eligible": "None of the selected references can be transformed to non-versioned references.", + "transform_no_references_changed": "No references were transformed.", + "transform_delete_failed": "{{count}} non-versioned reference(s) were added, but the old pinned references could not be removed. Refresh and review the references list for duplicates.", + "already_non_versioned": "Already non-versioned", + "unsupported_transform": "Unsupported", + "new_expression": "New Expression", "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.", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 075df0f5..27dc1e86 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -24,6 +24,10 @@ "about": "Acerca de", "view_all_attributes": "Ver todos los atributos", "actions": "Acciones", + "selected": "seleccionados", + "eligible": "elegibles", + "skipped": "omitidos", + "status": "Estado", "metadata": "Metadatos", "copied_to_clipboard": "Copiado al portapapeles", "navigate": "Navegar", @@ -102,6 +106,22 @@ "website": "Sitio web", "last_login": "Último inicio de sesión" }, + "reference": { + "actions": "Acciones", + "expression": "Expresión", + "new_expression": "Nueva expresión", + "transform_to_non_versioned": "Transformar a referencia sin versión", + "transform_confirm_title": "¿Transformar {{count}} referencia(s)?", + "transform_confirm_body": "Esto agregará primero referencias de reemplazo sin versión y luego eliminará las referencias fijadas anteriores cuando la adición se complete correctamente.", + "transform_references": "Transformar referencias", + "transform_success": "{{count}} referencia(s) transformada(s) correctamente.", + "transform_partial_success": "{{transformed}} referencia(s) transformada(s). {{failed}} referencia(s) no pudieron transformarse y se dejaron sin cambios.", + "transform_no_eligible": "Ninguna de las referencias seleccionadas puede transformarse en una referencia sin versión.", + "transform_no_references_changed": "No se transformó ninguna referencia.", + "transform_delete_failed": "Se agregaron {{count}} referencia(s) sin versión, pero no se pudieron eliminar las referencias fijadas anteriores. Actualiza y revisa la lista de referencias para detectar duplicados.", + "already_non_versioned": "Ya está sin versión", + "unsupported_transform": "No compatible" + }, "search": { "filters": "Filtros", "concepts": "Conceptos", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index e4b5a350..c27bef47 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -41,6 +41,10 @@ "view_all_attributes": "查看全部属性", "all": "全部", "actions": "操作", + "selected": "已选择", + "eligible": "可处理", + "skipped": "已跳过", + "status": "状态", "metadata": "元数据", "copied_to_clipboard": "已复制到粘贴板", "navigate": "导航", @@ -289,6 +293,20 @@ "recent_activity": "最近的活动" }, "reference": { + "actions": "操作", + "expression": "表达式", + "new_expression": "新表达式", + "transform_to_non_versioned": "转换为非版本化引用", + "transform_confirm_title": "要转换 {{count}} 个引用吗?", + "transform_confirm_body": "系统会先添加非版本化的替代引用,添加成功后再移除原来的固定版本引用。", + "transform_references": "转换引用", + "transform_success": "已成功转换 {{count}} 个引用。", + "transform_partial_success": "已转换 {{transformed}} 个引用。{{failed}} 个引用无法转换,已保持不变。", + "transform_no_eligible": "所选引用中没有可以转换为非版本化引用的项目。", + "transform_no_references_changed": "没有转换任何引用。", + "transform_delete_failed": "已添加 {{count}} 个非版本化引用,但无法移除原来的固定版本引用。请刷新并检查引用列表中是否有重复项。", + "already_non_versioned": "已经是非版本化", + "unsupported_transform": "不支持", "cascade": "级联", "source_mappings": "源映射", "all_source_concepts_and_mappings": "所有源概念和映射",