diff --git a/src/components/common/Breadcrumbs.jsx b/src/components/common/Breadcrumbs.jsx index 20b2bccf9..dd04b90cc 100644 --- a/src/components/common/Breadcrumbs.jsx +++ b/src/components/common/Breadcrumbs.jsx @@ -2,15 +2,16 @@ import React from 'react'; import RepoIcon from '../repos/RepoIcon'; import ConceptIcon from '../concepts/ConceptIcon'; import MappingIcon from '../mappings/MappingIcon'; +import ReferenceIcon from '@mui/icons-material/PentagonRounded'; import DotSeparator from './DotSeparator' import RepoVersionButton from '../repos/RepoVersionButton' import RepoTooltip from '../repos/RepoTooltip' import Box from '@mui/material/Box'; import OwnerButton from './OwnerButton' -const Breadcrumbs = ({owner, ownerType, repo, repoVersion, repoURL, concept, mapping, noIcons, color, fontSize, size, ownerURL, nested}) => { +const Breadcrumbs = ({owner, ownerType, repo, repoVersion, repoURL, concept, mapping, reference, noIcons, color, fontSize, size, ownerURL, nested}) => { const iconProps = {color: 'secondary', style: {marginRight: '8px', width: '0.8em'}} - const hideParents = Boolean((concept?.id || mapping?.id) && nested) + const hideParents = Boolean((concept?.id || mapping?.id || reference.id) && nested) return ( { @@ -125,6 +126,28 @@ const Breadcrumbs = ({owner, ownerType, repo, repoVersion, repoURL, concept, map } + { + reference?.id && + + {!hideParents && } + { + !noIcons && + + } + + {reference.id} + + + } ) } diff --git a/src/components/concepts/Associations.jsx b/src/components/concepts/Associations.jsx index fe3d23d57..2f4de1ff2 100644 --- a/src/components/concepts/Associations.jsx +++ b/src/components/concepts/Associations.jsx @@ -31,7 +31,7 @@ const groupMappings = (orderedMappings, concept, mappings, forward) => { if(!mapType) mapType = forward ? 'children' : 'parent'; orderedMappings[mapType] = orderedMappings[mapType] || {order: null, direct: [], indirect: [], unknown: [], hierarchy: [], reverseHierarchy: [], self: []} - const isSelfMapping = isMapping && dropVersion(concept.url) === dropVersion(resource.cascade_target_concept_url) && toParentURI(concept.url) === dropVersion(resource.cascade_target_concept_url) + const isSelfMapping = isMapping && dropVersion(concept?.url) === dropVersion(resource.cascade_target_concept_url) && concept?.url && toParentURI(concept.url) === dropVersion(resource.cascade_target_concept_url) let _resource = isMapping ? {...resource, isSelf: isSelfMapping, cascade_target_concept_name: resource.cascade_target_concept_name || get(find(mappings, m => dropVersion(m.url) === dropVersion(resource.cascade_target_concept_url)), 'display_name')} : {...resource, cascade_target_concept_name: resource.display_name} if(isSelfMapping) { if(!map(orderedMappings[mapType].self, 'id').includes(resource.id)) @@ -131,7 +131,7 @@ const AssociationRow = ({mappings, id, mapType, isSelf, isIndirect, hide}) => { const borderColor = 'rgba(0, 0, 0, 0.12)' -const Associations = ({concept, mappings, reverseMappings, ownerMappings, reverseOwnerMappings, onLoadOwnerMappings, loadingOwnerMappings}) => { +const Associations = ({concept, mappings, reverseMappings, ownerMappings, reverseOwnerMappings, onLoadOwnerMappings, loadingOwnerMappings, nested}) => { const [scope, setScope] = React.useState('repo') const [orderedMappings, setOrderedMappings] = React.useState({}); const [orderedOwnerMappings, setOrderedOwnerMappings] = React.useState({}); @@ -190,7 +190,9 @@ const Associations = ({concept, mappings, reverseMappings, ownerMappings, revers const toggleSection = repoURI => setCollapsedSections(collapsedSections?.includes(repoURI) ? without(collapsedSections, repoURI) : [...collapsedSections, repoURI]) return ( - + + { + !nested && @@ -205,6 +207,7 @@ const Associations = ({concept, mappings, reverseMappings, ownerMappings, revers + } diff --git a/src/components/references/ReferenceDetails.jsx b/src/components/references/ReferenceDetails.jsx new file mode 100644 index 000000000..d54ec8d74 --- /dev/null +++ b/src/components/references/ReferenceDetails.jsx @@ -0,0 +1,134 @@ +import React from 'react' +import { useTranslation } from 'react-i18next'; +import Table from '@mui/material/Table' +import TableBody from '@mui/material/TableBody' +import TableRow from '@mui/material/TableRow' +import TableCell from '@mui/material/TableCell' +import Paper from '@mui/material/Paper' +import Typography from '@mui/material/Typography' +import { startCase, isString, isPlainObject, map } from 'lodash' +import { formatDateTime } from '../../common/utils' +import Link from '../common/Link' + + +const borderColor = 'rgba(0, 0, 0, 0.12)' + +const ReferenceDetails = ({ reference, style }) => { + const { t } = useTranslation() + const getCascadeDetails = () => { + if(!reference?.cascade) + return {} + if(isString(reference.cascade)) + return {method: reference.cascade} + if(isPlainObject(reference.cascade)) + return reference.cascade + return {} + } + return ( +
+ + + {t('reference.details')} + +
+ + + + {t('reference.type')} + + + {startCase(reference.reference_type)} + + + + + {t('reference.translation')} + + + {reference.translation} + + + { + map(getCascadeDetails(), (value, key) => { + let val = isPlainObject(value) ? {JSON.stringify(value, undefined, 2)} : value + return ( + + + {`Cascade ${startCase(key)}`} + + + {val} + + + ) + }) + } + { + Boolean(reference.transform) && + + + {t('reference.transform')} + + + { + ['resourceversions', 'intensional'].includes(reference.transform?.toLowerCase()) ? + t('reference.intensional') : + ( + reference.transform?.toLowerCase() === 'extensional' ? + t('reference.extensional') : + reference.transform + ) + } + + + } + +
+
+ { + reference.filter?.length > 0 && + + + {t('repo.filters')} + + + + { + map(reference.filter, (refFilter, index) => { + return ( + + + {refFilter?.property} {refFilter?.op} + + + {refFilter?.value} + + + ) + }) + } + +
+
+ } +
+ + { + `${t('reference.last_resolved_at')} ` + } + { + reference.last_resolved_at ? formatDateTime(reference.last_resolved_at) : - + } + + + {t('common.created_on')} { + <>{formatDateTime(reference.created_at)} {t('common.by')} + } + + +
+ + ) +} + +export default ReferenceDetails; diff --git a/src/components/references/ReferenceExpansionResults.jsx b/src/components/references/ReferenceExpansionResults.jsx new file mode 100644 index 000000000..f25dbbb08 --- /dev/null +++ b/src/components/references/ReferenceExpansionResults.jsx @@ -0,0 +1,201 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import Card from '@mui/material/Card' +import CardContent from '@mui/material/CardContent' +import ListItemText from '@mui/material/ListItemText' +import Paper from '@mui/material/Paper' +import Typography from '@mui/material/Typography' +import Table from '@mui/material/Table' +import TableContainer from '@mui/material/TableContainer' +import TableHead from '@mui/material/TableHead' +import TableBody from '@mui/material/TableBody' +import TableRow from '@mui/material/TableRow' +import TableCell from '@mui/material/TableCell' +import Skeleton from '@mui/material/Skeleton' +import Button from '@mui/material/Button' +import Collapse from '@mui/material/Collapse'; +import Box from '@mui/material/Box'; + + +import AddIcon from '@mui/icons-material/Add'; +import RemoveIcon from '@mui/icons-material/Remove'; + +import RepoChip from '../repos/RepoChip' +import ConceptIcon from '../concepts/ConceptIcon' +import Associations from '../concepts/Associations' + +import { map, times, without } from 'lodash' + +import { BLACK } from '../../common/colors' + +const borderColor = 'rgba(0, 0, 0, 0.12)' + +const MappingsTable = ({mappings, loading, t, hasMore, onLoadMore}) => { + return ( + + + {t('mapping.mappings')} + + + { + hasMore ? +
+ +
: + null + } +
+ ) +} + +const ConceptsAndMappingsTable = ({reference, concepts, loading, t, hasMore, onLoadMore}) => { + const [open, setOpen] = React.useState([]) + const toggleRow = conceptURL => { + setOpen(open.includes(conceptURL) ? without(open, conceptURL) : [...open, conceptURL]) + } + return ( + + + {t('reference.concepts_and_mappings')} + + + + + + {t('common.id')} + {t('concept.display_name')} + {t('mapping.mappings')} + {t('repo.repo')} + + + + { + (loading && !concepts?.length) ? + <> + { + times((reference.concepts || 1), i => ( + + + + + + )) + } + : + <> + { + map(concepts, (concept, index) => { + let key = concept.version_url || concept.url + const hasMappings = concept.mappings?.length > 0 + const isOpen = open.includes(key) + const isLastConcept = index === (concepts?.length || 0) - 1 + return ( + + .MuiTableCell-root': { + borderBottom: 0, + '&:first-of-type': { + borderBottomLeftRadius: '10px' + }, + '&:last-child': { + borderBottomRightRadius: '10px' + } + } + } : undefined} + > + + + + {concept.id} + + + + {concept.display_name} + + + { + hasMappings ? + : + '0' + } + + + + + + .MuiTableCell-root': { + borderBottom: 0, + borderBottomLeftRadius: isOpen ? '10px' : 0, + borderBottomRightRadius: isOpen ? '10px' : 0 + } + } : undefined} + > + + + + + + + + + + ) + }) + } + + } + +
+
+ { + hasMore ? +
+ +
: + null + } +
+ ) +} + +const ReferenceExpansionResults = ({ reference, concepts, mappings, conceptHeaders, mappingHeaders, loading, onLoadMore }) => { + const { t } = useTranslation() + + const statSx = { + '.MuiListItemText-primary': { + color: 'rgba(0, 0, 0, 0.6)', + fontSize: '1rem' + }, + '.MuiListItemText-secondary': { + color: BLACK, + fontSize: '1.5rem' + } + } + + const isMappingsOnly = Boolean(!reference.concepts && reference.mappings) + const hasMoreConcepts = Boolean(conceptHeaders?.next) + const hasMoreMappings = Boolean(mappingHeaders?.next) + + return ( +
+ + + + + + + + { + isMappingsOnly ? + : + + } + +
+ ) +} + +export default ReferenceExpansionResults; diff --git a/src/components/references/ReferenceHeader.jsx b/src/components/references/ReferenceHeader.jsx new file mode 100644 index 000000000..47a590c3d --- /dev/null +++ b/src/components/references/ReferenceHeader.jsx @@ -0,0 +1,63 @@ +import React from 'react' +import { useTranslation } from 'react-i18next'; + +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import DownIcon from '@mui/icons-material/ArrowDropDown'; + +import { BLACK } from '../../common/colors' +import { toFullAPIURL, copyURL } from '../../common/utils' +import Breadcrumbs from '../common/Breadcrumbs' +import CloseIconButton from '../common/CloseIconButton'; +import ReferenceManagementList from './ReferenceManagementList' + +const ReferenceHeader = ({ reference, onClose }) => { + const { t } = useTranslation() + const [menu, setMenu] = React.useState(false) + const [menuAnchorEl, setMenuAnchorEl] = React.useState(false) + const onMenuOpen = event => { + setMenuAnchorEl(event.currentTarget) + setMenu(true) + } + const onMenuClose = () => { + setMenuAnchorEl(false) + setMenu(false) + } + + const onClick = option => { + if(option === 'copyExpression') + copyURL(toFullAPIURL(reference.expression)) + else if(option === 'copyURL') + copyURL(toFullAPIURL(reference.uri)) + onMenuClose() + } + + return ( +
+
+ + + + + + +
+
+ + {reference.expression} + +
+
+ + + + + +
+
+ ) +} + +export default ReferenceHeader; diff --git a/src/components/references/ReferenceHome.jsx b/src/components/references/ReferenceHome.jsx new file mode 100644 index 000000000..ae8e955f6 --- /dev/null +++ b/src/components/references/ReferenceHome.jsx @@ -0,0 +1,121 @@ +import React from 'react' + +import APIService from '../../services/APIService'; + +import ReferenceHeader from './ReferenceHeader' +import ReferenceDetails from './ReferenceDetails' +import ReferenceTabs from './ReferenceTabs' +import ReferenceExpansionResults from './ReferenceExpansionResults' + +const ReferenceHome = props => { + const { reference } = props + const [loading, setLoading] = 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 repoURL = props?.repo?.version_url || props?.repo?.url + + const resetExpansionState = () => { + setLoading(false) + setConcepts(false) + setMappings(false) + setConceptHeaders(false) + setMappingHeaders(false) + } + + React.useEffect(() => { + activeReferenceIdRef.current = reference?.id + resetExpansionState() + }, [reference?.id]) + + React.useEffect(() => { + if(tab === 'expansion') + getResults({ force: true, reset: true, currentReferenceId: reference?.id }) + }, [reference?.id, tab]) + + const onTabChange = newTab => { + setTab(newTab) + } + + const getResults = ({ force=false, reset=false, currentReferenceId=reference?.id } = {}) => { + if(reference.reference_type === 'mappings' && (force || !mappings?.length)) { + fetchMappings({ reset, currentReferenceId }) + } + else if (force || !concepts?.length) { + fetchConcepts({ reset, currentReferenceId }) + } + } + const getRefService = () => APIService.new().overrideURL(repoURL).appendToUrl(`references/${reference.id}/`) + + const fetchConcepts = ({ reset=false, currentReferenceId=reference?.id } = {}) => { + const { limit, page } = getLimits(reset ? false : conceptHeaders) + if(limit === 0) + return + + setLoading(true) + getRefService().appendToUrl('concepts/').get(null, null, {limit: limit, page: page, includeMappings: true, mappingBrief: true}).then(response => { + if(activeReferenceIdRef.current !== currentReferenceId) + return + + setConcepts(currentConcepts => (page === 1 ? response.data : [...(currentConcepts || []), ...response.data])) + setConceptHeaders(response.headers) + setLoading(false) + }) + } + + const fetchMappings = ({ reset=false, currentReferenceId=reference?.id } = {}) => { + const { limit, page } = getLimits(reset ? false : mappingHeaders) + if(limit === 0) + return + + setLoading(true) + getRefService().appendToUrl('mappings/').get(null, null, {limit: limit, page: page}).then(response => { + if(activeReferenceIdRef.current !== currentReferenceId) + return + + setMappings(currentMappings => (page === 1 ? response.data : [...(currentMappings || []), ...response.data])) + setMappingHeaders(response.headers) + setLoading(false) + }) + } + + const getLimits = headers => { + if(headers?.page_number && !headers?.next) + return { limit: 0, page: 0 } + + return { + limit: 10, + page: (parseInt(headers?.page_number || 0, 10)) + 1 + } + } + + const onLoadMore = (resource) => { + if(resource === 'concepts') + fetchConcepts() + else if (resource === 'mappings') + fetchMappings() + } + + + + return ( +
+ + onTabChange(newTab)} loading={loading} /> + { + tab === 'metadata' && + + } + { + tab === 'expansion' && + + } +
+ ) +} + +export default ReferenceHome diff --git a/src/components/references/ReferenceManagementList.jsx b/src/components/references/ReferenceManagementList.jsx new file mode 100644 index 000000000..ab61a8b2b --- /dev/null +++ b/src/components/references/ReferenceManagementList.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next' +import { Menu, ListItem, ListItemButton, ListItemText, ListItemIcon, Divider} 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 { t } = useTranslation() + return ( + + + onClick('copyExpression')} sx={{padding: '8px 12px'}}> + + + + + + + + onClick('copyURL')} sx={{padding: '8px 12px'}}> + + + + + + + + + onClick('delete')} sx={{padding: '8px 12px', color: 'error.main'}}> + + + + + + + + ) +} + +export default ReferenceManagementList; diff --git a/src/components/references/ReferenceTabs.jsx b/src/components/references/ReferenceTabs.jsx new file mode 100644 index 000000000..298930a70 --- /dev/null +++ b/src/components/references/ReferenceTabs.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next' +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; + +const TAB_STYLES = {textTransform: 'none'} +const ReferenceTabs = ({ tab, onTabChange }) => { + const { t } = useTranslation() + return ( + + + + + ) +} + +export default ReferenceTabs diff --git a/src/components/repos/RepoHome.jsx b/src/components/repos/RepoHome.jsx index 915d79f99..d365ef2f1 100644 --- a/src/components/repos/RepoHome.jsx +++ b/src/components/repos/RepoHome.jsx @@ -23,6 +23,7 @@ import RepoOverview from './RepoOverview' import VersionForm from './VersionForm' import ReleaseVersion from './ReleaseVersion' import RepoHeader from './RepoHeader'; +import ReferenceHome from '../references/ReferenceHome' const RepoHome = () => { const { t } = useTranslation() @@ -77,7 +78,7 @@ const RepoHome = () => { else setTabs([...TABS]) - if(isConceptURL || isMappingURL) + if(isConceptURL || isMappingURL || isReferenceURL) setShowItem(true) }) } @@ -232,7 +233,7 @@ const RepoHome = () => { const getReferenceURLFromMainURL = () => (isReferenceURL && params.resource) ? getURL() + 'references/' + params.resource + '/' : false const showConceptURL = ((showItem?.concept_class || params.resource) && isConceptURL) ? showItem?.version_url || showItem?.url || getConceptURLFromMainURL() : false const showMappingURL = ((showItem?.map_type || params.resource) && isMappingURL) ? showItem?.version_url || showItem?.url || getMappingURLFromMainURL() : false - const showReferenceURL = ((showItem?.expression || params.resource) && isReferenceURL) ? showItem?.version_url || showItem?.url || getReferenceURLFromMainURL() : false + const showReferenceURL = ((showItem?.expression || params.resource) && isReferenceURL) ? showItem?.uri || getReferenceURLFromMainURL() : false const isSplitView = conceptForm || mappingForm || showConceptURL || showMappingURL || showReferenceURL || versionForm const onVersionEditClick = () => isVersion && setVersionForm(true) @@ -303,9 +304,13 @@ const RepoHome = () => { setShowItem(false)} repoVersions={versions} nested /> } { - showMappingURL && + Boolean(showMappingURL && !mappingForm) && setShowItem(false)} repoVersions={versions} nested /> } + { + showReferenceURL && + setShowItem(false)} repoVersions={versions} nested /> + } { conceptForm && setConceptForm(false)} /> diff --git a/src/components/search/SearchResults.jsx b/src/components/search/SearchResults.jsx index 6021d6185..8dcac5da6 100644 --- a/src/components/search/SearchResults.jsx +++ b/src/components/search/SearchResults.jsx @@ -159,13 +159,10 @@ const SearchResults = props => { }; const handleRowClick = (event, id) => { - if(props.resource === 'references') - return - event.preventDefault() event.stopPropagation() const item = rows.find(row => id == (row.version_url || row.url || row.id)) || false - if(['concepts', 'mappings'].includes(props.resource)) { + if(['concepts', 'mappings', 'references'].includes(props.resource)) { props.onShowItemSelect(item) } else if (props.resource === 'repos') { history.push(item.url) diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 18bb140de..6d9195f13 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -111,6 +111,7 @@ "your_url_will_be": "Your URL will be", "proceed": "Proceed", "loading": "Loading...", + "load_more": "Load more", "release": "Release", "unrelease": "Un-Release", "reason": "Reason", @@ -255,6 +256,14 @@ "expression": "Expression", "references": "References", "reference": "Reference", + "type": "Reference Type", + "last_resolved_at": "Last resolved at", + "unresolved": "unresolved", + "details": "Reference Details", + "expansion_results": "Expansion Results", + "concepts_and_mappings": "Concepts and Mappings", + "copy_expression": "Copy Reference Expression", + "copy_url": "Copy Reference URL", "reference_type": "Ref Type", "exclude": "Exclude", "intensional": "Intensional", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 0c8f55997..cbff07ee4 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -29,7 +29,8 @@ "navigate": "Navegar", "select": "Seleccionar", "search": "Buscar", - "for": "para" + "for": "para", + "load_more": "Cargar más" }, "errors": { "404": "Lo siento, no se pudo encontrar tu página." diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index c448ab7de..04f8739b2 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -87,7 +87,8 @@ "from": "始于", "target": "目标", "properties": "特性", - "custom": "自定义" + "custom": "自定义", + "load_more": "加载更多" }, "errors": { "404": "很抱歉,未能找到您的页面。",