All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new timeline view in the ObjectTable component, allowing users to visualize data over time. - Updated view mode utilities to include timeline-specific settings for start and end dates, and color properties. - Enhanced ObjectTableViewButton to manage timeline settings and integrate timeline icon. - Added CSS styles for timeline components to improve layout and visual feedback. - Refactored StateTag component to utilize a utility function for state representation, improving maintainability.
2292 lines
69 KiB
JavaScript
2292 lines
69 KiB
JavaScript
import {
|
|
forwardRef,
|
|
useImperativeHandle,
|
|
useRef,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useState,
|
|
useCallback,
|
|
useMemo,
|
|
createElement
|
|
} from 'react'
|
|
import {
|
|
Table,
|
|
Skeleton,
|
|
Row,
|
|
Col,
|
|
Flex,
|
|
Button,
|
|
Space,
|
|
Form,
|
|
Splitter,
|
|
Card
|
|
} from 'antd'
|
|
import { LoadingOutlined } from '@ant-design/icons'
|
|
import PropTypes from 'prop-types'
|
|
import { useMediaQuery } from 'react-responsive'
|
|
import { useContext } from 'react'
|
|
import { ApiServerContext } from '../context/ApiServerContext'
|
|
import config from '../../../config'
|
|
import loglevel from 'loglevel'
|
|
import {
|
|
getModelProperties,
|
|
getModelByName
|
|
} from '../../../database/ObjectModels'
|
|
import ObjectProperty from './ObjectProperty'
|
|
import ObjectCard from './ObjectCard'
|
|
import ObjectKanban from './ObjectKanban'
|
|
import ObjectTimeline from './ObjectTimeline'
|
|
import FilterSidebar from './FilterSidebar'
|
|
import SortSidebar from './SortSidebar'
|
|
import CheckIcon from '../../Icons/CheckIcon'
|
|
import { useLocation } from 'react-router-dom'
|
|
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
|
|
import { AuthContext } from '../context/AuthContext'
|
|
import { ElectronContext } from '../context/ElectronContext'
|
|
import { useActions } from '../context/ActionsContext'
|
|
import ActionsIcon from '../../Icons/ActionsIcon'
|
|
import FilterIcon from '../../Icons/FilterIcon'
|
|
import ScrollBox from './ScrollBox'
|
|
import Spin from './Spin'
|
|
import SimplePropertyFilter from './SimplePropertyFilter'
|
|
import QuickPropertyFilters from './QuickPropertyFilters'
|
|
import FilterInput from './FilterInput'
|
|
import {
|
|
getActiveFilterValues,
|
|
useTableState,
|
|
useTableStatePersistence
|
|
} from '../context/TableStateContext'
|
|
import { hasActionPermission } from '../../../database/permissions'
|
|
import Tooltip from './Tooltip'
|
|
import { ObjectTableFilterContext } from './ObjectTableFilterContext'
|
|
import ObjectListViewContext from '../context/ObjectListViewContext'
|
|
import {
|
|
isCardsView,
|
|
isKanbanView,
|
|
isTimelineView,
|
|
normalizeViewMode
|
|
} from './viewModeUtils'
|
|
import { areValuesEqual } from '../utils/Utils'
|
|
import MissingPlaceholder from './MissingPlaceholder'
|
|
import LoadingPlaceholder from './LoadingPlaceholder'
|
|
|
|
const logger = loglevel.getLogger('DasboardTable')
|
|
logger.setLevel(config.logLevel)
|
|
|
|
const SCROLL_THRESHOLD = 50
|
|
const SKELETON_HEIGHT = 49.5
|
|
const EMPTY_MASTER_FILTER = {}
|
|
|
|
const getCardColSpan = (containerWidth) => {
|
|
if (containerWidth >= 2980) return 2
|
|
if (containerWidth >= 1780) return 4
|
|
if (containerWidth >= 1080) return 8
|
|
if (containerWidth >= 680) return 12
|
|
return 24
|
|
}
|
|
|
|
const toFilterExpression = (values) => {
|
|
if (!values?.length) return undefined
|
|
const parts = values.map((value) => {
|
|
if (value && typeof value === 'object') {
|
|
return String(value._id ?? value.type ?? JSON.stringify(value))
|
|
}
|
|
return String(value)
|
|
})
|
|
return parts.length === 1 ? parts[0] : parts.join('|')
|
|
}
|
|
|
|
const fromFilterExpression = (expr) => {
|
|
if (expr === undefined || expr === null || expr === '') return null
|
|
if (typeof expr === 'string' && expr.includes('|')) {
|
|
return expr.split('|').filter((part) => part !== '')
|
|
}
|
|
return [expr]
|
|
}
|
|
|
|
const idsEqual = (a, b) => {
|
|
if (a == null || b == null) return false
|
|
return String(a).toLowerCase() === String(b).toLowerCase()
|
|
}
|
|
|
|
const getUpdateKeys = (updated) => {
|
|
if (!updated || typeof updated !== 'object') return []
|
|
return Object.keys(updated).filter(
|
|
(key) => key !== '_id' && key !== 'objectType'
|
|
)
|
|
}
|
|
|
|
const updateAffectsFilterOrSort = (
|
|
existingItem,
|
|
updatedData,
|
|
filter,
|
|
masterFilter,
|
|
sorter
|
|
) => {
|
|
const updateKeys = getUpdateKeys(updatedData)
|
|
if (!updateKeys.length) return false
|
|
|
|
const filterKeys = new Set([
|
|
...Object.keys(filter || {}),
|
|
...Object.keys(masterFilter || {})
|
|
])
|
|
|
|
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.some(isAffectingKey)
|
|
}
|
|
|
|
// Item already fetched: reload when a filter or sort key's value changed.
|
|
return updateKeys.some(
|
|
(key) =>
|
|
isAffectingKey(key) &&
|
|
!areValuesEqual(existingItem[key], updatedData[key])
|
|
)
|
|
}
|
|
|
|
const ColumnFilterDropdown = ({
|
|
setSelectedKeys,
|
|
selectedKeys,
|
|
confirm,
|
|
clearFilters,
|
|
visible,
|
|
propertyName,
|
|
propertyLabel,
|
|
modelType,
|
|
filter = {},
|
|
masterFilter = {}
|
|
}) => {
|
|
const [expression, setExpression] = useState('')
|
|
const [draft, setDraft] = useState(selectedKeys || [])
|
|
const selectedKeysRef = useRef(selectedKeys)
|
|
selectedKeysRef.current = selectedKeys
|
|
|
|
// Re-sync from the applied sidebar/column filter each time the dropdown opens
|
|
useEffect(() => {
|
|
if (!visible) return
|
|
const keys = selectedKeysRef.current || []
|
|
setDraft(keys)
|
|
setExpression(toFilterExpression(keys) ?? '')
|
|
}, [visible])
|
|
|
|
const handleDraftChange = (next) => {
|
|
setDraft(next)
|
|
setExpression(toFilterExpression(next) ?? '')
|
|
}
|
|
|
|
const handleExpressionChange = (text) => {
|
|
setExpression(text)
|
|
setDraft(fromFilterExpression(text) || [])
|
|
}
|
|
|
|
const applyFilter = () => {
|
|
const trimmed = expression.trim()
|
|
if (!trimmed) {
|
|
clearFilters()
|
|
} else if (draft?.length) {
|
|
setSelectedKeys(draft)
|
|
} else {
|
|
setSelectedKeys([trimmed])
|
|
}
|
|
confirm()
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: 8 }}>
|
|
<Flex vertical gap='small'>
|
|
<Space.Compact>
|
|
<FilterInput
|
|
placeholder={'Filter ' + propertyLabel}
|
|
value={expression}
|
|
onChange={handleExpressionChange}
|
|
onPressEnter={applyFilter}
|
|
style={{ width: 260 }}
|
|
/>
|
|
<Button type='primary' onClick={applyFilter} icon={<CheckIcon />} />
|
|
</Space.Compact>
|
|
<QuickPropertyFilters
|
|
modelType={modelType}
|
|
propertyName={propertyName}
|
|
value={draft}
|
|
onChange={handleDraftChange}
|
|
/>
|
|
<Card size='small' styles={{ body: { padding: 0, height: 200 } }}>
|
|
<ScrollBox inner={true} smallPadding={true}>
|
|
<div style={{ padding: '18px 20px', minWidth: 0 }}>
|
|
<SimplePropertyFilter
|
|
modelType={modelType}
|
|
propertyName={propertyName}
|
|
value={draft}
|
|
onChange={handleDraftChange}
|
|
filter={filter}
|
|
masterFilter={masterFilter}
|
|
visible={visible}
|
|
/>
|
|
</div>
|
|
</ScrollBox>
|
|
</Card>
|
|
</Flex>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
ColumnFilterDropdown.propTypes = {
|
|
setSelectedKeys: PropTypes.func,
|
|
selectedKeys: PropTypes.array,
|
|
confirm: PropTypes.func,
|
|
clearFilters: PropTypes.func,
|
|
visible: PropTypes.bool,
|
|
propertyName: PropTypes.string,
|
|
propertyLabel: PropTypes.string,
|
|
modelType: PropTypes.string,
|
|
filter: PropTypes.object,
|
|
masterFilter: PropTypes.object
|
|
}
|
|
|
|
const RowForm = ({ record, isEditing, onRegister, children }) => {
|
|
const [form] = Form.useForm()
|
|
useEffect(() => {
|
|
if (isEditing && record && !record.isSkeleton) {
|
|
form.setFieldsValue(record)
|
|
onRegister(record._id, form)
|
|
}
|
|
return () => {
|
|
if (record?._id) onRegister(record._id, null)
|
|
}
|
|
}, [isEditing, record, form, onRegister])
|
|
|
|
return (
|
|
<Form form={form} component={false}>
|
|
{children}
|
|
</Form>
|
|
)
|
|
}
|
|
|
|
RowForm.propTypes = {
|
|
record: PropTypes.object,
|
|
isEditing: PropTypes.bool,
|
|
onRegister: PropTypes.func,
|
|
children: PropTypes.node
|
|
}
|
|
|
|
const EditableRow = ({ record, isEditing, onRegister, ...props }) => {
|
|
return (
|
|
<RowForm record={record} isEditing={isEditing} onRegister={onRegister}>
|
|
<tr {...props} />
|
|
</RowForm>
|
|
)
|
|
}
|
|
|
|
EditableRow.propTypes = {
|
|
record: PropTypes.object,
|
|
isEditing: PropTypes.bool,
|
|
onRegister: PropTypes.func
|
|
}
|
|
|
|
const ObjectTable = forwardRef(
|
|
(
|
|
{
|
|
type,
|
|
pageSize = 25,
|
|
scrollHeight = 'calc(var(--unit-100vh) - 258px)',
|
|
onDataChange,
|
|
initialPage = 1,
|
|
viewMode: viewModeProp,
|
|
cards = false,
|
|
visibleColumns = {},
|
|
masterFilter,
|
|
size = 'middle',
|
|
onStateChange,
|
|
showFilterSidebar = false,
|
|
showSortSidebar = false,
|
|
expandHeight = false,
|
|
showActions = true,
|
|
saveFilterInSession = false,
|
|
saveFilterInUrl = false,
|
|
useFilterInSession = false,
|
|
useFilterInUrl = false,
|
|
saveSortInSession = false,
|
|
saveSortInUrl = false,
|
|
useSortInSession = false,
|
|
useSortInUrl = false,
|
|
onRowAction
|
|
},
|
|
ref
|
|
) => {
|
|
const objectListView = useContext(ObjectListViewContext)
|
|
const viewMode = normalizeViewMode(
|
|
viewModeProp ??
|
|
objectListView?.viewMode ??
|
|
(cards ? { type: 'cards' } : { type: 'list' })
|
|
)
|
|
const isCards = isCardsView(viewMode)
|
|
const isKanban = isKanbanView(viewMode)
|
|
const isTimeline = isTimelineView(viewMode)
|
|
const kanbanRef = useRef(null)
|
|
const prevViewModeKeyRef = useRef(null)
|
|
const { token, userProfile } = useContext(AuthContext)
|
|
const { isElectron } = useContext(ElectronContext)
|
|
const { callAction } = useActions()
|
|
const resolvedMasterFilter = masterFilter ?? EMPTY_MASTER_FILTER
|
|
const listViewId = objectListView?.listViewId ?? null
|
|
const listViewFilter = objectListView?.listViewFilter ?? null
|
|
const listViewSort = objectListView?.listViewSort ?? null
|
|
const activeObjectView = objectListView?.activeObjectView ?? null
|
|
const userOverridesRef = objectListView?.userOverridesRef ?? null
|
|
const onObjectViewFilterSortChange = objectListView?.isEditing
|
|
? objectListView.handleFilterSortChange
|
|
: null
|
|
const onStateChangeRef = useRef(onStateChange)
|
|
useEffect(() => {
|
|
onStateChangeRef.current = onStateChange
|
|
}, [onStateChange])
|
|
const {
|
|
fetchObjects,
|
|
connected,
|
|
subscribeToAllObjectUpdates,
|
|
subscribeToObjectTypeUpdates,
|
|
updateMultipleObjects,
|
|
setObjectActivity,
|
|
clearObjectActivity
|
|
} = useContext(ApiServerContext)
|
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
|
const location = useLocation()
|
|
const {
|
|
getPersistedFilter,
|
|
getPersistedSorter,
|
|
getAllTabPersistedFilter,
|
|
getAllTabPersistedSorter,
|
|
persistFilter,
|
|
persistSort,
|
|
persistTableState,
|
|
persistAllTabTableState,
|
|
registerPageFilter,
|
|
registerPageSorter,
|
|
registerObjectListFilter,
|
|
registerObjectListSorter
|
|
} = useTableStatePersistence({
|
|
scope: type,
|
|
viewId: listViewId,
|
|
pagePath: location.pathname,
|
|
saveFilterInSession,
|
|
saveFilterInUrl,
|
|
useFilterInSession,
|
|
useFilterInUrl,
|
|
saveSortInSession,
|
|
saveSortInUrl,
|
|
useSortInSession,
|
|
useSortInUrl
|
|
})
|
|
const { getViewFromUrl } = useTableState()
|
|
var adjustedScrollHeight = scrollHeight
|
|
if (isMobile) {
|
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 298px)'
|
|
}
|
|
if (isCards || isKanban || isTimeline) {
|
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 210px)'
|
|
}
|
|
if (isElectron) {
|
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 238px)'
|
|
}
|
|
if (isMobile && isElectron) {
|
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 282px)'
|
|
}
|
|
if ((isCards || isKanban || isTimeline) && isElectron) {
|
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 258px)'
|
|
}
|
|
const tableRef = useRef(null)
|
|
const model = getModelByName(type)
|
|
const activeFilterRef = useRef({})
|
|
const tableSorterRef = useRef({})
|
|
const listViewIdRef = useRef(listViewId)
|
|
const listViewFilterRef = useRef(listViewFilter)
|
|
const sidebarViewIdRef = useRef(listViewId)
|
|
const activeObjectViewRef = useRef(activeObjectView)
|
|
activeObjectViewRef.current = activeObjectView
|
|
const onObjectViewFilterSortChangeRef = useRef(onObjectViewFilterSortChange)
|
|
onObjectViewFilterSortChangeRef.current = onObjectViewFilterSortChange
|
|
const [sidebarFilter, setSidebarFilter] = useState({})
|
|
const [tableSorter, setTableSorter] = useState({})
|
|
const [initialized, setInitialized] = useState(false)
|
|
|
|
// Keep refs in sync with props. applyViewState may update them earlier in
|
|
// the same tick; props remain the source of truth after commit.
|
|
useEffect(() => {
|
|
listViewIdRef.current = listViewId
|
|
listViewFilterRef.current = listViewFilter || {}
|
|
}, [listViewId, listViewFilter])
|
|
|
|
const assignSidebarToView = useCallback((viewId) => {
|
|
sidebarViewIdRef.current = viewId ?? null
|
|
}, [])
|
|
|
|
const lastAppliedViewKeyRef = useRef(null)
|
|
|
|
const buildEffectiveFilter = useCallback(
|
|
(userFilter, viewFilter = listViewFilterRef.current) => {
|
|
const active = getActiveFilterValues(userFilter)
|
|
const viewId = listViewIdRef.current
|
|
// While editing a view, the sidebar IS the view filter. Merging the
|
|
// previous definition would put removed keys back.
|
|
if (!viewId || activeObjectViewRef.current) return active
|
|
return {
|
|
...getActiveFilterValues(viewFilter || {}),
|
|
...active
|
|
}
|
|
},
|
|
[]
|
|
)
|
|
|
|
// Table state
|
|
const [pages, setPages] = useState([])
|
|
const pagesRef = useRef(pages)
|
|
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)) {
|
|
logger.warn('Duplicate table row omitted:', id)
|
|
return false
|
|
}
|
|
seen.add(id)
|
|
return true
|
|
})
|
|
}, [pages])
|
|
const [loading, setLoading] = useState(true)
|
|
const [lazyLoading, setLazyLoading] = useState(false)
|
|
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
const [editLoading, setEditLoading] = useState(false)
|
|
const rowFormsRef = useRef({})
|
|
const registerForm = useCallback((id, form) => {
|
|
if (form) {
|
|
rowFormsRef.current[id] = form
|
|
} else {
|
|
delete rowFormsRef.current[id]
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
onStateChangeRef.current?.({ isEditing, editLoading })
|
|
}, [isEditing, editLoading])
|
|
|
|
const updateEventHandlerRef = useRef()
|
|
const subscribeToObjectTypeUpdatesRef = useRef(null)
|
|
const subscribedTypeRef = useRef(null)
|
|
const subscribeToAllObjectUpdatesRef = useRef(null)
|
|
const newEventHandlerRef = useRef()
|
|
const subscriptionFilterRef = useRef()
|
|
const effectiveSorterRef = useRef({})
|
|
const reloadRef = useRef(null)
|
|
const silentReloadRef = useRef(null)
|
|
const subscribeToObjectTypeUpdatesFnRef = useRef(
|
|
subscribeToObjectTypeUpdates
|
|
)
|
|
const prevValuesRef = useRef({ type, masterFilter: resolvedMasterFilter })
|
|
|
|
const getMasterFilter = useCallback(
|
|
() => resolvedMasterFilter || EMPTY_MASTER_FILTER,
|
|
[resolvedMasterFilter]
|
|
)
|
|
|
|
const resolveSorter = useCallback((userSorter) => {
|
|
if (userSorter?.field && userSorter?.order) {
|
|
return { field: userSorter.field, order: userSorter.order }
|
|
}
|
|
return {}
|
|
}, [])
|
|
|
|
const rowActions =
|
|
model.actions?.filter((action) => action.row == true) || []
|
|
|
|
const createSkeletonData = useCallback(
|
|
(pageNum) => {
|
|
return Array(pageSize)
|
|
.fill(null)
|
|
.map((_, index) => ({
|
|
_id: `skeleton-${pageNum}-${index}`,
|
|
isSkeleton: true
|
|
}))
|
|
},
|
|
[pageSize]
|
|
)
|
|
|
|
const createSkeletonPage = useCallback(
|
|
(pageNum) => ({
|
|
pageNum,
|
|
items: createSkeletonData(pageNum),
|
|
isSkeletonPage: true
|
|
}),
|
|
[createSkeletonData]
|
|
)
|
|
|
|
const loadingPagesRef = useRef(new Set())
|
|
const pendingScrollAnchorRef = useRef(null)
|
|
const dataLoadGenerationRef = useRef(0)
|
|
const [tableListKey, setTableListKey] = useState(0)
|
|
// Suppress Ant Design Table onChange while we remount / set filter+sort
|
|
// programmatically. Remounting with controlled filteredValue often fires
|
|
// onChange with all-null filters (sorter intact) and would wipe the view.
|
|
const isInternalTableChangeRef = useRef(false)
|
|
const internalTableChangeClearRafRef = useRef(null)
|
|
|
|
const suppressTableChange = useCallback(() => {
|
|
isInternalTableChangeRef.current = true
|
|
if (internalTableChangeClearRafRef.current != null) {
|
|
cancelAnimationFrame(internalTableChangeClearRafRef.current)
|
|
}
|
|
// Double rAF: wait until after the remounted Table commits and any
|
|
// synchronous/mount onChange from Ant Design has run.
|
|
internalTableChangeClearRafRef.current = requestAnimationFrame(() => {
|
|
internalTableChangeClearRafRef.current = requestAnimationFrame(() => {
|
|
internalTableChangeClearRafRef.current = null
|
|
isInternalTableChangeRef.current = false
|
|
})
|
|
})
|
|
}, [])
|
|
|
|
const beginDataReload = useCallback(() => {
|
|
dataLoadGenerationRef.current += 1
|
|
loadingPagesRef.current.clear()
|
|
pendingScrollAnchorRef.current = null
|
|
pagesRef.current = []
|
|
suppressTableChange()
|
|
setTableListKey((key) => key + 1)
|
|
return dataLoadGenerationRef.current
|
|
}, [suppressTableChange])
|
|
|
|
const isStaleDataLoad = useCallback((generation) => {
|
|
return generation !== dataLoadGenerationRef.current
|
|
}, [])
|
|
|
|
const clearTablePages = useCallback(() => {
|
|
pagesRef.current = []
|
|
setPages([])
|
|
}, [])
|
|
|
|
const setTablePages = useCallback((nextPages) => {
|
|
pagesRef.current = nextPages
|
|
setPages(nextPages)
|
|
}, [])
|
|
|
|
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 renderActions = (objectData, actionsDisabled = false) => {
|
|
return (
|
|
<Flex gap='small' align='center' justify='center'>
|
|
{rowActions.map((action, index) => {
|
|
const denied = !hasActionPermission(userProfile, model, action.name)
|
|
var disabled = denied
|
|
if (action.disabled) {
|
|
if (typeof action.disabled === 'function') {
|
|
disabled =
|
|
denied ||
|
|
action.disabled({
|
|
...objectData,
|
|
_user: userProfile
|
|
}) ||
|
|
actionsDisabled
|
|
} else {
|
|
disabled = denied || action.disabled || actionsDisabled
|
|
}
|
|
}
|
|
return (
|
|
<Tooltip key={index} title={action.label}>
|
|
<Button
|
|
icon={
|
|
action.icon ? (
|
|
createElement(action.icon)
|
|
) : (
|
|
<QuestionCircleIcon />
|
|
)
|
|
}
|
|
disabled={disabled || objectData?.isSkeleton}
|
|
type={'text'}
|
|
size={'small'}
|
|
danger={action?.danger || false}
|
|
onClick={() => {
|
|
if (denied) return
|
|
if (
|
|
onRowAction &&
|
|
onRowAction(action, objectData) !== false
|
|
) {
|
|
return
|
|
}
|
|
callAction(
|
|
action,
|
|
{ ...objectData, _user: userProfile },
|
|
type
|
|
)
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
)
|
|
})}
|
|
</Flex>
|
|
)
|
|
}
|
|
|
|
const fetchPage = useCallback(
|
|
async (pageNum = 1, filter = null, sorter = null) => {
|
|
if (filter == null) {
|
|
filter = activeFilterRef.current
|
|
} else {
|
|
activeFilterRef.current = filter
|
|
}
|
|
if (sorter == null) {
|
|
sorter = tableSorterRef.current
|
|
} else {
|
|
tableSorterRef.current = {
|
|
field: sorter.field,
|
|
order: sorter.order
|
|
}
|
|
}
|
|
return fetchObjects(type, {
|
|
page: pageNum,
|
|
limit: pageSize,
|
|
filter: { ...getMasterFilter(), ...filter },
|
|
sorter: resolveSorter(sorter),
|
|
onDataChange
|
|
})
|
|
},
|
|
[
|
|
type,
|
|
getMasterFilter,
|
|
pageSize,
|
|
onDataChange,
|
|
fetchObjects,
|
|
resolveSorter
|
|
]
|
|
)
|
|
|
|
const skeletonsHeight = useMemo(() => {
|
|
return SKELETON_HEIGHT * pageSize
|
|
}, [pageSize])
|
|
|
|
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])
|
|
|
|
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 fetchData = useCallback(
|
|
async (pageNum = 1, filter = null, sorter = null) => {
|
|
const generation = dataLoadGenerationRef.current
|
|
try {
|
|
const result = await fetchPage(pageNum, filter, sorter)
|
|
if (isStaleDataLoad(generation)) return []
|
|
const loadedPage = {
|
|
pageNum,
|
|
items: result.data || [],
|
|
hasMore: result.hasMore
|
|
}
|
|
setPages((prev) =>
|
|
prev.map((page) => (page.pageNum === pageNum ? loadedPage : page))
|
|
)
|
|
setLoading(false)
|
|
return result.data || []
|
|
} catch (error) {
|
|
if (!isStaleDataLoad(generation)) {
|
|
setLoading(false)
|
|
}
|
|
throw error
|
|
}
|
|
},
|
|
[fetchPage, isStaleDataLoad]
|
|
)
|
|
|
|
const findRenderedRow = useCallback((scrollTarget, id) => {
|
|
if (!scrollTarget || id == null) return null
|
|
return Array.from(scrollTarget.querySelectorAll('tr[data-row-key]')).find(
|
|
(row) => row.getAttribute('data-row-key') === String(id)
|
|
)
|
|
}, [])
|
|
|
|
const captureScrollAnchor = useCallback(
|
|
(scrollTarget, placeholderPage, direction, currentPages) => {
|
|
if (!scrollTarget) return null
|
|
|
|
const targetTop = scrollTarget.getBoundingClientRect().top
|
|
const closestRow = (items) => {
|
|
return items
|
|
.map((item, index) => {
|
|
const element = findRenderedRow(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 = closestRow(placeholderPage.items)
|
|
const loadedPages = currentPages.filter((page) => !page.isSkeletonPage)
|
|
const retainedPage =
|
|
direction === 'previous'
|
|
? loadedPages[0]
|
|
: loadedPages[loadedPages.length - 1]
|
|
const fallbackAnchor = retainedPage
|
|
? closestRow(retainedPage.items)
|
|
: null
|
|
|
|
return { placeholderAnchor, fallbackAnchor }
|
|
},
|
|
[findRenderedRow]
|
|
)
|
|
|
|
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 = findRenderedRow(scrollTarget, anchorId)
|
|
if (!element) return
|
|
const renderedTop = element.getBoundingClientRect().top
|
|
scrollTarget.scrollTop += renderedTop - previousTop
|
|
},
|
|
[findRenderedRow]
|
|
)
|
|
|
|
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)
|
|
setLazyLoading(true)
|
|
logger.debug(`Loading ${direction} page...`)
|
|
|
|
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) {
|
|
logger.error(`Error loading page ${pageNum}:`, error)
|
|
} finally {
|
|
loadingPagesRef.current.delete(pageNum)
|
|
if (
|
|
!isStaleDataLoad(generation) &&
|
|
loadingPagesRef.current.size === 0
|
|
) {
|
|
setLazyLoading(false)
|
|
}
|
|
}
|
|
},
|
|
[
|
|
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 handleScroll = useCallback(
|
|
(e) => {
|
|
const { target } = e
|
|
const firstPage = pagesRef.current[0]
|
|
const lastPage = pagesRef.current[pagesRef.current.length - 1]
|
|
|
|
if (
|
|
lastPage?.isSkeletonPage &&
|
|
target.scrollHeight - target.scrollTop - target.clientHeight <=
|
|
skeletonsHeight + SCROLL_THRESHOLD
|
|
) {
|
|
loadNextPage(target)
|
|
} else if (
|
|
firstPage?.isSkeletonPage &&
|
|
target.scrollTop <= skeletonsHeight + SCROLL_THRESHOLD
|
|
) {
|
|
loadPreviousPage(target)
|
|
}
|
|
},
|
|
[loadNextPage, loadPreviousPage, skeletonsHeight]
|
|
)
|
|
|
|
const reloadLoadedPages = useCallback(
|
|
async ({ silent = false } = {}) => {
|
|
if (isKanban) {
|
|
await kanbanRef.current?.reload?.()
|
|
return
|
|
}
|
|
if (!silent) {
|
|
setLazyLoading(true)
|
|
}
|
|
try {
|
|
const loadedPages = pagesRef.current.filter(
|
|
(page) => !page.isSkeletonPage
|
|
)
|
|
for (let i = 0; i < loadedPages.length; i++) {
|
|
await fetchData(loadedPages[i].pageNum)
|
|
}
|
|
} finally {
|
|
if (!silent) {
|
|
setLazyLoading(false)
|
|
}
|
|
}
|
|
},
|
|
[fetchData, isKanban]
|
|
)
|
|
|
|
const reload = useCallback(() => reloadLoadedPages(), [reloadLoadedPages])
|
|
const silentReload = useCallback(
|
|
() => reloadLoadedPages({ silent: true }),
|
|
[reloadLoadedPages]
|
|
)
|
|
|
|
reloadRef.current = reload
|
|
silentReloadRef.current = silentReload
|
|
|
|
const startEditing = useCallback(() => {
|
|
setIsEditing(true)
|
|
tableData.forEach((item) => {
|
|
if (!item.isSkeleton) {
|
|
setObjectActivity(item._id, type, 'editing')
|
|
}
|
|
})
|
|
}, [tableData, setObjectActivity, type])
|
|
|
|
const cancelEditing = useCallback(() => {
|
|
setIsEditing(false)
|
|
tableData.forEach((item) => {
|
|
if (!item.isSkeleton) {
|
|
clearObjectActivity(item._id, type)
|
|
}
|
|
})
|
|
}, [tableData, clearObjectActivity, type])
|
|
|
|
const handleUpdate = useCallback(async () => {
|
|
setEditLoading(true)
|
|
try {
|
|
const updates = await Promise.all(
|
|
Object.entries(rowFormsRef.current).map(async ([id, form]) => {
|
|
const values = await form.validateFields()
|
|
return { _id: id, ...values }
|
|
})
|
|
)
|
|
await updateMultipleObjects(type, updates)
|
|
setIsEditing(false)
|
|
reload()
|
|
tableData.forEach((item) => {
|
|
if (!item.isSkeleton) {
|
|
clearObjectActivity(item._id, type)
|
|
}
|
|
})
|
|
} catch (err) {
|
|
logger.error('Error updating objects:', err)
|
|
} finally {
|
|
setEditLoading(false)
|
|
}
|
|
}, [type, updateMultipleObjects, reload, tableData, clearObjectActivity])
|
|
|
|
const findTableItem = 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
|
|
}, [])
|
|
|
|
// Update event handler for real-time updates
|
|
const updateEventHandler = useCallback(
|
|
(id, updatedData) => {
|
|
const existingItem = findTableItem(id)
|
|
|
|
// Always merge into the local item when it already exists.
|
|
if (existingItem) {
|
|
setPages((prevPages) =>
|
|
prevPages.map((page) => {
|
|
const updatedItems = page.items.map((item) => {
|
|
if (idsEqual(item._id, id)) {
|
|
// Keep the row's original _id casing; update payloads often
|
|
// arrive lowercased from the NATS subject.
|
|
return { ...item, ...updatedData, _id: item._id }
|
|
}
|
|
return item
|
|
})
|
|
return {
|
|
...page,
|
|
items: updatedItems
|
|
}
|
|
})
|
|
)
|
|
|
|
if (rowFormsRef.current[id]) {
|
|
rowFormsRef.current[id].setFieldsValue(updatedData)
|
|
} else if (
|
|
existingItem._id != null &&
|
|
rowFormsRef.current[existingItem._id]
|
|
) {
|
|
rowFormsRef.current[existingItem._id].setFieldsValue(updatedData)
|
|
}
|
|
}
|
|
|
|
if (
|
|
updateAffectsFilterOrSort(
|
|
existingItem,
|
|
updatedData,
|
|
subscriptionFilterRef.current,
|
|
{},
|
|
effectiveSorterRef.current
|
|
)
|
|
) {
|
|
silentReloadRef.current?.()
|
|
}
|
|
},
|
|
[findTableItem]
|
|
)
|
|
|
|
// Store the latest updateEventHandler in a ref
|
|
updateEventHandlerRef.current = updateEventHandler
|
|
|
|
const newEventHandler = useCallback((params) => {
|
|
logger.debug('New event handler:', params)
|
|
silentReloadRef.current?.()
|
|
}, [])
|
|
|
|
// Effective filter for ObjectTableFilterContext, breadcrumb, and neighbors:
|
|
// view definition + any user-entered sidebar filters.
|
|
const effectiveFilter = useMemo(() => {
|
|
const userFilter = getActiveFilterValues(sidebarFilter)
|
|
const sidebarMatchesView = sidebarViewIdRef.current === listViewId
|
|
|
|
if (!listViewId || activeObjectView) {
|
|
if (!listViewId && !sidebarMatchesView) return {}
|
|
return userFilter
|
|
}
|
|
|
|
const viewFilter = getActiveFilterValues(listViewFilter || {})
|
|
if (!sidebarMatchesView) {
|
|
return viewFilter
|
|
}
|
|
return {
|
|
...viewFilter,
|
|
...userFilter
|
|
}
|
|
}, [activeObjectView, listViewId, listViewFilter, sidebarFilter])
|
|
|
|
const effectiveSorter = useMemo(() => {
|
|
const sorterMatchesView = sidebarViewIdRef.current === listViewId
|
|
if (sorterMatchesView && tableSorter?.field && tableSorter?.order) {
|
|
return { field: tableSorter.field, order: tableSorter.order }
|
|
}
|
|
if (listViewId && listViewSort?.field && listViewSort?.order) {
|
|
return { field: listViewSort.field, order: listViewSort.order }
|
|
}
|
|
return {}
|
|
}, [listViewId, listViewSort, tableSorter])
|
|
|
|
const subscriptionFilter = useMemo(
|
|
() => ({ ...resolvedMasterFilter, ...effectiveFilter }),
|
|
[effectiveFilter, resolvedMasterFilter]
|
|
)
|
|
|
|
const tableFilterContextValue = useMemo(
|
|
() => ({
|
|
filter: effectiveFilter,
|
|
masterFilter: resolvedMasterFilter
|
|
}),
|
|
[effectiveFilter, resolvedMasterFilter]
|
|
)
|
|
|
|
newEventHandlerRef.current = newEventHandler
|
|
subscriptionFilterRef.current = subscriptionFilter
|
|
effectiveSorterRef.current = effectiveSorter
|
|
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
|
|
|
|
// Cleanup subscriptions on unmount. Kanban owns its own subscriptions.
|
|
useEffect(() => {
|
|
return () => {
|
|
if (connected == true && subscribeToObjectTypeUpdatesRef.current) {
|
|
subscribeToObjectTypeUpdatesRef.current()
|
|
subscribeToObjectTypeUpdatesRef.current = null
|
|
}
|
|
if (connected == true && subscribeToAllObjectUpdatesRef.current) {
|
|
subscribeToAllObjectUpdatesRef.current()
|
|
subscribeToAllObjectUpdatesRef.current = null
|
|
}
|
|
}
|
|
}, [connected])
|
|
|
|
// Subscribe to all object updates for this type (list/cards only)
|
|
useEffect(() => {
|
|
if (isKanban || connected !== true || !type) 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, isKanban])
|
|
|
|
useEffect(() => {
|
|
if (isKanban || connected !== true) 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, isKanban])
|
|
|
|
const updateData = useCallback(
|
|
(id, updatedData) => {
|
|
updateEventHandler(id, updatedData)
|
|
},
|
|
[updateEventHandler]
|
|
)
|
|
|
|
const loadPage = useCallback(
|
|
async (pageNum, filter = null, sorter = null, options = {}) => {
|
|
const silent = options.silent === true
|
|
|
|
if (isKanban) {
|
|
const generation = dataLoadGenerationRef.current + 1
|
|
dataLoadGenerationRef.current = generation
|
|
const activeFilter = filter != null ? filter : activeFilterRef.current
|
|
const resolvedSorter =
|
|
sorter != null
|
|
? resolveSorter(sorter)
|
|
: resolveSorter(tableSorterRef.current)
|
|
const sorterForLoad = resolvedSorter?.field ? resolvedSorter : {}
|
|
|
|
if (!silent) {
|
|
setLoading(true)
|
|
setLazyLoading(false)
|
|
} else {
|
|
setLoading(false)
|
|
setLazyLoading(true)
|
|
}
|
|
try {
|
|
let loadKanban = kanbanRef.current?.load
|
|
if (!loadKanban) {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
if (generation !== dataLoadGenerationRef.current) return
|
|
loadKanban = kanbanRef.current?.load
|
|
}
|
|
|
|
if (!loadKanban) return
|
|
await loadKanban(activeFilter, sorterForLoad, { silent })
|
|
} catch (error) {
|
|
logger.error('Error loading kanban view:', error)
|
|
} finally {
|
|
if (generation === dataLoadGenerationRef.current) {
|
|
if (!silent) {
|
|
setLoading(false)
|
|
} else {
|
|
setLazyLoading(false)
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if (silent) {
|
|
const generation = dataLoadGenerationRef.current + 1
|
|
dataLoadGenerationRef.current = generation
|
|
loadingPagesRef.current.clear()
|
|
pendingScrollAnchorRef.current = null
|
|
setLoading(false)
|
|
setLazyLoading(true)
|
|
|
|
try {
|
|
const firstResult = await fetchPage(pageNum, filter, sorter)
|
|
if (isStaleDataLoad(generation)) return
|
|
const loadedPages = [
|
|
{
|
|
pageNum,
|
|
items: firstResult.data || [],
|
|
hasMore: firstResult.hasMore
|
|
}
|
|
]
|
|
|
|
if (!isStaleDataLoad(generation)) {
|
|
setTablePages(createPageWindow(loadedPages, 'next'))
|
|
suppressTableChange()
|
|
}
|
|
} catch (error) {
|
|
if (!isStaleDataLoad(generation)) {
|
|
logger.error(`Error loading page ${pageNum}:`, error)
|
|
}
|
|
} finally {
|
|
if (!isStaleDataLoad(generation)) {
|
|
setLazyLoading(false)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
const generation = beginDataReload()
|
|
clearTablePages()
|
|
if (isStaleDataLoad(generation)) return
|
|
|
|
const skeletonPage = createSkeletonPage(pageNum)
|
|
setTablePages([skeletonPage])
|
|
setLoading(true)
|
|
setLazyLoading(false)
|
|
if (isStaleDataLoad(generation)) return
|
|
|
|
try {
|
|
const firstResult = await fetchPage(pageNum, filter, sorter)
|
|
if (isStaleDataLoad(generation)) return
|
|
const loadedPages = [
|
|
{
|
|
pageNum,
|
|
items: firstResult.data || [],
|
|
hasMore: firstResult.hasMore
|
|
}
|
|
]
|
|
|
|
if (!isStaleDataLoad(generation)) {
|
|
setTablePages(createPageWindow(loadedPages, 'next'))
|
|
}
|
|
} catch (error) {
|
|
if (!isStaleDataLoad(generation)) {
|
|
logger.error(`Error loading page ${pageNum}:`, error)
|
|
setTablePages([])
|
|
}
|
|
} finally {
|
|
if (!isStaleDataLoad(generation)) {
|
|
setLoading(false)
|
|
// Data/column swap can also trigger a spurious Table onChange.
|
|
suppressTableChange()
|
|
}
|
|
}
|
|
},
|
|
[
|
|
beginDataReload,
|
|
clearTablePages,
|
|
createPageWindow,
|
|
createSkeletonPage,
|
|
fetchPage,
|
|
isStaleDataLoad,
|
|
setTablePages,
|
|
suppressTableChange,
|
|
isKanban,
|
|
resolveSorter
|
|
]
|
|
)
|
|
|
|
const loadInitialPage = useCallback(async () => {
|
|
// A selected view applies its own filter/sort via applyViewState.
|
|
// Do not load (or clear) filters here — that races and wipes the view.
|
|
if (getViewFromUrl() || listViewIdRef.current) {
|
|
return
|
|
}
|
|
|
|
const filter = getPersistedFilter()
|
|
const sorter = getPersistedSorter()
|
|
const activeFilter = getActiveFilterValues(filter)
|
|
const resolvedSorter = resolveSorter(sorter)
|
|
|
|
if (Object.keys(filter).length > 0) {
|
|
assignSidebarToView(listViewIdRef.current)
|
|
setSidebarFilter(filter)
|
|
}
|
|
if (sorter?.field) {
|
|
tableSorterRef.current = sorter
|
|
setTableSorter(sorter)
|
|
}
|
|
|
|
loadPage(
|
|
initialPage,
|
|
activeFilter,
|
|
resolvedSorter.field ? resolvedSorter : null
|
|
)
|
|
}, [
|
|
assignSidebarToView,
|
|
getPersistedFilter,
|
|
getPersistedSorter,
|
|
getViewFromUrl,
|
|
initialPage,
|
|
loadPage,
|
|
resolveSorter
|
|
])
|
|
const loadInitialPageRef = useRef(loadInitialPage)
|
|
loadInitialPageRef.current = loadInitialPage
|
|
|
|
// When a view becomes available, apply its definition once. Skip empty
|
|
// prop-lag updates so we never clear an already-applied view filter.
|
|
useEffect(() => {
|
|
if (!listViewId || activeObjectView) {
|
|
if (!listViewId) lastAppliedViewKeyRef.current = null
|
|
return
|
|
}
|
|
|
|
const viewFilter = getActiveFilterValues(listViewFilter || {})
|
|
const viewSort =
|
|
listViewSort?.field && listViewSort?.order
|
|
? { field: listViewSort.field, order: listViewSort.order }
|
|
: {}
|
|
const viewKey = `${listViewId}:${JSON.stringify(viewFilter)}:${JSON.stringify(viewSort)}`
|
|
|
|
if (lastAppliedViewKeyRef.current === viewKey) return
|
|
|
|
lastAppliedViewKeyRef.current = viewKey
|
|
|
|
assignSidebarToView(listViewId)
|
|
setSidebarFilter(viewFilter)
|
|
setTableSorter(viewSort)
|
|
tableSorterRef.current = viewSort
|
|
activeFilterRef.current = viewFilter
|
|
loadPage(initialPage, viewFilter, viewSort.field ? viewSort : null)
|
|
}, [
|
|
activeObjectView,
|
|
initialPage,
|
|
listViewFilter,
|
|
listViewId,
|
|
listViewSort,
|
|
loadPage
|
|
])
|
|
|
|
const applyViewState = useCallback(
|
|
(filterState, sorterState, options = {}) => {
|
|
// Switching views clears user overrides
|
|
if (userOverridesRef) userOverridesRef.current = null
|
|
|
|
let filter
|
|
let sorter
|
|
|
|
if (filterState == null && sorterState == null) {
|
|
// Ignore accidental All restores while a view is still selected
|
|
// (e.g. load race). Explicit All tab switches pass forceAll.
|
|
if (
|
|
!options.forceAll &&
|
|
(listViewIdRef.current || getViewFromUrl())
|
|
) {
|
|
return
|
|
}
|
|
|
|
listViewIdRef.current = null
|
|
listViewFilterRef.current = {}
|
|
lastAppliedViewKeyRef.current = null
|
|
assignSidebarToView(null)
|
|
filter = getAllTabPersistedFilter()
|
|
sorter = getAllTabPersistedSorter()
|
|
|
|
const resolvedSorter = resolveSorter(sorter)
|
|
const nextSorter = sorter?.field ? sorter : {}
|
|
const activeFilter = getActiveFilterValues(filter)
|
|
|
|
setSidebarFilter(filter)
|
|
tableSorterRef.current = nextSorter
|
|
setTableSorter(nextSorter)
|
|
activeFilterRef.current = activeFilter
|
|
persistAllTabTableState(filter, nextSorter)
|
|
loadPage(
|
|
initialPage,
|
|
activeFilter,
|
|
resolvedSorter.field ? resolvedSorter : null
|
|
)
|
|
return
|
|
}
|
|
|
|
// View tab: apply the view definition. Sidebar starts as the view
|
|
// filter so the UI matches; All-tab user settings must not leak in.
|
|
const viewId =
|
|
options.viewId !== undefined ? options.viewId : listViewIdRef.current
|
|
const viewFilter =
|
|
options.viewFilter !== undefined
|
|
? options.viewFilter || {}
|
|
: filterState || {}
|
|
|
|
listViewIdRef.current = viewId
|
|
listViewFilterRef.current = viewFilter
|
|
|
|
filter = viewFilter
|
|
sorter = options.viewSort?.field
|
|
? options.viewSort
|
|
: sorterState?.field
|
|
? sorterState
|
|
: {}
|
|
|
|
const resolvedSorter = resolveSorter(sorter)
|
|
const nextSorter = sorter?.field ? sorter : {}
|
|
const activeFilter = getActiveFilterValues(filter)
|
|
|
|
lastAppliedViewKeyRef.current = `${viewId || ''}:${JSON.stringify(activeFilter)}:${JSON.stringify(nextSorter)}`
|
|
|
|
assignSidebarToView(viewId)
|
|
setSidebarFilter(filter)
|
|
tableSorterRef.current = nextSorter
|
|
setTableSorter(nextSorter)
|
|
activeFilterRef.current = activeFilter
|
|
|
|
loadPage(
|
|
initialPage,
|
|
activeFilter,
|
|
resolvedSorter.field ? resolvedSorter : null
|
|
)
|
|
},
|
|
[
|
|
assignSidebarToView,
|
|
getAllTabPersistedFilter,
|
|
getAllTabPersistedSorter,
|
|
getViewFromUrl,
|
|
initialPage,
|
|
loadPage,
|
|
persistAllTabTableState,
|
|
resolveSorter
|
|
]
|
|
)
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
reload,
|
|
applyViewState,
|
|
setData: (newData) => {
|
|
setPages([{ pageNum: 1, items: newData }])
|
|
},
|
|
updateData,
|
|
startEditing,
|
|
cancelEditing,
|
|
handleUpdate,
|
|
isEditing,
|
|
editLoading
|
|
}))
|
|
|
|
useEffect(() => {
|
|
if (connected !== true || token == null || initialized) {
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
|
|
const run = async () => {
|
|
await loadInitialPageRef.current()
|
|
if (!cancelled) {
|
|
setInitialized(true)
|
|
}
|
|
}
|
|
|
|
run()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [token, initialized, connected])
|
|
|
|
useEffect(() => {
|
|
const viewModeKey = [
|
|
viewMode.type,
|
|
viewMode?.settings?.categoryProperty || '',
|
|
viewMode?.settings?.startDate || '',
|
|
viewMode?.settings?.endDate || '',
|
|
viewMode?.settings?.colorProperty || ''
|
|
].join(':')
|
|
|
|
if (connected !== true || token == null || !initialized) {
|
|
prevViewModeKeyRef.current = viewModeKey
|
|
return
|
|
}
|
|
|
|
if (prevViewModeKeyRef.current === viewModeKey) return
|
|
prevViewModeKeyRef.current = viewModeKey
|
|
|
|
if (isKanban && !viewMode?.settings?.categoryProperty) return
|
|
if (isTimeline && !viewMode?.settings?.startDate) return
|
|
|
|
loadPage(
|
|
initialPage,
|
|
activeFilterRef.current,
|
|
tableSorterRef.current?.field ? tableSorterRef.current : null
|
|
)
|
|
}, [
|
|
isKanban,
|
|
isTimeline,
|
|
connected,
|
|
token,
|
|
initialized,
|
|
loadPage,
|
|
initialPage,
|
|
viewMode.type,
|
|
viewMode?.settings?.categoryProperty,
|
|
viewMode?.settings?.startDate,
|
|
viewMode?.settings?.endDate,
|
|
viewMode?.settings?.colorProperty
|
|
])
|
|
|
|
// Watch for changes in type and masterFilter, reset component state when they change
|
|
useEffect(() => {
|
|
const prevValues = prevValuesRef.current
|
|
|
|
const hasChanged =
|
|
prevValues.type !== type ||
|
|
JSON.stringify(prevValues.masterFilter) !==
|
|
JSON.stringify(resolvedMasterFilter)
|
|
|
|
if (hasChanged) {
|
|
beginDataReload()
|
|
pagesRef.current = []
|
|
setPages([])
|
|
activeFilterRef.current = {}
|
|
tableSorterRef.current = {}
|
|
assignSidebarToView(null)
|
|
setSidebarFilter({})
|
|
setTableSorter({})
|
|
setInitialized(false)
|
|
setLoading(true)
|
|
setLazyLoading(false)
|
|
prevValuesRef.current = { type, masterFilter: resolvedMasterFilter }
|
|
}
|
|
}, [type, resolvedMasterFilter, beginDataReload])
|
|
|
|
useEffect(() => {
|
|
registerPageFilter(effectiveFilter)
|
|
registerObjectListFilter(effectiveFilter)
|
|
return () => registerPageFilter({})
|
|
}, [effectiveFilter, registerPageFilter, registerObjectListFilter])
|
|
|
|
useEffect(() => {
|
|
registerPageSorter(effectiveSorter)
|
|
registerObjectListSorter(effectiveSorter)
|
|
return () => registerPageSorter({})
|
|
}, [effectiveSorter, registerPageSorter, registerObjectListSorter])
|
|
|
|
const getFilterDropdown = ({
|
|
setSelectedKeys,
|
|
selectedKeys,
|
|
confirm,
|
|
clearFilters,
|
|
visible,
|
|
propertyName,
|
|
propertyLabel
|
|
}) => (
|
|
<ColumnFilterDropdown
|
|
setSelectedKeys={setSelectedKeys}
|
|
selectedKeys={selectedKeys}
|
|
confirm={confirm}
|
|
clearFilters={clearFilters}
|
|
visible={visible}
|
|
propertyName={propertyName}
|
|
propertyLabel={propertyLabel}
|
|
modelType={type}
|
|
filter={effectiveFilter}
|
|
masterFilter={resolvedMasterFilter}
|
|
/>
|
|
)
|
|
|
|
const handleTableChange = (pagination, filters, sorter) => {
|
|
if (isInternalTableChangeRef.current) return
|
|
|
|
const next = { ...sidebarFilter }
|
|
|
|
Object.entries(filters).forEach(([key, value]) => {
|
|
const expression = toFilterExpression(value)
|
|
if (expression !== undefined) {
|
|
next[key] = expression
|
|
} else {
|
|
delete next[key]
|
|
}
|
|
})
|
|
|
|
const nextSorter = {
|
|
field: sorter.columnKey,
|
|
order: sorter.order
|
|
}
|
|
|
|
setSidebarFilter(next)
|
|
assignSidebarToView(listViewIdRef.current)
|
|
setTableSorter(nextSorter)
|
|
if (activeObjectViewRef.current) {
|
|
listViewFilterRef.current = next
|
|
// Editing a view: keep filter/sort on the draft view only
|
|
onObjectViewFilterSortChangeRef.current?.(next, nextSorter)
|
|
} else if (listViewIdRef.current) {
|
|
// On a view but not editing: track as user override for startEditing
|
|
if (userOverridesRef) {
|
|
userOverridesRef.current = {
|
|
...(userOverridesRef.current || {}),
|
|
filter: next,
|
|
sort: nextSorter
|
|
}
|
|
}
|
|
} else {
|
|
// All tab: persist personal filter/sort to URL/session
|
|
persistTableState(next, nextSorter)
|
|
}
|
|
const effective = buildEffectiveFilter(next)
|
|
activeFilterRef.current = effective
|
|
loadPage(initialPage, effective, resolveSorter(nextSorter), {
|
|
silent: true
|
|
})
|
|
}
|
|
|
|
const handleSidebarFilterChange = useCallback(
|
|
(newSidebarFilter) => {
|
|
setSidebarFilter(newSidebarFilter)
|
|
assignSidebarToView(listViewIdRef.current)
|
|
if (activeObjectViewRef.current) {
|
|
listViewFilterRef.current = newSidebarFilter
|
|
onObjectViewFilterSortChangeRef.current?.(
|
|
newSidebarFilter,
|
|
tableSorter
|
|
)
|
|
} else if (listViewIdRef.current) {
|
|
// On a view but not editing: track as user override for startEditing
|
|
if (userOverridesRef) {
|
|
userOverridesRef.current = {
|
|
...(userOverridesRef.current || {}),
|
|
filter: newSidebarFilter,
|
|
sort:
|
|
userOverridesRef.current?.sort ??
|
|
(tableSorterRef.current?.field
|
|
? tableSorterRef.current
|
|
: undefined)
|
|
}
|
|
}
|
|
} else {
|
|
persistFilter(newSidebarFilter)
|
|
}
|
|
const effective = buildEffectiveFilter(newSidebarFilter)
|
|
activeFilterRef.current = effective
|
|
loadPage(initialPage, effective, resolveSorter(tableSorter), {
|
|
silent: true
|
|
})
|
|
},
|
|
[
|
|
assignSidebarToView,
|
|
buildEffectiveFilter,
|
|
initialPage,
|
|
loadPage,
|
|
persistFilter,
|
|
resolveSorter,
|
|
tableSorter,
|
|
userOverridesRef
|
|
]
|
|
)
|
|
|
|
const handleSidebarSortChange = useCallback(
|
|
(newSorter) => {
|
|
const nextSorter = newSorter?.field
|
|
? { field: newSorter.field, order: newSorter.order }
|
|
: {}
|
|
suppressTableChange()
|
|
setTableSorter(nextSorter)
|
|
tableSorterRef.current = nextSorter
|
|
if (activeObjectViewRef.current) {
|
|
onObjectViewFilterSortChangeRef.current?.(sidebarFilter, nextSorter)
|
|
} else if (listViewIdRef.current) {
|
|
// On a view but not editing: track as user override for startEditing
|
|
if (userOverridesRef) {
|
|
userOverridesRef.current = {
|
|
...(userOverridesRef.current || {}),
|
|
sort: nextSorter,
|
|
filter: userOverridesRef.current?.filter ?? sidebarFilter
|
|
}
|
|
}
|
|
} else {
|
|
persistSort(nextSorter)
|
|
}
|
|
const effective = buildEffectiveFilter(sidebarFilter)
|
|
loadPage(initialPage, effective, resolveSorter(nextSorter), {
|
|
silent: true
|
|
})
|
|
},
|
|
[
|
|
buildEffectiveFilter,
|
|
initialPage,
|
|
loadPage,
|
|
persistSort,
|
|
resolveSorter,
|
|
sidebarFilter,
|
|
suppressTableChange,
|
|
userOverridesRef
|
|
]
|
|
)
|
|
|
|
const modelProperties = getModelProperties(type)
|
|
// Table columns from model properties
|
|
const columnsWithSkeleton = [
|
|
{
|
|
title: lazyLoading ? <LoadingOutlined /> : isCards ? model.icon : null,
|
|
key: 'icon',
|
|
width: 45,
|
|
fixed: 'left',
|
|
render: () => {
|
|
return <Flex justify='center'>{createElement(model.icon)}</Flex>
|
|
}
|
|
}
|
|
]
|
|
|
|
useEffect(() => {
|
|
pagesRef.current = pages
|
|
}, [pages])
|
|
|
|
useEffect(() => {
|
|
if (!expandHeight || isCards || isKanban) return
|
|
|
|
const findAntTableBody = (element) => {
|
|
if (!element) return null
|
|
if (element.classList?.contains('ant-table-body')) return element
|
|
for (const child of element.children) {
|
|
const found = findAntTableBody(child)
|
|
if (found) return found
|
|
}
|
|
return null
|
|
}
|
|
|
|
const root = tableRef.current?.nativeElement ?? tableRef.current
|
|
const tableBody = findAntTableBody(root)
|
|
if (!tableBody) return
|
|
|
|
tableBody.style.minHeight = adjustedScrollHeight
|
|
|
|
return () => {
|
|
tableBody.style.minHeight = ''
|
|
}
|
|
}, [
|
|
expandHeight,
|
|
adjustedScrollHeight,
|
|
isCards,
|
|
isKanban,
|
|
loading,
|
|
tableData
|
|
])
|
|
|
|
// Add columns in the order specified by model.columns
|
|
model.columns.forEach((colName) => {
|
|
const prop = modelProperties.find((p) => p.name === colName)
|
|
if (prop) {
|
|
// Check if column should be visible based on visibleColumns prop
|
|
if (
|
|
Object.keys(visibleColumns).length > 0 &&
|
|
visibleColumns[prop.name] === false
|
|
) {
|
|
return // Skip this column if it's not visible
|
|
}
|
|
|
|
var fixed = prop.columnFixed || undefined
|
|
var width = 200
|
|
|
|
switch (prop.type) {
|
|
case 'text':
|
|
width = 200
|
|
break
|
|
case 'number':
|
|
width = 100
|
|
break
|
|
case 'dateTime':
|
|
width = 200
|
|
break
|
|
case 'state':
|
|
width = 200
|
|
break
|
|
case 'id':
|
|
width = 180
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
// Check if this property should be filterable based on model.filters
|
|
const isFilterable = model.filters && model.filters.includes(prop.name)
|
|
|
|
// Check if this property should be sortable based on model.sorters
|
|
const isSortable = model.sorters && model.sorters.includes(prop.name)
|
|
|
|
const columnConfig = {
|
|
sorter: isSortable ? { multiple: 1 } : undefined,
|
|
sortOrder:
|
|
tableSorter?.field === prop.name ? tableSorter.order : null,
|
|
title: prop.label,
|
|
width: prop.columnWidth || width,
|
|
fixed: isMobile ? undefined : fixed,
|
|
key: prop.name,
|
|
filterIcon: () => {
|
|
return (
|
|
<Tooltip title='Filter' listenParents={1}>
|
|
<FilterIcon />
|
|
</Tooltip>
|
|
)
|
|
},
|
|
render: (text, record) => {
|
|
if (record?.isSkeleton) {
|
|
return (
|
|
<Skeleton.Input
|
|
active
|
|
size='small'
|
|
style={{ width: '100%', height: 24.5 }}
|
|
/>
|
|
)
|
|
}
|
|
return (
|
|
<ObjectProperty
|
|
{...prop}
|
|
longId={false}
|
|
inTable={true}
|
|
objectData={record}
|
|
isEditing={isEditing}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
if (
|
|
isFilterable &&
|
|
!Object.keys(resolvedMasterFilter).includes(prop.name)
|
|
) {
|
|
columnConfig.filterDropdown = ({
|
|
setSelectedKeys,
|
|
selectedKeys,
|
|
confirm,
|
|
clearFilters,
|
|
visible
|
|
}) =>
|
|
getFilterDropdown({
|
|
setSelectedKeys,
|
|
selectedKeys,
|
|
confirm,
|
|
clearFilters,
|
|
visible,
|
|
propertyName: prop.name,
|
|
propertyLabel: prop.label
|
|
})
|
|
columnConfig.filteredValue = fromFilterExpression(
|
|
effectiveFilter[prop.name]
|
|
)
|
|
}
|
|
|
|
columnsWithSkeleton.push(columnConfig)
|
|
}
|
|
})
|
|
|
|
if (
|
|
showActions &&
|
|
rowActions.length > 0 &&
|
|
tableData.some((item) => !item?.isSkeleton)
|
|
) {
|
|
columnsWithSkeleton.push({
|
|
title: (
|
|
<Flex gap='small' align='center' justify='center'>
|
|
<ActionsIcon />
|
|
</Flex>
|
|
),
|
|
key: 'actions',
|
|
fixed: 'right',
|
|
width: 20 + rowActions.length * 30, // Adjust width based on number of actions
|
|
render: (record) => {
|
|
return renderActions(record)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Card view rendering
|
|
const [cardColSpan, setCardColSpan] = useState(24)
|
|
const cardsContainerNodeRef = useRef(null)
|
|
const cardsResizeObserverRef = useRef(null)
|
|
|
|
const measureCardColSpan = useCallback(() => {
|
|
const node = cardsContainerNodeRef.current
|
|
if (!node) return
|
|
const panel = node.closest('.ant-splitter-panel')
|
|
const width = (panel ?? node).getBoundingClientRect().width
|
|
if (width <= 0) return
|
|
const nextSpan = getCardColSpan(width)
|
|
setCardColSpan((prev) => (prev === nextSpan ? prev : nextSpan))
|
|
}, [])
|
|
|
|
const objectTableCardsContainerRef = useCallback(
|
|
(node) => {
|
|
cardsResizeObserverRef.current?.disconnect()
|
|
cardsResizeObserverRef.current = null
|
|
cardsContainerNodeRef.current = node
|
|
|
|
if (!node) return
|
|
|
|
let rafId = null
|
|
const scheduleMeasure = () => {
|
|
if (rafId != null) cancelAnimationFrame(rafId)
|
|
rafId = requestAnimationFrame(() => {
|
|
rafId = null
|
|
measureCardColSpan()
|
|
})
|
|
}
|
|
|
|
scheduleMeasure()
|
|
|
|
const observer = new ResizeObserver(scheduleMeasure)
|
|
const panel = node.closest('.ant-splitter-panel')
|
|
if (panel) {
|
|
observer.observe(panel)
|
|
} else {
|
|
observer.observe(node)
|
|
}
|
|
|
|
cardsResizeObserverRef.current = observer
|
|
},
|
|
[measureCardColSpan]
|
|
)
|
|
|
|
useLayoutEffect(() => {
|
|
if (!isCards) return
|
|
measureCardColSpan()
|
|
}, [isCards, showFilterSidebar, showSortSidebar, measureCardColSpan])
|
|
|
|
useEffect(() => {
|
|
return () => cardsResizeObserverRef.current?.disconnect()
|
|
}, [])
|
|
|
|
// Card view: load when the leading skeleton card in a boundary page enters view
|
|
useLayoutEffect(() => {
|
|
if (!isCards) return
|
|
const container = cardsContainerNodeRef.current
|
|
if (!container) return
|
|
const scrollEl = container.querySelector('.simplebar-content-wrapper')
|
|
if (!scrollEl) 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(scrollEl)
|
|
} else if (boundary === 'previous') {
|
|
loadPreviousPage(scrollEl)
|
|
}
|
|
}
|
|
},
|
|
{
|
|
root: scrollEl,
|
|
rootMargin: `${SCROLL_THRESHOLD}px`,
|
|
threshold: 0
|
|
}
|
|
)
|
|
|
|
container
|
|
.querySelectorAll('[data-skeleton-boundary]')
|
|
.forEach((el) => observer.observe(el))
|
|
|
|
return () => observer.disconnect()
|
|
}, [isCards, skeletonBoundaryIds, loadNextPage, loadPreviousPage])
|
|
|
|
const renderCards = () => {
|
|
return (
|
|
<ScrollBox
|
|
style={{
|
|
height: '100%',
|
|
flex: 1,
|
|
minHeight: 0
|
|
}}
|
|
>
|
|
<div className='objectTableCards'>
|
|
<Row gutter={[16, 16]} key={`${cardColSpan}-${tableListKey}`}>
|
|
{tableData.map((record) => {
|
|
if (record?._id == undefined) {
|
|
return null
|
|
}
|
|
|
|
const skeletonBoundary =
|
|
record._id === skeletonBoundaryIds.next
|
|
? 'next'
|
|
: record._id === skeletonBoundaryIds.previous
|
|
? 'previous'
|
|
: undefined
|
|
|
|
return (
|
|
<Col span={cardColSpan} key={record._id}>
|
|
<div
|
|
style={{ width: '100%', overflow: 'hidden' }}
|
|
data-skeleton-boundary={skeletonBoundary}
|
|
>
|
|
<RowForm
|
|
record={record}
|
|
isEditing={isEditing}
|
|
onRegister={registerForm}
|
|
>
|
|
<Flex align={'center'} vertical gap={'middle'}>
|
|
<ObjectCard
|
|
isSkeleton={record?.isSkeleton || false}
|
|
model={model}
|
|
modelProperties={modelProperties}
|
|
visibleColumns={visibleColumns}
|
|
record={record}
|
|
lazyLoading={lazyLoading}
|
|
isEditing={isEditing}
|
|
rowActions={rowActions}
|
|
renderActions={renderActions}
|
|
/>
|
|
</Flex>
|
|
</RowForm>
|
|
</div>
|
|
</Col>
|
|
)
|
|
})}
|
|
</Row>
|
|
{!lazyLoading && tableData.length <= 0 && (
|
|
<MissingPlaceholder
|
|
message='No data.'
|
|
hasBackground={true}
|
|
hasBorder={false}
|
|
height='260px'
|
|
/>
|
|
)}
|
|
{lazyLoading && tableData.length <= 0 && (
|
|
<LoadingPlaceholder
|
|
message='Loading, please wait...'
|
|
hasBackground={true}
|
|
hasBorder={false}
|
|
height='260px'
|
|
/>
|
|
)}
|
|
</div>
|
|
</ScrollBox>
|
|
)
|
|
}
|
|
|
|
const components = useMemo(
|
|
() => ({
|
|
body: {
|
|
row: EditableRow
|
|
}
|
|
}),
|
|
[]
|
|
)
|
|
|
|
const onRow = useCallback(
|
|
(record) => ({
|
|
record,
|
|
isEditing,
|
|
onRegister: registerForm
|
|
}),
|
|
[isEditing, registerForm]
|
|
)
|
|
|
|
useEffect(() => {
|
|
console.log('loading', loading)
|
|
}, [loading])
|
|
|
|
const tableContent = (
|
|
<Flex
|
|
gap={'middle'}
|
|
vertical
|
|
style={{ flex: 1, minWidth: 0, minHeight: 0 }}
|
|
>
|
|
<Splitter
|
|
className={'farmcontrol-splitter large'}
|
|
onResize={measureCardColSpan}
|
|
onResizeEnd={measureCardColSpan}
|
|
>
|
|
<Splitter.Panel>
|
|
{isKanban ? (
|
|
<div className='objectKanbanWrap'>
|
|
<Spin spinning={loading}>
|
|
<ObjectKanban
|
|
ref={kanbanRef}
|
|
type={type}
|
|
categoryProperty={viewMode.settings?.categoryProperty}
|
|
baseFilter={effectiveFilter}
|
|
masterFilter={resolvedMasterFilter}
|
|
sorter={effectiveSorter}
|
|
lazyLoading={lazyLoading}
|
|
pageSize={pageSize}
|
|
model={model}
|
|
modelProperties={modelProperties}
|
|
visibleColumns={visibleColumns}
|
|
isEditing={isEditing}
|
|
rowActions={rowActions}
|
|
renderActions={renderActions}
|
|
columns={viewMode.settings?.columns}
|
|
onColumnsChange={(columns) => {
|
|
objectListView?.setViewMode?.({
|
|
...viewMode,
|
|
settings: {
|
|
...viewMode.settings,
|
|
columns
|
|
}
|
|
})
|
|
}}
|
|
/>
|
|
</Spin>
|
|
</div>
|
|
) : isTimeline ? (
|
|
<div style={{ width: '100%', height: '100%', minHeight: 0 }}>
|
|
<Spin spinning={loading}>
|
|
<ObjectTimeline
|
|
type={type}
|
|
records={tableData}
|
|
model={model}
|
|
startDate={viewMode.settings?.startDate}
|
|
endDate={viewMode.settings?.endDate}
|
|
colorProperty={viewMode.settings?.colorProperty}
|
|
filter={effectiveFilter}
|
|
masterFilter={resolvedMasterFilter}
|
|
isEditing={isEditing}
|
|
rowActions={rowActions}
|
|
renderActions={renderActions}
|
|
lazyLoading={lazyLoading}
|
|
loading={loading}
|
|
skeletonBoundaryIds={skeletonBoundaryIds}
|
|
onScroll={handleScroll}
|
|
rowWrapper={(record, row) => (
|
|
<RowForm
|
|
key={record._id}
|
|
record={record}
|
|
isEditing={isEditing}
|
|
onRegister={registerForm}
|
|
>
|
|
{row}
|
|
</RowForm>
|
|
)}
|
|
/>
|
|
</Spin>
|
|
</div>
|
|
) : isCards ? (
|
|
<div
|
|
className='objectTableCardsContainer'
|
|
ref={objectTableCardsContainerRef}
|
|
>
|
|
<Spin spinning={loading}>{renderCards()}</Spin>
|
|
</div>
|
|
) : (
|
|
<Spin spinning={loading}>
|
|
<Table
|
|
key={tableListKey}
|
|
ref={tableRef}
|
|
dataSource={tableData}
|
|
columns={columnsWithSkeleton}
|
|
className='dashboard-table'
|
|
pagination={false}
|
|
scroll={{ y: adjustedScrollHeight }}
|
|
rowKey='_id'
|
|
onScroll={handleScroll}
|
|
onChange={handleTableChange}
|
|
showSorterTooltip={false}
|
|
style={{ height: '100%' }}
|
|
size={size}
|
|
components={components}
|
|
onRow={onRow}
|
|
/>
|
|
</Spin>
|
|
)}
|
|
</Splitter.Panel>
|
|
{(showSortSidebar || showFilterSidebar) && (
|
|
<Splitter.Panel defaultSize={'25%'}>
|
|
<Flex
|
|
vertical
|
|
gap='large'
|
|
style={{ height: '100%', minHeight: 0 }}
|
|
>
|
|
{showSortSidebar && (
|
|
<SortSidebar
|
|
type={type}
|
|
sorter={effectiveSorter}
|
|
onSortChange={handleSidebarSortChange}
|
|
stretchHeight={showSortSidebar && !showFilterSidebar}
|
|
/>
|
|
)}
|
|
{showFilterSidebar && (
|
|
<Flex style={{ flex: 1, minHeight: 0 }}>
|
|
<FilterSidebar
|
|
type={type}
|
|
filter={
|
|
activeObjectView ? sidebarFilter : effectiveFilter
|
|
}
|
|
onFilterChange={handleSidebarFilterChange}
|
|
masterFilter={resolvedMasterFilter}
|
|
viewFilter={listViewFilter}
|
|
isEditing={!!activeObjectView}
|
|
/>
|
|
</Flex>
|
|
)}
|
|
</Flex>
|
|
</Splitter.Panel>
|
|
)}
|
|
</Splitter>
|
|
</Flex>
|
|
)
|
|
|
|
return (
|
|
<ObjectTableFilterContext.Provider value={tableFilterContextValue}>
|
|
{tableContent}
|
|
</ObjectTableFilterContext.Provider>
|
|
)
|
|
}
|
|
)
|
|
|
|
ObjectTable.displayName = 'ObjectTable'
|
|
|
|
ObjectTable.propTypes = {
|
|
type: PropTypes.string.isRequired,
|
|
pageSize: PropTypes.number,
|
|
scrollHeight: PropTypes.string,
|
|
onDataChange: PropTypes.func,
|
|
initialPage: PropTypes.number,
|
|
viewMode: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
|
cards: PropTypes.bool,
|
|
cardRenderer: PropTypes.func,
|
|
visibleColumns: PropTypes.object,
|
|
masterFilter: PropTypes.object,
|
|
size: PropTypes.string,
|
|
onStateChange: PropTypes.func,
|
|
showFilterSidebar: PropTypes.bool,
|
|
showSortSidebar: PropTypes.bool,
|
|
expandHeight: PropTypes.bool,
|
|
showActions: PropTypes.bool,
|
|
saveFilterInSession: PropTypes.bool,
|
|
saveFilterInUrl: PropTypes.bool,
|
|
useFilterInSession: PropTypes.bool,
|
|
useFilterInUrl: PropTypes.bool,
|
|
saveSortInSession: PropTypes.bool,
|
|
saveSortInUrl: PropTypes.bool,
|
|
useSortInSession: PropTypes.bool,
|
|
useSortInUrl: PropTypes.bool,
|
|
onRowAction: PropTypes.func
|
|
}
|
|
|
|
export default ObjectTable
|