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 (
{tableData.map((record) => { if (record?._id == undefined) return null const skeletonBoundary = record._id === skeletonBoundaryIds.next ? 'next' : record._id === skeletonBoundaryIds.previous ? 'previous' : undefined return (
) })}
{onResizeStart && (
onResizeStart(event)} /> )} ) } ) 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