diff --git a/src/components/collections/DeleteReferencesDialog.jsx b/src/components/collections/DeleteReferencesDialog.jsx new file mode 100644 index 00000000..558f11b0 --- /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 00000000..2f1a2e1b --- /dev/null +++ b/src/components/collections/RemoveFromCollectionDialog.jsx @@ -0,0 +1,256 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import Dialog from '@mui/material/Dialog' +import DialogTitle from '@mui/material/DialogTitle' +import DialogContent from '@mui/material/DialogContent' +import DialogActions from '@mui/material/DialogActions' +import Button from '@mui/material/Button' +import Box from '@mui/material/Box' +import Typography from '@mui/material/Typography' +import Checkbox from '@mui/material/Checkbox' +import List from '@mui/material/List' +import ListItem from '@mui/material/ListItem' +import ListItemText from '@mui/material/ListItemText' +import CircularProgress from '@mui/material/CircularProgress' +import APIService from '../../services/APIService' +import { dropVersion } from '../../common/utils' +import RepoChip from '../repos/RepoChip' + +const getUrlPart = (url, part) => { + const parts = (url || '').replace(/\/$/, '').split('/') + const index = parts.lastIndexOf(part) + return index !== -1 ? parts[index + 1] : null +} + +const getRepoFromConcept = concept => { + const sourceId = concept.source || getUrlPart(concept.source_url || concept.url, 'sources') + if(!sourceId) return null + const sourceURL = concept.source_url || (concept.owner_url ? `${concept.owner_url}sources/${sourceId}/` : undefined) + + return { + id: sourceId, + short_code: sourceId, + type: 'Source', + url: sourceURL, + owner: concept.owner, + owner_type: concept.owner_type, + owner_url: concept.owner_url, + version: concept.latest_source_version, + version_url: concept.latest_source_version && sourceURL ? `${sourceURL}${concept.latest_source_version}/` : undefined, + } +} + +const getVersionToken = version => { + if(!version) return null + if(String(version).toUpperCase() === 'HEAD') return version + return String(version).match(/^v/i) ? version : `v${version}` +} + +const getMappingSourceToken = (mapping, direction) => { + const source = mapping[`${direction}_source`] || + mapping[`${direction}_source_name`] || + getUrlPart(mapping[`${direction}_source_url`] || mapping[`${direction}_concept_url`], 'sources') || + (direction === 'from' ? mapping.source : null) + const version = getVersionToken(mapping[`${direction}_source_version`] || (source === mapping.source ? mapping.latest_source_version : null)) + + return source && version ? `${source}(${version})` : source +} + +const getMappingConceptSyntax = (mapping, direction) => { + const source = getMappingSourceToken(mapping, direction) + const code = mapping[`${direction}_concept_code`] || mapping[`${direction}_concept`] || mapping.id + const name = mapping[`${direction}_concept_name_resolved`] || mapping[`${direction}_concept_name`] + const escapedName = name ? name.replace(/"/g, '\\"') : '' + + return `${source ? `${source}:` : ''}${code || ''}${escapedName ? ` "${escapedName}"` : ''}` +} + +const getMappingInlineSyntax = mapping => { + const mapType = mapping.map_type ? `[${mapping.map_type}]` : '[SAME-AS]' + return `${getMappingConceptSyntax(mapping, 'from')} ${mapType} ${getMappingConceptSyntax(mapping, 'to')}` +} + +const getResourcePath = resource => resource.concept_class !== undefined ? 'concepts' : 'mappings' + +const getResourceUrl = (resource, collectionUrl) => { + if(collectionUrl) + return `${collectionUrl}${getResourcePath(resource)}/${encodeURIComponent(resource.id)}/` + if(resource.version_url || resource.url) + return resource.version_url || resource.url + + return '' +} + +const hasReferenceIds = references => Array.isArray(references) && references.some(ref => ref?.id) + +const getReferenceLabel = reference => { + if(typeof reference === 'string') return reference + return reference?.expression || reference?.url || reference?.uri || '' +} + +const ResourceLabel = ({ resource }) => { + if(resource.concept_class !== undefined) { + const repo = getRepoFromConcept(resource) + + return ( + + + {resource.id} {resource.display_name || resource.name || ''} + + {repo && ( + + · + + + )} + + ) + } + + return ( + + {getMappingInlineSyntax(resource)} + + ) +} + +const RemoveFromCollectionDialog = ({ open, onClose, onConfirm, resources, collectionUrl, lookupCollectionUrl, loading }) => { + const { t } = useTranslation() + const [fetchingRefs, setFetchingRefs] = React.useState(false) + const [resourcesWithRefs, setResourcesWithRefs] = React.useState([]) + const [checkedRefIds, setCheckedRefIds] = React.useState(new Set()) + const baseCollectionUrl = dropVersion(collectionUrl) + const resourceLookupUrl = lookupCollectionUrl || baseCollectionUrl + + React.useEffect(() => { + if(!open || !resources?.length || !resourceLookupUrl) { + setFetchingRefs(false) + setResourcesWithRefs([]) + setCheckedRefIds(new Set()) + return + } + + let active = true + setFetchingRefs(true) + setResourcesWithRefs([]) + setCheckedRefIds(new Set()) + + Promise.all( + resources.map(resource => { + if(hasReferenceIds(resource.references)) + return Promise.resolve({ resource, references: resource.references }) + + return APIService.new() + .overrideURL(getResourceUrl(resource, resourceLookupUrl)) + .get(null, null, { includeReferences: true }) + .then(response => ({ resource, references: response?.data?.references || [] })) + .catch(() => ({ resource, references: [] })) + }) + ).then(results => { + if(!active) return + setResourcesWithRefs(results) + const allIds = new Set() + results.forEach(({ references }) => references.forEach(ref => ref.id && allIds.add(ref.id))) + setCheckedRefIds(allIds) + setFetchingRefs(false) + }) + + return () => { + active = false + } + }, [open, resources, resourceLookupUrl]) + + const onToggleRef = refId => { + setCheckedRefIds(prev => { + const next = new Set(prev) + if(next.has(refId)) next.delete(refId) + else next.add(refId) + return next + }) + } + + const showGroupHeaders = resourcesWithRefs.length > 0 + const checkedCount = checkedRefIds.size + const isDisabled = loading || fetchingRefs || checkedCount === 0 + + return ( + + + {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'}}> + + + )} + {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': getReferenceLabel(ref)}} + /> + + + ) + }) + )} + + ))} + + )} + + + + + + + ) +} + +export default RemoveFromCollectionDialog diff --git a/src/components/common/ResourceReferences.jsx b/src/components/common/ResourceReferences.jsx new file mode 100644 index 00000000..665ee890 --- /dev/null +++ b/src/components/common/ResourceReferences.jsx @@ -0,0 +1,39 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import Paper from '@mui/material/Paper' +import Typography from '@mui/material/Typography' +import List from '@mui/material/List' +import ListItem from '@mui/material/ListItem' +import ListItemText from '@mui/material/ListItemText' +import Tooltip from '@mui/material/Tooltip' +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' +import { map } from 'lodash' + +const borderColor = 'rgba(0, 0, 0, 0.12)' + +const ResourceReferences = ({ references, resourceType }) => { + const { t } = useTranslation() + if (!references?.length) return null + return ( + + + {t('reference.references')} ({references.length}) + + + + + + {map(references, reference => ( + + + + ))} + + + ) +} + +export default ResourceReferences diff --git a/src/components/concepts/ConceptDetails.jsx b/src/components/concepts/ConceptDetails.jsx index 9951d5d7..5cdb2d3a 100644 --- a/src/components/concepts/ConceptDetails.jsx +++ b/src/components/concepts/ConceptDetails.jsx @@ -10,6 +10,7 @@ import Locales from './Locales' import Associations from './Associations' import ConceptProperties from './ConceptProperties' import ExternalIdLabel from '../common/ExternalIdLabel' +import ResourceReferences from '../common/ResourceReferences' const borderColor = 'rgba(0, 0, 0, 0.12)' @@ -48,6 +49,7 @@ const ConceptDetails = ({ concept, repo, mappings, reverseMappings, loading, loa } +
{ concept?.external_id && diff --git a/src/components/concepts/ConceptHome.jsx b/src/components/concepts/ConceptHome.jsx index 460e4ed1..79b99e25 100644 --- a/src/components/concepts/ConceptHome.jsx +++ b/src/components/concepts/ConceptHome.jsx @@ -9,6 +9,7 @@ import { toParentURI, dropVersion } from '../../common/utils' import { OperationsContext } from '../app/LayoutContext'; import RetireConfirmDialog from '../common/RetireConfirmDialog' +import RemoveFromCollectionDialog from '../collections/RemoveFromCollectionDialog' import ConceptHeader from './ConceptHeader'; import ConceptTabs from './ConceptTabs'; @@ -39,13 +40,18 @@ const ConceptHome = props => { const [reverseOwnerMappings, setReverseOwnerMappings] = React.useState([]) const [retireDialog, setRetireDialog] = React.useState(false) + const [removeFromCollectionDialog, setRemoveFromCollectionDialog] = React.useState(false) + const [removingFromCollection, setRemovingFromCollection] = React.useState(false) const { setAlert } = React.useContext(OperationsContext); + const isInCollection = Boolean(props.repo?.type?.includes('Collection') || props.url?.includes('/collections/')) + React.useEffect(() => { setLoading(true) setConcept(props.concept || {}) setVersions([]) - getService().get().then(response => { + const queryParams = isInCollection ? { includeReferences: true } : {} + getService().get(null, null, queryParams).then(response => { const resource = response.data setConcept(resource) props.repo?.id ? setRepo(repo) : fetchRepo(resource) @@ -215,6 +221,22 @@ const ConceptHome = props => { }) } + const onRemoveFromCollection = deleteBody => { + const collectionUrl = dropVersion(props.repo?.version_url || props.repo?.url) + const body = deleteBody || { ids: (concept.references || []).map(r => r.id).filter(Boolean) } + setRemovingFromCollection(true) + APIService.new().overrideURL(collectionUrl).appendToUrl('references/').delete(body).then(response => { + setRemovingFromCollection(false) + if(response?.status === 204 || response?.status === 200) { + setRemoveFromCollectionDialog(false) + setAlert({ severity: 'success', message: t('reference.remove_success') }) + props.onClose && props.onClose() + } else { + setAlert({ severity: 'error', message: response?.data?.detail || t('common.generic_error') }) + } + }) + } + return (concept?.id && repo?.id) ? ( <> @@ -243,7 +265,7 @@ const ConceptHome = props => { !edit && <>
- setEdit(true)} repo={repo} nested={props.nested} loading={loading} onRetire={() => setRetireDialog(true)} /> + setEdit(true)} repo={repo} nested={props.nested} loading={loading} onRetire={() => setRetireDialog(true)} isInCollection={isInCollection} onRemoveFromCollection={() => setRemoveFromCollectionDialog(true)} />
onTabChange(newTab)} loading={loading} /> { @@ -277,6 +299,15 @@ const ConceptHome = props => { title={`${t('common.retire')} ${t('concept.concept')}`} onSubmit={toggleRetire} /> + setRemoveFromCollectionDialog(false)} + onConfirm={onRemoveFromCollection} + resources={[concept]} + collectionUrl={dropVersion(props.repo?.version_url || props.repo?.url)} + lookupCollectionUrl={props.repo?.version_url || props.repo?.url} + loading={removingFromCollection} + /> }
diff --git a/src/components/mappings/MappingDetails.jsx b/src/components/mappings/MappingDetails.jsx index 911a6286..3f0b2959 100644 --- a/src/components/mappings/MappingDetails.jsx +++ b/src/components/mappings/MappingDetails.jsx @@ -12,6 +12,7 @@ import FromConceptCard from './FromConceptCard' import ToConceptCard from './ToConceptCard' import MappingIcon from './MappingIcon' import MappingProperties from './MappingProperties' +import ResourceReferences from '../common/ResourceReferences' const borderColor = 'rgba(0, 0, 0, 0.12)' @@ -37,6 +38,7 @@ const MappingDetails = ({ mapping }) => { } + {t('common.last_updated')} {formatDateTime(mapping.versioned_updated_on || mapping.updated_on)} {t('common.by')} diff --git a/src/components/mappings/MappingHome.jsx b/src/components/mappings/MappingHome.jsx index de5980b9..aa9fc342 100644 --- a/src/components/mappings/MappingHome.jsx +++ b/src/components/mappings/MappingHome.jsx @@ -9,6 +9,7 @@ import { toParentURI, dropVersion } from '../../common/utils' import { OperationsContext } from '../app/LayoutContext'; import RetireConfirmDialog from '../common/RetireConfirmDialog' +import RemoveFromCollectionDialog from '../collections/RemoveFromCollectionDialog' import MappingHeader from './MappingHeader'; import MappingTabs from './MappingTabs'; import MappingDetails from './MappingDetails' @@ -31,13 +32,18 @@ const MappingHome = props => { const [loading, setLoading] = React.useState(false) const [retireDialog, setRetireDialog] = React.useState(false) + const [removeFromCollectionDialog, setRemoveFromCollectionDialog] = React.useState(false) + const [removingFromCollection, setRemovingFromCollection] = React.useState(false) const { setAlert } = React.useContext(OperationsContext); + const isInCollection = Boolean(props.repo?.type?.includes('Collection') || props.url?.includes('/collections/')) + React.useEffect(() => { setLoading(true) setMapping(props.mapping || {}) setVersions([]) - getService().get().then(response => { + const queryParams = isInCollection ? { includeReferences: true } : {} + getService().get(null, null, queryParams).then(response => { const resource = response.data setMapping(resource) props.repo?.id ? setRepo(props.repo) : fetchRepo(resource) @@ -129,6 +135,22 @@ const MappingHome = props => { }) } + const onRemoveFromCollection = deleteBody => { + const collectionUrl = dropVersion(props.repo?.version_url || props.repo?.url) + const body = deleteBody || { ids: (mapping.references || []).map(r => r.id).filter(Boolean) } + setRemovingFromCollection(true) + APIService.new().overrideURL(collectionUrl).appendToUrl('references/').delete(body).then(response => { + setRemovingFromCollection(false) + if(response?.status === 204 || response?.status === 200) { + setRemoveFromCollectionDialog(false) + setAlert({ severity: 'success', message: t('reference.remove_success') }) + props.onClose && props.onClose() + } else { + setAlert({ severity: 'error', message: response?.data?.detail || t('common.generic_error') }) + } + }) + } + return (mapping?.id && repo?.id) ? ( <> @@ -154,7 +176,7 @@ const MappingHome = props => {
- setEdit(true)} onRetire={() => setRetireDialog(true)} /> + setEdit(true)} onRetire={() => setRetireDialog(true)} isInCollection={isInCollection} onRemoveFromCollection={() => setRemoveFromCollectionDialog(true)} />
onTabChange(newTab)} /> { @@ -179,6 +201,15 @@ const MappingHome = props => { title={`${t('common.retire')} ${t('mapping.mapping')}`} onSubmit={toggleRetire} /> + setRemoveFromCollectionDialog(false)} + onConfirm={onRemoveFromCollection} + resources={[mapping]} + collectionUrl={dropVersion(props.repo?.version_url || props.repo?.url)} + lookupCollectionUrl={props.repo?.version_url || props.repo?.url} + loading={removingFromCollection} + />
diff --git a/src/components/search/Search.jsx b/src/components/search/Search.jsx index 6168732b..2c4170da 100644 --- a/src/components/search/Search.jsx +++ b/src/components/search/Search.jsx @@ -4,11 +4,14 @@ import { useLocation, useHistory } from 'react-router-dom'; import { useTranslation } from 'react-i18next' import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; +import Button from '@mui/material/Button'; +import Tooltip from '@mui/material/Tooltip'; import OrgIcon from '@mui/icons-material/AccountBalance'; import UserIcon from '@mui/icons-material/Person'; +import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import { forEach, keys, pickBy, isEmpty, find, uniq, has, orderBy as sortBy, uniqBy, omit, max, isEqual, isBoolean } from 'lodash'; import { COLORS } from '../../common/colors'; -import { highlightTexts } from '../../common/utils'; +import { dropVersion, highlightTexts, isLoggedIn } from '../../common/utils'; import APIService from '../../services/APIService'; import RepoIcon from '../repos/RepoIcon'; import ConceptIcon from '../concepts/ConceptIcon'; @@ -17,11 +20,21 @@ import SearchResults from './SearchResults'; import SearchFilters from './SearchFilters' import { OperationsContext } from '../app/LayoutContext'; import ReferenceFilters from '../repos/ReferenceFilters' +import DeleteReferencesDialog from '../collections/DeleteReferencesDialog' +import RemoveFromCollectionDialog from '../collections/RemoveFromCollectionDialog' +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline' const DEFAULT_LIMIT = 25; const FILTERS_WIDTH = 250 const FILTERABLE_RESOURCES = ['concepts', 'mappings', 'repos', 'sources', 'collections', 'references'] +const getBaseCollectionUrl = url => { + const match = (url || '').match(/^(.*\/collections\/[^/]+\/)(?:[^/]+\/)?(?:concepts|mappings|references)\/?$/) + return match ? match[1] : dropVersion(url) +} + +const getCollectionLookupUrl = url => (url || '').replace(/\/(concepts|mappings|references)\/?$/, '/') + const Search = props => { const { setAlert, contextRepo } = React.useContext(OperationsContext); const { t } = useTranslation() @@ -41,6 +54,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 +435,80 @@ const Search = props => { history.push(getCurrentLayoutURL(getQueryParams(input, page, pageSize, filters, newOrderByField, newOrder))) } + 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)) + : [] + + const onDeleteReferences = deleteBody => { + const body = deleteBody || { ids: selectedReferenceObjects.map(r => r.id).filter(Boolean) } + const deleteUrl = isInCollection ? `${collectionUrl}references/` : props.url + setDeletingReferences(true) + APIService.new().overrideURL(deleteUrl).delete(body).then(response => { + setDeletingReferences(false) + if(response?.status === 204 || response?.status === 200) { + setDeleteReferencesOpen(false) + setSelected([]) + setAlert({ severity: 'success', message: t('reference.remove_success') }) + fetchResults(getQueryParams(input, page, pageSize, filters, orderBy, order)) + } else { + setAlert({ severity: 'error', message: response?.data?.detail || t('common.generic_error') }) + } + }) + } + + const selectedRows = (result[resource]?.results || []).filter(r => selected.includes(r.version_url || r.url || r.id)) + + const onBulkRemoveFromCollection = deleteBody => { + const body = deleteBody || { ids: [] } + setBulkRemoving(true) + APIService.new().overrideURL(collectionUrl).appendToUrl('references/').delete(body).then(response => { + setBulkRemoving(false) + if(response?.status === 204 || response?.status === 200) { + setBulkRemoveOpen(false) + setSelected([]) + setAlert({ severity: 'success', message: t('reference.remove_success') }) + fetchResults(getQueryParams(input, page, pageSize, filters, orderBy, order)) + } else { + setAlert({ severity: 'error', message: response?.data?.detail || t('common.generic_error') }) + } + }) + } + + const bulkRemoveFromCollectionAction = isInCollection && isHead && ['concepts', 'mappings'].includes(resource) && isLoggedIn() && selected.length > 0 ? ( + + ) : null + + const deleteReferencesControl = resource === 'references' && isLoggedIn() && selected.length > 0 ? ( + + + + + + ) : null React.useEffect(() => { setShowItem(props.showItem || false) @@ -497,6 +588,8 @@ const Search = props => { properties={props.properties} propertyFilters={props.propertyFilters} isMatch={isMatchOp} + toolbarControl={deleteReferencesControl} + extraBulkActions={bulkRemoveFromCollectionAction} /> @@ -512,6 +605,22 @@ const Search = props => { } } + setDeleteReferencesOpen(false)} + onConfirm={onDeleteReferences} + references={selectedReferenceObjects} + loading={deletingReferences} + /> + setBulkRemoveOpen(false)} + onConfirm={onBulkRemoveFromCollection} + resources={selectedRows} + collectionUrl={collectionUrl} + lookupCollectionUrl={collectionLookupUrl} + loading={bulkRemoving} + /> ) } diff --git a/src/components/search/SearchResults.jsx b/src/components/search/SearchResults.jsx index 8dcac5da..aff2969a 100644 --- a/src/components/search/SearchResults.jsx +++ b/src/components/search/SearchResults.jsx @@ -235,6 +235,9 @@ const SearchResults = props => { ) : null + const allBulkActions = [addToCollectionBulkAction, props.extraBulkActions].filter(Boolean) + const bulkActionsElement = allBulkActions.length > 0 ? <>{allBulkActions} : null + React.useEffect(() => { setSelected(props.selected || []) }, [props.selected]) @@ -261,7 +264,7 @@ const SearchResults = props => { noCardDisplay={noCardDisplay} toolbarControl={props.toolbarControl} appliedFilters={props.appliedFilters} - bulkActions={addToCollectionBulkAction} + bulkActions={bulkActionsElement} /> } { diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index d3f5747b..0f125a3e 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",