All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
975 lines
29 KiB
JavaScript
975 lines
29 KiB
JavaScript
import {
|
|
forwardRef,
|
|
useImperativeHandle,
|
|
useRef,
|
|
useEffect,
|
|
useState,
|
|
useCallback,
|
|
useMemo,
|
|
createElement
|
|
} from 'react'
|
|
import {
|
|
Table,
|
|
Skeleton,
|
|
Card,
|
|
Row,
|
|
Col,
|
|
Descriptions,
|
|
Flex,
|
|
Spin,
|
|
Button,
|
|
Input,
|
|
Space,
|
|
Tooltip,
|
|
Form
|
|
} 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 XMarkIcon from '../../Icons/XMarkIcon'
|
|
import CheckIcon from '../../Icons/CheckIcon'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
|
|
import { AuthContext } from '../context/AuthContext'
|
|
import { ElectronContext } from '../context/ElectronContext'
|
|
import ActionsIcon from '../../Icons/ActionsIcon'
|
|
|
|
const logger = loglevel.getLogger('DasboardTable')
|
|
logger.setLevel(config.logLevel)
|
|
|
|
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) - 260px)',
|
|
onDataChange,
|
|
initialPage = 1,
|
|
cards = false,
|
|
visibleColumns = {},
|
|
masterFilter = {},
|
|
size = 'middle',
|
|
onStateChange
|
|
},
|
|
ref
|
|
) => {
|
|
const { token } = useContext(AuthContext)
|
|
const { isElectron } = useContext(ElectronContext)
|
|
const onStateChangeRef = useRef(onStateChange)
|
|
const { userProfile } = useContext(AuthContext)
|
|
useEffect(() => {
|
|
onStateChangeRef.current = onStateChange
|
|
}, [onStateChange])
|
|
const {
|
|
fetchObjects,
|
|
connected,
|
|
subscribeToObjectUpdates,
|
|
subscribeToObjectTypeUpdates,
|
|
updateMultipleObjects,
|
|
lockObject,
|
|
unlockObject
|
|
} = useContext(ApiServerContext)
|
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
|
const navigate = useNavigate()
|
|
var adjustedScrollHeight = scrollHeight
|
|
if (isMobile) {
|
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 316px)'
|
|
}
|
|
if (cards) {
|
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 280px)'
|
|
}
|
|
if (isElectron) {
|
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 244px)'
|
|
}
|
|
if (isMobile && isElectron) {
|
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 282px)'
|
|
}
|
|
if (cards && isElectron) {
|
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 260px)'
|
|
}
|
|
const tableRef = useRef(null)
|
|
const model = getModelByName(type)
|
|
const tableFilterRef = useRef({})
|
|
const tableSorterRef = useRef({})
|
|
const [initialized, setInitialized] = useState(false)
|
|
|
|
// Table state
|
|
const [pages, setPages] = useState([])
|
|
const pagesRef = useRef(pages)
|
|
const [hasMore, setHasMore] = useState(true)
|
|
const [loading, setLoading] = useState(true)
|
|
const [lazyLoading, setLazyLoading] = useState(false)
|
|
const [tableData, setTableData] = useState([])
|
|
|
|
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 subscribedIdsRef = useRef([])
|
|
// const [typeSubscribed, setTypeSubscribed] = useState(false)
|
|
const unsubscribesRef = useRef([])
|
|
const updateEventHandlerRef = useRef()
|
|
const subscribeToObjectTypeUpdatesRef = useRef(null)
|
|
const prevValuesRef = useRef({ type, masterFilter })
|
|
|
|
const rowActions =
|
|
model.actions?.filter((action) => action.row == true) || []
|
|
|
|
const createSkeletonData = useCallback(() => {
|
|
return Array(pageSize)
|
|
.fill(null)
|
|
.map(() => ({
|
|
_id: `skeleton-${Math.random().toString(36).substring(2, 15)}`,
|
|
isSkeleton: true
|
|
}))
|
|
}, [pageSize])
|
|
|
|
const renderActions = (objectData) => {
|
|
return (
|
|
<Flex gap='small' align='center' justify='center'>
|
|
{rowActions.map((action, index) => {
|
|
var disabled = false
|
|
if (action.disabled) {
|
|
if (typeof action.disabled === 'function') {
|
|
disabled = action.disabled({
|
|
...objectData,
|
|
_user: userProfile
|
|
})
|
|
} else {
|
|
disabled = action.disabled
|
|
}
|
|
}
|
|
return (
|
|
<Tooltip key={index} title={action.label} arrow={false}>
|
|
<Button
|
|
icon={
|
|
action.icon ? (
|
|
createElement(action.icon)
|
|
) : (
|
|
<QuestionCircleIcon />
|
|
)
|
|
}
|
|
disabled={disabled}
|
|
type={'text'}
|
|
size={'small'}
|
|
onClick={() => {
|
|
if (action.url) {
|
|
navigate(action.url(objectData._id))
|
|
}
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
)
|
|
})}
|
|
</Flex>
|
|
)
|
|
}
|
|
|
|
const fetchData = useCallback(
|
|
async (pageNum = 1, filter = null, sorter = null) => {
|
|
if (filter == null) {
|
|
filter = tableFilterRef.current
|
|
} else {
|
|
tableFilterRef.current = filter
|
|
}
|
|
if (sorter == null) {
|
|
sorter = tableSorterRef.current
|
|
} else {
|
|
tableSorterRef.current = {
|
|
field: sorter.field,
|
|
order: sorter.order
|
|
}
|
|
}
|
|
try {
|
|
const result = await fetchObjects(type, {
|
|
page: pageNum,
|
|
limit: pageSize,
|
|
filter: { ...filter, ...masterFilter },
|
|
sorter,
|
|
onDataChange
|
|
})
|
|
|
|
setHasMore(result.hasMore)
|
|
|
|
setPages((prev) => {
|
|
const existingPageIndex = prev.findIndex(
|
|
(p) => p.pageNum === pageNum
|
|
)
|
|
if (existingPageIndex !== -1) {
|
|
// Update existing page
|
|
const newPages = [...prev]
|
|
newPages[existingPageIndex] = { pageNum, items: result.data }
|
|
return newPages
|
|
}
|
|
// If page doesn't exist, return unchanged
|
|
return prev
|
|
})
|
|
|
|
setLoading(false)
|
|
setLazyLoading(false)
|
|
return result.data || []
|
|
} catch (error) {
|
|
setPages((prev) =>
|
|
prev.map((page) => ({
|
|
...page,
|
|
items: page.items.filter((item) => !item.isSkeleton)
|
|
}))
|
|
)
|
|
setLoading(false)
|
|
setLazyLoading(false)
|
|
throw error
|
|
}
|
|
},
|
|
[type, masterFilter, pageSize, onDataChange, fetchObjects]
|
|
)
|
|
|
|
const loadNextPage = useCallback(() => {
|
|
const highestPage = Math.max(...pages.map((p) => p.pageNum))
|
|
const nextPage = highestPage + 1
|
|
if (hasMore && lazyLoading == false) {
|
|
setPages((prev) => {
|
|
const filteredPages = prev.map((page) => ({
|
|
...page,
|
|
items: page.items.filter((item) => !item.isSkeleton)
|
|
}))
|
|
const minPage = Math.min(...filteredPages.map((p) => p.pageNum))
|
|
const relevantPages = filteredPages.filter(
|
|
(p) => p.pageNum !== minPage
|
|
)
|
|
return [
|
|
...relevantPages,
|
|
{ pageNum: nextPage, items: createSkeletonData() }
|
|
]
|
|
})
|
|
fetchData(nextPage)
|
|
}
|
|
}, [pages, createSkeletonData, fetchData, hasMore, lazyLoading])
|
|
|
|
const loadPreviousPage = useCallback(() => {
|
|
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
|
|
const prevPage = lowestPage - 1
|
|
|
|
if (prevPage > 0 && lazyLoading == false) {
|
|
setPages((prev) => {
|
|
const filteredPages = prev.map((page) => ({
|
|
...page,
|
|
items: page.items.filter((item) => !item.isSkeleton)
|
|
}))
|
|
const maxPage = Math.max(...filteredPages.map((p) => p.pageNum))
|
|
const relevantPages = filteredPages.filter(
|
|
(p) => p.pageNum !== maxPage
|
|
)
|
|
return [
|
|
{ pageNum: prevPage, items: createSkeletonData() },
|
|
...relevantPages
|
|
]
|
|
})
|
|
fetchData(prevPage)
|
|
}
|
|
}, [pages, createSkeletonData, fetchData, lazyLoading])
|
|
|
|
const handleScroll = useCallback(
|
|
(e) => {
|
|
const { target } = e
|
|
const scrollHeight = target.scrollHeight
|
|
const scrollTop = target.scrollTop
|
|
const clientHeight = target.clientHeight
|
|
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
|
|
const prevPage = lowestPage - 1
|
|
|
|
// Load more data when scrolling down
|
|
if (scrollHeight - scrollTop - clientHeight < 100 && hasMore) {
|
|
setTimeout(() => {
|
|
target.scrollTop = scrollHeight / 2
|
|
}, 0)
|
|
setLazyLoading(true)
|
|
logger.debug('Loading next page...')
|
|
loadNextPage()
|
|
}
|
|
|
|
// Load previous data when scrolling up
|
|
if (scrollTop < 100 && prevPage > 0) {
|
|
setTimeout(() => {
|
|
target.scrollTop = scrollHeight / 2
|
|
}, 0)
|
|
setLazyLoading(true)
|
|
logger.debug('Loading previous page...')
|
|
loadPreviousPage()
|
|
}
|
|
},
|
|
[loadNextPage, loadPreviousPage, hasMore, pages]
|
|
)
|
|
|
|
const reload = useCallback(async () => {
|
|
setLazyLoading(true)
|
|
for (let i = 0; i < pagesRef.current.length; i++) {
|
|
const page = pagesRef.current[i]
|
|
await fetchData(page.pageNum)
|
|
}
|
|
}, [fetchData])
|
|
|
|
const startEditing = useCallback(() => {
|
|
setIsEditing(true)
|
|
tableData.forEach((item) => {
|
|
if (!item.isSkeleton) {
|
|
lockObject(item._id, type)
|
|
}
|
|
})
|
|
}, [tableData, lockObject, type])
|
|
|
|
const cancelEditing = useCallback(() => {
|
|
setIsEditing(false)
|
|
tableData.forEach((item) => {
|
|
if (!item.isSkeleton) {
|
|
unlockObject(item._id, type)
|
|
}
|
|
})
|
|
}, [tableData, unlockObject, 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) {
|
|
unlockObject(item._id, type)
|
|
}
|
|
})
|
|
} catch (err) {
|
|
logger.error('Error updating objects:', err)
|
|
} finally {
|
|
setEditLoading(false)
|
|
}
|
|
}, [type, updateMultipleObjects, reload, tableData, unlockObject])
|
|
|
|
// Update event handler for real-time updates
|
|
const updateEventHandler = useCallback((id, updatedData) => {
|
|
setPages((prevPages) =>
|
|
prevPages.map((page) => {
|
|
const updatedItems = page.items.map((item) => {
|
|
if (item._id === id) {
|
|
return { ...item, ...updatedData }
|
|
}
|
|
return item
|
|
})
|
|
return {
|
|
...page,
|
|
items: updatedItems
|
|
}
|
|
})
|
|
)
|
|
|
|
if (rowFormsRef.current[id]) {
|
|
rowFormsRef.current[id].setFieldsValue(updatedData)
|
|
}
|
|
}, [])
|
|
|
|
// Store the latest updateEventHandler in a ref
|
|
updateEventHandlerRef.current = updateEventHandler
|
|
|
|
const newEventHandler = useCallback(() => {
|
|
reload()
|
|
}, [reload])
|
|
|
|
// Subscribe to real-time updates for all items
|
|
useEffect(() => {
|
|
if (pages.length > 0 && connected == true) {
|
|
// Get all non-skeleton item IDs from all pages
|
|
const allItemIds = pages
|
|
.flatMap((page) => page.items || [])
|
|
.filter((item) => !item.isSkeleton)
|
|
.map((item) => item._id)
|
|
.filter(Boolean)
|
|
|
|
// Find new items that need subscription
|
|
const newItemIds = allItemIds.filter(
|
|
(id) => !subscribedIdsRef.current.includes(id)
|
|
)
|
|
|
|
// Subscribe to new items only
|
|
newItemIds.forEach((itemId) => {
|
|
const unsubscribe = subscribeToObjectUpdates(
|
|
itemId,
|
|
type,
|
|
(updateData) => {
|
|
updateEventHandlerRef.current(itemId, updateData)
|
|
}
|
|
)
|
|
subscribedIdsRef.current.push(itemId)
|
|
if (unsubscribe) {
|
|
unsubscribesRef.current.push(unsubscribe)
|
|
}
|
|
})
|
|
|
|
// Clean up subscriptions for items that are no longer in the pages
|
|
const currentSubscribedIds = subscribedIdsRef.current
|
|
const itemsToUnsubscribe = currentSubscribedIds.filter(
|
|
(id) => !allItemIds.includes(id)
|
|
)
|
|
|
|
itemsToUnsubscribe.forEach((itemId) => {
|
|
const index = subscribedIdsRef.current.indexOf(itemId)
|
|
if (index > -1) {
|
|
subscribedIdsRef.current.splice(index, 1)
|
|
const unsubscribe = unsubscribesRef.current[index]
|
|
if (unsubscribe) {
|
|
unsubscribe()
|
|
}
|
|
unsubscribesRef.current.splice(index, 1)
|
|
}
|
|
})
|
|
}
|
|
}, [pages, type, subscribeToObjectUpdates, connected])
|
|
|
|
// Cleanup effect for component unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (connected == true && unsubscribesRef.current) {
|
|
// Clean up all subscriptions when component unmounts
|
|
unsubscribesRef.current.forEach((unsubscribe) => {
|
|
if (unsubscribe) unsubscribe()
|
|
})
|
|
unsubscribesRef.current = []
|
|
subscribedIdsRef.current = []
|
|
|
|
// Clean up type subscription
|
|
}
|
|
if (connected == true && subscribeToObjectTypeUpdatesRef.current) {
|
|
subscribeToObjectTypeUpdatesRef.current()
|
|
subscribeToObjectTypeUpdatesRef.current = null
|
|
}
|
|
}
|
|
}, [connected])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
connected == true &&
|
|
subscribeToObjectTypeUpdatesRef.current == null
|
|
) {
|
|
subscribeToObjectTypeUpdatesRef.current = subscribeToObjectTypeUpdates(
|
|
type,
|
|
newEventHandler
|
|
)
|
|
}
|
|
}, [type, subscribeToObjectTypeUpdates, connected, newEventHandler])
|
|
|
|
const updateData = useCallback(
|
|
(id, updatedData) => {
|
|
updateEventHandler(id, updatedData)
|
|
},
|
|
[updateEventHandler]
|
|
)
|
|
|
|
const loadPage = useCallback(
|
|
async (pageNum, filter = null, sorter = null) => {
|
|
// Create initial page with skeletons
|
|
setPages([{ pageNum: pageNum, items: createSkeletonData() }])
|
|
|
|
const items = await fetchData(pageNum, filter, sorter)
|
|
|
|
if (items.length >= 25) {
|
|
setPages((prev) => {
|
|
// Remove any existing page with the same pageNum
|
|
const filtered = prev.filter((p) => p.pageNum !== pageNum + 1)
|
|
return [
|
|
...filtered,
|
|
{ pageNum: pageNum + 1, items: createSkeletonData() }
|
|
]
|
|
})
|
|
await fetchData(pageNum + 1, filter, sorter)
|
|
}
|
|
},
|
|
[createSkeletonData, fetchData]
|
|
)
|
|
|
|
const loadInitialPage = useCallback(async () => {
|
|
loadPage(initialPage)
|
|
}, [initialPage, loadPage])
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
reload,
|
|
setData: (newData) => {
|
|
setPages([{ pageNum: 1, items: newData }])
|
|
},
|
|
updateData,
|
|
startEditing,
|
|
cancelEditing,
|
|
handleUpdate,
|
|
isEditing,
|
|
editLoading
|
|
}))
|
|
|
|
useEffect(() => {
|
|
if (
|
|
connected == true &&
|
|
token != null &&
|
|
!pages.includes(initialPage) &&
|
|
!initialized
|
|
) {
|
|
loadInitialPage()
|
|
setInitialized(true)
|
|
}
|
|
}, [token, loadInitialPage, initialPage, pages, initialized, connected])
|
|
|
|
// Watch for changes in type and masterFilter, reset component state when they change
|
|
useEffect(() => {
|
|
const prevValues = prevValuesRef.current
|
|
|
|
// Deep comparison for objects, simple comparison for primitives
|
|
const hasChanged =
|
|
prevValues.type !== type ||
|
|
JSON.stringify(prevValues.masterFilter) !== JSON.stringify(masterFilter)
|
|
|
|
if (hasChanged) {
|
|
setPages([])
|
|
tableFilterRef.current = {}
|
|
tableSorterRef.current = {}
|
|
setInitialized(false)
|
|
setLoading(true)
|
|
setLazyLoading(false)
|
|
setHasMore(true)
|
|
prevValuesRef.current = { type, masterFilter }
|
|
}
|
|
}, [type, masterFilter])
|
|
|
|
const getFilterDropdown = ({
|
|
setSelectedKeys,
|
|
selectedKeys,
|
|
confirm,
|
|
clearFilters,
|
|
propertyName
|
|
}) => {
|
|
return (
|
|
<div style={{ padding: 8 }}>
|
|
<Space.Compact>
|
|
<Input
|
|
placeholder={'Search ' + propertyName}
|
|
value={selectedKeys[0]}
|
|
onChange={(e) =>
|
|
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
|
}
|
|
onPressEnter={() => confirm()}
|
|
style={{ width: 200, display: 'block' }}
|
|
/>
|
|
<Button
|
|
onClick={() => {
|
|
clearFilters()
|
|
confirm()
|
|
}}
|
|
icon={<XMarkIcon />}
|
|
/>
|
|
<Button
|
|
type='primary'
|
|
onClick={() => confirm()}
|
|
icon={<CheckIcon />}
|
|
/>
|
|
</Space.Compact>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const handleTableChange = (pagination, filters, sorter) => {
|
|
const newFilters = {}
|
|
|
|
Object.entries(filters).forEach(([key, value]) => {
|
|
if (value && value.length > 0) {
|
|
newFilters[key] = value[0]
|
|
}
|
|
})
|
|
|
|
setPages([])
|
|
setLoading(true)
|
|
loadPage(initialPage, newFilters, {
|
|
field: sorter.field,
|
|
order: sorter.order
|
|
})
|
|
}
|
|
|
|
const modelProperties = getModelProperties(type)
|
|
// Table columns from model properties
|
|
const columnsWithSkeleton = [
|
|
{
|
|
title: lazyLoading ? <LoadingOutlined /> : cards ? model.icon : null,
|
|
key: 'icon',
|
|
width: 45,
|
|
fixed: 'left',
|
|
render: () => {
|
|
return <Flex justify='center'>{createElement(model.icon)}</Flex>
|
|
}
|
|
}
|
|
]
|
|
|
|
useEffect(() => {
|
|
pagesRef.current = pages
|
|
}, [pages])
|
|
|
|
// Flatten pages array for table display
|
|
useEffect(() => {
|
|
setTableData(pages.flatMap((page) => page.items))
|
|
}, [pages])
|
|
|
|
// 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,
|
|
title: prop.label,
|
|
width: prop.columnWidth || width,
|
|
fixed: isMobile ? undefined : fixed,
|
|
key: prop.name,
|
|
render: (text, record) => {
|
|
if (record?.isSkeleton) {
|
|
return (
|
|
<Skeleton.Input active size='small' style={{ width: '100%' }} />
|
|
)
|
|
}
|
|
return (
|
|
<ObjectProperty
|
|
{...prop}
|
|
longId={false}
|
|
objectData={record}
|
|
isEditing={isEditing}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
// Add filter configuration if the property is filterable and not in masterFilter
|
|
if (isFilterable && !Object.keys(masterFilter).includes(prop.name)) {
|
|
columnConfig.filterDropdown = ({
|
|
setSelectedKeys,
|
|
selectedKeys,
|
|
confirm,
|
|
clearFilters
|
|
}) =>
|
|
getFilterDropdown({
|
|
setSelectedKeys,
|
|
selectedKeys,
|
|
confirm,
|
|
clearFilters,
|
|
propertyName: prop.label
|
|
})
|
|
// Remove local filtering - let the server handle it
|
|
columnConfig.filtered = false
|
|
}
|
|
|
|
columnsWithSkeleton.push(columnConfig)
|
|
}
|
|
})
|
|
|
|
if (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 cardsContainerRef = useRef(null)
|
|
|
|
// Card view scroll handler
|
|
useEffect(() => {
|
|
if (!cards) return
|
|
const container = cardsContainerRef.current
|
|
if (!container) return
|
|
|
|
const handleCardsScroll = (e) => {
|
|
const { scrollTop, scrollHeight, clientHeight } = e.target
|
|
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
|
|
const prevPage = lowestPage - 1
|
|
|
|
// Load more data when scrolling down
|
|
if (
|
|
scrollHeight - scrollTop - clientHeight < 100 &&
|
|
hasMore &&
|
|
!lazyLoading
|
|
) {
|
|
setTimeout(() => {
|
|
e.target.scrollTop = scrollHeight / 2
|
|
}, 0)
|
|
setLazyLoading(true)
|
|
loadNextPage()
|
|
}
|
|
|
|
// Load previous data when scrolling up
|
|
if (scrollTop < 100 && prevPage > 0 && !lazyLoading) {
|
|
setTimeout(() => {
|
|
e.target.scrollTop = scrollHeight / 2
|
|
}, 0)
|
|
setLazyLoading(true)
|
|
loadPreviousPage()
|
|
}
|
|
}
|
|
|
|
container.addEventListener('scroll', handleCardsScroll)
|
|
return () => container.removeEventListener('scroll', handleCardsScroll)
|
|
}, [cards, pages, hasMore, lazyLoading, loadNextPage, loadPreviousPage])
|
|
|
|
const renderCards = () => {
|
|
return (
|
|
<Row
|
|
gutter={[16, 16]}
|
|
style={{ overflowY: 'auto', maxHeight: adjustedScrollHeight }}
|
|
ref={cardsContainerRef}
|
|
>
|
|
{tableData.map((record) => (
|
|
<Col xs={24} sm={12} md={12} lg={8} xl={6} xxl={6} key={record._id}>
|
|
<Card
|
|
style={{ width: '100%', overflow: 'hidden' }}
|
|
styles={{ body: { padding: 0 } }}
|
|
loading={record.isSkeleton}
|
|
variant={'borderless'}
|
|
>
|
|
<RowForm
|
|
record={record}
|
|
isEditing={isEditing}
|
|
onRegister={registerForm}
|
|
>
|
|
<Flex align={'center'} vertical gap={'middle'}>
|
|
<Descriptions
|
|
column={1}
|
|
size='small'
|
|
bordered={true}
|
|
style={{ width: '100%', tableLayout: 'fixed' }}
|
|
className='objectTableDescritions'
|
|
>
|
|
{(() => {
|
|
const descriptionItems = []
|
|
|
|
// Add columns in the order specified by model.columns (same logic as table)
|
|
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
|
|
}
|
|
|
|
descriptionItems.push(
|
|
<Descriptions.Item
|
|
label={prop.label}
|
|
key={prop.name}
|
|
colspan={2}
|
|
>
|
|
<ObjectProperty
|
|
{...prop}
|
|
longId={false}
|
|
objectData={record}
|
|
isEditing={isEditing}
|
|
name={prop.name}
|
|
/>
|
|
</Descriptions.Item>
|
|
)
|
|
}
|
|
})
|
|
|
|
// Add actions if they exist (same as table)
|
|
if (rowActions.length > 0) {
|
|
descriptionItems.push(
|
|
<Descriptions.Item
|
|
label={'Actions'}
|
|
key={'actions'}
|
|
>
|
|
{renderActions(record)}
|
|
</Descriptions.Item>
|
|
)
|
|
}
|
|
|
|
return descriptionItems
|
|
})()}
|
|
</Descriptions>
|
|
</Flex>
|
|
</RowForm>
|
|
</Card>
|
|
</Col>
|
|
))}
|
|
</Row>
|
|
)
|
|
}
|
|
|
|
const components = useMemo(
|
|
() => ({
|
|
body: {
|
|
row: EditableRow
|
|
}
|
|
}),
|
|
[]
|
|
)
|
|
|
|
const onRow = useCallback(
|
|
(record) => ({
|
|
record,
|
|
isEditing,
|
|
onRegister: registerForm
|
|
}),
|
|
[isEditing, registerForm]
|
|
)
|
|
|
|
const tableContent = (
|
|
<Flex gap={'middle'} vertical>
|
|
<Table
|
|
ref={tableRef}
|
|
dataSource={tableData}
|
|
columns={columnsWithSkeleton}
|
|
className={cards ? 'dashboard-cards-header' : 'dashboard-table'}
|
|
pagination={false}
|
|
scroll={{ y: adjustedScrollHeight }}
|
|
rowKey='_id'
|
|
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
|
|
onScroll={handleScroll}
|
|
onChange={handleTableChange}
|
|
showSorterTooltip={false}
|
|
style={{ height: '100%' }}
|
|
size={size}
|
|
components={components}
|
|
onRow={onRow}
|
|
/>
|
|
{cards ? (
|
|
<Spin indicator={<LoadingOutlined />} spinning={loading}>
|
|
{renderCards()}
|
|
</Spin>
|
|
) : null}
|
|
</Flex>
|
|
)
|
|
|
|
return tableContent
|
|
}
|
|
)
|
|
|
|
ObjectTable.displayName = 'ObjectTable'
|
|
|
|
ObjectTable.propTypes = {
|
|
type: PropTypes.string.isRequired,
|
|
pageSize: PropTypes.number,
|
|
scrollHeight: PropTypes.string,
|
|
onDataChange: PropTypes.func,
|
|
initialPage: PropTypes.number,
|
|
cards: PropTypes.bool,
|
|
cardRenderer: PropTypes.func,
|
|
visibleColumns: PropTypes.object,
|
|
masterFilter: PropTypes.object,
|
|
size: PropTypes.string,
|
|
onStateChange: PropTypes.func
|
|
}
|
|
|
|
export default ObjectTable
|