- Added AuthContext to both ObjectKanban and ObjectTimeline components to manage subscriptions based on user authentication status. - Updated useEffect hooks to conditionally subscribe to updates only when the user is connected and authenticated, improving performance and reliability. - Refactored dependency arrays in useEffect hooks to include token checks, ensuring proper cleanup and subscription management.
1100 lines
34 KiB
JavaScript
1100 lines
34 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 { 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 }) => (
|
|
<div
|
|
className={classNames('objectKanbanColumnResizeHandle', { active })}
|
|
role='separator'
|
|
aria-orientation='vertical'
|
|
aria-label='Resize column'
|
|
onMouseDown={onMouseDown}
|
|
/>
|
|
)
|
|
|
|
ObjectKanbanColumnResizeHandle.propTypes = {
|
|
active: PropTypes.bool,
|
|
onMouseDown: PropTypes.func
|
|
}
|
|
|
|
const ObjectKanbanColumnResizeOverlay = ({
|
|
columnKey,
|
|
width,
|
|
columnIndex,
|
|
isResizing,
|
|
onResizeStart
|
|
}) => {
|
|
return (
|
|
<div
|
|
className='objectKanbanColumnResizeOverlay'
|
|
key={columnIndex}
|
|
style={{
|
|
width,
|
|
flex: `0 0 ${width}px`
|
|
}}
|
|
>
|
|
{onResizeStart && columnKey && (
|
|
<ObjectKanbanColumnResizeHandle
|
|
active={isResizing}
|
|
onMouseDown={(event) => onResizeStart(event, columnKey)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const ObjectKanbanColumnSkeleton = ({
|
|
columnKey,
|
|
width,
|
|
cardCount = 0,
|
|
model,
|
|
modelProperties,
|
|
visibleColumns,
|
|
keyPrefix = 'skeleton',
|
|
columnIndex = 0
|
|
}) => (
|
|
<div
|
|
className='objectKanbanColumnSkeleton'
|
|
style={{
|
|
width,
|
|
flex: `0 0 ${width}px`
|
|
}}
|
|
>
|
|
<div className='objectKanbanColumnCards'>
|
|
{cardCount > 0 && (
|
|
<Flex vertical gap='middle' className='objectKanbanColumnCardsInner'>
|
|
{Array.from({ length: cardCount }).map((_, cardIndex) => (
|
|
<ObjectCard
|
|
key={cardIndex}
|
|
isSkeleton
|
|
model={model}
|
|
modelProperties={modelProperties}
|
|
visibleColumns={visibleColumns}
|
|
record={{
|
|
_id: `${keyPrefix}-${columnKey ?? columnIndex}-${cardIndex}`
|
|
}}
|
|
cardStyle='bordered'
|
|
/>
|
|
))}
|
|
</Flex>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
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 (
|
|
<div className='objectKanbanSkeletonColumns objectKanbanSkeletonColumns--loading'>
|
|
<div className='objectKanbanSkeletonTrack' ref={trackRef}>
|
|
<Flex gap='middle' className='objectKanbanSkeletonRow'>
|
|
{skeletonColumns.map((column, columnIndex) => (
|
|
<ObjectKanbanColumnSkeleton
|
|
key={column.key || `${keyPrefix}-${columnIndex}`}
|
|
columnKey={column.key}
|
|
width={column.width}
|
|
cardCount={cardCount}
|
|
model={model}
|
|
modelProperties={modelProperties}
|
|
visibleColumns={visibleColumns}
|
|
keyPrefix={keyPrefix}
|
|
columnIndex={columnIndex}
|
|
isResizing={resizingKey === column.key}
|
|
onResizeStart={onColumnResizeStart}
|
|
/>
|
|
))}
|
|
</Flex>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Flex align='center' justify='center'>
|
|
<MissingPlaceholder
|
|
message='Select a category property to continue.'
|
|
hasBackground={true}
|
|
hasBorder={false}
|
|
height='260px'
|
|
/>
|
|
</Flex>
|
|
)
|
|
}
|
|
|
|
if (hasLoaded && categoryValues.length === 0) {
|
|
return (
|
|
<MissingPlaceholder
|
|
message='No data.'
|
|
hasBackground={true}
|
|
hasBorder={false}
|
|
height='260px'
|
|
/>
|
|
)
|
|
}
|
|
|
|
const showSkeleton = !hasLoaded
|
|
|
|
return (
|
|
<div className='objectKanbanContainerWrapper'>
|
|
<div className='objectKanbanOverlayColumns'>
|
|
<div className='objectKanbanOverlayTrack' ref={overlayTrackRef}>
|
|
<Flex gap='middle' className='objectKanbanOverlayRow'>
|
|
{displayColumns.map(({ key: columnKey, width }, columnIndex) => (
|
|
<ObjectKanbanColumnResizeOverlay
|
|
key={columnKey}
|
|
columnKey={columnKey}
|
|
width={width}
|
|
columnIndex={columnIndex}
|
|
isResizing={resizingKey === columnKey}
|
|
onResizeStart={handleColumnResizeStart}
|
|
/>
|
|
))}
|
|
</Flex>
|
|
</div>
|
|
</div>
|
|
|
|
<Spin spinning={showSkeleton}>
|
|
<div className='objectKanbanContainerSkeletons'>
|
|
<ObjectKanbanHeader
|
|
columns={displayColumns}
|
|
categoryProperty={categoryProperty}
|
|
categoryPropertyDef={categoryPropertyDef}
|
|
trackRef={headerTrackRef}
|
|
skeletonColumnCount={
|
|
showSkeleton ? skeletonLayout.columnCount : 0
|
|
}
|
|
lazyLoading={showSkeleton || lazyLoading}
|
|
loadingColumnKeys={reloadingColumnKeys}
|
|
draggedKey={draggedKey}
|
|
dropTargetKey={dropTargetKey}
|
|
onColumnDragStart={handleColumnDragStart}
|
|
onColumnDragOver={handleColumnDragOver}
|
|
onColumnDrop={handleColumnDrop}
|
|
onColumnDragEnd={handleColumnDragEnd}
|
|
/>
|
|
<div className='objectKanbanKanbanBody' ref={kanbanBodyRef}>
|
|
{!showSkeleton && (
|
|
<div className='objectKanbanContainer'>
|
|
<ScrollBox
|
|
className='objectKanbanScroll'
|
|
scrollableNodeProps={{ ref: setScrollContainerRef }}
|
|
>
|
|
<Flex gap='middle' className='objectKanban'>
|
|
{displayColumns.map(
|
|
({ key: columnKey, value: categoryValue, width }) => (
|
|
<ObjectKanbanColumn
|
|
key={columnKey}
|
|
ref={(node) => {
|
|
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}
|
|
/>
|
|
)
|
|
)}
|
|
</Flex>
|
|
</ScrollBox>
|
|
</div>
|
|
)}
|
|
{showSkeleton ? (
|
|
<ObjectKanbanSkeletonColumns
|
|
columnCount={skeletonLayout.columnCount}
|
|
cardCount={skeletonLayout.cardCount}
|
|
trackRef={skeletonTrackRef}
|
|
model={model}
|
|
modelProperties={modelProperties}
|
|
visibleColumns={visibleColumns}
|
|
/>
|
|
) : (
|
|
<div className='objectKanbanSkeletonColumns'>
|
|
<div
|
|
className='objectKanbanSkeletonTrack'
|
|
ref={skeletonTrackRef}
|
|
>
|
|
<Flex gap='middle' className='objectKanbanSkeletonRow'>
|
|
{displayColumns.map(({ key: columnKey, width }) => (
|
|
<ObjectKanbanColumnSkeleton
|
|
key={columnKey}
|
|
columnKey={columnKey}
|
|
width={width}
|
|
model={model}
|
|
modelProperties={modelProperties}
|
|
visibleColumns={visibleColumns}
|
|
isResizing={resizingKey === columnKey}
|
|
onResizeStart={handleColumnResizeStart}
|
|
/>
|
|
))}
|
|
</Flex>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Spin>
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
|
|
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
|