Enhance ObjectCard and ObjectTable components with skeleton loading support
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Added an `isSkeleton` prop to ObjectCard for conditional rendering of skeleton loaders, improving user experience during data fetching.
- Updated ObjectTable to manage skeleton boundaries, allowing for dynamic loading of pages based on visibility of skeleton cards.
- Refactored scroll handling in ObjectTable to utilize IntersectionObserver for better performance and responsiveness.
This commit is contained in:
Tom Butcher 2026-08-01 12:19:27 +01:00
parent 3c634a90d0
commit 84b7197bb9
2 changed files with 61 additions and 28 deletions

View File

@ -1,10 +1,11 @@
import { Descriptions, Card, Flex, Divider } from 'antd'
import { Descriptions, Card, Flex, Divider, Skeleton } from 'antd'
import PropTypes from 'prop-types'
import ObjectProperty from './ObjectProperty'
import { createElement } from 'react'
import Thumbnail from './Thumbnail'
const ObjectCard = ({
isSkeleton = false,
model,
modelProperties,
visibleColumns = {},
@ -40,7 +41,9 @@ const ObjectCard = ({
key: prop.name,
label: prop.label,
span: 2,
children: (
children: isSkeleton ? (
<Skeleton.Input active size='small' style={{ height: '24px' }} />
) : (
<ObjectProperty
{...prop}
longId={false}
@ -162,7 +165,8 @@ ObjectCard.propTypes = {
isEditing: PropTypes.bool,
rowActions: PropTypes.array,
renderActions: PropTypes.func.isRequired,
cardStyle: PropTypes.string
cardStyle: PropTypes.string,
isSkeleton: PropTypes.bool
}
export default ObjectCard

View File

@ -252,7 +252,7 @@ const ObjectTable = forwardRef(
<QuestionCircleIcon />
)
}
disabled={disabled}
disabled={disabled || objectData?.isSkeleton}
type={'text'}
size={'small'}
onClick={() => {
@ -304,6 +304,21 @@ const ObjectTable = forwardRef(
return SKELETON_HEIGHT * pageSize
}, [pageSize])
const skeletonBoundaryIds = useMemo(() => {
const firstPage = pages[0]
const lastPage = pages[pages.length - 1]
return {
previous:
firstPage?.isSkeletonPage && firstPage.items.length > 0
? firstPage.items[firstPage.items.length - 1]._id
: null,
next:
lastPage?.isSkeletonPage && lastPage.items[0]
? lastPage.items[0]._id
: null
}
}, [pages])
const createPageWindow = useCallback(
(loadedPages, direction) => {
const sortedPages = [...loadedPages].sort(
@ -1101,37 +1116,39 @@ const ObjectTable = forwardRef(
return () => cardsResizeObserverRef.current?.disconnect()
}, [])
// Card view scroll handler
useEffect(() => {
// Card view: load when the leading skeleton card in a boundary page enters view
useLayoutEffect(() => {
if (!cards) return
const container = cardsContainerNodeRef.current
if (!container) return
const scrollEl = container.querySelector('.simplebar-content-wrapper')
if (!scrollEl) return
const handleCardsScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target
const firstPage = pagesRef.current[0]
const lastPage = pagesRef.current[pagesRef.current.length - 1]
if (
lastPage?.isSkeletonPage &&
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading
) {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
const boundary = entry.target.dataset.skeletonBoundary
if (boundary === 'next') {
loadNextPage(scrollEl)
} else if (
firstPage?.isSkeletonPage &&
scrollTop < 100 &&
!lazyLoading
) {
} else if (boundary === 'previous') {
loadPreviousPage(scrollEl)
}
}
},
{
root: scrollEl,
rootMargin: `${SCROLL_THRESHOLD}px`,
threshold: 0
}
)
scrollEl.addEventListener('scroll', handleCardsScroll)
return () => scrollEl.removeEventListener('scroll', handleCardsScroll)
}, [cards, pages, lazyLoading, loadNextPage, loadPreviousPage])
container
.querySelectorAll('[data-skeleton-boundary]')
.forEach((el) => observer.observe(el))
return () => observer.disconnect()
}, [cards, skeletonBoundaryIds, loadNextPage, loadPreviousPage])
const renderCards = () => {
return (
@ -1146,9 +1163,20 @@ const ObjectTable = forwardRef(
if (record?._id == undefined) {
return null
}
const skeletonBoundary =
record._id === skeletonBoundaryIds.next
? 'next'
: record._id === skeletonBoundaryIds.previous
? 'previous'
: undefined
return (
<Col span={cardColSpan} key={record._id}>
<div style={{ width: '100%', overflow: 'hidden' }}>
<div
style={{ width: '100%', overflow: 'hidden' }}
data-skeleton-boundary={skeletonBoundary}
>
<RowForm
record={record}
isEditing={isEditing}
@ -1156,6 +1184,7 @@ const ObjectTable = forwardRef(
>
<Flex align={'center'} vertical gap={'middle'}>
<ObjectCard
isSkeleton={record?.isSkeleton || false}
model={model}
modelProperties={modelProperties}
visibleColumns={visibleColumns}