Enhance ObjectKanban and ObjectTable Components with Column Resizing and Drag-and-Drop Functionality

- Implemented dynamic column resizing in ObjectKanban and ObjectKanbanColumn, allowing users to adjust column widths for better layout control.
- Added drag-and-drop support for reordering columns in ObjectKanbanHeader, improving user interaction and customization of the kanban view.
- Updated CSS styles in App.css to accommodate new resizing handles and visual feedback during drag-and-drop actions.
- Refactored ObjectKanban and related components to manage column states and widths effectively, ensuring a responsive and user-friendly experience.
This commit is contained in:
Tom Butcher 2026-09-03 14:27:34 +01:00
parent 65e25ae077
commit 5e6b4213ab
8 changed files with 593 additions and 108 deletions

View File

@ -1520,14 +1520,35 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
} }
.objectKanbanHeaderCell { .objectKanbanHeaderCell {
flex: 0 0 360px; flex: 0 0 auto;
min-width: 360px; min-width: 200px;
flex-shrink: 0; flex-shrink: 0;
padding: 8px 8px; padding: 8px 8px;
border: 1px solid var(--ant-color-border-secondary); border: 1px solid var(--ant-color-border-secondary);
border-bottom: 1px solid var(--color-descriptions-border); border-bottom: 1px solid var(--color-descriptions-border);
border-radius: 12px 12px 0 0; border-radius: 12px 12px 0 0;
background-color: var(--layout-header-bg); background-color: var(--layout-header-bg);
transition:
outline-color 0.15s ease,
opacity 0.15s ease;
}
.objectKanbanHeaderCell--dragging {
opacity: 0.45;
}
.objectKanbanHeaderCell--dropTarget {
outline: 2px dashed color-mix(in srgb, var(--color-primary) 55%, transparent);
outline-offset: -2px;
}
.objectKanbanHeaderCellActions {
flex-shrink: 0;
margin-left: auto;
}
.objectKanbanHeaderDragHandle {
flex-shrink: 0;
} }
.objectKanban { .objectKanban {
@ -1563,13 +1584,80 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
.objectKanbanColumn, .objectKanbanColumn,
.objectKanbanColumnSkeleton { .objectKanbanColumnSkeleton {
flex: 0 0 360px; position: relative;
min-width: 360px; flex: 0 0 auto;
min-width: 200px;
min-height: 0; min-height: 0;
border-top: none; border-top: none;
border-radius: 0 0 12px 12px; border-radius: 0 0 12px 12px;
} }
.objectKanbanColumnResizeHandle {
position: absolute;
top: 0;
right: -10px;
bottom: 0;
width: 11px;
z-index: 3;
cursor: col-resize;
touch-action: none;
user-select: none;
}
.objectKanbanColumnResizeHandle::before {
content: '';
position: absolute;
top: 11px;
bottom: 11px;
right: 0;
width: 2px;
transform: translateX(-50%);
opacity: 0.6;
transition: opacity 0.15s ease;
}
.objectKanbanColumnResizeHandle::after {
content: '';
position: absolute;
top: 50%;
right: 0;
width: 2px;
height: 20px;
transform: translate(-50%, -50%);
opacity: 0.6;
transition: opacity 0.15s ease;
}
.objectKanbanColumnResizeHandle.active::before,
.objectKanbanColumnResizeHandle.active::after {
background: #0e3a5b;
opacity: 1;
}
.dark-mode .objectKanbanColumnResizeHandle::after {
background: rgba(255, 255, 255, 0.18);
}
.dark-mode .objectKanbanColumnResizeHandle::before {
background: rgba(255, 255, 255, 0.08);
}
.dark-mode .objectKanbanColumnResizeHandle.active::before,
.dark-mode .objectKanbanColumnResizeHandle.active::after {
background: #0b4c7e;
opacity: 1;
}
.objectKanbanColumn:hover .objectKanbanColumnResizeHandle::before {
background: #0e3a5b;
}
body.objectKanbanColumnResizing,
body.objectKanbanColumnResizing * {
cursor: col-resize !important;
user-select: none !important;
}
.objectKanbanColumnSkeleton { .objectKanbanColumnSkeleton {
border-radius: 0 0 12px 12px; border-radius: 0 0 12px 12px;
overflow: hidden; overflow: hidden;

View File

@ -98,7 +98,7 @@ const ObjectCard = ({
return ( return (
<Card <Card
styles={{ body: { padding: 18 } }} styles={{ body: { padding: '14px 16px' } }}
style={{ width: '100%' }} style={{ width: '100%' }}
variant={cardStyle} variant={cardStyle}
> >

View File

@ -17,13 +17,31 @@ import ObjectKanbanColumn from './ObjectKanbanColumn'
import ObjectKanbanHeader from './ObjectKanbanHeader' import ObjectKanbanHeader from './ObjectKanbanHeader'
import ScrollBox from './ScrollBox' import ScrollBox from './ScrollBox'
import Spin from './Spin' import Spin from './Spin'
import { getCategoryColumnKeys, getCategoryValueKey } from './viewModeUtils' import {
getCategoryColumnKeys,
getCategoryValueKey,
KANBAN_DEFAULT_COLUMN_WIDTH,
KANBAN_MIN_COLUMN_WIDTH,
resolveKanbanColumns,
toKanbanColumnsConfig
} from './viewModeUtils'
import { areValuesEqual } from '../utils/Utils' import { areValuesEqual } from '../utils/Utils'
const KANBAN_COLUMN_WIDTH = 360 const KANBAN_COLUMN_WIDTH = KANBAN_DEFAULT_COLUMN_WIDTH
const KANBAN_GAP = 16 const KANBAN_GAP = 16
const KANBAN_CARD_MIN_HEIGHT = 136 const KANBAN_CARD_MIN_HEIGHT = 136
const reorderColumnsByKey = (columns, fromKey, toKey) => {
if (!fromKey || !toKey || fromKey === toKey) return columns
const fromIndex = columns.findIndex((column) => column.key === fromKey)
const toIndex = columns.findIndex((column) => column.key === toKey)
if (fromIndex < 0 || toIndex < 0) return columns
const next = [...columns]
const [moved] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, moved)
return next
}
const getUpdateKeys = (updated) => { const getUpdateKeys = (updated) => {
if (!updated || typeof updated !== 'object') return [] if (!updated || typeof updated !== 'object') return []
return Object.keys(updated).filter( return Object.keys(updated).filter(
@ -86,45 +104,60 @@ const ObjectKanbanSkeletonColumns = ({
model, model,
modelProperties, modelProperties,
visibleColumns, visibleColumns,
keyPrefix = 'skeleton' keyPrefix = 'skeleton',
}) => ( columns = null
<div className='objectKanbanSkeletonColumns objectKanbanSkeletonColumns--loading'> }) => {
<div className='objectKanbanSkeletonTrack' ref={trackRef}> const skeletonColumns =
<Flex gap='middle' className='objectKanbanSkeletonRow'> columns?.length > 0
{Array.from({ length: columnCount }).map((_, columnIndex) => ( ? columns
<div : Array.from({ length: columnCount }).map((_, columnIndex) => ({
key={`${keyPrefix}-${columnIndex}`} key: `${keyPrefix}-${columnIndex}`,
className='objectKanbanColumnSkeleton' width: KANBAN_COLUMN_WIDTH
> }))
<div className='objectKanbanColumnCards'>
{cardCount > 0 && ( return (
<Flex <div className='objectKanbanSkeletonColumns objectKanbanSkeletonColumns--loading'>
vertical <div className='objectKanbanSkeletonTrack' ref={trackRef}>
gap='middle' <Flex gap='middle' className='objectKanbanSkeletonRow'>
className='objectKanbanColumnCardsInner' {skeletonColumns.map((column, columnIndex) => (
> <div
{Array.from({ length: cardCount }).map((_, cardIndex) => ( key={column.key || `${keyPrefix}-${columnIndex}`}
<ObjectCard className='objectKanbanColumnSkeleton'
key={cardIndex} style={{
isSkeleton width: column.width,
model={model} flex: `0 0 ${column.width}px`
modelProperties={modelProperties} }}
visibleColumns={visibleColumns} >
record={{ <div className='objectKanbanColumnCards'>
_id: `${keyPrefix}-${columnIndex}-${cardIndex}` {cardCount > 0 && (
}} <Flex
cardStyle='bordered' vertical
/> gap='middle'
))} className='objectKanbanColumnCardsInner'
</Flex> >
)} {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> </div>
</div> ))}
))} </Flex>
</Flex> </div>
</div> </div>
</div> )
) }
ObjectKanbanSkeletonColumns.propTypes = { ObjectKanbanSkeletonColumns.propTypes = {
columnCount: PropTypes.number.isRequired, columnCount: PropTypes.number.isRequired,
@ -133,7 +166,8 @@ ObjectKanbanSkeletonColumns.propTypes = {
model: PropTypes.object.isRequired, model: PropTypes.object.isRequired,
modelProperties: PropTypes.array.isRequired, modelProperties: PropTypes.array.isRequired,
visibleColumns: PropTypes.object, visibleColumns: PropTypes.object,
keyPrefix: PropTypes.string keyPrefix: PropTypes.string,
columns: PropTypes.array
} }
const ObjectKanban = forwardRef( const ObjectKanban = forwardRef(
@ -151,7 +185,9 @@ const ObjectKanban = forwardRef(
visibleColumns = {}, visibleColumns = {},
isEditing = false, isEditing = false,
rowActions = [], rowActions = [],
renderActions renderActions,
columns: storedColumns = [],
onColumnsChange
}, },
ref ref
) => { ) => {
@ -175,6 +211,11 @@ const ObjectKanban = forwardRef(
cardCount: 3 cardCount: 3
}) })
const [reloadingColumnKeys, setReloadingColumnKeys] = useState([]) const [reloadingColumnKeys, setReloadingColumnKeys] = useState([])
const [draggedKey, setDraggedKey] = useState(null)
const [dropTargetKey, setDropTargetKey] = useState(null)
const [widthOverrides, setWidthOverrides] = useState({})
const [optimisticColumns, setOptimisticColumns] = useState(null)
const [resizingKey, setResizingKey] = useState(null)
const baseFilterRef = useRef(baseFilter) const baseFilterRef = useRef(baseFilter)
const masterFilterRef = useRef(masterFilter) const masterFilterRef = useRef(masterFilter)
@ -622,6 +663,168 @@ const ObjectKanban = forwardRef(
[load, reload] [load, reload]
) )
const resolvedColumns = useMemo(() => {
const fromStore = resolveKanbanColumns(categoryValues, storedColumns)
if (!optimisticColumns) return fromStore
const valueByKey = new Map(
fromStore.map((column) => [column.key, column.value])
)
const ordered = []
const seen = new Set()
for (const column of optimisticColumns) {
if (!valueByKey.has(column.key) || seen.has(column.key)) continue
ordered.push({
key: column.key,
value: valueByKey.get(column.key),
width: column.width
})
seen.add(column.key)
}
for (const column of fromStore) {
if (seen.has(column.key)) continue
ordered.push(column)
}
return ordered
}, [categoryValues, optimisticColumns, storedColumns])
useEffect(() => {
setOptimisticColumns(null)
setWidthOverrides({})
setDraggedKey(null)
setDropTargetKey(null)
setResizingKey(null)
}, [categoryProperty])
useEffect(() => {
if (!optimisticColumns) return
const fromStore = resolveKanbanColumns(categoryValues, storedColumns)
if (
JSON.stringify(toKanbanColumnsConfig(fromStore)) ===
JSON.stringify(toKanbanColumnsConfig(optimisticColumns))
) {
setOptimisticColumns(null)
}
}, [categoryValues, optimisticColumns, storedColumns])
const displayColumns = useMemo(() => {
if (!Object.keys(widthOverrides).length) return resolvedColumns
return resolvedColumns.map((column) =>
widthOverrides[column.key] != null
? { ...column, width: widthOverrides[column.key] }
: column
)
}, [resolvedColumns, widthOverrides])
const persistColumns = useCallback(
(nextColumns) => {
setOptimisticColumns(nextColumns)
onColumnsChange?.(toKanbanColumnsConfig(nextColumns))
},
[onColumnsChange]
)
const handleColumnDragStart = useCallback((event, columnKey) => {
event.dataTransfer.effectAllowed = 'move'
try {
event.dataTransfer.setData('text/plain', columnKey)
} catch {
// Some browsers restrict setData outside certain types.
}
setDraggedKey(columnKey)
setDropTargetKey(null)
}, [])
const handleColumnDragOver = useCallback(
(event, columnKey) => {
if (!draggedKey || draggedKey === columnKey) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
setDropTargetKey((prev) => (prev === columnKey ? prev : columnKey))
},
[draggedKey]
)
const finishColumnReorder = useCallback(
(fromKey, toKey) => {
if (!fromKey || !toKey || fromKey === toKey) {
setDraggedKey(null)
setDropTargetKey(null)
return
}
const nextColumns = reorderColumnsByKey(
resolvedColumns,
fromKey,
toKey
)
persistColumns(nextColumns)
setDraggedKey(null)
setDropTargetKey(null)
},
[persistColumns, resolvedColumns]
)
const handleColumnDrop = useCallback(
(event, columnKey) => {
event.preventDefault()
const fromKey = draggedKey || event.dataTransfer.getData('text/plain')
finishColumnReorder(fromKey, columnKey)
},
[draggedKey, finishColumnReorder]
)
const handleColumnDragEnd = useCallback(() => {
setDraggedKey(null)
setDropTargetKey(null)
}, [])
const handleColumnResizeStart = useCallback(
(event, columnKey) => {
event.preventDefault()
event.stopPropagation()
const startX = event.clientX
const startWidth =
displayColumns.find((column) => column.key === columnKey)?.width ??
KANBAN_COLUMN_WIDTH
let currentWidth = startWidth
setResizingKey(columnKey)
const handleMouseMove = (moveEvent) => {
currentWidth = Math.max(
KANBAN_MIN_COLUMN_WIDTH,
startWidth + (moveEvent.clientX - startX)
)
setWidthOverrides((prev) => ({
...prev,
[columnKey]: currentWidth
}))
}
const handleMouseUp = () => {
const nextColumns = resolvedColumns.map((column) =>
column.key === columnKey
? { ...column, width: currentWidth }
: column
)
setWidthOverrides({})
setResizingKey(null)
persistColumns(nextColumns)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.classList.remove('objectKanbanColumnResizing')
}
document.body.classList.add('objectKanbanColumnResizing')
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
},
[displayColumns, persistColumns, resolvedColumns]
)
if (!categoryProperty) { if (!categoryProperty) {
return ( return (
<Flex align='center' justify='center' style={{ height: '100%' }}> <Flex align='center' justify='center' style={{ height: '100%' }}>
@ -645,7 +848,7 @@ const ObjectKanban = forwardRef(
<Spin spinning={showSkeleton}> <Spin spinning={showSkeleton}>
<div className='objectKanbanContainerSkeletons'> <div className='objectKanbanContainerSkeletons'>
<ObjectKanbanHeader <ObjectKanbanHeader
categoryValues={categoryValues} columns={displayColumns}
categoryProperty={categoryProperty} categoryProperty={categoryProperty}
categoryPropertyDef={categoryPropertyDef} categoryPropertyDef={categoryPropertyDef}
trackRef={headerTrackRef} trackRef={headerTrackRef}
@ -654,6 +857,12 @@ const ObjectKanban = forwardRef(
} }
lazyLoading={showSkeleton || lazyLoading} lazyLoading={showSkeleton || lazyLoading}
loadingColumnKeys={reloadingColumnKeys} loadingColumnKeys={reloadingColumnKeys}
draggedKey={draggedKey}
dropTargetKey={dropTargetKey}
onColumnDragStart={handleColumnDragStart}
onColumnDragOver={handleColumnDragOver}
onColumnDrop={handleColumnDrop}
onColumnDragEnd={handleColumnDragEnd}
/> />
<div className='objectKanbanKanbanBody' ref={kanbanBodyRef}> <div className='objectKanbanKanbanBody' ref={kanbanBodyRef}>
{!showSkeleton && ( {!showSkeleton && (
@ -663,9 +872,8 @@ const ObjectKanban = forwardRef(
scrollableNodeProps={{ ref: setScrollContainerRef }} scrollableNodeProps={{ ref: setScrollContainerRef }}
> >
<Flex gap='middle' className='objectKanban'> <Flex gap='middle' className='objectKanban'>
{categoryValues.map((categoryValue) => { {displayColumns.map(
const columnKey = getCategoryValueKey(categoryValue) ({ key: columnKey, value: categoryValue, width }) => (
return (
<ObjectKanbanColumn <ObjectKanbanColumn
key={columnKey} key={columnKey}
ref={(node) => { ref={(node) => {
@ -689,9 +897,14 @@ const ObjectKanban = forwardRef(
isEditing={isEditing} isEditing={isEditing}
rowActions={rowActions} rowActions={rowActions}
renderActions={renderActions} renderActions={renderActions}
width={width}
isResizing={resizingKey === columnKey}
onResizeStart={(event) =>
handleColumnResizeStart(event, columnKey)
}
/> />
) )
})} )}
</Flex> </Flex>
</ScrollBox> </ScrollBox>
</div> </div>
@ -712,17 +925,18 @@ const ObjectKanban = forwardRef(
ref={skeletonTrackRef} ref={skeletonTrackRef}
> >
<Flex gap='middle' className='objectKanbanSkeletonRow'> <Flex gap='middle' className='objectKanbanSkeletonRow'>
{categoryValues.map((categoryValue) => { {displayColumns.map(({ key: columnKey, width }) => (
const columnKey = getCategoryValueKey(categoryValue) <div
return ( key={columnKey}
<div className='objectKanbanColumnSkeleton'
key={columnKey} style={{
className='objectKanbanColumnSkeleton' width,
> flex: `0 0 ${width}px`
<div className='objectKanbanColumnCards' /> }}
</div> >
) <div className='objectKanbanColumnCards' />
})} </div>
))}
</Flex> </Flex>
</div> </div>
</div> </div>
@ -750,7 +964,14 @@ ObjectKanban.propTypes = {
visibleColumns: PropTypes.object, visibleColumns: PropTypes.object,
isEditing: PropTypes.bool, isEditing: PropTypes.bool,
rowActions: PropTypes.array, rowActions: PropTypes.array,
renderActions: PropTypes.func renderActions: PropTypes.func,
columns: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
width: PropTypes.number
})
),
onColumnsChange: PropTypes.func
} }
export default ObjectKanban export default ObjectKanban

View File

@ -11,10 +11,11 @@ import {
} from 'react' } from 'react'
import { Flex } from 'antd' import { Flex } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import classNames from 'classnames'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import ObjectCard from './ObjectCard' import ObjectCard from './ObjectCard'
import Spin from './Spin' import Spin from './Spin'
import { toCategoryFilterValue } from './viewModeUtils' import { KANBAN_MIN_COLUMN_WIDTH, toCategoryFilterValue } from './viewModeUtils'
const SCROLL_THRESHOLD = 50 const SCROLL_THRESHOLD = 50
@ -40,7 +41,10 @@ const ObjectKanbanColumn = forwardRef(
rowActions = [], rowActions = [],
lazyLoading = false, lazyLoading = false,
renderActions, renderActions,
scrollElement = null scrollElement = null,
width,
isResizing = false,
onResizeStart
}, },
ref ref
) => { ) => {
@ -545,8 +549,13 @@ const ObjectKanbanColumn = forwardRef(
tableData tableData
]) ])
const columnStyle =
typeof width === 'number'
? { width, flex: `0 0 ${width}px`, minWidth: KANBAN_MIN_COLUMN_WIDTH }
: undefined
return ( return (
<Flex vertical className='objectKanbanColumn'> <Flex vertical className='objectKanbanColumn' style={columnStyle}>
<div className='objectKanbanColumnCards' ref={containerRef}> <div className='objectKanbanColumnCards' ref={containerRef}>
<Spin spinning={loading}> <Spin spinning={loading}>
<Flex <Flex
@ -588,6 +597,17 @@ const ObjectKanbanColumn = forwardRef(
</Flex> </Flex>
</Spin> </Spin>
</div> </div>
{onResizeStart && (
<div
className={classNames('objectKanbanColumnResizeHandle', {
active: isResizing
})}
role='separator'
aria-orientation='vertical'
aria-label='Resize column'
onMouseDown={(event) => onResizeStart(event)}
/>
)}
</Flex> </Flex>
) )
} }
@ -610,7 +630,10 @@ ObjectKanbanColumn.propTypes = {
rowActions: PropTypes.array, rowActions: PropTypes.array,
lazyLoading: PropTypes.bool, lazyLoading: PropTypes.bool,
renderActions: PropTypes.func, renderActions: PropTypes.func,
scrollElement: PropTypes.object scrollElement: PropTypes.object,
width: PropTypes.number,
isResizing: PropTypes.bool,
onResizeStart: PropTypes.func
} }
export default ObjectKanbanColumn export default ObjectKanbanColumn

View File

@ -1,17 +1,23 @@
import { Flex, Skeleton } from 'antd' import { Flex, Skeleton } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { HolderOutlined, LoadingOutlined } from '@ant-design/icons'
import ObjectProperty from './ObjectProperty' import ObjectProperty from './ObjectProperty'
import { getCategoryValueKey } from './viewModeUtils' import { KANBAN_DEFAULT_COLUMN_WIDTH } from './viewModeUtils'
import { LoadingOutlined } from '@ant-design/icons'
const ObjectKanbanHeader = ({ const ObjectKanbanHeader = ({
categoryValues, columns = [],
categoryProperty, categoryProperty,
categoryPropertyDef, categoryPropertyDef,
trackRef, trackRef,
skeletonColumnCount = 0, skeletonColumnCount = 0,
lazyLoading = false, lazyLoading = false,
loadingColumnKeys = [] loadingColumnKeys = [],
draggedKey = null,
dropTargetKey = null,
onColumnDragStart,
onColumnDragOver,
onColumnDrop,
onColumnDragEnd
}) => { }) => {
const showSkeleton = skeletonColumnCount > 0 const showSkeleton = skeletonColumnCount > 0
const loadingColumnKeySet = new Set(loadingColumnKeys) const loadingColumnKeySet = new Set(loadingColumnKeys)
@ -25,6 +31,10 @@ const ObjectKanbanHeader = ({
<div <div
key={`skeleton-header-${index}`} key={`skeleton-header-${index}`}
className='objectKanbanHeaderCell' className='objectKanbanHeaderCell'
style={{
width: KANBAN_DEFAULT_COLUMN_WIDTH,
flex: `0 0 ${KANBAN_DEFAULT_COLUMN_WIDTH}px`
}}
> >
<Skeleton.Input <Skeleton.Input
active active
@ -33,17 +43,33 @@ const ObjectKanbanHeader = ({
/> />
</div> </div>
)) ))
: categoryValues.map((categoryValue) => { : columns.map(({ key: columnKey, value: categoryValue, width }) => {
const columnKey = getCategoryValueKey(categoryValue)
const showColumnLoading = const showColumnLoading =
lazyLoading || loadingColumnKeySet.has(columnKey) lazyLoading || loadingColumnKeySet.has(columnKey)
const isDragging = draggedKey === columnKey
const isDropTarget =
dropTargetKey === columnKey && draggedKey !== columnKey
return ( return (
<Flex <Flex
key={columnKey} key={columnKey}
className='objectKanbanHeaderCell' className={[
'objectKanbanHeaderCell',
isDragging ? 'objectKanbanHeaderCell--dragging' : '',
isDropTarget ? 'objectKanbanHeaderCell--dropTarget' : ''
]
.filter(Boolean)
.join(' ')}
align='center' align='center'
justify='space-between' justify='space-between'
gap={8} gap={8}
style={{
width,
flex: `0 0 ${width}px`
}}
onDragOver={(event) =>
onColumnDragOver?.(event, columnKey)
}
onDrop={(event) => onColumnDrop?.(event, columnKey)}
> >
{categoryPropertyDef ? ( {categoryPropertyDef ? (
<ObjectProperty <ObjectProperty
@ -64,12 +90,28 @@ const ObjectKanbanHeader = ({
name={categoryProperty} name={categoryProperty}
/> />
) : null} ) : null}
{showColumnLoading && ( <Flex
<LoadingOutlined align='center'
spin gap={4}
style={{ flexShrink: 0, marginRight: 6 }} className='objectKanbanHeaderCellActions'
/> >
)} {showColumnLoading && (
<LoadingOutlined spin style={{ flexShrink: 0 }} />
)}
<span
className='objectKanbanHeaderDragHandle overview-drag-handle'
draggable
title='Drag to reorder column'
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onDragStart={(event) =>
onColumnDragStart?.(event, columnKey)
}
onDragEnd={onColumnDragEnd}
>
<HolderOutlined />
</span>
</Flex>
</Flex> </Flex>
) )
})} })}
@ -82,13 +124,25 @@ const ObjectKanbanHeader = ({
ObjectKanbanHeader.displayName = 'ObjectKanbanHeader' ObjectKanbanHeader.displayName = 'ObjectKanbanHeader'
ObjectKanbanHeader.propTypes = { ObjectKanbanHeader.propTypes = {
categoryValues: PropTypes.array.isRequired, columns: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.string.isRequired,
value: PropTypes.any,
width: PropTypes.number.isRequired
})
),
categoryProperty: PropTypes.string.isRequired, categoryProperty: PropTypes.string.isRequired,
categoryPropertyDef: PropTypes.object, categoryPropertyDef: PropTypes.object,
trackRef: PropTypes.object, trackRef: PropTypes.object,
skeletonColumnCount: PropTypes.number, skeletonColumnCount: PropTypes.number,
lazyLoading: PropTypes.bool, lazyLoading: PropTypes.bool,
loadingColumnKeys: PropTypes.arrayOf(PropTypes.string) loadingColumnKeys: PropTypes.arrayOf(PropTypes.string),
draggedKey: PropTypes.string,
dropTargetKey: PropTypes.string,
onColumnDragStart: PropTypes.func,
onColumnDragOver: PropTypes.func,
onColumnDrop: PropTypes.func,
onColumnDragEnd: PropTypes.func
} }
export default ObjectKanbanHeader export default ObjectKanbanHeader

View File

@ -2082,6 +2082,16 @@ const ObjectTable = forwardRef(
isEditing={isEditing} isEditing={isEditing}
rowActions={rowActions} rowActions={rowActions}
renderActions={renderActions} renderActions={renderActions}
columns={viewMode.settings?.columns}
onColumnsChange={(columns) => {
objectListView?.setViewMode?.({
...viewMode,
settings: {
...viewMode.settings,
columns
}
})
}}
/> />
</Spin> </Spin>
</div> </div>

View File

@ -15,6 +15,7 @@ import GridIcon from '../../Icons/GridIcon'
import ListIcon from '../../Icons/ListIcon' import ListIcon from '../../Icons/ListIcon'
import KanbanIcon from '../../Icons/KanbanIcon' import KanbanIcon from '../../Icons/KanbanIcon'
import SettingsIcon from '../../Icons/SettingsIcon' import SettingsIcon from '../../Icons/SettingsIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { import {
getViewModeType, getViewModeType,
@ -67,16 +68,22 @@ const ObjectTableViewButton = ({
const handleTypeChange = (nextType) => { const handleTypeChange = (nextType) => {
if (nextType === 'kanban') { if (nextType === 'kanban') {
setViewMode({ const categoryProperty =
normalizedViewMode.type === 'kanban'
? normalizedViewMode.settings?.categoryProperty ||
defaultCategoryProperty
: defaultCategoryProperty
const nextMode = {
type: 'kanban', type: 'kanban',
settings: { settings: { categoryProperty }
categoryProperty: }
normalizedViewMode.type === 'kanban' if (
? normalizedViewMode.settings?.categoryProperty || normalizedViewMode.type === 'kanban' &&
defaultCategoryProperty Array.isArray(normalizedViewMode.settings?.columns)
: defaultCategoryProperty ) {
} nextMode.settings.columns = normalizedViewMode.settings.columns
}) }
setViewMode(nextMode)
return return
} }
@ -168,25 +175,46 @@ const ObjectTableViewButton = ({
/> />
</Popover> </Popover>
<Modal <Modal
title='Kanban settings'
open={settingsOpen} open={settingsOpen}
onCancel={() => setSettingsOpen(false)} onCancel={() => setSettingsOpen(false)}
onOk={() => setSettingsOpen(false)} destroyOnHidden
destroyOnClose focusTriggerAfterClose={false}
footer={null}
centered
closeIcon={null}
getContainer={() => document.body}
width={520}
> >
<Select <Flex vertical gap='middle' onMouseDown={(event) => event.stopPropagation()}>
style={{ width: '100%' }} <Flex gap='middle'>
placeholder='Select category property' <InfoCircleIcon />
value={ <Text strong>Kanban settings</Text>
normalizedViewMode.settings?.categoryProperty || </Flex>
defaultCategoryProperty <Text>
} Select the property used to group cards into columns:
onChange={handleCategoryPropertyChange} </Text>
options={categoryProperties.map((property) => ({ <Select
value: property.name, style={{ width: '100%' }}
label: property.label || property.name placeholder='Select category property'
}))} value={
/> normalizedViewMode.settings?.categoryProperty ||
defaultCategoryProperty
}
onChange={handleCategoryPropertyChange}
options={categoryProperties.map((property) => ({
value: property.name,
label: property.label || property.name
}))}
/>
<Flex justify='end' gap='small'>
<Button type='default' onClick={() => setSettingsOpen(false)}>
Cancel
</Button>
<Button type='primary' onClick={() => setSettingsOpen(false)}>
Apply
</Button>
</Flex>
</Flex>
</Modal> </Modal>
</> </>
) )

View File

@ -3,6 +3,9 @@ export const DEFAULT_VIEW_MODE = { type: 'list' }
/** Property types that can be used as kanban column categories. */ /** Property types that can be used as kanban column categories. */
export const KANBAN_CATEGORY_PROPERTY_TYPES = ['state', 'tags'] export const KANBAN_CATEGORY_PROPERTY_TYPES = ['state', 'tags']
export const KANBAN_DEFAULT_COLUMN_WIDTH = 360
export const KANBAN_MIN_COLUMN_WIDTH = 200
export const isKanbanCategoryProperty = (property) => export const isKanbanCategoryProperty = (property) =>
KANBAN_CATEGORY_PROPERTY_TYPES.includes(property?.type) KANBAN_CATEGORY_PROPERTY_TYPES.includes(property?.type)
@ -32,7 +35,12 @@ export const toCategoryFilterValue = (value) => {
} }
export const getCategoryValueKey = (value) => { export const getCategoryValueKey = (value) => {
if (value && typeof value === 'object' && !Array.isArray(value) && value.type != null) { if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
value.type != null
) {
return String(value.type) return String(value.type)
} }
if (value != null) return String(value) if (value != null) return String(value)
@ -56,3 +64,56 @@ export const getCategoryColumnKeys = (value) => {
} }
return [getCategoryValueKey(value)] return [getCategoryValueKey(value)]
} }
/**
* Merge fetched category values with persisted kanban column order/widths.
* Unknown stored columns are dropped; new values are appended with the default width.
*/
export const resolveKanbanColumns = (
categoryValues = [],
storedColumns = []
) => {
const byKey = new Map(
categoryValues.map((value) => [getCategoryValueKey(value), value])
)
const seen = new Set()
const ordered = []
for (const column of storedColumns || []) {
if (column == null || column.value == null) continue
const key = String(column.value)
if (!byKey.has(key) || seen.has(key)) continue
const width =
typeof column.width === 'number' && column.width > 0
? column.width
: KANBAN_DEFAULT_COLUMN_WIDTH
ordered.push({
key,
value: byKey.get(key),
width: Math.max(KANBAN_MIN_COLUMN_WIDTH, width)
})
seen.add(key)
}
for (const value of categoryValues) {
const key = getCategoryValueKey(value)
if (seen.has(key)) continue
ordered.push({
key,
value,
width: KANBAN_DEFAULT_COLUMN_WIDTH
})
seen.add(key)
}
return ordered
}
export const toKanbanColumnsConfig = (columns) =>
(columns || []).map(({ key, width }) => ({
value: key,
width:
typeof width === 'number' && width > 0
? Math.max(KANBAN_MIN_COLUMN_WIDTH, width)
: KANBAN_DEFAULT_COLUMN_WIDTH
}))