285 lines
7.9 KiB
JavaScript
285 lines
7.9 KiB
JavaScript
import PropTypes from 'prop-types'
|
|
import { Typography, Flex, Badge, Tag, Popover } from 'antd'
|
|
import { LoadingOutlined } from '@ant-design/icons'
|
|
import { useState, useEffect, useContext, useCallback, useRef } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { getModelByName } from '../../../database/ObjectModels'
|
|
import { ApiServerContext } from '../context/ApiServerContext'
|
|
import { AuthContext } from '../context/AuthContext'
|
|
import merge from 'lodash/merge'
|
|
import IdDisplay from './IdDisplay'
|
|
import SpotlightTooltip from './SpotlightTooltip'
|
|
|
|
const { Text, Link } = Typography
|
|
|
|
const ObjectDisplay = ({
|
|
object,
|
|
objectType,
|
|
showHyperlink = false,
|
|
showSpotlight = true
|
|
}) => {
|
|
const [objectData, setObjectData] = useState(object)
|
|
const [isHydrating, setIsHydrating] = useState(false)
|
|
const { subscribeToObjectUpdates, connected, fetchSpotlightData } =
|
|
useContext(ApiServerContext)
|
|
const { token } = useContext(AuthContext)
|
|
const navigate = useNavigate()
|
|
const idRef = useRef(null)
|
|
|
|
// Update event handler
|
|
const updateObjectEventHandler = useCallback((value) => {
|
|
setObjectData((prev) => merge({}, prev, value))
|
|
}, [])
|
|
|
|
// Ensure ID is valid before hydrate/subscribe (non-empty, not null/undefined)
|
|
const isValidId = useCallback((id) => {
|
|
return id != null && String(id).trim() !== ''
|
|
}, [])
|
|
|
|
// Extract string ID from object; handles both primitive _id and populated ref (_id as object)
|
|
const getStringId = useCallback((obj) => {
|
|
const id = obj?._id
|
|
if (id == null) return null
|
|
if (typeof id === 'string') return id
|
|
if (typeof id === 'object' && id !== null && typeof id._id === 'string')
|
|
return id._id
|
|
return null
|
|
}, [])
|
|
|
|
// Detect minimal objects that only contain an _id (must be string, not populated object)
|
|
const isMinimalObject = useCallback((obj) => {
|
|
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false
|
|
const keys = Object.keys(obj)
|
|
const id = obj._id
|
|
return (
|
|
keys.length === 1 &&
|
|
keys[0] === '_id' &&
|
|
typeof id === 'string' &&
|
|
isValidId(id)
|
|
)
|
|
}, [isValidId])
|
|
|
|
// If only an _id is provided, fetch the full object via spotlight
|
|
const fetchFullObjectIfNeeded = useCallback(
|
|
async (obj) => {
|
|
if (!isMinimalObject(obj) || !objectType) return obj
|
|
try {
|
|
const model = getModelByName(objectType)
|
|
const spotlightQuery = `${model.prefix}:${obj._id}`
|
|
const spotlightResult = await fetchSpotlightData(spotlightQuery)
|
|
// Spotlight returns [] when not found; only use single-object results
|
|
if (
|
|
spotlightResult &&
|
|
typeof spotlightResult === 'object' &&
|
|
!Array.isArray(spotlightResult)
|
|
) {
|
|
return spotlightResult
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch spotlight data:', err)
|
|
}
|
|
return obj
|
|
},
|
|
[fetchSpotlightData, isMinimalObject, objectType]
|
|
)
|
|
|
|
// Subscribe to object updates when component mounts
|
|
useEffect(() => {
|
|
const id = getStringId(object)
|
|
if (isValidId(id) && objectType && connected && token != null) {
|
|
const objectUpdatesUnsubscribe = subscribeToObjectUpdates(
|
|
id,
|
|
objectType,
|
|
updateObjectEventHandler
|
|
)
|
|
|
|
return () => {
|
|
if (objectUpdatesUnsubscribe) objectUpdatesUnsubscribe()
|
|
}
|
|
}
|
|
}, [
|
|
object,
|
|
objectType,
|
|
subscribeToObjectUpdates,
|
|
connected,
|
|
token,
|
|
updateObjectEventHandler,
|
|
isValidId,
|
|
getStringId
|
|
])
|
|
|
|
// Update local state when object prop changes
|
|
useEffect(() => {
|
|
if (token == null) return
|
|
const id = getStringId(object)
|
|
if (!isValidId(id)) return
|
|
const isMinimal = isMinimalObject(object)
|
|
// Only skip re-fetch when we have a minimal object and already hydrated this id
|
|
if (isMinimal && idRef.current === id) return
|
|
let cancelled = false
|
|
if (isMinimal) setIsHydrating(true)
|
|
const hydrateObject = async () => {
|
|
const fullObject = await fetchFullObjectIfNeeded(object)
|
|
if (!cancelled) {
|
|
setObjectData((prev) => merge({}, prev, fullObject))
|
|
if (isMinimal) idRef.current = id
|
|
setIsHydrating(false)
|
|
}
|
|
}
|
|
hydrateObject()
|
|
return () => {
|
|
cancelled = true
|
|
setIsHydrating(false)
|
|
}
|
|
}, [object, fetchFullObjectIfNeeded, isMinimalObject, isValidId, getStringId, token])
|
|
if (!objectData) {
|
|
return <Text type='secondary'>n/a</Text>
|
|
}
|
|
|
|
if (isHydrating) {
|
|
return (
|
|
<Tag
|
|
style={{
|
|
margin: 0,
|
|
border: 'none',
|
|
minWidth: 0,
|
|
maxWidth: '100%'
|
|
}}
|
|
className='object-display-tag'
|
|
>
|
|
<Flex gap='4px' align='center' style={{ minWidth: 0, height: '24px' }}>
|
|
<LoadingOutlined spin />
|
|
<Text type='secondary'>Loading...</Text>
|
|
</Flex>
|
|
</Tag>
|
|
)
|
|
}
|
|
|
|
const model = getModelByName(objectType)
|
|
const Icon = model.icon
|
|
const prefix = model.prefix
|
|
|
|
// Get hyperlink URL from model's default actions
|
|
var hyperlink = null
|
|
const defaultModelActions =
|
|
model.actions?.filter((action) => action.default == true) || []
|
|
const objectId = getStringId(objectData)
|
|
|
|
if (defaultModelActions.length >= 1 && objectId) {
|
|
hyperlink = defaultModelActions[0].url(objectId)
|
|
}
|
|
|
|
// Render name with hyperlink/spotlight support
|
|
const renderNameDisplay = () => {
|
|
if (!objectData?.name) return null
|
|
|
|
const textElement = (
|
|
<Text ellipsis style={{ lineHeight: '1', paddingBottom: '2px' }}>
|
|
{objectData.name}
|
|
</Text>
|
|
)
|
|
|
|
// If hyperlink is enabled
|
|
if (showHyperlink && hyperlink != null) {
|
|
const linkElement = (
|
|
<Link onClick={() => navigate(hyperlink)} ellipsis>
|
|
{textElement}
|
|
</Link>
|
|
)
|
|
|
|
if (showSpotlight && objectId) {
|
|
return (
|
|
<Popover
|
|
content={
|
|
<SpotlightTooltip
|
|
query={prefix + ':' + objectId}
|
|
type={objectType}
|
|
/>
|
|
}
|
|
trigger={['hover', 'click']}
|
|
placement='topLeft'
|
|
arrow={false}
|
|
style={{ padding: 0 }}
|
|
>
|
|
{linkElement}
|
|
</Popover>
|
|
)
|
|
}
|
|
return linkElement
|
|
}
|
|
|
|
// If hyperlink is disabled
|
|
if (showSpotlight && objectId) {
|
|
return (
|
|
<Popover
|
|
content={
|
|
<SpotlightTooltip
|
|
query={prefix + ':' + objectId}
|
|
type={objectType}
|
|
/>
|
|
}
|
|
trigger={['hover', 'click']}
|
|
placement='topLeft'
|
|
arrow={false}
|
|
>
|
|
{textElement}
|
|
</Popover>
|
|
)
|
|
}
|
|
return textElement
|
|
}
|
|
|
|
return (
|
|
<Tag
|
|
style={{
|
|
margin: 0,
|
|
border: 'none',
|
|
minWidth: 0,
|
|
maxWidth: '100%'
|
|
}}
|
|
className='object-display-tag'
|
|
>
|
|
<Flex
|
|
gap={objectData?.color ? 'small' : '5px'}
|
|
align='center'
|
|
style={{ minWidth: 0, height: '24px' }}
|
|
>
|
|
<Icon />
|
|
<Flex gap={'small'} align='center' style={{ minWidth: 0 }}>
|
|
{objectData?.color ? (
|
|
<Badge
|
|
color={objectData?.color}
|
|
style={{ marginBottom: '1.5px' }}
|
|
/>
|
|
) : null}
|
|
<div style={{ minWidth: 0 }}>
|
|
{renderNameDisplay()}
|
|
|
|
{objectId && !objectData?.name ? (
|
|
<IdDisplay
|
|
id={objectId}
|
|
reference={objectData?._reference || undefined}
|
|
type={objectType}
|
|
longId={false}
|
|
showCopy={false}
|
|
showHyperlink={showHyperlink}
|
|
showSpotlight={showSpotlight}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</Flex>
|
|
</Flex>
|
|
</Tag>
|
|
)
|
|
}
|
|
|
|
ObjectDisplay.propTypes = {
|
|
object: PropTypes.object,
|
|
objectType: PropTypes.string,
|
|
style: PropTypes.object,
|
|
showHyperlink: PropTypes.bool,
|
|
showSpotlight: PropTypes.bool
|
|
}
|
|
|
|
export default ObjectDisplay
|