- 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.
640 lines
18 KiB
JavaScript
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
|