farmcontrol-ui/src/components/Dashboard/common/ObjectKanbanColumn.jsx
Tom Butcher 5e6b4213ab Enhance ObjectKanban and ObjectTable Components with Column Resizing and Drag-and-Drop Functionality
- Implemented dynamic column resizing in ObjectKanban and ObjectKanbanColumn, allowing users to adjust column widths for better layout control.
- Added drag-and-drop support for reordering columns in ObjectKanbanHeader, improving user interaction and customization of the kanban view.
- Updated CSS styles in App.css to accommodate new resizing handles and visual feedback during drag-and-drop actions.
- Refactored ObjectKanban and related components to manage column states and widths effectively, ensuring a responsive and user-friendly experience.
2026-09-03 14:27:34 +01:00

640 lines
18 KiB
JavaScript

import {
forwardRef,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
import { Flex } from 'antd'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { ApiServerContext } from '../context/ApiServerContext'
import ObjectCard from './ObjectCard'
import Spin from './Spin'
import { KANBAN_MIN_COLUMN_WIDTH, toCategoryFilterValue } from './viewModeUtils'
const SCROLL_THRESHOLD = 50
const idsEqual = (a, b) => {
if (a == null || b == null) return false
return String(a).toLowerCase() === String(b).toLowerCase()
}
const ObjectKanbanColumn = forwardRef(
(
{
type,
categoryProperty,
categoryValue,
baseFilter = {},
masterFilter = {},
sorter = {},
pageSize = 25,
model,
modelProperties,
visibleColumns = {},
isEditing = false,
rowActions = [],
lazyLoading = false,
renderActions,
scrollElement = null,
width,
isResizing = false,
onResizeStart
},
ref
) => {
const { fetchObjects } = useContext(ApiServerContext)
const containerRef = useRef(null)
const pagesRef = useRef([])
const loadingPagesRef = useRef(new Set())
const dataLoadGenerationRef = useRef(0)
const pendingScrollAnchorRef = useRef(null)
const lastColumnQueryKeyRef = useRef(null)
const baseFilterRef = useRef(baseFilter)
const masterFilterRef = useRef(masterFilter)
const sorterRef = useRef(sorter)
const fetchObjectsRef = useRef(fetchObjects)
baseFilterRef.current = baseFilter
masterFilterRef.current = masterFilter
sorterRef.current = sorter
fetchObjectsRef.current = fetchObjects
const [pages, setPages] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
pagesRef.current = pages
}, [pages])
const columnFilter = useMemo(
() => ({
...baseFilter,
[categoryProperty]: toCategoryFilterValue(categoryValue)
}),
[baseFilter, categoryProperty, categoryValue]
)
const columnQueryKey = useMemo(
() =>
JSON.stringify({
type,
categoryProperty,
categoryValue: toCategoryFilterValue(categoryValue),
filter: columnFilter,
sorter
}),
[columnFilter, categoryProperty, categoryValue, sorter, type]
)
const tableData = useMemo(() => {
const items = pages.flatMap((page) => page.items)
const seen = new Set()
return items.filter((item) => {
const id = item?._id
if (id == null) return true
if (seen.has(id)) return false
seen.add(id)
return true
})
}, [pages])
const createSkeletonData = useCallback(
(pageNum) =>
Array(pageSize)
.fill(null)
.map((_, index) => ({
_id: `skeleton-${categoryProperty}-${pageNum}-${index}`,
isSkeleton: true
})),
[categoryProperty, pageSize]
)
const createSkeletonPage = useCallback(
(pageNum) => ({
pageNum,
items: createSkeletonData(pageNum),
isSkeletonPage: true
}),
[createSkeletonData]
)
const createPageWindow = useCallback(
(loadedPages, direction) => {
const dedupedByPageNum = new Map()
loadedPages.forEach((page) => {
dedupedByPageNum.set(page.pageNum, page)
})
const sortedPages = [...dedupedByPageNum.values()].sort(
(a, b) => a.pageNum - b.pageNum
)
const visiblePages =
direction === 'previous'
? sortedPages.slice(0, 2)
: sortedPages.slice(-2)
const firstPage = visiblePages[0]
const lastPage = visiblePages[visiblePages.length - 1]
const nextPages = []
if (firstPage?.pageNum > 1) {
nextPages.push(createSkeletonPage(firstPage.pageNum - 1))
}
nextPages.push(...visiblePages)
if (lastPage?.hasMore) {
nextPages.push(createSkeletonPage(lastPage.pageNum + 1))
}
return nextPages
},
[createSkeletonPage]
)
const mergeLoadedPage = useCallback((currentPages, loadedPage) => {
const withoutSkeletons = currentPages.filter(
(page) => !page.isSkeletonPage
)
const withoutDuplicate = withoutSkeletons.filter(
(page) => page.pageNum !== loadedPage.pageNum
)
return [...withoutDuplicate, loadedPage].sort(
(a, b) => a.pageNum - b.pageNum
)
}, [])
const setTablePages = useCallback((nextPages) => {
pagesRef.current = nextPages
setPages(nextPages)
}, [])
const isStaleDataLoad = useCallback(
(generation) => generation !== dataLoadGenerationRef.current,
[]
)
const fetchPage = useCallback(
async (pageNum, overrides = {}) => {
const filter = overrides.filter ?? {
...masterFilterRef.current,
...baseFilterRef.current,
[categoryProperty]: toCategoryFilterValue(categoryValue)
}
const sorter = overrides.sorter ?? sorterRef.current
return fetchObjectsRef.current(type, {
page: pageNum,
limit: pageSize,
filter,
sorter
})
},
[categoryProperty, categoryValue, pageSize, type]
)
const fetchData = useCallback(
async (pageNum) => {
const generation = dataLoadGenerationRef.current
try {
const result = await fetchPage(pageNum)
if (isStaleDataLoad(generation)) return []
const loadedPage = {
pageNum,
items: result.data || [],
hasMore: result.hasMore
}
setPages((prev) =>
prev.map((page) => (page.pageNum === pageNum ? loadedPage : page))
)
return result.data || []
} catch (error) {
console.error(`Error loading kanban page ${pageNum}:`, error)
return []
}
},
[fetchPage, isStaleDataLoad]
)
const findRenderedCard = useCallback((scrollTarget, id) => {
if (!scrollTarget || id == null) return null
return scrollTarget.querySelector(`[data-kanban-card-id="${String(id)}"]`)
}, [])
const captureScrollAnchor = useCallback(
(scrollTarget, placeholderPage, direction, currentPages) => {
if (!scrollTarget) return null
const targetTop = scrollTarget.getBoundingClientRect().top
const closestCard = (items) =>
items
.map((item, index) => {
const element = findRenderedCard(scrollTarget, item._id)
return element
? {
index,
id: item._id,
top: element.getBoundingClientRect().top
}
: null
})
.filter(Boolean)
.sort(
(a, b) =>
Math.abs(a.top - targetTop) - Math.abs(b.top - targetTop)
)[0]
const placeholderAnchor = closestCard(placeholderPage.items)
const loadedPages = currentPages.filter((page) => !page.isSkeletonPage)
const retainedPage =
direction === 'previous'
? loadedPages[0]
: loadedPages[loadedPages.length - 1]
const fallbackAnchor = retainedPage
? closestCard(retainedPage.items)
: null
return { placeholderAnchor, fallbackAnchor }
},
[findRenderedCard]
)
const restoreScrollAnchor = useCallback(
(scrollTarget, anchor, loadedPage) => {
if (!scrollTarget || !anchor) return
const loadedItem = loadedPage.items[anchor.placeholderAnchor?.index]
const anchorId = loadedItem?._id ?? anchor.fallbackAnchor?.id
const previousTop = loadedItem
? anchor.placeholderAnchor?.top
: anchor.fallbackAnchor?.top
if (anchorId == null || previousTop == null) return
const element = findRenderedCard(scrollTarget, anchorId)
if (!element) return
const renderedTop = element.getBoundingClientRect().top
scrollTarget.scrollTop += renderedTop - previousTop
},
[findRenderedCard]
)
useLayoutEffect(() => {
const pendingAnchor = pendingScrollAnchorRef.current
if (!pendingAnchor) return
pendingScrollAnchorRef.current = null
restoreScrollAnchor(
pendingAnchor.scrollTarget,
pendingAnchor.anchor,
pendingAnchor.loadedPage
)
}, [pages, restoreScrollAnchor])
const loadBoundaryPage = useCallback(
async (pageNum, direction, scrollTarget) => {
if (loadingPagesRef.current.size > 0) return
const currentPages = pagesRef.current
const placeholderPage = currentPages.find(
(page) => page.pageNum === pageNum && page.isSkeletonPage
)
if (!placeholderPage) return
const scrollAnchor = captureScrollAnchor(
scrollTarget,
placeholderPage,
direction,
currentPages
)
const generation = dataLoadGenerationRef.current
loadingPagesRef.current.add(pageNum)
try {
const result = await fetchPage(pageNum)
if (isStaleDataLoad(generation)) return
const loadedPage = {
pageNum,
items: result.data || [],
hasMore: result.hasMore
}
pendingScrollAnchorRef.current = {
scrollTarget,
anchor: scrollAnchor,
loadedPage
}
setPages((prev) => {
if (
!prev.some(
(page) => page.pageNum === pageNum && page.isSkeletonPage
)
) {
return prev
}
const loadedPages = mergeLoadedPage(prev, loadedPage)
return createPageWindow(loadedPages, direction)
})
} catch (error) {
console.error(`Error loading kanban page ${pageNum}:`, error)
} finally {
loadingPagesRef.current.delete(pageNum)
}
},
[
captureScrollAnchor,
createPageWindow,
fetchPage,
isStaleDataLoad,
mergeLoadedPage
]
)
const loadNextPage = useCallback(
(scrollTarget) => {
const nextPage = pagesRef.current[pagesRef.current.length - 1]
if (nextPage?.isSkeletonPage) {
loadBoundaryPage(nextPage.pageNum, 'next', scrollTarget)
}
},
[loadBoundaryPage]
)
const loadPreviousPage = useCallback(
(scrollTarget) => {
const previousPage = pagesRef.current[0]
if (previousPage?.isSkeletonPage) {
loadBoundaryPage(previousPage.pageNum, 'previous', scrollTarget)
}
},
[loadBoundaryPage]
)
const loadInitialPage = useCallback(
async ({ silent = false } = {}) => {
dataLoadGenerationRef.current += 1
loadingPagesRef.current.clear()
pendingScrollAnchorRef.current = null
const generation = dataLoadGenerationRef.current
const filter = {
...masterFilterRef.current,
...baseFilterRef.current,
[categoryProperty]: toCategoryFilterValue(categoryValue)
}
const sorter = sorterRef.current
if (!silent) {
pagesRef.current = []
setPages([])
setLoading(true)
const skeletonPage = createSkeletonPage(1)
setTablePages([skeletonPage])
} else {
setLoading(false)
}
try {
const firstResult = await fetchPage(1, { filter, sorter })
if (isStaleDataLoad(generation)) return
const loadedPages = [
{
pageNum: 1,
items: firstResult.data || [],
hasMore: firstResult.hasMore
}
]
if (!isStaleDataLoad(generation)) {
setTablePages(createPageWindow(loadedPages, 'next'))
}
} catch {
if (!silent && !isStaleDataLoad(generation)) {
setTablePages([])
}
} finally {
if (!silent && !isStaleDataLoad(generation)) {
setLoading(false)
}
}
},
[
categoryProperty,
categoryValue,
createPageWindow,
createSkeletonPage,
fetchPage,
isStaleDataLoad,
setTablePages
]
)
const reloadLoadedPages = useCallback(async () => {
const loadedPages = pagesRef.current.filter(
(page) => !page.isSkeletonPage
)
for (let i = 0; i < loadedPages.length; i++) {
await fetchData(loadedPages[i].pageNum)
}
}, [fetchData])
const findItem = useCallback((id) => {
for (const page of pagesRef.current) {
const item = page.items?.find(
(entry) => idsEqual(entry._id, id) && !entry.isSkeleton
)
if (item) return item
}
return null
}, [])
const updateItem = useCallback((id, updatedData) => {
setPages((prevPages) => {
const nextPages = prevPages.map((page) => {
let changed = false
const updatedItems = page.items.map((item) => {
if (idsEqual(item._id, id) && !item.isSkeleton) {
changed = true
return { ...item, ...updatedData, _id: item._id }
}
return item
})
return changed ? { ...page, items: updatedItems } : page
})
pagesRef.current = nextPages
return nextPages
})
}, [])
useImperativeHandle(
ref,
() => ({
reload: reloadLoadedPages,
findItem,
updateItem
}),
[findItem, reloadLoadedPages, updateItem]
)
useEffect(() => {
if (lastColumnQueryKeyRef.current === columnQueryKey) return
// First mount shows skeleton; later filter/sort updates load silently.
const silent = lastColumnQueryKeyRef.current != null
let cancelled = false
const runLoad = async () => {
await loadInitialPage({ silent })
if (!cancelled) {
lastColumnQueryKeyRef.current = columnQueryKey
}
}
runLoad()
return () => {
cancelled = true
}
}, [columnQueryKey, loadInitialPage])
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])
useLayoutEffect(() => {
const container = containerRef.current
if (!container || !scrollElement) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
const boundary = entry.target.dataset.skeletonBoundary
if (boundary === 'next') {
loadNextPage(scrollElement)
} else if (boundary === 'previous') {
loadPreviousPage(scrollElement)
}
}
},
{
root: scrollElement,
rootMargin: `${SCROLL_THRESHOLD}px`,
threshold: 0
}
)
container
.querySelectorAll('[data-skeleton-boundary]')
.forEach((el) => observer.observe(el))
return () => observer.disconnect()
}, [
scrollElement,
skeletonBoundaryIds,
loadNextPage,
loadPreviousPage,
tableData
])
const columnStyle =
typeof width === 'number'
? { width, flex: `0 0 ${width}px`, minWidth: KANBAN_MIN_COLUMN_WIDTH }
: undefined
return (
<Flex vertical className='objectKanbanColumn' style={columnStyle}>
<div className='objectKanbanColumnCards' ref={containerRef}>
<Spin spinning={loading}>
<Flex
vertical
gap='middle'
className='objectKanbanColumnCardsInner'
>
{tableData.map((record) => {
if (record?._id == undefined) return null
const skeletonBoundary =
record._id === skeletonBoundaryIds.next
? 'next'
: record._id === skeletonBoundaryIds.previous
? 'previous'
: undefined
return (
<div
key={record._id}
data-kanban-card-id={record._id}
data-skeleton-boundary={skeletonBoundary}
>
<ObjectCard
isSkeleton={record?.isSkeleton || false}
model={model}
modelProperties={modelProperties}
visibleColumns={visibleColumns}
record={record}
isEditing={isEditing}
rowActions={rowActions}
renderActions={renderActions}
cardStyle='bordered'
lazyLoading={lazyLoading}
/>
</div>
)
})}
</Flex>
</Spin>
</div>
{onResizeStart && (
<div
className={classNames('objectKanbanColumnResizeHandle', {
active: isResizing
})}
role='separator'
aria-orientation='vertical'
aria-label='Resize column'
onMouseDown={(event) => onResizeStart(event)}
/>
)}
</Flex>
)
}
)
ObjectKanbanColumn.displayName = 'ObjectKanbanColumn'
ObjectKanbanColumn.propTypes = {
type: PropTypes.string.isRequired,
categoryProperty: PropTypes.string.isRequired,
categoryValue: PropTypes.any,
baseFilter: PropTypes.object,
masterFilter: PropTypes.object,
sorter: PropTypes.object,
pageSize: PropTypes.number,
model: PropTypes.object.isRequired,
modelProperties: PropTypes.array.isRequired,
visibleColumns: PropTypes.object,
isEditing: PropTypes.bool,
rowActions: PropTypes.array,
lazyLoading: PropTypes.bool,
renderActions: PropTypes.func,
scrollElement: PropTypes.object,
width: PropTypes.number,
isResizing: PropTypes.bool,
onResizeStart: PropTypes.func
}
export default ObjectKanbanColumn