Enhance ObjectKanban with Skeleton Loading and Responsive Layout

- Introduced a skeleton loading state in the ObjectKanban component to improve user experience during data fetching.
- Updated CSS styles for the ObjectKanban and related components to ensure a responsive layout, accommodating various screen sizes.
- Refactored ObjectKanbanHeader to conditionally render skeletons based on loading state, enhancing visual feedback.
- Improved filter and sorter handling in ObjectKanbanColumn for better data management during loading.
This commit is contained in:
Tom Butcher 2026-09-03 02:58:29 +01:00
parent 0eae922a9e
commit 26d8c80d54
6 changed files with 394 additions and 166 deletions

View File

@ -1372,16 +1372,29 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
} }
.objectTableCardsContainer > .ant-spin-nested-loading,
.objectTableCardsContainer > .ant-spin-nested-loading > .ant-spin-container,
.objectTableCardsContainer .ant-spin-nested-loading { .objectTableCardsContainer .ant-spin-nested-loading {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
} }
.objectTableCardsContainer .ant-spin-container { .objectTableCardsContainer .ant-spin-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
} }
.objectKanbanContainerWrapper { .objectKanbanContainerWrapper {
@ -1394,6 +1407,15 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
flex-direction: column; flex-direction: column;
} }
.objectKanbanContainerWrapper > .ant-spin-nested-loading,
.objectKanbanContainerWrapper > .ant-spin-nested-loading > .ant-spin-container {
height: 100%;
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
}
.objectKanbanContainerSkeletons { .objectKanbanContainerSkeletons {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -8,14 +8,91 @@ import {
useRef, useRef,
useState useState
} from 'react' } from 'react'
import { Empty, Flex } from 'antd' import { Empty, Flex, Spin } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import ObjectCard from './ObjectCard'
import ObjectKanbanColumn from './ObjectKanbanColumn' import ObjectKanbanColumn from './ObjectKanbanColumn'
import ObjectKanbanHeader from './ObjectKanbanHeader' import ObjectKanbanHeader from './ObjectKanbanHeader'
import ScrollBox from './ScrollBox' import ScrollBox from './ScrollBox'
import { getCategoryValueKey } from './viewModeUtils' import { getCategoryValueKey } from './viewModeUtils'
const KANBAN_COLUMN_WIDTH = 360
const KANBAN_GAP = 16
const KANBAN_CARD_MIN_HEIGHT = 136
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 ObjectKanbanSkeletonColumns = ({
columnCount,
cardCount = 0,
trackRef,
model,
modelProperties,
visibleColumns,
keyPrefix = 'skeleton'
}) => (
<div className='objectKanbanSkeletonColumns objectKanbanSkeletonColumns--loading'>
<div className='objectKanbanSkeletonTrack' ref={trackRef}>
<Flex gap='middle' className='objectKanbanSkeletonRow'>
{Array.from({ length: columnCount }).map((_, columnIndex) => (
<div
key={`${keyPrefix}-${columnIndex}`}
className='objectKanbanColumnSkeleton'
>
<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}-${columnIndex}-${cardIndex}`
}}
cardStyle='bordered'
/>
))}
</Flex>
)}
</div>
</div>
))}
</Flex>
</div>
</div>
)
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
}
const ObjectKanban = forwardRef( const ObjectKanban = forwardRef(
( (
{ {
@ -38,15 +115,24 @@ const ObjectKanban = forwardRef(
const columnRefs = useRef({}) const columnRefs = useRef({})
const headerTrackRef = useRef(null) const headerTrackRef = useRef(null)
const skeletonTrackRef = useRef(null) const skeletonTrackRef = useRef(null)
const kanbanBodyRef = useRef(null)
const [scrollElement, setScrollElement] = useState(null) const [scrollElement, setScrollElement] = useState(null)
const [categoryValues, setCategoryValues] = useState([]) const [categoryValues, setCategoryValues] = useState([])
const [hasLoaded, setHasLoaded] = useState(false) const [hasLoaded, setHasLoaded] = useState(false)
const [loadedFilter, setLoadedFilter] = useState(baseFilter)
const [loadedSorter, setLoadedSorter] = useState(sorter)
const [skeletonLayout, setSkeletonLayout] = useState({
columnCount: 3,
cardCount: 3
})
const baseFilterRef = useRef(baseFilter) const baseFilterRef = useRef(baseFilter)
const masterFilterRef = useRef(masterFilter) const masterFilterRef = useRef(masterFilter)
const sorterRef = useRef(sorter) const sorterRef = useRef(sorter)
const getModelPropertyValuesRef = useRef(getModelPropertyValues) const getModelPropertyValuesRef = useRef(getModelPropertyValues)
const lastCategoryQueryKeyRef = useRef(null) const lastCategoryQueryKeyRef = useRef(null)
const hasLoadedRef = useRef(false)
const loadGenerationRef = useRef(0)
baseFilterRef.current = baseFilter baseFilterRef.current = baseFilter
masterFilterRef.current = masterFilter masterFilterRef.current = masterFilter
@ -57,6 +143,23 @@ const ObjectKanban = forwardRef(
setScrollElement(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(() => { useLayoutEffect(() => {
const mainScroll = scrollElement const mainScroll = scrollElement
const headerTrack = headerTrackRef.current const headerTrack = headerTrackRef.current
@ -109,8 +212,11 @@ const ObjectKanban = forwardRef(
const load = useCallback( const load = useCallback(
async (filter = null, sorterArg = null) => { async (filter = null, sorterArg = null) => {
const generation = ++loadGenerationRef.current
if (!categoryProperty) { if (!categoryProperty) {
lastCategoryQueryKeyRef.current = null lastCategoryQueryKeyRef.current = null
hasLoadedRef.current = true
setCategoryValues([]) setCategoryValues([])
setHasLoaded(true) setHasLoaded(true)
return return
@ -118,14 +224,21 @@ const ObjectKanban = forwardRef(
const activeFilter = filter ?? baseFilterRef.current const activeFilter = filter ?? baseFilterRef.current
const activeSorter = sorterArg ?? sorterRef.current const activeSorter = sorterArg ?? sorterRef.current
const activeMasterFilter = masterFilterRef.current
const queryKey = buildCategoryQueryKey(activeFilter, activeSorter) const queryKey = buildCategoryQueryKey(activeFilter, activeSorter)
if (lastCategoryQueryKeyRef.current === queryKey) { const canReuseColumns =
lastCategoryQueryKeyRef.current === queryKey && hasLoadedRef.current
if (canReuseColumns) {
await reloadColumns() await reloadColumns()
if (generation !== loadGenerationRef.current) return
hasLoadedRef.current = true
setHasLoaded(true) setHasLoaded(true)
return return
} }
hasLoadedRef.current = false
setHasLoaded(false) setHasLoaded(false)
try { try {
@ -134,19 +247,25 @@ const ObjectKanban = forwardRef(
categoryProperty, categoryProperty,
{ {
filter: activeFilter, filter: activeFilter,
masterFilter: masterFilterRef.current masterFilter: activeMasterFilter
} }
) )
if (generation !== loadGenerationRef.current) return
const nextValues = Array.isArray(values) ? values : [] const nextValues = Array.isArray(values) ? values : []
setCategoryValues(nextValues)
lastCategoryQueryKeyRef.current = queryKey lastCategoryQueryKeyRef.current = queryKey
} catch (error) { hasLoadedRef.current = true
console.error('Error fetching kanban category values:', error) setLoadedFilter(activeFilter)
setCategoryValues([]) setLoadedSorter(activeSorter)
lastCategoryQueryKeyRef.current = null setCategoryValues(nextValues)
throw error
} finally {
setHasLoaded(true) setHasLoaded(true)
} catch (error) {
if (generation !== loadGenerationRef.current) return
console.error('Error fetching kanban category values:', error)
lastCategoryQueryKeyRef.current = null
hasLoadedRef.current = true
setCategoryValues([])
setHasLoaded(true)
throw error
} }
}, },
[buildCategoryQueryKey, categoryProperty, reloadColumns, type] [buildCategoryQueryKey, categoryProperty, reloadColumns, type]
@ -154,6 +273,7 @@ const ObjectKanban = forwardRef(
const reload = useCallback(async () => { const reload = useCallback(async () => {
lastCategoryQueryKeyRef.current = null lastCategoryQueryKeyRef.current = null
hasLoadedRef.current = false
await load(baseFilterRef.current, sorterRef.current) await load(baseFilterRef.current, sorterRef.current)
}, [load]) }, [load])
@ -182,82 +302,100 @@ const ObjectKanban = forwardRef(
) )
} }
if (!hasLoaded || categoryValues.length === 0) { const showSkeleton = !hasLoaded
return (
<div className='objectKanbanContainer' style={{ height: '100%' }} />
)
}
return ( return (
<div className='objectKanbanContainerWrapper'> <div className='objectKanbanContainerWrapper'>
<div className='objectKanbanContainerSkeletons'> <Spin
<ObjectKanbanHeader indicator={<LoadingOutlined />}
categoryValues={categoryValues} spinning={showSkeleton}
categoryProperty={categoryProperty} style={{ height: '100%', flex: 1, minHeight: 0 }}
categoryPropertyDef={categoryPropertyDef} >
trackRef={headerTrackRef} <div className='objectKanbanContainerSkeletons'>
/> <ObjectKanbanHeader
<div className='objectKanbanKanbanBody'> categoryValues={categoryValues}
<div className='objectKanbanContainer'> categoryProperty={categoryProperty}
<ScrollBox categoryPropertyDef={categoryPropertyDef}
className='objectKanbanScroll' trackRef={headerTrackRef}
scrollableNodeProps={{ ref: setScrollContainerRef }} skeletonColumnCount={
> showSkeleton ? skeletonLayout.columnCount : 0
<Flex gap='middle' className='objectKanban'> }
{categoryValues.map((categoryValue) => { />
const columnKey = getCategoryValueKey(categoryValue) <div className='objectKanbanKanbanBody' ref={kanbanBodyRef}>
return ( {!showSkeleton && (
<ObjectKanbanColumn <div className='objectKanbanContainer'>
key={columnKey} <ScrollBox
ref={(node) => { className='objectKanbanScroll'
if (node) { scrollableNodeProps={{ ref: setScrollContainerRef }}
columnRefs.current[columnKey] = node >
} else { <Flex gap='middle' className='objectKanban'>
delete columnRefs.current[columnKey] {categoryValues.map((categoryValue) => {
} const columnKey = getCategoryValueKey(categoryValue)
}} return (
scrollElement={scrollElement} <ObjectKanbanColumn
type={type} key={columnKey}
categoryProperty={categoryProperty} ref={(node) => {
categoryValue={categoryValue} if (node) {
baseFilter={baseFilter} columnRefs.current[columnKey] = node
masterFilter={masterFilter} } else {
sorter={sorter} delete columnRefs.current[columnKey]
pageSize={pageSize} }
model={model} }}
modelProperties={modelProperties} scrollElement={scrollElement}
visibleColumns={visibleColumns} type={type}
isEditing={isEditing} categoryProperty={categoryProperty}
rowActions={rowActions} categoryValue={categoryValue}
renderActions={renderActions} baseFilter={loadedFilter}
/> masterFilter={masterFilter}
) sorter={loadedSorter}
})} pageSize={pageSize}
</Flex> model={model}
</ScrollBox> modelProperties={modelProperties}
</div> visibleColumns={visibleColumns}
<div className='objectKanbanSkeletonColumns'> isEditing={isEditing}
<div rowActions={rowActions}
className='objectKanbanSkeletonTrack' renderActions={renderActions}
ref={skeletonTrackRef} />
> )
<Flex gap='middle' className='objectKanbanSkeletonRow'> })}
{categoryValues.map((categoryValue) => { </Flex>
const columnKey = getCategoryValueKey(categoryValue) </ScrollBox>
return ( </div>
<div )}
key={columnKey} {showSkeleton ? (
className='objectKanbanColumnSkeleton' <ObjectKanbanSkeletonColumns
> columnCount={skeletonLayout.columnCount}
<div className='objectKanbanColumnCards' /> cardCount={skeletonLayout.cardCount}
</div> trackRef={skeletonTrackRef}
) model={model}
})} modelProperties={modelProperties}
</Flex> visibleColumns={visibleColumns}
</div> />
) : (
<div className='objectKanbanSkeletonColumns'>
<div
className='objectKanbanSkeletonTrack'
ref={skeletonTrackRef}
>
<Flex gap='middle' className='objectKanbanSkeletonRow'>
{categoryValues.map((categoryValue) => {
const columnKey = getCategoryValueKey(categoryValue)
return (
<div
key={columnKey}
className='objectKanbanColumnSkeleton'
>
<div className='objectKanbanColumnCards' />
</div>
)
})}
</Flex>
</div>
</div>
)}
</div> </div>
</div> </div>
</div> </Spin>
</div> </div>
) )
} }

View File

@ -164,17 +164,18 @@ const ObjectKanbanColumn = forwardRef(
) )
const fetchPage = useCallback( const fetchPage = useCallback(
async (pageNum) => { async (pageNum, overrides = {}) => {
const filter = { const filter = overrides.filter ?? {
...masterFilterRef.current, ...masterFilterRef.current,
...baseFilterRef.current, ...baseFilterRef.current,
[categoryProperty]: toCategoryFilterValue(categoryValue) [categoryProperty]: toCategoryFilterValue(categoryValue)
} }
const sorter = overrides.sorter ?? sorterRef.current
return fetchObjectsRef.current(type, { return fetchObjectsRef.current(type, {
page: pageNum, page: pageNum,
limit: pageSize, limit: pageSize,
filter, filter,
sorter: sorterRef.current sorter
}) })
}, },
[categoryProperty, categoryValue, pageSize, type] [categoryProperty, categoryValue, pageSize, type]
@ -362,6 +363,12 @@ const ObjectKanbanColumn = forwardRef(
loadingPagesRef.current.clear() loadingPagesRef.current.clear()
pendingScrollAnchorRef.current = null pendingScrollAnchorRef.current = null
const generation = dataLoadGenerationRef.current const generation = dataLoadGenerationRef.current
const filter = {
...masterFilterRef.current,
...baseFilterRef.current,
[categoryProperty]: toCategoryFilterValue(categoryValue)
}
const sorter = sorterRef.current
pagesRef.current = [] pagesRef.current = []
setPages([]) setPages([])
@ -371,7 +378,7 @@ const ObjectKanbanColumn = forwardRef(
setTablePages([skeletonPage]) setTablePages([skeletonPage])
try { try {
const firstResult = await fetchPage(1) const firstResult = await fetchPage(1, { filter, sorter })
if (isStaleDataLoad(generation)) return if (isStaleDataLoad(generation)) return
const loadedPages = [ const loadedPages = [
{ {
@ -393,6 +400,8 @@ const ObjectKanbanColumn = forwardRef(
} }
} }
}, [ }, [
categoryProperty,
categoryValue,
createPageWindow, createPageWindow,
createSkeletonPage, createSkeletonPage,
fetchPage, fetchPage,

View File

@ -1,4 +1,4 @@
import { Flex } from 'antd' import { Flex, Skeleton } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import ObjectProperty from './ObjectProperty' import ObjectProperty from './ObjectProperty'
import { getCategoryValueKey } from './viewModeUtils' import { getCategoryValueKey } from './viewModeUtils'
@ -7,27 +7,43 @@ const ObjectKanbanHeader = ({
categoryValues, categoryValues,
categoryProperty, categoryProperty,
categoryPropertyDef, categoryPropertyDef,
trackRef trackRef,
skeletonColumnCount = 0
}) => { }) => {
const showSkeleton = skeletonColumnCount > 0
return ( return (
<div className='objectKanbanHeader'> <div className='objectKanbanHeader'>
<div className='objectKanbanHeaderTrack' ref={trackRef}> <div className='objectKanbanHeaderTrack' ref={trackRef}>
<Flex gap='middle' className='objectKanbanHeaderRow'> <Flex gap='middle' className='objectKanbanHeaderRow'>
{categoryValues.map((categoryValue) => { {showSkeleton
const columnKey = getCategoryValueKey(categoryValue) ? Array.from({ length: skeletonColumnCount }).map((_, index) => (
return ( <div
<div key={columnKey} className='objectKanbanHeaderCell'> key={`skeleton-header-${index}`}
{categoryPropertyDef ? ( className='objectKanbanHeaderCell'
<ObjectProperty >
{...categoryPropertyDef} <Skeleton.Input
value={categoryValue} active
objectData={{ [categoryProperty]: categoryValue }} size='small'
name={categoryProperty} style={{ width: '60%', height: 24 }}
/> />
) : null} </div>
</div> ))
) : categoryValues.map((categoryValue) => {
})} const columnKey = getCategoryValueKey(categoryValue)
return (
<div key={columnKey} className='objectKanbanHeaderCell'>
{categoryPropertyDef ? (
<ObjectProperty
{...categoryPropertyDef}
value={categoryValue}
objectData={{ [categoryProperty]: categoryValue }}
name={categoryProperty}
/>
) : null}
</div>
)
})}
</Flex> </Flex>
</div> </div>
</div> </div>
@ -40,7 +56,8 @@ ObjectKanbanHeader.propTypes = {
categoryValues: PropTypes.array.isRequired, categoryValues: PropTypes.array.isRequired,
categoryProperty: PropTypes.string.isRequired, categoryProperty: PropTypes.string.isRequired,
categoryPropertyDef: PropTypes.object, categoryPropertyDef: PropTypes.object,
trackRef: PropTypes.object trackRef: PropTypes.object,
skeletonColumnCount: PropTypes.number
} }
export default ObjectKanbanHeader export default ObjectKanbanHeader

View File

@ -66,6 +66,7 @@ logger.setLevel(config.logLevel)
const SCROLL_THRESHOLD = 50 const SCROLL_THRESHOLD = 50
const SKELETON_HEIGHT = 49.5 const SKELETON_HEIGHT = 49.5
const EMPTY_MASTER_FILTER = {}
const getCardColSpan = (containerWidth) => { const getCardColSpan = (containerWidth) => {
if (containerWidth >= 2980) return 2 if (containerWidth >= 2980) return 2
@ -275,7 +276,7 @@ const ObjectTable = forwardRef(
viewMode: viewModeProp, viewMode: viewModeProp,
cards = false, cards = false,
visibleColumns = {}, visibleColumns = {},
masterFilter = {}, masterFilter,
size = 'middle', size = 'middle',
onStateChange, onStateChange,
showFilterSidebar = false, showFilterSidebar = false,
@ -303,10 +304,11 @@ const ObjectTable = forwardRef(
const isCards = isCardsView(viewMode) const isCards = isCardsView(viewMode)
const isKanban = isKanbanView(viewMode) const isKanban = isKanbanView(viewMode)
const kanbanRef = useRef(null) const kanbanRef = useRef(null)
const wasKanbanRef = useRef(false) const prevViewModeKeyRef = useRef(null)
const { token, userProfile } = useContext(AuthContext) const { token, userProfile } = useContext(AuthContext)
const { isElectron } = useContext(ElectronContext) const { isElectron } = useContext(ElectronContext)
const { callAction } = useActions() const { callAction } = useActions()
const resolvedMasterFilter = masterFilter ?? EMPTY_MASTER_FILTER
const listViewId = objectListView?.listViewId ?? null const listViewId = objectListView?.listViewId ?? null
const listViewFilter = objectListView?.listViewFilter ?? null const listViewFilter = objectListView?.listViewFilter ?? null
const listViewSort = objectListView?.listViewSort ?? null const listViewSort = objectListView?.listViewSort ?? null
@ -378,6 +380,7 @@ const ObjectTable = forwardRef(
const tableSorterRef = useRef({}) const tableSorterRef = useRef({})
const listViewIdRef = useRef(listViewId) const listViewIdRef = useRef(listViewId)
const listViewFilterRef = useRef(listViewFilter) const listViewFilterRef = useRef(listViewFilter)
const sidebarViewIdRef = useRef(listViewId)
const activeObjectViewRef = useRef(activeObjectView) const activeObjectViewRef = useRef(activeObjectView)
activeObjectViewRef.current = activeObjectView activeObjectViewRef.current = activeObjectView
const onObjectViewFilterSortChangeRef = useRef(onObjectViewFilterSortChange) const onObjectViewFilterSortChangeRef = useRef(onObjectViewFilterSortChange)
@ -393,13 +396,19 @@ const ObjectTable = forwardRef(
listViewFilterRef.current = listViewFilter || {} listViewFilterRef.current = listViewFilter || {}
}, [listViewId, listViewFilter]) }, [listViewId, listViewFilter])
const assignSidebarToView = useCallback((viewId) => {
sidebarViewIdRef.current = viewId ?? null
}, [])
const lastAppliedViewKeyRef = useRef(null) const lastAppliedViewKeyRef = useRef(null)
const buildEffectiveFilter = useCallback( const buildEffectiveFilter = useCallback(
(userFilter, viewFilter = listViewFilterRef.current) => { (userFilter, viewFilter = listViewFilterRef.current) => {
const active = getActiveFilterValues(userFilter) const active = getActiveFilterValues(userFilter)
const viewId = listViewIdRef.current const viewId = listViewIdRef.current
if (!viewId) return active // 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 { return {
...getActiveFilterValues(viewFilter || {}), ...getActiveFilterValues(viewFilter || {}),
...active ...active
@ -455,11 +464,11 @@ const ObjectTable = forwardRef(
const subscribeToObjectTypeUpdatesFnRef = useRef( const subscribeToObjectTypeUpdatesFnRef = useRef(
subscribeToObjectTypeUpdates subscribeToObjectTypeUpdates
) )
const prevValuesRef = useRef({ type, masterFilter }) const prevValuesRef = useRef({ type, masterFilter: resolvedMasterFilter })
const getMasterFilter = useCallback( const getMasterFilter = useCallback(
() => masterFilter || {}, () => resolvedMasterFilter || EMPTY_MASTER_FILTER,
[masterFilter] [resolvedMasterFilter]
) )
const resolveSorter = useCallback((userSorter) => { const resolveSorter = useCallback((userSorter) => {
@ -1042,15 +1051,26 @@ const ObjectTable = forwardRef(
// view definition + any user-entered sidebar filters. // view definition + any user-entered sidebar filters.
const effectiveFilter = useMemo(() => { const effectiveFilter = useMemo(() => {
const userFilter = getActiveFilterValues(sidebarFilter) const userFilter = getActiveFilterValues(sidebarFilter)
if (!listViewId) return userFilter const sidebarMatchesView = sidebarViewIdRef.current === listViewId
if (!listViewId || activeObjectView) {
if (!listViewId && !sidebarMatchesView) return {}
return userFilter
}
const viewFilter = getActiveFilterValues(listViewFilter || {})
if (!sidebarMatchesView) {
return viewFilter
}
return { return {
...getActiveFilterValues(listViewFilter || {}), ...viewFilter,
...userFilter ...userFilter
} }
}, [listViewId, listViewFilter, sidebarFilter]) }, [activeObjectView, listViewId, listViewFilter, sidebarFilter])
const effectiveSorter = useMemo(() => { const effectiveSorter = useMemo(() => {
if (tableSorter?.field && tableSorter?.order) { const sorterMatchesView = sidebarViewIdRef.current === listViewId
if (sorterMatchesView && tableSorter?.field && tableSorter?.order) {
return { field: tableSorter.field, order: tableSorter.order } return { field: tableSorter.field, order: tableSorter.order }
} }
if (listViewId && listViewSort?.field && listViewSort?.order) { if (listViewId && listViewSort?.field && listViewSort?.order) {
@ -1060,16 +1080,16 @@ const ObjectTable = forwardRef(
}, [listViewId, listViewSort, tableSorter]) }, [listViewId, listViewSort, tableSorter])
const subscriptionFilter = useMemo( const subscriptionFilter = useMemo(
() => ({ ...masterFilter, ...effectiveFilter }), () => ({ ...resolvedMasterFilter, ...effectiveFilter }),
[effectiveFilter, masterFilter] [effectiveFilter, resolvedMasterFilter]
) )
const tableFilterContextValue = useMemo( const tableFilterContextValue = useMemo(
() => ({ () => ({
filter: effectiveFilter, filter: effectiveFilter,
masterFilter masterFilter: resolvedMasterFilter
}), }),
[effectiveFilter, masterFilter] [effectiveFilter, resolvedMasterFilter]
) )
newEventHandlerRef.current = newEventHandler newEventHandlerRef.current = newEventHandler
@ -1142,27 +1162,33 @@ const ObjectTable = forwardRef(
const loadPage = useCallback( const loadPage = useCallback(
async (pageNum, filter = null, sorter = null) => { async (pageNum, filter = null, sorter = null) => {
if (isKanban) { 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 : {}
setLoading(true) setLoading(true)
try { try {
const activeFilter = let loadKanban = kanbanRef.current?.load
filter != null ? filter : activeFilterRef.current if (!loadKanban) {
const resolvedSorter =
sorter != null
? resolveSorter(sorter)
: resolveSorter(tableSorterRef.current)
if (!kanbanRef.current?.load) {
await new Promise((resolve) => requestAnimationFrame(resolve)) await new Promise((resolve) => requestAnimationFrame(resolve))
if (generation !== dataLoadGenerationRef.current) return
loadKanban = kanbanRef.current?.load
} }
await kanbanRef.current?.load?.( if (!loadKanban) return
activeFilter, await loadKanban(activeFilter, sorterForLoad)
resolvedSorter?.field ? resolvedSorter : {}
)
} catch (error) { } catch (error) {
logger.error('Error loading kanban view:', error) logger.error('Error loading kanban view:', error)
} finally { } finally {
setLoading(false) if (generation === dataLoadGenerationRef.current) {
setLoading(false)
}
} }
return return
} }
@ -1229,6 +1255,7 @@ const ObjectTable = forwardRef(
const resolvedSorter = resolveSorter(sorter) const resolvedSorter = resolveSorter(sorter)
if (Object.keys(filter).length > 0) { if (Object.keys(filter).length > 0) {
assignSidebarToView(listViewIdRef.current)
setSidebarFilter(filter) setSidebarFilter(filter)
} }
if (sorter?.field) { if (sorter?.field) {
@ -1242,6 +1269,7 @@ const ObjectTable = forwardRef(
resolvedSorter.field ? resolvedSorter : null resolvedSorter.field ? resolvedSorter : null
) )
}, [ }, [
assignSidebarToView,
getPersistedFilter, getPersistedFilter,
getPersistedSorter, getPersistedSorter,
getViewFromUrl, getViewFromUrl,
@ -1249,6 +1277,8 @@ const ObjectTable = forwardRef(
loadPage, loadPage,
resolveSorter resolveSorter
]) ])
const loadInitialPageRef = useRef(loadInitialPage)
loadInitialPageRef.current = loadInitialPage
// When a view becomes available, apply its definition once. Skip empty // When a view becomes available, apply its definition once. Skip empty
// prop-lag updates so we never clear an already-applied view filter. // prop-lag updates so we never clear an already-applied view filter.
@ -1275,6 +1305,7 @@ const ObjectTable = forwardRef(
lastAppliedViewKeyRef.current = viewKey lastAppliedViewKeyRef.current = viewKey
assignSidebarToView(listViewId)
setSidebarFilter(viewFilter) setSidebarFilter(viewFilter)
setTableSorter(viewSort) setTableSorter(viewSort)
tableSorterRef.current = viewSort tableSorterRef.current = viewSort
@ -1307,6 +1338,7 @@ const ObjectTable = forwardRef(
listViewIdRef.current = null listViewIdRef.current = null
listViewFilterRef.current = {} listViewFilterRef.current = {}
lastAppliedViewKeyRef.current = null lastAppliedViewKeyRef.current = null
assignSidebarToView(null)
filter = getAllTabPersistedFilter() filter = getAllTabPersistedFilter()
sorter = getAllTabPersistedSorter() sorter = getAllTabPersistedSorter()
@ -1352,6 +1384,7 @@ const ObjectTable = forwardRef(
lastAppliedViewKeyRef.current = `${viewId || ''}:${JSON.stringify(activeFilter)}:${JSON.stringify(nextSorter)}` lastAppliedViewKeyRef.current = `${viewId || ''}:${JSON.stringify(activeFilter)}:${JSON.stringify(nextSorter)}`
assignSidebarToView(viewId)
setSidebarFilter(filter) setSidebarFilter(filter)
tableSorterRef.current = nextSorter tableSorterRef.current = nextSorter
setTableSorter(nextSorter) setTableSorter(nextSorter)
@ -1364,6 +1397,7 @@ const ObjectTable = forwardRef(
) )
}, },
[ [
assignSidebarToView,
getAllTabPersistedFilter, getAllTabPersistedFilter,
getAllTabPersistedSorter, getAllTabPersistedSorter,
getViewFromUrl, getViewFromUrl,
@ -1396,7 +1430,7 @@ const ObjectTable = forwardRef(
let cancelled = false let cancelled = false
const run = async () => { const run = async () => {
await loadInitialPage() await loadInitialPageRef.current()
if (!cancelled) { if (!cancelled) {
setInitialized(true) setInitialized(true)
} }
@ -1407,35 +1441,34 @@ const ObjectTable = forwardRef(
return () => { return () => {
cancelled = true cancelled = true
} }
}, [token, loadInitialPage, initialized, connected]) }, [token, initialized, connected])
useEffect(() => { useEffect(() => {
if (!isKanban || connected !== true || token == null) { const viewModeKey = `${viewMode.type}:${viewMode?.settings?.categoryProperty || ''}`
wasKanbanRef.current = isKanban
if (connected !== true || token == null || !initialized) {
prevViewModeKeyRef.current = viewModeKey
return return
} }
const categoryProperty = viewMode?.settings?.categoryProperty if (prevViewModeKeyRef.current === viewModeKey) return
if (!categoryProperty) { prevViewModeKeyRef.current = viewModeKey
wasKanbanRef.current = isKanban
return
}
if (!wasKanbanRef.current) { if (isKanban && !viewMode?.settings?.categoryProperty) return
loadPage(
initialPage,
activeFilterRef.current,
tableSorterRef.current?.field ? tableSorterRef.current : null
)
}
wasKanbanRef.current = isKanban loadPage(
initialPage,
activeFilterRef.current,
tableSorterRef.current?.field ? tableSorterRef.current : null
)
}, [ }, [
isKanban, isKanban,
connected, connected,
token, token,
initialized,
loadPage, loadPage,
initialPage, initialPage,
viewMode.type,
viewMode?.settings?.categoryProperty viewMode?.settings?.categoryProperty
]) ])
@ -1445,7 +1478,8 @@ const ObjectTable = forwardRef(
const hasChanged = const hasChanged =
prevValues.type !== type || prevValues.type !== type ||
JSON.stringify(prevValues.masterFilter) !== JSON.stringify(masterFilter) JSON.stringify(prevValues.masterFilter) !==
JSON.stringify(resolvedMasterFilter)
if (hasChanged) { if (hasChanged) {
beginDataReload() beginDataReload()
@ -1453,14 +1487,15 @@ const ObjectTable = forwardRef(
setPages([]) setPages([])
activeFilterRef.current = {} activeFilterRef.current = {}
tableSorterRef.current = {} tableSorterRef.current = {}
assignSidebarToView(null)
setSidebarFilter({}) setSidebarFilter({})
setTableSorter({}) setTableSorter({})
setInitialized(false) setInitialized(false)
setLoading(true) setLoading(true)
setLazyLoading(false) setLazyLoading(false)
prevValuesRef.current = { type, masterFilter } prevValuesRef.current = { type, masterFilter: resolvedMasterFilter }
} }
}, [type, masterFilter, beginDataReload]) }, [type, resolvedMasterFilter, beginDataReload])
useEffect(() => { useEffect(() => {
registerPageFilter(effectiveFilter) registerPageFilter(effectiveFilter)
@ -1493,7 +1528,7 @@ const ObjectTable = forwardRef(
propertyLabel={propertyLabel} propertyLabel={propertyLabel}
modelType={type} modelType={type}
filter={effectiveFilter} filter={effectiveFilter}
masterFilter={masterFilter} masterFilter={resolvedMasterFilter}
/> />
) )
@ -1517,25 +1552,27 @@ const ObjectTable = forwardRef(
} }
setSidebarFilter(next) setSidebarFilter(next)
assignSidebarToView(listViewIdRef.current)
setTableSorter(nextSorter) setTableSorter(nextSorter)
const effective = buildEffectiveFilter(next)
activeFilterRef.current = effective
if (activeObjectViewRef.current) { if (activeObjectViewRef.current) {
listViewFilterRef.current = next
// Editing a view: keep filter/sort on the draft view only // Editing a view: keep filter/sort on the draft view only
onObjectViewFilterSortChangeRef.current?.(next, nextSorter) onObjectViewFilterSortChangeRef.current?.(next, nextSorter)
} else if (!listViewIdRef.current) { } else if (!listViewIdRef.current) {
// All tab: persist personal filter/sort to URL/session // All tab: persist personal filter/sort to URL/session
persistTableState(next, nextSorter) persistTableState(next, nextSorter)
} }
const effective = buildEffectiveFilter(next)
activeFilterRef.current = effective
loadPage(initialPage, effective, resolveSorter(nextSorter)) loadPage(initialPage, effective, resolveSorter(nextSorter))
} }
const handleSidebarFilterChange = useCallback( const handleSidebarFilterChange = useCallback(
(newSidebarFilter) => { (newSidebarFilter) => {
setSidebarFilter(newSidebarFilter) setSidebarFilter(newSidebarFilter)
const effective = buildEffectiveFilter(newSidebarFilter) assignSidebarToView(listViewIdRef.current)
activeFilterRef.current = effective
if (activeObjectViewRef.current) { if (activeObjectViewRef.current) {
listViewFilterRef.current = newSidebarFilter
onObjectViewFilterSortChangeRef.current?.( onObjectViewFilterSortChangeRef.current?.(
newSidebarFilter, newSidebarFilter,
tableSorter tableSorter
@ -1543,9 +1580,12 @@ const ObjectTable = forwardRef(
} else if (!listViewIdRef.current) { } else if (!listViewIdRef.current) {
persistFilter(newSidebarFilter) persistFilter(newSidebarFilter)
} }
const effective = buildEffectiveFilter(newSidebarFilter)
activeFilterRef.current = effective
loadPage(initialPage, effective, resolveSorter(tableSorter)) loadPage(initialPage, effective, resolveSorter(tableSorter))
}, },
[ [
assignSidebarToView,
buildEffectiveFilter, buildEffectiveFilter,
initialPage, initialPage,
loadPage, loadPage,
@ -1568,11 +1608,8 @@ const ObjectTable = forwardRef(
} else if (!listViewIdRef.current) { } else if (!listViewIdRef.current) {
persistSort(nextSorter) persistSort(nextSorter)
} }
loadPage( const effective = buildEffectiveFilter(sidebarFilter)
initialPage, loadPage(initialPage, effective, resolveSorter(nextSorter))
buildEffectiveFilter(sidebarFilter),
resolveSorter(nextSorter)
)
}, },
[ [
buildEffectiveFilter, buildEffectiveFilter,
@ -1711,7 +1748,10 @@ const ObjectTable = forwardRef(
} }
} }
if (isFilterable && !Object.keys(masterFilter).includes(prop.name)) { if (
isFilterable &&
!Object.keys(resolvedMasterFilter).includes(prop.name)
) {
columnConfig.filterDropdown = ({ columnConfig.filterDropdown = ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,
@ -1851,7 +1891,9 @@ const ObjectTable = forwardRef(
return ( return (
<ScrollBox <ScrollBox
style={{ style={{
maxHeight: `100%` height: '100%',
flex: 1,
minHeight: 0
}} }}
> >
<div className='objectTableCards'> <div className='objectTableCards'>
@ -1944,7 +1986,7 @@ const ObjectTable = forwardRef(
type={type} type={type}
categoryProperty={viewMode.settings?.categoryProperty} categoryProperty={viewMode.settings?.categoryProperty}
baseFilter={effectiveFilter} baseFilter={effectiveFilter}
masterFilter={masterFilter} masterFilter={resolvedMasterFilter}
sorter={effectiveSorter} sorter={effectiveSorter}
pageSize={pageSize} pageSize={pageSize}
model={model} model={model}
@ -1964,7 +2006,7 @@ const ObjectTable = forwardRef(
<Spin <Spin
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
spinning={loading} spinning={loading}
style={{ height: '100%' }} style={{ height: '100%', flex: 1, minHeight: 0 }}
> >
{renderCards()} {renderCards()}
</Spin> </Spin>
@ -2012,9 +2054,9 @@ const ObjectTable = forwardRef(
<Flex style={{ flex: 1, minHeight: 0 }}> <Flex style={{ flex: 1, minHeight: 0 }}>
<FilterSidebar <FilterSidebar
type={type} type={type}
filter={effectiveFilter} filter={activeObjectView ? sidebarFilter : effectiveFilter}
onFilterChange={handleSidebarFilterChange} onFilterChange={handleSidebarFilterChange}
masterFilter={masterFilter} masterFilter={resolvedMasterFilter}
/> />
</Flex> </Flex>
)} )}

View File

@ -119,7 +119,7 @@ const ObjectTableViewButton = ({
).map((option) => ( ).map((option) => (
<Radio key={option.type} value={option.type}> <Radio key={option.type} value={option.type}>
<Flex <Flex
gap='4px' gap='8px'
align='center' align='center'
style={{ marginLeft: '4px' }} style={{ marginLeft: '4px' }}
> >