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 { AuthContext } from '../context/AuthContext' import ObjectCard from './ObjectCard' import ObjectKanbanColumn from './ObjectKanbanColumn' import ObjectKanbanHeader from './ObjectKanbanHeader' import ScrollBox from './ScrollBox' import Spin from './Spin' import { getCategoryColumnKeys, getCategoryValueKey, KANBAN_DEFAULT_COLUMN_WIDTH, KANBAN_MIN_COLUMN_WIDTH, resolveKanbanColumns, toKanbanColumnsConfig } from './viewModeUtils' import { areValuesEqual } from '../utils/Utils' import MissingPlaceholder from './MissingPlaceholder' const KANBAN_COLUMN_WIDTH = KANBAN_DEFAULT_COLUMN_WIDTH const KANBAN_GAP = 16 const KANBAN_CARD_MIN_HEIGHT = 136 const reorderColumnsByKey = (columns, fromKey, toKey) => { if (!fromKey || !toKey || fromKey === toKey) return columns const fromIndex = columns.findIndex((column) => column.key === fromKey) const toIndex = columns.findIndex((column) => column.key === toKey) if (fromIndex < 0 || toIndex < 0) return columns const next = [...columns] const [moved] = next.splice(fromIndex, 1) next.splice(toIndex, 0, moved) return next } const getUpdateKeys = (updated) => { if (!updated || typeof updated !== 'object') return [] return Object.keys(updated).filter( (key) => key !== '_id' && key !== 'objectType' ) } const getAffectingFilterOrSortKeys = ( existingItem, updatedData, filter, masterFilter, sorter, categoryProperty ) => { const updateKeys = getUpdateKeys(updatedData) if (!updateKeys.length) return [] const filterKeys = new Set([ ...Object.keys(filter || {}), ...Object.keys(masterFilter || {}) ]) if (categoryProperty) filterKeys.add(categoryProperty) const isAffectingKey = (key) => filterKeys.has(key) || (sorter?.field && key === sorter.field) // Item not in the current fetch: any filter/sort key in the update // may change whether it belongs in this view. if (!existingItem) { return updateKeys.filter(isAffectingKey) } // Item already fetched: reload when a filter or sort key's value changed. return updateKeys.filter( (key) => isAffectingKey(key) && !areValuesEqual(existingItem[key], updatedData[key]) ) } const getKanbanSkeletonLayout = (width, height) => { const columnCount = Math.max( 1, Math.floor((width + KANBAN_GAP) / (KANBAN_COLUMN_WIDTH + KANBAN_GAP)) ) + 1 const cardCount = Math.max( 1, Math.floor((height - 32) / KANBAN_CARD_MIN_HEIGHT) ) return { columnCount, cardCount } } const ObjectKanbanColumnResizeHandle = ({ active = false, onMouseDown }) => (
) ObjectKanbanColumnResizeHandle.propTypes = { active: PropTypes.bool, onMouseDown: PropTypes.func } const ObjectKanbanColumnResizeOverlay = ({ columnKey, width, columnIndex, isResizing, onResizeStart }) => { return (
{onResizeStart && columnKey && ( onResizeStart(event, columnKey)} /> )}
) } const ObjectKanbanColumnSkeleton = ({ columnKey, width, cardCount = 0, model, modelProperties, visibleColumns, keyPrefix = 'skeleton', columnIndex = 0 }) => (
{cardCount > 0 && ( {Array.from({ length: cardCount }).map((_, cardIndex) => ( ))} )}
) ObjectKanbanColumnSkeleton.propTypes = { columnKey: PropTypes.string, width: PropTypes.number.isRequired, cardCount: PropTypes.number, model: PropTypes.object.isRequired, modelProperties: PropTypes.array.isRequired, visibleColumns: PropTypes.object, keyPrefix: PropTypes.string, columnIndex: PropTypes.number, isResizing: PropTypes.bool, onResizeStart: PropTypes.func } const ObjectKanbanSkeletonColumns = ({ columnCount, cardCount = 0, trackRef, model, modelProperties, visibleColumns, keyPrefix = 'skeleton', columns = null, resizingKey = null, onColumnResizeStart }) => { const skeletonColumns = columns?.length > 0 ? columns : Array.from({ length: columnCount }).map((_, columnIndex) => ({ key: `${keyPrefix}-${columnIndex}`, width: KANBAN_COLUMN_WIDTH })) return (
{skeletonColumns.map((column, columnIndex) => ( ))}
) } ObjectKanbanColumnResizeOverlay.propTypes = { columnKey: PropTypes.string, width: PropTypes.number.isRequired, columnIndex: PropTypes.number, isResizing: PropTypes.bool, onResizeStart: PropTypes.func } ObjectKanbanSkeletonColumns.propTypes = { columnCount: PropTypes.number.isRequired, cardCount: PropTypes.number, trackRef: PropTypes.object, model: PropTypes.object.isRequired, modelProperties: PropTypes.array.isRequired, visibleColumns: PropTypes.object, keyPrefix: PropTypes.string, columns: PropTypes.array, resizingKey: PropTypes.string, onColumnResizeStart: PropTypes.func } const ObjectKanban = forwardRef( ( { type, categoryProperty, baseFilter = {}, masterFilter = {}, sorter = {}, lazyLoading = false, pageSize = 25, model, modelProperties, visibleColumns = {}, isEditing = false, rowActions = [], renderActions, columns: storedColumns = [], onColumnsChange }, ref ) => { const { getModelPropertyValues, connected, subscribeToAllObjectUpdates, subscribeToObjectTypeUpdates } = useContext(ApiServerContext) const { token } = useContext(AuthContext) const columnRefs = useRef({}) const headerTrackRef = useRef(null) const skeletonTrackRef = useRef(null) const overlayTrackRef = useRef(null) const kanbanBodyRef = useRef(null) const [scrollElement, setScrollElement] = useState(null) const [categoryValues, setCategoryValues] = useState([]) const [hasLoaded, setHasLoaded] = useState(false) const [loadedFilter, setLoadedFilter] = useState(baseFilter) const [loadedSorter, setLoadedSorter] = useState(sorter) const [skeletonLayout, setSkeletonLayout] = useState({ columnCount: 3, cardCount: 3 }) const [reloadingColumnKeys, setReloadingColumnKeys] = useState([]) const [draggedKey, setDraggedKey] = useState(null) const [dropTargetKey, setDropTargetKey] = useState(null) const [widthOverrides, setWidthOverrides] = useState({}) const [optimisticColumns, setOptimisticColumns] = useState(null) const [resizingKey, setResizingKey] = useState(null) const baseFilterRef = useRef(baseFilter) const masterFilterRef = useRef(masterFilter) const sorterRef = useRef(sorter) const categoryPropertyRef = useRef(categoryProperty) const getModelPropertyValuesRef = useRef(getModelPropertyValues) const lastCategoryQueryKeyRef = useRef(null) const hasLoadedRef = useRef(false) const loadGenerationRef = useRef(0) const updateEventHandlerRef = useRef() const newEventHandlerRef = useRef() const silentReloadRef = useRef(null) const silentReloadItemColumnsRef = useRef(null) const silentReloadCategoryColumnsRef = useRef(null) const categoryValuesRef = useRef([]) const subscriptionFilterRef = useRef({}) const subscribeToObjectTypeUpdatesRef = useRef(null) const subscribeToAllObjectUpdatesRef = useRef(null) const subscribedTypeRef = useRef(null) const subscribeToObjectTypeUpdatesFnRef = useRef( subscribeToObjectTypeUpdates ) baseFilterRef.current = baseFilter masterFilterRef.current = masterFilter sorterRef.current = sorter categoryPropertyRef.current = categoryProperty getModelPropertyValuesRef.current = getModelPropertyValues subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates categoryValuesRef.current = categoryValues const setScrollContainerRef = useCallback((node) => { setScrollElement(node) }, []) useLayoutEffect(() => { const kanbanBody = kanbanBodyRef.current if (!kanbanBody) return undefined const updateLayout = () => { const { width, height } = kanbanBody.getBoundingClientRect() setSkeletonLayout(getKanbanSkeletonLayout(width, height)) } updateLayout() const observer = new ResizeObserver(updateLayout) observer.observe(kanbanBody) return () => observer.disconnect() }, [categoryProperty]) useLayoutEffect(() => { const mainScroll = scrollElement const headerTrack = headerTrackRef.current const skeletonTrack = skeletonTrackRef.current const overlayTrack = overlayTrackRef.current if (!mainScroll || !headerTrack) return undefined const handleMainScroll = () => { const offset = `translate3d(-${mainScroll.scrollLeft}px, 0, 0)` headerTrack.style.transform = offset if (skeletonTrack) { skeletonTrack.style.transform = offset } if (overlayTrack) { overlayTrack.style.transform = offset } } handleMainScroll() mainScroll.addEventListener('scroll', handleMainScroll, { passive: true }) return () => { mainScroll.removeEventListener('scroll', handleMainScroll) } }, [scrollElement, categoryValues]) const categoryPropertyDef = useMemo( () => model?.properties?.find( (property) => property.name === categoryProperty ), [categoryProperty, model] ) const buildCategoryQueryKey = useCallback( (filter, activeSorter) => JSON.stringify({ type, categoryProperty, filter, masterFilter: masterFilterRef.current, sorter: activeSorter }), [categoryProperty, type] ) const reloadColumns = useCallback(async (columnKeys = null) => { const refs = columnKeys == null ? Object.values(columnRefs.current) : columnKeys.map((key) => columnRefs.current[key]) await Promise.all( refs.filter(Boolean).map((columnRef) => columnRef.reload?.()) ) }, []) const findItem = useCallback((id) => { for (const columnRef of Object.values(columnRefs.current)) { const item = columnRef?.findItem?.(id) if (item) return item } return null }, []) const findItemColumnKeys = useCallback((id) => { return Object.entries(columnRefs.current) .filter(([, columnRef]) => columnRef?.findItem?.(id)) .map(([key]) => key) }, []) const updateItem = useCallback((id, updatedData) => { for (const columnRef of Object.values(columnRefs.current)) { if (columnRef?.findItem?.(id)) { columnRef.updateItem?.(id, updatedData) } } }, []) const load = useCallback( async (filter = null, sorterArg = null, options = {}) => { const silent = options.silent === true const generation = ++loadGenerationRef.current if (!categoryProperty) { lastCategoryQueryKeyRef.current = null hasLoadedRef.current = true categoryValuesRef.current = [] setCategoryValues([]) setHasLoaded(true) return [] } const activeFilter = filter ?? baseFilterRef.current const activeSorter = sorterArg ?? sorterRef.current const activeMasterFilter = masterFilterRef.current const queryKey = buildCategoryQueryKey(activeFilter, activeSorter) const canReuseColumns = lastCategoryQueryKeyRef.current === queryKey && hasLoadedRef.current if (canReuseColumns) { await reloadColumns() if (generation !== loadGenerationRef.current) { return categoryValuesRef.current } hasLoadedRef.current = true setHasLoaded(true) return categoryValuesRef.current } // Filter/sort changes keep current columns visible; objectView / // initial loads clear to skeleton + Spin. if (!silent || !hasLoadedRef.current) { hasLoadedRef.current = false setHasLoaded(false) } try { const values = await getModelPropertyValuesRef.current( type, categoryProperty, { filter: activeFilter, masterFilter: activeMasterFilter } ) if (generation !== loadGenerationRef.current) { return categoryValuesRef.current } const nextValues = Array.isArray(values) ? values : [] lastCategoryQueryKeyRef.current = queryKey hasLoadedRef.current = true setLoadedFilter(activeFilter) setLoadedSorter(activeSorter) categoryValuesRef.current = nextValues setCategoryValues(nextValues) setHasLoaded(true) return nextValues } catch (error) { if (generation !== loadGenerationRef.current) { return categoryValuesRef.current } console.error('Error fetching kanban category values:', error) lastCategoryQueryKeyRef.current = null hasLoadedRef.current = true categoryValuesRef.current = [] setCategoryValues([]) setHasLoaded(true) throw error } }, [buildCategoryQueryKey, categoryProperty, reloadColumns, type] ) const reload = useCallback(async () => { lastCategoryQueryKeyRef.current = null hasLoadedRef.current = false await load(baseFilterRef.current, sorterRef.current) }, [load]) const silentReload = useCallback(async () => { const previousKeys = categoryValuesRef.current.map(getCategoryValueKey) setReloadingColumnKeys(previousKeys) try { lastCategoryQueryKeyRef.current = null const nextValues = await load( baseFilterRef.current, sorterRef.current, { silent: true } ) const nextKeySet = new Set((nextValues || []).map(getCategoryValueKey)) // Only reload columns that existed before; newly created columns // mount and load themselves. const keysToReload = previousKeys.filter((key) => nextKeySet.has(key)) setReloadingColumnKeys(keysToReload) await reloadColumns(keysToReload) } finally { setReloadingColumnKeys([]) } }, [load, reloadColumns]) const silentReloadItemColumns = useCallback( async (id) => { const keysToReload = findItemColumnKeys(id) if (!keysToReload.length) { await silentReload() return } setReloadingColumnKeys(keysToReload) try { await reloadColumns(keysToReload) } finally { setReloadingColumnKeys([]) } }, [findItemColumnKeys, reloadColumns, silentReload] ) const silentReloadCategoryColumns = useCallback( async (existingItem, updatedData) => { const categoryPropertyName = categoryPropertyRef.current const previousKeys = new Set( categoryValuesRef.current.map(getCategoryValueKey) ) const oldKeys = existingItem && categoryPropertyName ? getCategoryColumnKeys(existingItem[categoryPropertyName]) : [] const updatedKeys = categoryPropertyName && updatedData && Object.prototype.hasOwnProperty.call( updatedData, categoryPropertyName ) ? getCategoryColumnKeys(updatedData[categoryPropertyName]).filter( (key) => previousKeys.has(key) ) : [] const tentativeKeys = [...new Set([...oldKeys, ...updatedKeys])] setReloadingColumnKeys(tentativeKeys) try { lastCategoryQueryKeyRef.current = null const nextValues = await load( baseFilterRef.current, sorterRef.current, { silent: true } ) const nextKeySet = new Set( (nextValues || []).map(getCategoryValueKey) ) const keysToReload = [] for (const oldColumnKey of oldKeys) { if (nextKeySet.has(oldColumnKey)) { keysToReload.push(oldColumnKey) } } if ( categoryPropertyName && updatedData && Object.prototype.hasOwnProperty.call( updatedData, categoryPropertyName ) ) { for (const newColumnKey of getCategoryColumnKeys( updatedData[categoryPropertyName] )) { // Newly created columns mount and fetch themselves — skip reload. if ( previousKeys.has(newColumnKey) && nextKeySet.has(newColumnKey) ) { keysToReload.push(newColumnKey) } } } const uniqueKeys = [...new Set(keysToReload)] setReloadingColumnKeys(uniqueKeys) await reloadColumns(uniqueKeys) } finally { setReloadingColumnKeys([]) } }, [load, reloadColumns] ) silentReloadRef.current = silentReload silentReloadItemColumnsRef.current = silentReloadItemColumns silentReloadCategoryColumnsRef.current = silentReloadCategoryColumns const subscriptionFilter = useMemo( () => ({ ...masterFilter, ...baseFilter }), [baseFilter, masterFilter] ) subscriptionFilterRef.current = subscriptionFilter const updateEventHandler = useCallback( (id, updatedData) => { const existingItem = findItem(id) // Always merge into the local item when it already exists. if (existingItem) { updateItem(id, updatedData) } const affectingKeys = getAffectingFilterOrSortKeys( existingItem, updatedData, subscriptionFilterRef.current, {}, sorterRef.current, categoryPropertyRef.current ) if (!affectingKeys.length) return const categoryPropertyName = categoryPropertyRef.current if ( categoryPropertyName && affectingKeys.includes(categoryPropertyName) ) { silentReloadCategoryColumnsRef.current?.(existingItem, updatedData) return } const sorterField = sorterRef.current?.field const onlySortKeyChanged = existingItem && sorterField && affectingKeys.length === 1 && affectingKeys[0] === sorterField if (onlySortKeyChanged) { silentReloadItemColumnsRef.current?.(id) return } silentReloadRef.current?.() }, [findItem, updateItem] ) updateEventHandlerRef.current = updateEventHandler const newEventHandler = useCallback(() => { silentReloadRef.current?.() }, []) newEventHandlerRef.current = newEventHandler useEffect(() => { return () => { if ( connected == true && token && subscribeToObjectTypeUpdatesRef.current ) { subscribeToObjectTypeUpdatesRef.current() subscribeToObjectTypeUpdatesRef.current = null } if ( connected == true && token && subscribeToAllObjectUpdatesRef.current ) { subscribeToAllObjectUpdatesRef.current() subscribeToAllObjectUpdatesRef.current = null } } }, [connected, token]) useEffect(() => { if (connected !== true || !type || !token) return const unsubscribe = subscribeToAllObjectUpdates( type, (id, updateData) => { updateEventHandlerRef.current(id, updateData) } ) subscribeToAllObjectUpdatesRef.current = unsubscribe return () => { if (unsubscribe) unsubscribe() if (subscribeToAllObjectUpdatesRef.current === unsubscribe) { subscribeToAllObjectUpdatesRef.current = null } } }, [type, connected, subscribeToAllObjectUpdates, token]) useEffect(() => { if (connected !== true || !token) return if (subscribedTypeRef.current === type) return const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current( type, subscriptionFilterRef.current, (params) => newEventHandlerRef.current(params) ) subscribeToObjectTypeUpdatesRef.current = unsubscribe subscribedTypeRef.current = type return () => { if (unsubscribe) unsubscribe() if (subscribeToObjectTypeUpdatesRef.current === unsubscribe) { subscribeToObjectTypeUpdatesRef.current = null } if (subscribedTypeRef.current === type) { subscribedTypeRef.current = null } } }, [type, connected, token]) useImperativeHandle( ref, () => ({ load, reload }), [load, reload] ) const resolvedColumns = useMemo(() => { const fromStore = resolveKanbanColumns(categoryValues, storedColumns) if (!optimisticColumns) return fromStore const valueByKey = new Map( fromStore.map((column) => [column.key, column.value]) ) const ordered = [] const seen = new Set() for (const column of optimisticColumns) { if (!valueByKey.has(column.key) || seen.has(column.key)) continue ordered.push({ key: column.key, value: valueByKey.get(column.key), width: column.width }) seen.add(column.key) } for (const column of fromStore) { if (seen.has(column.key)) continue ordered.push(column) } return ordered }, [categoryValues, optimisticColumns, storedColumns]) useEffect(() => { setOptimisticColumns(null) setWidthOverrides({}) setDraggedKey(null) setDropTargetKey(null) setResizingKey(null) }, [categoryProperty]) useEffect(() => { if (!optimisticColumns) return const fromStore = resolveKanbanColumns(categoryValues, storedColumns) if ( JSON.stringify(toKanbanColumnsConfig(fromStore)) === JSON.stringify(toKanbanColumnsConfig(optimisticColumns)) ) { setOptimisticColumns(null) } }, [categoryValues, optimisticColumns, storedColumns]) const displayColumns = useMemo(() => { if (!Object.keys(widthOverrides).length) return resolvedColumns return resolvedColumns.map((column) => widthOverrides[column.key] != null ? { ...column, width: widthOverrides[column.key] } : column ) }, [resolvedColumns, widthOverrides]) const persistColumns = useCallback( (nextColumns) => { setOptimisticColumns(nextColumns) onColumnsChange?.(toKanbanColumnsConfig(nextColumns)) }, [onColumnsChange] ) const handleColumnDragStart = useCallback((event, columnKey) => { event.dataTransfer.effectAllowed = 'move' try { event.dataTransfer.setData('text/plain', columnKey) } catch { // Some browsers restrict setData outside certain types. } setDraggedKey(columnKey) setDropTargetKey(null) }, []) const handleColumnDragOver = useCallback( (event, columnKey) => { if (!draggedKey || draggedKey === columnKey) return event.preventDefault() event.dataTransfer.dropEffect = 'move' setDropTargetKey((prev) => (prev === columnKey ? prev : columnKey)) }, [draggedKey] ) const finishColumnReorder = useCallback( (fromKey, toKey) => { if (!fromKey || !toKey || fromKey === toKey) { setDraggedKey(null) setDropTargetKey(null) return } const nextColumns = reorderColumnsByKey(resolvedColumns, fromKey, toKey) persistColumns(nextColumns) setDraggedKey(null) setDropTargetKey(null) }, [persistColumns, resolvedColumns] ) const handleColumnDrop = useCallback( (event, columnKey) => { event.preventDefault() const fromKey = draggedKey || event.dataTransfer.getData('text/plain') finishColumnReorder(fromKey, columnKey) }, [draggedKey, finishColumnReorder] ) const handleColumnDragEnd = useCallback(() => { setDraggedKey(null) setDropTargetKey(null) }, []) const handleColumnResizeStart = useCallback( (event, columnKey) => { event.preventDefault() event.stopPropagation() const startX = event.clientX const startWidth = displayColumns.find((column) => column.key === columnKey)?.width ?? KANBAN_COLUMN_WIDTH let currentWidth = startWidth setResizingKey(columnKey) const handleMouseMove = (moveEvent) => { currentWidth = Math.max( KANBAN_MIN_COLUMN_WIDTH, startWidth + (moveEvent.clientX - startX) ) setWidthOverrides((prev) => ({ ...prev, [columnKey]: currentWidth })) } const handleMouseUp = () => { const nextColumns = resolvedColumns.map((column) => column.key === columnKey ? { ...column, width: currentWidth } : column ) setWidthOverrides({}) setResizingKey(null) persistColumns(nextColumns) document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) document.body.classList.remove('objectKanbanColumnResizing') } document.body.classList.add('objectKanbanColumnResizing') document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) }, [displayColumns, persistColumns, resolvedColumns] ) if (!categoryProperty) { return ( ) } if (hasLoaded && categoryValues.length === 0) { return ( ) } const showSkeleton = !hasLoaded return (
{displayColumns.map(({ key: columnKey, width }, columnIndex) => ( ))}
{!showSkeleton && (
{displayColumns.map( ({ key: columnKey, value: categoryValue, width }) => ( { if (node) { columnRefs.current[columnKey] = node } else { delete columnRefs.current[columnKey] } }} scrollElement={scrollElement} type={type} categoryProperty={categoryProperty} categoryValue={categoryValue} baseFilter={loadedFilter} masterFilter={masterFilter} sorter={loadedSorter} pageSize={pageSize} model={model} modelProperties={modelProperties} visibleColumns={visibleColumns} isEditing={isEditing} rowActions={rowActions} renderActions={renderActions} width={width} /> ) )}
)} {showSkeleton ? ( ) : (
{displayColumns.map(({ key: columnKey, width }) => ( ))}
)}
) } ) ObjectKanban.displayName = 'ObjectKanban' ObjectKanban.propTypes = { type: PropTypes.string.isRequired, categoryProperty: PropTypes.string, baseFilter: PropTypes.object, masterFilter: PropTypes.object, sorter: PropTypes.object, lazyLoading: PropTypes.bool, pageSize: PropTypes.number, model: PropTypes.object.isRequired, modelProperties: PropTypes.array.isRequired, visibleColumns: PropTypes.object, isEditing: PropTypes.bool, rowActions: PropTypes.array, renderActions: PropTypes.func, columns: PropTypes.arrayOf( PropTypes.shape({ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), width: PropTypes.number }) ), onColumnsChange: PropTypes.func } export default ObjectKanban