From 185f4403e67619cd10029132f0c62988ecda1ff4 Mon Sep 17 00:00:00 2001 From: Joseph Amlung Date: Tue, 5 May 2026 11:04:11 -0400 Subject: [PATCH 1/7] Remove reference first pass --- .../collections/DeleteReferencesDialog.jsx | 45 ++++++ .../RemoveFromCollectionDialog.jsx | 140 ++++++++++++++++++ src/components/common/ResourceReferences.jsx | 39 +++++ src/components/concepts/ConceptDetails.jsx | 2 + src/components/concepts/ConceptHome.jsx | 34 ++++- src/components/mappings/MappingDetails.jsx | 2 + src/components/mappings/MappingHome.jsx | 34 ++++- src/components/search/Search.jsx | 102 ++++++++++++- src/components/search/SearchResults.jsx | 5 +- src/i18n/locales/en/translations.json | 15 +- 10 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 src/components/collections/DeleteReferencesDialog.jsx create mode 100644 src/components/collections/RemoveFromCollectionDialog.jsx create mode 100644 src/components/common/ResourceReferences.jsx diff --git a/src/components/collections/DeleteReferencesDialog.jsx b/src/components/collections/DeleteReferencesDialog.jsx new file mode 100644 index 000000000..558f11b04 --- /dev/null +++ b/src/components/collections/DeleteReferencesDialog.jsx @@ -0,0 +1,45 @@ +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 conceptsCount = references.reduce((sum, r) => sum + (r.concepts || 0), 0) + const mappingsCount = references.reduce((sum, r) => sum + (r.mappings || 0), 0) + const referenceIds = references.map(r => r.id).filter(Boolean) + + return ( + + + {t('reference.remove_confirm_title', { count: references.length })} + + + + {t('reference.remove_confirm_body', { concepts: conceptsCount, mappings: mappingsCount })} + + + + + + + + ) +} + +export default DeleteReferencesDialog diff --git a/src/components/collections/RemoveFromCollectionDialog.jsx b/src/components/collections/RemoveFromCollectionDialog.jsx new file mode 100644 index 000000000..a74112def --- /dev/null +++ b/src/components/collections/RemoveFromCollectionDialog.jsx @@ -0,0 +1,140 @@ +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' + +const getResourceLabel = resource => { + if(resource.display_name) return resource.display_name + if(resource.map_type) return `${resource.from_concept_code || ''} [${resource.map_type}] ${resource.to_concept_code || ''}` + return resource.id +} + +const getResourcePath = resource => resource.concept_class !== undefined ? 'concepts' : 'mappings' + +const RemoveFromCollectionDialog = ({ open, onClose, onConfirm, resources, collectionUrl, loading }) => { + const { t } = useTranslation() + const [fetchingRefs, setFetchingRefs] = React.useState(false) + const [resourcesWithRefs, setResourcesWithRefs] = React.useState([]) + const [checkedRefIds, setCheckedRefIds] = React.useState(new Set()) + + React.useEffect(() => { + if(!open || !resources?.length || !collectionUrl) return + setFetchingRefs(true) + setResourcesWithRefs([]) + setCheckedRefIds(new Set()) + + Promise.all( + resources.map(resource => + APIService.new() + .overrideURL(`${collectionUrl}${getResourcePath(resource)}/${encodeURIComponent(resource.id)}/`) + .get(null, null, { includeReferences: true }) + .then(response => ({ resource, references: response?.data?.references || [] })) + .catch(() => ({ resource, references: [] })) + ) + ).then(results => { + setResourcesWithRefs(results) + const allIds = new Set() + results.forEach(({ references }) => references.forEach(ref => ref.id && allIds.add(ref.id))) + setCheckedRefIds(allIds) + setFetchingRefs(false) + }) + }, [open]) + + 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 > 1 + const checkedCount = checkedRefIds.size + const isDisabled = loading || fetchingRefs || checkedCount === 0 + + return ( + + + {t('reference.remove_from_collection')} + + + {fetchingRefs ? ( + + + {t('common.loading')} + + ) : ( + + {resourcesWithRefs.map(({ resource, references }, groupIndex) => ( + + {showGroupHeaders && ( + 0 ? '1px solid rgba(0,0,0,0.12)' : 'none'}}> + {getResourceLabel(resource)} + + )} + {references.length === 0 ? ( + + {t('reference.no_references_found')} + + ) : ( + references.map((ref, refIndex) => { + const isLastInGroup = refIndex === references.length - 1 + const isLastGroup = groupIndex === resourcesWithRefs.length - 1 + return ( + + onToggleRef(ref.id)} + size='small' + inputProps={{'aria-label': ref.expression}} + /> + + + ) + }) + )} + + ))} + + )} + + + + + + + ) +} + +export default RemoveFromCollectionDialog diff --git a/src/components/common/ResourceReferences.jsx b/src/components/common/ResourceReferences.jsx new file mode 100644 index 000000000..665ee890d --- /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 9951d5d78..5cdb2d3ac 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 460e4ed1f..4d41c9553 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 = 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,14 @@ const ConceptHome = props => { title={`${t('common.retire')} ${t('concept.concept')}`} onSubmit={toggleRetire} /> + setRemoveFromCollectionDialog(false)} + onConfirm={onRemoveFromCollection} + resources={[concept]} + collectionUrl={props.repo?.version_url || props.repo?.url} + loading={removingFromCollection} + /> }
diff --git a/src/components/mappings/MappingDetails.jsx b/src/components/mappings/MappingDetails.jsx index 911a6286b..3f0b2959f 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 de5980b90..7104c6e38 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 = 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,14 @@ const MappingHome = props => { title={`${t('common.retire')} ${t('mapping.mapping')}`} onSubmit={toggleRetire} /> + setRemoveFromCollectionDialog(false)} + onConfirm={onRemoveFromCollection} + resources={[mapping]} + collectionUrl={props.repo?.version_url || props.repo?.url} + loading={removingFromCollection} + />
diff --git a/src/components/search/Search.jsx b/src/components/search/Search.jsx index 6168732bf..48fcae1c6 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 { highlightTexts, isLoggedIn } from '../../common/utils'; import APIService from '../../services/APIService'; import RepoIcon from '../repos/RepoIcon'; import ConceptIcon from '../concepts/ConceptIcon'; @@ -17,6 +20,9 @@ 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 @@ -41,6 +47,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 +428,79 @@ const Search = props => { history.push(getCurrentLayoutURL(getQueryParams(input, page, pageSize, filters, newOrderByField, newOrder))) } + const isHead = props.url?.includes('/HEAD/') + + 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) } + setDeletingReferences(true) + APIService.new().overrideURL(props.url).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 isInCollection = props.url?.includes('/collections/') + const collectionUrl = isInCollection ? props.url?.replace(/\/(concepts|mappings)\/$/, '/') : null + + 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 ? ( + + ) : null + + const deleteReferencesControl = resource === 'references' && isLoggedIn() && selected.length > 0 ? ( + + + + + + ) : null React.useEffect(() => { setShowItem(props.showItem || false) @@ -497,6 +580,8 @@ const Search = props => { properties={props.properties} propertyFilters={props.propertyFilters} isMatch={isMatchOp} + toolbarControl={deleteReferencesControl} + extraBulkActions={bulkRemoveFromCollectionAction} /> @@ -512,6 +597,21 @@ const Search = props => { } } + setDeleteReferencesOpen(false)} + onConfirm={onDeleteReferences} + references={selectedReferenceObjects} + loading={deletingReferences} + /> + setBulkRemoveOpen(false)} + onConfirm={onBulkRemoveFromCollection} + resources={selectedRows} + collectionUrl={collectionUrl} + loading={bulkRemoving} + /> ) } diff --git a/src/components/search/SearchResults.jsx b/src/components/search/SearchResults.jsx index 8dcac5da6..aff2969a2 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 d3f5747b7..0f125a3e3 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -278,7 +278,20 @@ "versioned_resource": "Versioned (Resource)", "resolved_repo": "Resolved Repo", "raw": "Raw", - "translation": "Translation" + "translation": "Translation", + "remove_selected": "Remove selected", + "remove_confirm_title": "Remove {{count}} reference(s)?", + "remove_confirm_body": "This will remove {{concepts}} concepts and {{mappings}} mappings 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", From 7740629a033985e2988e3132d3b92e2197c03867 Mon Sep 17 00:00:00 2001 From: Joseph Amlung Date: Tue, 5 May 2026 11:18:53 -0400 Subject: [PATCH 2/7] Some fixes --- .../RemoveFromCollectionDialog.jsx | 102 ++++++++++++++++-- src/components/concepts/ConceptHome.jsx | 4 +- src/components/mappings/MappingHome.jsx | 4 +- src/components/search/Search.jsx | 15 ++- 4 files changed, 106 insertions(+), 19 deletions(-) diff --git a/src/components/collections/RemoveFromCollectionDialog.jsx b/src/components/collections/RemoveFromCollectionDialog.jsx index a74112def..c0f74226c 100644 --- a/src/components/collections/RemoveFromCollectionDialog.jsx +++ b/src/components/collections/RemoveFromCollectionDialog.jsx @@ -13,23 +13,100 @@ 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 getResourceLabel = resource => { - if(resource.display_name) return resource.display_name - if(resource.map_type) return `${resource.from_concept_code || ''} [${resource.map_type}] ${resource.to_concept_code || ''}` - return resource.id +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 getMappingSourceToken = mapping => { + const source = mapping.to_source || mapping.to_source_name || getUrlPart(mapping.to_source_url || mapping.to_concept_url, 'sources') || mapping.source + const version = mapping.to_source_version || mapping.latest_source_version + return source && version ? `${source}(v${version})` : source +} + +const getMappingInlineSyntax = mapping => { + const mapType = mapping.map_type ? `[${mapping.map_type}] ` : '' + const source = getMappingSourceToken(mapping) + const code = mapping.to_concept_code || mapping.to_concept || mapping.id + const name = mapping.to_concept_name_resolved || mapping.to_concept_name + const escapedName = name ? name.replace(/"/g, '\\"') : '' + + return `${mapType}${source ? `${source}:` : ''}${code || ''}${escapedName ? ` "${escapedName}"` : ''}` } const getResourcePath = resource => resource.concept_class !== undefined ? 'concepts' : 'mappings' +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, 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) React.useEffect(() => { - if(!open || !resources?.length || !collectionUrl) return + if(!open || !resources?.length || !baseCollectionUrl) { + setFetchingRefs(false) + setResourcesWithRefs([]) + setCheckedRefIds(new Set()) + return + } + + let active = true setFetchingRefs(true) setResourcesWithRefs([]) setCheckedRefIds(new Set()) @@ -37,19 +114,24 @@ const RemoveFromCollectionDialog = ({ open, onClose, onConfirm, resources, colle Promise.all( resources.map(resource => APIService.new() - .overrideURL(`${collectionUrl}${getResourcePath(resource)}/${encodeURIComponent(resource.id)}/`) + .overrideURL(`${baseCollectionUrl}${getResourcePath(resource)}/${encodeURIComponent(resource.id)}/`) .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) }) - }, [open]) + + return () => { + active = false + } + }, [open, resources, baseCollectionUrl]) const onToggleRef = refId => { setCheckedRefIds(prev => { @@ -60,7 +142,7 @@ const RemoveFromCollectionDialog = ({ open, onClose, onConfirm, resources, colle }) } - const showGroupHeaders = resourcesWithRefs.length > 1 + const showGroupHeaders = resourcesWithRefs.length > 0 const checkedCount = checkedRefIds.size const isDisabled = loading || fetchingRefs || checkedCount === 0 @@ -81,7 +163,7 @@ const RemoveFromCollectionDialog = ({ open, onClose, onConfirm, resources, colle {showGroupHeaders && ( 0 ? '1px solid rgba(0,0,0,0.12)' : 'none'}}> - {getResourceLabel(resource)} + )} {references.length === 0 ? ( @@ -94,7 +176,7 @@ const RemoveFromCollectionDialog = ({ open, onClose, onConfirm, resources, colle const isLastGroup = groupIndex === resourcesWithRefs.length - 1 return ( diff --git a/src/components/concepts/ConceptHome.jsx b/src/components/concepts/ConceptHome.jsx index 4d41c9553..2c7875402 100644 --- a/src/components/concepts/ConceptHome.jsx +++ b/src/components/concepts/ConceptHome.jsx @@ -222,7 +222,7 @@ const ConceptHome = props => { } const onRemoveFromCollection = deleteBody => { - const collectionUrl = props.repo?.version_url || props.repo?.url + 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 => { @@ -304,7 +304,7 @@ const ConceptHome = props => { onClose={() => setRemoveFromCollectionDialog(false)} onConfirm={onRemoveFromCollection} resources={[concept]} - collectionUrl={props.repo?.version_url || props.repo?.url} + collectionUrl={dropVersion(props.repo?.version_url || props.repo?.url)} loading={removingFromCollection} /> diff --git a/src/components/mappings/MappingHome.jsx b/src/components/mappings/MappingHome.jsx index 7104c6e38..0a12276a5 100644 --- a/src/components/mappings/MappingHome.jsx +++ b/src/components/mappings/MappingHome.jsx @@ -136,7 +136,7 @@ const MappingHome = props => { } const onRemoveFromCollection = deleteBody => { - const collectionUrl = props.repo?.version_url || props.repo?.url + 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 => { @@ -206,7 +206,7 @@ const MappingHome = props => { onClose={() => setRemoveFromCollectionDialog(false)} onConfirm={onRemoveFromCollection} resources={[mapping]} - collectionUrl={props.repo?.version_url || props.repo?.url} + collectionUrl={dropVersion(props.repo?.version_url || props.repo?.url)} loading={removingFromCollection} /> diff --git a/src/components/search/Search.jsx b/src/components/search/Search.jsx index 48fcae1c6..efc49d5ca 100644 --- a/src/components/search/Search.jsx +++ b/src/components/search/Search.jsx @@ -11,7 +11,7 @@ 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, isLoggedIn } 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'; @@ -28,6 +28,11 @@ 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 Search = props => { const { setAlert, contextRepo } = React.useContext(OperationsContext); const { t } = useTranslation() @@ -429,6 +434,8 @@ const Search = props => { } const isHead = props.url?.includes('/HEAD/') + const isInCollection = props.url?.includes('/collections/') + const collectionUrl = isInCollection ? getBaseCollectionUrl(props.url) : null const selectedReferenceObjects = resource === 'references' && selected.length > 0 ? (result['references']?.results || []).filter(r => selected.includes(r.version_url || r.url || r.id)) @@ -436,8 +443,9 @@ const Search = props => { 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(props.url).delete(body).then(response => { + APIService.new().overrideURL(deleteUrl).delete(body).then(response => { setDeletingReferences(false) if(response?.status === 204 || response?.status === 200) { setDeleteReferencesOpen(false) @@ -450,9 +458,6 @@ const Search = props => { }) } - const isInCollection = props.url?.includes('/collections/') - const collectionUrl = isInCollection ? props.url?.replace(/\/(concepts|mappings)\/$/, '/') : null - const selectedRows = (result[resource]?.results || []).filter(r => selected.includes(r.version_url || r.url || r.id)) const onBulkRemoveFromCollection = deleteBody => { From 19824e7b19d10865d1d8643455e17f2ece2e4074 Mon Sep 17 00:00:00 2001 From: Joseph Amlung Date: Tue, 5 May 2026 11:27:27 -0400 Subject: [PATCH 3/7] Incorporated inline mapping syntax --- .../RemoveFromCollectionDialog.jsx | 54 +++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/src/components/collections/RemoveFromCollectionDialog.jsx b/src/components/collections/RemoveFromCollectionDialog.jsx index c0f74226c..7676885be 100644 --- a/src/components/collections/RemoveFromCollectionDialog.jsx +++ b/src/components/collections/RemoveFromCollectionDialog.jsx @@ -40,24 +40,45 @@ const getRepoFromConcept = concept => { } } -const getMappingSourceToken = mapping => { - const source = mapping.to_source || mapping.to_source_name || getUrlPart(mapping.to_source_url || mapping.to_concept_url, 'sources') || mapping.source - const version = mapping.to_source_version || mapping.latest_source_version - return source && version ? `${source}(v${version})` : source +const getVersionToken = version => { + if(!version) return null + if(String(version).toUpperCase() === 'HEAD') return version + return String(version).match(/^v/i) ? version : `v${version}` } -const getMappingInlineSyntax = mapping => { - const mapType = mapping.map_type ? `[${mapping.map_type}] ` : '' - const source = getMappingSourceToken(mapping) - const code = mapping.to_concept_code || mapping.to_concept || mapping.id - const name = mapping.to_concept_name_resolved || mapping.to_concept_name +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 `${mapType}${source ? `${source}:` : ''}${code || ''}${escapedName ? ` "${escapedName}"` : ''}` + 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(resource.version_url || resource.url) + return resource.version_url || resource.url + + return `${collectionUrl}${getResourcePath(resource)}/${encodeURIComponent(resource.id)}/` +} + const ResourceLabel = ({ resource }) => { if(resource.concept_class !== undefined) { const repo = getRepoFromConcept(resource) @@ -85,7 +106,7 @@ const ResourceLabel = ({ resource }) => { } return ( - + {getMappingInlineSyntax(resource)} ) @@ -112,13 +133,16 @@ const RemoveFromCollectionDialog = ({ open, onClose, onConfirm, resources, colle setCheckedRefIds(new Set()) Promise.all( - resources.map(resource => - APIService.new() - .overrideURL(`${baseCollectionUrl}${getResourcePath(resource)}/${encodeURIComponent(resource.id)}/`) + resources.map(resource => { + if(Array.isArray(resource.references)) + return Promise.resolve({ resource, references: resource.references }) + + return APIService.new() + .overrideURL(getResourceUrl(resource, baseCollectionUrl)) .get(null, null, { includeReferences: true }) .then(response => ({ resource, references: response?.data?.references || [] })) .catch(() => ({ resource, references: [] })) - ) + }) ).then(results => { if(!active) return setResourcesWithRefs(results) From cb8b7256c966224a81b4e65ac208c2736e28df17 Mon Sep 17 00:00:00 2001 From: Joseph Amlung Date: Tue, 5 May 2026 11:36:50 -0400 Subject: [PATCH 4/7] Dialog fixes - ready to go? --- .../RemoveFromCollectionDialog.jsx | 28 +++++++++++++------ src/components/concepts/ConceptHome.jsx | 1 + src/components/mappings/MappingHome.jsx | 1 + src/components/search/Search.jsx | 4 +++ 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/components/collections/RemoveFromCollectionDialog.jsx b/src/components/collections/RemoveFromCollectionDialog.jsx index 7676885be..2f1a2e1ba 100644 --- a/src/components/collections/RemoveFromCollectionDialog.jsx +++ b/src/components/collections/RemoveFromCollectionDialog.jsx @@ -73,10 +73,19 @@ const getMappingInlineSyntax = mapping => { 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 `${collectionUrl}${getResourcePath(resource)}/${encodeURIComponent(resource.id)}/` + 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 }) => { @@ -112,15 +121,16 @@ const ResourceLabel = ({ resource }) => { ) } -const RemoveFromCollectionDialog = ({ open, onClose, onConfirm, resources, collectionUrl, loading }) => { +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 || !baseCollectionUrl) { + if(!open || !resources?.length || !resourceLookupUrl) { setFetchingRefs(false) setResourcesWithRefs([]) setCheckedRefIds(new Set()) @@ -134,11 +144,11 @@ const RemoveFromCollectionDialog = ({ open, onClose, onConfirm, resources, colle Promise.all( resources.map(resource => { - if(Array.isArray(resource.references)) + if(hasReferenceIds(resource.references)) return Promise.resolve({ resource, references: resource.references }) return APIService.new() - .overrideURL(getResourceUrl(resource, baseCollectionUrl)) + .overrideURL(getResourceUrl(resource, resourceLookupUrl)) .get(null, null, { includeReferences: true }) .then(response => ({ resource, references: response?.data?.references || [] })) .catch(() => ({ resource, references: [] })) @@ -155,7 +165,7 @@ const RemoveFromCollectionDialog = ({ open, onClose, onConfirm, resources, colle return () => { active = false } - }, [open, resources, baseCollectionUrl]) + }, [open, resources, resourceLookupUrl]) const onToggleRef = refId => { setCheckedRefIds(prev => { @@ -200,7 +210,7 @@ const RemoveFromCollectionDialog = ({ open, onClose, onConfirm, resources, colle const isLastGroup = groupIndex === resourcesWithRefs.length - 1 return ( @@ -210,10 +220,10 @@ const RemoveFromCollectionDialog = ({ open, onClose, onConfirm, resources, colle disabled={loading || !ref.id} onChange={() => onToggleRef(ref.id)} size='small' - inputProps={{'aria-label': ref.expression}} + inputProps={{'aria-label': getReferenceLabel(ref)}} /> diff --git a/src/components/concepts/ConceptHome.jsx b/src/components/concepts/ConceptHome.jsx index 2c7875402..79b99e251 100644 --- a/src/components/concepts/ConceptHome.jsx +++ b/src/components/concepts/ConceptHome.jsx @@ -305,6 +305,7 @@ const ConceptHome = props => { 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/MappingHome.jsx b/src/components/mappings/MappingHome.jsx index 0a12276a5..aa9fc3425 100644 --- a/src/components/mappings/MappingHome.jsx +++ b/src/components/mappings/MappingHome.jsx @@ -207,6 +207,7 @@ const MappingHome = props => { 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/search/Search.jsx b/src/components/search/Search.jsx index efc49d5ca..2c4170dad 100644 --- a/src/components/search/Search.jsx +++ b/src/components/search/Search.jsx @@ -33,6 +33,8 @@ const getBaseCollectionUrl = url => { 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() @@ -436,6 +438,7 @@ const Search = props => { const isHead = props.url?.includes('/HEAD/') 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)) @@ -615,6 +618,7 @@ const Search = props => { onConfirm={onBulkRemoveFromCollection} resources={selectedRows} collectionUrl={collectionUrl} + lookupCollectionUrl={collectionLookupUrl} loading={bulkRemoving} /> From f98dce9b982a4b88c69e789a408eff968c8d7c60 Mon Sep 17 00:00:00 2001 From: Joseph Amlung Date: Tue, 12 May 2026 08:50:53 -0400 Subject: [PATCH 5/7] Enhance reference deletion functionality - Added delete confirmation dialog for references in ReferenceHome component. - Updated ReferenceHeader to handle delete action. - Improved ReferenceManagementList to conditionally enable delete button with tooltip. - Adjusted translations for delete confirmation messages. - Updated package dependencies in package.json and package-lock.json. --- package-lock.json | 67 ++++++++++++------- package.json | 4 +- .../collections/DeleteReferencesDialog.jsx | 23 +++++-- src/components/references/ReferenceHeader.jsx | 6 +- src/components/references/ReferenceHome.jsx | 39 ++++++++++- .../references/ReferenceManagementList.jsx | 20 +++--- src/components/repos/RepoHome.jsx | 4 +- src/components/search/ResultConstants.js | 9 +-- src/components/search/Search.jsx | 7 +- src/i18n/locales/en/translations.json | 7 +- 10 files changed, 129 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index c51d6b923..8fe77533b 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 c3a4a5e00..3a02157b0 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 index 558f11b04..0718f8a96 100644 --- a/src/components/collections/DeleteReferencesDialog.jsx +++ b/src/components/collections/DeleteReferencesDialog.jsx @@ -10,22 +10,32 @@ import CircularProgress from '@mui/material/CircularProgress' const DeleteReferencesDialog = ({ open, onClose, onConfirm, references, loading }) => { const { t } = useTranslation() - const conceptsCount = references.reduce((sum, r) => sum + (r.concepts || 0), 0) - const mappingsCount = references.reduce((sum, r) => sum + (r.mappings || 0), 0) - const referenceIds = references.map(r => r.id).filter(Boolean) + 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 ( - {t('reference.remove_confirm_title', { count: references.length })} + {t('reference.remove_confirm_title', { count: selectedReferences.length, reference: selectedReferences.length === 1 ? t('reference.reference').toLowerCase() : t('reference.references').toLowerCase() })} - {t('reference.remove_confirm_body', { concepts: conceptsCount, mappings: mappingsCount })} + { + resolvedCounts.length ? + t('reference.remove_confirm_body', { summary: resolvedCounts.join(', ') }) : + t('reference.remove_confirm_body_selected') + } - diff --git a/src/components/references/ReferenceHeader.jsx b/src/components/references/ReferenceHeader.jsx index 47a590c3d..b6f8a03bf 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 }) => { - + diff --git a/src/components/references/ReferenceHome.jsx b/src/components/references/ReferenceHome.jsx index ae8e955f6..837651556 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 ab61a8b2b..54f2ed57c 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 ( { - onClick('delete')} sx={{padding: '8px 12px', color: 'error.main'}}> - - - - - + + + onClick('delete')} sx={{padding: '8px 12px', color: 'error.main'}}> + + + + + + + ) diff --git a/src/components/repos/RepoHome.jsx b/src/components/repos/RepoHome.jsx index 30a09465b..e2e80cb11 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,6 +276,7 @@ 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 a0808381d..c08f24ac7 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 2c4170dad..8bb886db7 100644 --- a/src/components/search/Search.jsx +++ b/src/components/search/Search.jsx @@ -35,6 +35,11 @@ const getBaseCollectionUrl = url => { const getCollectionLookupUrl = url => (url || '').replace(/\/(concepts|mappings|references)\/?$/, '/') +const isHeadCollectionUrl = url => { + const normalizedUrl = url || '' + return normalizedUrl.includes('/HEAD/') || Boolean(normalizedUrl.match(/\/collections\/[^/]+\/(?:concepts|mappings|references)\/?$/)) +} + const Search = props => { const { setAlert, contextRepo } = React.useContext(OperationsContext); const { t } = useTranslation() @@ -435,7 +440,7 @@ const Search = props => { history.push(getCurrentLayoutURL(getQueryParams(input, page, pageSize, filters, newOrderByField, newOrder))) } - const isHead = props.url?.includes('/HEAD/') + const isHead = isHeadCollectionUrl(props.url) const isInCollection = props.url?.includes('/collections/') const collectionUrl = isInCollection ? getBaseCollectionUrl(props.url) : null const collectionLookupUrl = isInCollection ? getCollectionLookupUrl(props.url) : null diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 0f125a3e3..2895cabd3 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -279,9 +279,10 @@ "resolved_repo": "Resolved Repo", "raw": "Raw", "translation": "Translation", - "remove_selected": "Remove selected", - "remove_confirm_title": "Remove {{count}} reference(s)?", - "remove_confirm_body": "This will remove {{concepts}} concepts and {{mappings}} mappings from the collection expansion.", + "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", From 1e437ff3b17557a92c8fa24dfbf8f81adf21c0da Mon Sep 17 00:00:00 2001 From: Joseph Amlung Date: Thu, 14 May 2026 08:40:14 -0400 Subject: [PATCH 6/7] Responded to Sunny comment --- src/components/repos/RepoHome.jsx | 1 + src/components/search/Search.jsx | 6 +----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/repos/RepoHome.jsx b/src/components/repos/RepoHome.jsx index e2e80cb11..06b027d3d 100644 --- a/src/components/repos/RepoHome.jsx +++ b/src/components/repos/RepoHome.jsx @@ -283,6 +283,7 @@ const RepoHome = () => { url={getURL() + tab + '/'} defaultFiltersOpen={false} nested + repo={repo} noTabs onSaveAsDefaultFilters={onSaveAsDefaultFilters} repoDefaultFilters={(!tab || tab === 'concepts') ? repo?.meta?.display?.default_filter : {}} diff --git a/src/components/search/Search.jsx b/src/components/search/Search.jsx index 8bb886db7..327d8797c 100644 --- a/src/components/search/Search.jsx +++ b/src/components/search/Search.jsx @@ -35,10 +35,6 @@ const getBaseCollectionUrl = url => { const getCollectionLookupUrl = url => (url || '').replace(/\/(concepts|mappings|references)\/?$/, '/') -const isHeadCollectionUrl = url => { - const normalizedUrl = url || '' - return normalizedUrl.includes('/HEAD/') || Boolean(normalizedUrl.match(/\/collections\/[^/]+\/(?:concepts|mappings|references)\/?$/)) -} const Search = props => { const { setAlert, contextRepo } = React.useContext(OperationsContext); @@ -440,7 +436,7 @@ const Search = props => { history.push(getCurrentLayoutURL(getQueryParams(input, page, pageSize, filters, newOrderByField, newOrder))) } - const isHead = isHeadCollectionUrl(props.url) + 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 From cb55aaeab60b991e41649755af51bf5c433cb583 Mon Sep 17 00:00:00 2001 From: Joseph Amlung Date: Thu, 14 May 2026 08:47:31 -0400 Subject: [PATCH 7/7] Ignoring claud file for now --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 167c486c5..0661ceafb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ src/stories/ dist/ .idea/ .DS_Store +CLAUDE.md