Compare commits

..

5 Commits

Author SHA1 Message Date
c75876ab3c Enhance ObjectKanban Component with Column Resizing Overlays and Improved Skeleton Structure
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced ObjectKanbanColumnResizeHandle and ObjectKanbanColumnResizeOverlay components for better column resizing functionality.
- Updated CSS styles to support new overlays and improve visual feedback during resizing actions.
- Refactored ObjectKanbanSkeletonColumns to utilize the new skeleton structure for enhanced layout and responsiveness.
- Adjusted properties and styles for better content visibility and interaction during resizing.
2026-09-03 15:09:27 +01:00
d62431c4fa Update CSS for ObjectKanban Component to Improve Resizing and Visual Feedback
- Adjusted the positioning of the column resize handle for better alignment and user interaction.
- Modified hover styles for the resize handle to enhance visual feedback during resizing actions.
- Updated dimensions of the list view tabs color dot for a more consistent appearance.
- Changed overflow property in the ObjectKanbanColumnSkeleton to allow for better content visibility.
2026-09-03 14:48:19 +01:00
5e6b4213ab 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.
2026-09-03 14:27:34 +01:00
65e25ae077 Enhance ObjectKanban and ObjectTable Components with Update Handling and Filtering Logic
- Introduced new utility functions for managing updates and filtering in ObjectKanban and ObjectTable, improving data handling during updates.
- Enhanced ObjectKanban to support dynamic reloading of columns based on filter and sort changes, ensuring accurate data representation.
- Updated ObjectKanbanColumn to include item finding and updating capabilities, streamlining item management within columns.
- Refactored ObjectTable to improve item update handling and ensure consistent behavior with kanban view integration.
- Added lazy loading support in ObjectKanbanHeader for better loading state management during data fetching.
2026-09-03 13:11:00 +01:00
59f603d05d Refactor ElipsisText Component for Improved State Management
- Replaced refs with state variables for container and measure elements to enhance reactivity and maintainability.
- Updated layout effects to utilize new state variables, ensuring accurate measurement and truncation logic.
- Improved cleanup logic in useLayoutEffect to prevent memory leaks and ensure proper event handling for font loading and observers.
2026-09-03 11:27:30 +01:00
10 changed files with 1307 additions and 195 deletions

View File

@ -1451,11 +1451,20 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
.objectKanbanContainerSkeletons {
display: flex;
flex-direction: column;
position: relative;
flex: 1;
min-width: 0;
min-height: 0;
}
.objectKanbanOverlayColumns {
position: absolute;
inset: 0;
z-index: 4;
overflow: hidden;
pointer-events: none;
}
.objectKanbanKanbanBody {
position: relative;
flex: 1;
@ -1520,14 +1529,35 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
}
.objectKanbanHeaderCell {
flex: 0 0 360px;
min-width: 360px;
flex: 0 0 auto;
min-width: 200px;
flex-shrink: 0;
padding: 8px 8px;
border: 1px solid var(--ant-color-border-secondary);
border-bottom: 1px solid var(--color-descriptions-border);
border-radius: 12px 12px 0 0;
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 {
@ -1547,14 +1577,16 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
pointer-events: none;
}
.objectKanbanSkeletonTrack {
.objectKanbanSkeletonTrack,
.objectKanbanOverlayTrack {
width: max-content;
min-width: 100%;
height: 100%;
will-change: transform;
}
.objectKanbanSkeletonRow {
.objectKanbanSkeletonRow,
.objectKanbanOverlayRow {
width: max-content;
min-width: 100%;
height: 100%;
@ -1563,16 +1595,88 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
.objectKanbanColumn,
.objectKanbanColumnSkeleton {
flex: 0 0 360px;
min-width: 360px;
position: relative;
flex: 0 0 auto;
min-width: 200px;
min-height: 0;
border-top: none;
border-radius: 0 0 12px 12px;
}
.objectKanbanColumnResizeOverlay {
position: relative;
}
.objectKanbanColumnResizeHandle {
position: absolute;
top: 0;
right: -11px;
bottom: 0;
width: 11px;
z-index: 3;
cursor: col-resize;
touch-action: none;
user-select: none;
pointer-events: auto;
}
.objectKanbanColumnResizeHandle::before {
content: '';
position: absolute;
top: 11px;
bottom: 11px;
right: 1px;
width: 2px;
transform: translateX(-50%);
opacity: 0.6;
transition: opacity 0.15s ease;
}
.objectKanbanColumnResizeHandle::after {
content: '';
position: absolute;
top: 50%;
right: 1px;
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;
}
.objectKanbanColumnResizeHandle:hover::before {
background: #0e3a5b;
}
body.objectKanbanColumnResizing,
body.objectKanbanColumnResizing * {
cursor: col-resize !important;
user-select: none !important;
}
.objectKanbanColumnSkeleton {
border-radius: 0 0 12px 12px;
overflow: hidden;
overflow: visible;
}
.objectKanbanColumnCards {
@ -2941,8 +3045,8 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
.list-view-tabs-color-dot {
display: inline-block;
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}

View File

@ -31,42 +31,60 @@ const ElipsisText = ({ children, style, className, title, code, ...rest }) => {
const text = childrenToString(children)
const { start, end } = splitMiddle(text)
const containerRef = useRef(null)
const measureRef = useRef(null)
const [containerEl, setContainerEl] = useState(null)
const [measureEl, setMeasureEl] = useState(null)
const truncatedRef = useRef(false)
const [truncated, setTruncated] = useState(false)
useLayoutEffect(() => {
const container = containerRef.current
const measure = measureRef.current
if (!container || !measure) return
if (!containerEl || !measureEl) return
let raf1 = 0
let raf2 = 0
let cancelled = false
const update = () => {
const available = container.getBoundingClientRect().width
if (cancelled) return
const available = containerEl.getBoundingClientRect().width
if (available < 1) return
const textWidth = measure.getBoundingClientRect().width
const textWidth = measureEl.getBoundingClientRect().width
const next = Boolean(end) && textWidth > available
if (truncatedRef.current === next) return
truncatedRef.current = next
setTruncated(next)
}
update()
const frame = requestAnimationFrame(update)
raf1 = requestAnimationFrame(() => {
update()
raf2 = requestAnimationFrame(update)
})
const resizeObserver = new ResizeObserver(update)
resizeObserver.observe(container)
resizeObserver.observe(containerEl)
const intersectionObserver = new IntersectionObserver(update)
intersectionObserver.observe(container)
intersectionObserver.observe(containerEl)
const onFontsReady = () => {
if (!cancelled) update()
}
const fonts = typeof document !== 'undefined' ? document.fonts : null
fonts?.ready?.then(onFontsReady)
fonts?.addEventListener?.('loadingdone', onFontsReady)
return () => {
cancelAnimationFrame(frame)
cancelled = true
cancelAnimationFrame(raf1)
cancelAnimationFrame(raf2)
resizeObserver.disconnect()
intersectionObserver.disconnect()
fonts?.removeEventListener?.('loadingdone', onFontsReady)
}
}, [text, end])
}, [containerEl, measureEl, text, end])
const showTruncated = truncated && Boolean(end)
const RootTag = code ? 'code' : 'span'
@ -74,9 +92,9 @@ const ElipsisText = ({ children, style, className, title, code, ...rest }) => {
const content = (
<span
className={showTruncated ? 'elipsis-text is-truncated' : 'elipsis-text'}
ref={containerRef}
ref={setContainerEl}
>
<RootTag className='elipsis-text-measure' ref={measureRef}>
<RootTag className='elipsis-text-measure' ref={setMeasureEl}>
{text}
</RootTag>
<RootTag className='elipsis-text-full'>{text}</RootTag>
@ -96,7 +114,7 @@ const ElipsisText = ({ children, style, className, title, code, ...rest }) => {
className={['elipsis-text-wrapper', className].filter(Boolean).join(' ')}
style={style}
>
{truncated ? <Tooltip title={title ?? text}>{content}</Tooltip> : content}
<Tooltip title={truncated ? (title ?? text) : null}>{content}</Tooltip>
</Text>
)
}

View File

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

View File

@ -2,6 +2,7 @@ import {
forwardRef,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
@ -10,18 +11,79 @@ import {
} from 'react'
import { Empty, Flex } from 'antd'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { ApiServerContext } from '../context/ApiServerContext'
import ObjectCard from './ObjectCard'
import ObjectKanbanColumn from './ObjectKanbanColumn'
import ObjectKanbanHeader from './ObjectKanbanHeader'
import ScrollBox from './ScrollBox'
import Spin from './Spin'
import { getCategoryValueKey } from './viewModeUtils'
import {
getCategoryColumnKeys,
getCategoryValueKey,
KANBAN_DEFAULT_COLUMN_WIDTH,
KANBAN_MIN_COLUMN_WIDTH,
resolveKanbanColumns,
toKanbanColumnsConfig
} from './viewModeUtils'
import { areValuesEqual } from '../utils/Utils'
const KANBAN_COLUMN_WIDTH = 360
const KANBAN_COLUMN_WIDTH = KANBAN_DEFAULT_COLUMN_WIDTH
const KANBAN_GAP = 16
const KANBAN_CARD_MIN_HEIGHT = 136
const reorderColumnsByKey = (columns, fromKey, toKey) => {
if (!fromKey || !toKey || fromKey === toKey) return columns
const fromIndex = columns.findIndex((column) => column.key === fromKey)
const toIndex = columns.findIndex((column) => column.key === toKey)
if (fromIndex < 0 || toIndex < 0) return columns
const next = [...columns]
const [moved] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, moved)
return next
}
const getUpdateKeys = (updated) => {
if (!updated || typeof updated !== 'object') return []
return Object.keys(updated).filter(
(key) => key !== '_id' && key !== 'objectType'
)
}
const getAffectingFilterOrSortKeys = (
existingItem,
updatedData,
filter,
masterFilter,
sorter,
categoryProperty
) => {
const updateKeys = getUpdateKeys(updatedData)
if (!updateKeys.length) return []
const filterKeys = new Set([
...Object.keys(filter || {}),
...Object.keys(masterFilter || {})
])
if (categoryProperty) filterKeys.add(categoryProperty)
const isAffectingKey = (key) =>
filterKeys.has(key) || (sorter?.field && key === sorter.field)
// Item not in the current fetch: any filter/sort key in the update
// may change whether it belongs in this view.
if (!existingItem) {
return updateKeys.filter(isAffectingKey)
}
// Item already fetched: reload when a filter or sort key's value changed.
return updateKeys.filter(
(key) =>
isAffectingKey(key) &&
!areValuesEqual(existingItem[key], updatedData[key])
)
}
const getKanbanSkeletonLayout = (width, height) => {
const columnCount =
Math.max(
@ -36,6 +98,99 @@ const getKanbanSkeletonLayout = (width, height) => {
return { columnCount, cardCount }
}
const ObjectKanbanColumnResizeHandle = ({ active = false, onMouseDown }) => (
<div
className={classNames('objectKanbanColumnResizeHandle', { active })}
role='separator'
aria-orientation='vertical'
aria-label='Resize column'
onMouseDown={onMouseDown}
/>
)
ObjectKanbanColumnResizeHandle.propTypes = {
active: PropTypes.bool,
onMouseDown: PropTypes.func
}
const ObjectKanbanColumnResizeOverlay = ({
columnKey,
width,
columnIndex,
isResizing,
onResizeStart
}) => {
return (
<div
className='objectKanbanColumnResizeOverlay'
key={columnIndex}
style={{
width,
flex: `0 0 ${width}px`
}}
>
{onResizeStart && columnKey && (
<ObjectKanbanColumnResizeHandle
active={isResizing}
onMouseDown={(event) => onResizeStart(event, columnKey)}
/>
)}
</div>
)
}
const ObjectKanbanColumnSkeleton = ({
columnKey,
width,
cardCount = 0,
model,
modelProperties,
visibleColumns,
keyPrefix = 'skeleton',
columnIndex = 0
}) => (
<div
className='objectKanbanColumnSkeleton'
style={{
width,
flex: `0 0 ${width}px`
}}
>
<div className='objectKanbanColumnCards'>
{cardCount > 0 && (
<Flex vertical gap='middle' className='objectKanbanColumnCardsInner'>
{Array.from({ length: cardCount }).map((_, cardIndex) => (
<ObjectCard
key={cardIndex}
isSkeleton
model={model}
modelProperties={modelProperties}
visibleColumns={visibleColumns}
record={{
_id: `${keyPrefix}-${columnKey ?? columnIndex}-${cardIndex}`
}}
cardStyle='bordered'
/>
))}
</Flex>
)}
</div>
</div>
)
ObjectKanbanColumnSkeleton.propTypes = {
columnKey: PropTypes.string,
width: PropTypes.number.isRequired,
cardCount: PropTypes.number,
model: PropTypes.object.isRequired,
modelProperties: PropTypes.array.isRequired,
visibleColumns: PropTypes.object,
keyPrefix: PropTypes.string,
columnIndex: PropTypes.number,
isResizing: PropTypes.bool,
onResizeStart: PropTypes.func
}
const ObjectKanbanSkeletonColumns = ({
columnCount,
cardCount = 0,
@ -43,45 +198,51 @@ const ObjectKanbanSkeletonColumns = ({
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>
keyPrefix = 'skeleton',
columns = null,
resizingKey = null,
onColumnResizeStart
}) => {
const skeletonColumns =
columns?.length > 0
? columns
: Array.from({ length: columnCount }).map((_, columnIndex) => ({
key: `${keyPrefix}-${columnIndex}`,
width: KANBAN_COLUMN_WIDTH
}))
return (
<div className='objectKanbanSkeletonColumns objectKanbanSkeletonColumns--loading'>
<div className='objectKanbanSkeletonTrack' ref={trackRef}>
<Flex gap='middle' className='objectKanbanSkeletonRow'>
{skeletonColumns.map((column, columnIndex) => (
<ObjectKanbanColumnSkeleton
key={column.key || `${keyPrefix}-${columnIndex}`}
columnKey={column.key}
width={column.width}
cardCount={cardCount}
model={model}
modelProperties={modelProperties}
visibleColumns={visibleColumns}
keyPrefix={keyPrefix}
columnIndex={columnIndex}
isResizing={resizingKey === column.key}
onResizeStart={onColumnResizeStart}
/>
))}
</Flex>
</div>
</div>
</div>
)
)
}
ObjectKanbanColumnResizeOverlay.propTypes = {
columnKey: PropTypes.string,
width: PropTypes.number.isRequired,
columnIndex: PropTypes.number,
isResizing: PropTypes.bool,
onResizeStart: PropTypes.func
}
ObjectKanbanSkeletonColumns.propTypes = {
columnCount: PropTypes.number.isRequired,
@ -90,7 +251,10 @@ ObjectKanbanSkeletonColumns.propTypes = {
model: PropTypes.object.isRequired,
modelProperties: PropTypes.array.isRequired,
visibleColumns: PropTypes.object,
keyPrefix: PropTypes.string
keyPrefix: PropTypes.string,
columns: PropTypes.array,
resizingKey: PropTypes.string,
onColumnResizeStart: PropTypes.func
}
const ObjectKanban = forwardRef(
@ -108,14 +272,22 @@ const ObjectKanban = forwardRef(
visibleColumns = {},
isEditing = false,
rowActions = [],
renderActions
renderActions,
columns: storedColumns = [],
onColumnsChange
},
ref
) => {
const { getModelPropertyValues } = useContext(ApiServerContext)
const {
getModelPropertyValues,
connected,
subscribeToAllObjectUpdates,
subscribeToObjectTypeUpdates
} = useContext(ApiServerContext)
const columnRefs = useRef({})
const headerTrackRef = useRef(null)
const skeletonTrackRef = useRef(null)
const overlayTrackRef = useRef(null)
const kanbanBodyRef = useRef(null)
const [scrollElement, setScrollElement] = useState(null)
const [categoryValues, setCategoryValues] = useState([])
@ -126,19 +298,42 @@ const ObjectKanban = forwardRef(
columnCount: 3,
cardCount: 3
})
const [reloadingColumnKeys, setReloadingColumnKeys] = useState([])
const [draggedKey, setDraggedKey] = useState(null)
const [dropTargetKey, setDropTargetKey] = useState(null)
const [widthOverrides, setWidthOverrides] = useState({})
const [optimisticColumns, setOptimisticColumns] = useState(null)
const [resizingKey, setResizingKey] = useState(null)
const baseFilterRef = useRef(baseFilter)
const masterFilterRef = useRef(masterFilter)
const sorterRef = useRef(sorter)
const categoryPropertyRef = useRef(categoryProperty)
const getModelPropertyValuesRef = useRef(getModelPropertyValues)
const lastCategoryQueryKeyRef = useRef(null)
const hasLoadedRef = useRef(false)
const loadGenerationRef = useRef(0)
const updateEventHandlerRef = useRef()
const newEventHandlerRef = useRef()
const silentReloadRef = useRef(null)
const silentReloadItemColumnsRef = useRef(null)
const silentReloadCategoryColumnsRef = useRef(null)
const categoryValuesRef = useRef([])
const subscriptionFilterRef = useRef({})
const subscribeToObjectTypeUpdatesRef = useRef(null)
const subscribeToAllObjectUpdatesRef = useRef(null)
const subscribedTypeRef = useRef(null)
const subscribeToObjectTypeUpdatesFnRef = useRef(
subscribeToObjectTypeUpdates
)
baseFilterRef.current = baseFilter
masterFilterRef.current = masterFilter
sorterRef.current = sorter
categoryPropertyRef.current = categoryProperty
getModelPropertyValuesRef.current = getModelPropertyValues
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
categoryValuesRef.current = categoryValues
const setScrollContainerRef = useCallback((node) => {
setScrollElement(node)
@ -165,6 +360,7 @@ const ObjectKanban = forwardRef(
const mainScroll = scrollElement
const headerTrack = headerTrackRef.current
const skeletonTrack = skeletonTrackRef.current
const overlayTrack = overlayTrackRef.current
if (!mainScroll || !headerTrack) return undefined
const handleMainScroll = () => {
@ -173,6 +369,9 @@ const ObjectKanban = forwardRef(
if (skeletonTrack) {
skeletonTrack.style.transform = offset
}
if (overlayTrack) {
overlayTrack.style.transform = offset
}
}
handleMainScroll()
@ -203,14 +402,38 @@ const ObjectKanban = forwardRef(
[categoryProperty, type]
)
const reloadColumns = useCallback(async () => {
const reloadColumns = useCallback(async (columnKeys = null) => {
const refs =
columnKeys == null
? Object.values(columnRefs.current)
: columnKeys.map((key) => columnRefs.current[key])
await Promise.all(
Object.values(columnRefs.current)
.filter(Boolean)
.map((columnRef) => columnRef.reload?.())
refs.filter(Boolean).map((columnRef) => columnRef.reload?.())
)
}, [])
const findItem = useCallback((id) => {
for (const columnRef of Object.values(columnRefs.current)) {
const item = columnRef?.findItem?.(id)
if (item) return item
}
return null
}, [])
const findItemColumnKeys = useCallback((id) => {
return Object.entries(columnRefs.current)
.filter(([, columnRef]) => columnRef?.findItem?.(id))
.map(([key]) => key)
}, [])
const updateItem = useCallback((id, updatedData) => {
for (const columnRef of Object.values(columnRefs.current)) {
if (columnRef?.findItem?.(id)) {
columnRef.updateItem?.(id, updatedData)
}
}
}, [])
const load = useCallback(
async (filter = null, sorterArg = null, options = {}) => {
const silent = options.silent === true
@ -219,9 +442,10 @@ const ObjectKanban = forwardRef(
if (!categoryProperty) {
lastCategoryQueryKeyRef.current = null
hasLoadedRef.current = true
categoryValuesRef.current = []
setCategoryValues([])
setHasLoaded(true)
return
return []
}
const activeFilter = filter ?? baseFilterRef.current
@ -234,10 +458,12 @@ const ObjectKanban = forwardRef(
if (canReuseColumns) {
await reloadColumns()
if (generation !== loadGenerationRef.current) return
if (generation !== loadGenerationRef.current) {
return categoryValuesRef.current
}
hasLoadedRef.current = true
setHasLoaded(true)
return
return categoryValuesRef.current
}
// Filter/sort changes keep current columns visible; objectView /
@ -256,19 +482,26 @@ const ObjectKanban = forwardRef(
masterFilter: activeMasterFilter
}
)
if (generation !== loadGenerationRef.current) return
if (generation !== loadGenerationRef.current) {
return categoryValuesRef.current
}
const nextValues = Array.isArray(values) ? values : []
lastCategoryQueryKeyRef.current = queryKey
hasLoadedRef.current = true
setLoadedFilter(activeFilter)
setLoadedSorter(activeSorter)
categoryValuesRef.current = nextValues
setCategoryValues(nextValues)
setHasLoaded(true)
return nextValues
} catch (error) {
if (generation !== loadGenerationRef.current) return
if (generation !== loadGenerationRef.current) {
return categoryValuesRef.current
}
console.error('Error fetching kanban category values:', error)
lastCategoryQueryKeyRef.current = null
hasLoadedRef.current = true
categoryValuesRef.current = []
setCategoryValues([])
setHasLoaded(true)
throw error
@ -283,6 +516,238 @@ const ObjectKanban = forwardRef(
await load(baseFilterRef.current, sorterRef.current)
}, [load])
const silentReload = useCallback(async () => {
const previousKeys = categoryValuesRef.current.map(getCategoryValueKey)
setReloadingColumnKeys(previousKeys)
try {
lastCategoryQueryKeyRef.current = null
const nextValues = await load(
baseFilterRef.current,
sorterRef.current,
{
silent: true
}
)
const nextKeySet = new Set((nextValues || []).map(getCategoryValueKey))
// Only reload columns that existed before; newly created columns
// mount and load themselves.
const keysToReload = previousKeys.filter((key) => nextKeySet.has(key))
setReloadingColumnKeys(keysToReload)
await reloadColumns(keysToReload)
} finally {
setReloadingColumnKeys([])
}
}, [load, reloadColumns])
const silentReloadItemColumns = useCallback(
async (id) => {
const keysToReload = findItemColumnKeys(id)
if (!keysToReload.length) {
await silentReload()
return
}
setReloadingColumnKeys(keysToReload)
try {
await reloadColumns(keysToReload)
} finally {
setReloadingColumnKeys([])
}
},
[findItemColumnKeys, reloadColumns, silentReload]
)
const silentReloadCategoryColumns = useCallback(
async (existingItem, updatedData) => {
const categoryPropertyName = categoryPropertyRef.current
const previousKeys = new Set(
categoryValuesRef.current.map(getCategoryValueKey)
)
const oldKeys =
existingItem && categoryPropertyName
? getCategoryColumnKeys(existingItem[categoryPropertyName])
: []
const updatedKeys =
categoryPropertyName &&
updatedData &&
Object.prototype.hasOwnProperty.call(
updatedData,
categoryPropertyName
)
? getCategoryColumnKeys(updatedData[categoryPropertyName]).filter(
(key) => previousKeys.has(key)
)
: []
const tentativeKeys = [...new Set([...oldKeys, ...updatedKeys])]
setReloadingColumnKeys(tentativeKeys)
try {
lastCategoryQueryKeyRef.current = null
const nextValues = await load(
baseFilterRef.current,
sorterRef.current,
{ silent: true }
)
const nextKeySet = new Set(
(nextValues || []).map(getCategoryValueKey)
)
const keysToReload = []
for (const oldColumnKey of oldKeys) {
if (nextKeySet.has(oldColumnKey)) {
keysToReload.push(oldColumnKey)
}
}
if (
categoryPropertyName &&
updatedData &&
Object.prototype.hasOwnProperty.call(
updatedData,
categoryPropertyName
)
) {
for (const newColumnKey of getCategoryColumnKeys(
updatedData[categoryPropertyName]
)) {
// Newly created columns mount and fetch themselves — skip reload.
if (
previousKeys.has(newColumnKey) &&
nextKeySet.has(newColumnKey)
) {
keysToReload.push(newColumnKey)
}
}
}
const uniqueKeys = [...new Set(keysToReload)]
setReloadingColumnKeys(uniqueKeys)
await reloadColumns(uniqueKeys)
} finally {
setReloadingColumnKeys([])
}
},
[load, reloadColumns]
)
silentReloadRef.current = silentReload
silentReloadItemColumnsRef.current = silentReloadItemColumns
silentReloadCategoryColumnsRef.current = silentReloadCategoryColumns
const subscriptionFilter = useMemo(
() => ({ ...masterFilter, ...baseFilter }),
[baseFilter, masterFilter]
)
subscriptionFilterRef.current = subscriptionFilter
const updateEventHandler = useCallback(
(id, updatedData) => {
const existingItem = findItem(id)
// Always merge into the local item when it already exists.
if (existingItem) {
updateItem(id, updatedData)
}
const affectingKeys = getAffectingFilterOrSortKeys(
existingItem,
updatedData,
subscriptionFilterRef.current,
{},
sorterRef.current,
categoryPropertyRef.current
)
if (!affectingKeys.length) return
const categoryPropertyName = categoryPropertyRef.current
if (
categoryPropertyName &&
affectingKeys.includes(categoryPropertyName)
) {
silentReloadCategoryColumnsRef.current?.(existingItem, updatedData)
return
}
const sorterField = sorterRef.current?.field
const onlySortKeyChanged =
existingItem &&
sorterField &&
affectingKeys.length === 1 &&
affectingKeys[0] === sorterField
if (onlySortKeyChanged) {
silentReloadItemColumnsRef.current?.(id)
return
}
silentReloadRef.current?.()
},
[findItem, updateItem]
)
updateEventHandlerRef.current = updateEventHandler
const newEventHandler = useCallback(() => {
silentReloadRef.current?.()
}, [])
newEventHandlerRef.current = newEventHandler
useEffect(() => {
return () => {
if (connected == true && subscribeToObjectTypeUpdatesRef.current) {
subscribeToObjectTypeUpdatesRef.current()
subscribeToObjectTypeUpdatesRef.current = null
}
if (connected == true && subscribeToAllObjectUpdatesRef.current) {
subscribeToAllObjectUpdatesRef.current()
subscribeToAllObjectUpdatesRef.current = null
}
}
}, [connected])
useEffect(() => {
if (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])
useEffect(() => {
if (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])
useImperativeHandle(
ref,
() => ({
@ -292,6 +757,164 @@ const ObjectKanban = forwardRef(
[load, reload]
)
const resolvedColumns = useMemo(() => {
const fromStore = resolveKanbanColumns(categoryValues, storedColumns)
if (!optimisticColumns) return fromStore
const valueByKey = new Map(
fromStore.map((column) => [column.key, column.value])
)
const ordered = []
const seen = new Set()
for (const column of optimisticColumns) {
if (!valueByKey.has(column.key) || seen.has(column.key)) continue
ordered.push({
key: column.key,
value: valueByKey.get(column.key),
width: column.width
})
seen.add(column.key)
}
for (const column of fromStore) {
if (seen.has(column.key)) continue
ordered.push(column)
}
return ordered
}, [categoryValues, optimisticColumns, storedColumns])
useEffect(() => {
setOptimisticColumns(null)
setWidthOverrides({})
setDraggedKey(null)
setDropTargetKey(null)
setResizingKey(null)
}, [categoryProperty])
useEffect(() => {
if (!optimisticColumns) return
const fromStore = resolveKanbanColumns(categoryValues, storedColumns)
if (
JSON.stringify(toKanbanColumnsConfig(fromStore)) ===
JSON.stringify(toKanbanColumnsConfig(optimisticColumns))
) {
setOptimisticColumns(null)
}
}, [categoryValues, optimisticColumns, storedColumns])
const displayColumns = useMemo(() => {
if (!Object.keys(widthOverrides).length) return resolvedColumns
return resolvedColumns.map((column) =>
widthOverrides[column.key] != null
? { ...column, width: widthOverrides[column.key] }
: column
)
}, [resolvedColumns, widthOverrides])
const persistColumns = useCallback(
(nextColumns) => {
setOptimisticColumns(nextColumns)
onColumnsChange?.(toKanbanColumnsConfig(nextColumns))
},
[onColumnsChange]
)
const handleColumnDragStart = useCallback((event, columnKey) => {
event.dataTransfer.effectAllowed = 'move'
try {
event.dataTransfer.setData('text/plain', columnKey)
} catch {
// Some browsers restrict setData outside certain types.
}
setDraggedKey(columnKey)
setDropTargetKey(null)
}, [])
const handleColumnDragOver = useCallback(
(event, columnKey) => {
if (!draggedKey || draggedKey === columnKey) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
setDropTargetKey((prev) => (prev === columnKey ? prev : columnKey))
},
[draggedKey]
)
const finishColumnReorder = useCallback(
(fromKey, toKey) => {
if (!fromKey || !toKey || fromKey === toKey) {
setDraggedKey(null)
setDropTargetKey(null)
return
}
const nextColumns = reorderColumnsByKey(resolvedColumns, fromKey, toKey)
persistColumns(nextColumns)
setDraggedKey(null)
setDropTargetKey(null)
},
[persistColumns, resolvedColumns]
)
const handleColumnDrop = useCallback(
(event, columnKey) => {
event.preventDefault()
const fromKey = draggedKey || event.dataTransfer.getData('text/plain')
finishColumnReorder(fromKey, columnKey)
},
[draggedKey, finishColumnReorder]
)
const handleColumnDragEnd = useCallback(() => {
setDraggedKey(null)
setDropTargetKey(null)
}, [])
const handleColumnResizeStart = useCallback(
(event, columnKey) => {
event.preventDefault()
event.stopPropagation()
const startX = event.clientX
const startWidth =
displayColumns.find((column) => column.key === columnKey)?.width ??
KANBAN_COLUMN_WIDTH
let currentWidth = startWidth
setResizingKey(columnKey)
const handleMouseMove = (moveEvent) => {
currentWidth = Math.max(
KANBAN_MIN_COLUMN_WIDTH,
startWidth + (moveEvent.clientX - startX)
)
setWidthOverrides((prev) => ({
...prev,
[columnKey]: currentWidth
}))
}
const handleMouseUp = () => {
const nextColumns = resolvedColumns.map((column) =>
column.key === columnKey
? { ...column, width: currentWidth }
: column
)
setWidthOverrides({})
setResizingKey(null)
persistColumns(nextColumns)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.classList.remove('objectKanbanColumnResizing')
}
document.body.classList.add('objectKanbanColumnResizing')
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
},
[displayColumns, persistColumns, resolvedColumns]
)
if (!categoryProperty) {
return (
<Flex align='center' justify='center' style={{ height: '100%' }}>
@ -312,10 +935,27 @@ const ObjectKanban = forwardRef(
return (
<div className='objectKanbanContainerWrapper'>
<div className='objectKanbanOverlayColumns'>
<div className='objectKanbanOverlayTrack' ref={overlayTrackRef}>
<Flex gap='middle' className='objectKanbanOverlayRow'>
{displayColumns.map(({ key: columnKey, width }, columnIndex) => (
<ObjectKanbanColumnResizeOverlay
key={columnKey}
columnKey={columnKey}
width={width}
columnIndex={columnIndex}
isResizing={resizingKey === columnKey}
onResizeStart={handleColumnResizeStart}
/>
))}
</Flex>
</div>
</div>
<Spin spinning={showSkeleton}>
<div className='objectKanbanContainerSkeletons'>
<ObjectKanbanHeader
categoryValues={categoryValues}
columns={displayColumns}
categoryProperty={categoryProperty}
categoryPropertyDef={categoryPropertyDef}
trackRef={headerTrackRef}
@ -323,6 +963,13 @@ const ObjectKanban = forwardRef(
showSkeleton ? skeletonLayout.columnCount : 0
}
lazyLoading={showSkeleton || lazyLoading}
loadingColumnKeys={reloadingColumnKeys}
draggedKey={draggedKey}
dropTargetKey={dropTargetKey}
onColumnDragStart={handleColumnDragStart}
onColumnDragOver={handleColumnDragOver}
onColumnDrop={handleColumnDrop}
onColumnDragEnd={handleColumnDragEnd}
/>
<div className='objectKanbanKanbanBody' ref={kanbanBodyRef}>
{!showSkeleton && (
@ -332,9 +979,8 @@ const ObjectKanban = forwardRef(
scrollableNodeProps={{ ref: setScrollContainerRef }}
>
<Flex gap='middle' className='objectKanban'>
{categoryValues.map((categoryValue) => {
const columnKey = getCategoryValueKey(categoryValue)
return (
{displayColumns.map(
({ key: columnKey, value: categoryValue, width }) => (
<ObjectKanbanColumn
key={columnKey}
ref={(node) => {
@ -358,9 +1004,10 @@ const ObjectKanban = forwardRef(
isEditing={isEditing}
rowActions={rowActions}
renderActions={renderActions}
width={width}
/>
)
})}
)}
</Flex>
</ScrollBox>
</div>
@ -381,17 +1028,18 @@ const ObjectKanban = forwardRef(
ref={skeletonTrackRef}
>
<Flex gap='middle' className='objectKanbanSkeletonRow'>
{categoryValues.map((categoryValue) => {
const columnKey = getCategoryValueKey(categoryValue)
return (
<div
key={columnKey}
className='objectKanbanColumnSkeleton'
>
<div className='objectKanbanColumnCards' />
</div>
)
})}
{displayColumns.map(({ key: columnKey, width }) => (
<ObjectKanbanColumnSkeleton
key={columnKey}
columnKey={columnKey}
width={width}
model={model}
modelProperties={modelProperties}
visibleColumns={visibleColumns}
isResizing={resizingKey === columnKey}
onResizeStart={handleColumnResizeStart}
/>
))}
</Flex>
</div>
</div>
@ -419,7 +1067,14 @@ ObjectKanban.propTypes = {
visibleColumns: PropTypes.object,
isEditing: PropTypes.bool,
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

View File

@ -14,10 +14,15 @@ import PropTypes from 'prop-types'
import { ApiServerContext } from '../context/ApiServerContext'
import ObjectCard from './ObjectCard'
import Spin from './Spin'
import { toCategoryFilterValue } from './viewModeUtils'
import { KANBAN_MIN_COLUMN_WIDTH, toCategoryFilterValue } from './viewModeUtils'
const SCROLL_THRESHOLD = 50
const idsEqual = (a, b) => {
if (a == null || b == null) return false
return String(a).toLowerCase() === String(b).toLowerCase()
}
const ObjectKanbanColumn = forwardRef(
(
{
@ -35,7 +40,8 @@ const ObjectKanbanColumn = forwardRef(
rowActions = [],
lazyLoading = false,
renderActions,
scrollElement = null
scrollElement = null,
width
},
ref
) => {
@ -60,6 +66,10 @@ const ObjectKanbanColumn = forwardRef(
const [pages, setPages] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
pagesRef.current = pages
}, [pages])
const columnFilter = useMemo(
() => ({
...baseFilter,
@ -426,9 +436,43 @@ const ObjectKanbanColumn = forwardRef(
}
}, [fetchData])
useImperativeHandle(ref, () => ({
reload: reloadLoadedPages
}))
const findItem = 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
}, [])
const updateItem = useCallback((id, updatedData) => {
setPages((prevPages) => {
const nextPages = prevPages.map((page) => {
let changed = false
const updatedItems = page.items.map((item) => {
if (idsEqual(item._id, id) && !item.isSkeleton) {
changed = true
return { ...item, ...updatedData, _id: item._id }
}
return item
})
return changed ? { ...page, items: updatedItems } : page
})
pagesRef.current = nextPages
return nextPages
})
}, [])
useImperativeHandle(
ref,
() => ({
reload: reloadLoadedPages,
findItem,
updateItem
}),
[findItem, reloadLoadedPages, updateItem]
)
useEffect(() => {
if (lastColumnQueryKeyRef.current === columnQueryKey) return
@ -502,8 +546,13 @@ const ObjectKanbanColumn = forwardRef(
tableData
])
const columnStyle =
typeof width === 'number'
? { width, flex: `0 0 ${width}px`, minWidth: KANBAN_MIN_COLUMN_WIDTH }
: undefined
return (
<Flex vertical className='objectKanbanColumn'>
<Flex vertical className='objectKanbanColumn' style={columnStyle}>
<div className='objectKanbanColumnCards' ref={containerRef}>
<Spin spinning={loading}>
<Flex
@ -567,7 +616,8 @@ ObjectKanbanColumn.propTypes = {
rowActions: PropTypes.array,
lazyLoading: PropTypes.bool,
renderActions: PropTypes.func,
scrollElement: PropTypes.object
scrollElement: PropTypes.object,
width: PropTypes.number
}
export default ObjectKanbanColumn

View File

@ -1,18 +1,26 @@
import { Flex, Skeleton } from 'antd'
import PropTypes from 'prop-types'
import { HolderOutlined, LoadingOutlined } from '@ant-design/icons'
import ObjectProperty from './ObjectProperty'
import { getCategoryValueKey } from './viewModeUtils'
import { LoadingOutlined } from '@ant-design/icons'
import { KANBAN_DEFAULT_COLUMN_WIDTH } from './viewModeUtils'
const ObjectKanbanHeader = ({
categoryValues,
columns = [],
categoryProperty,
categoryPropertyDef,
trackRef,
skeletonColumnCount = 0,
lazyLoading = false
lazyLoading = false,
loadingColumnKeys = [],
draggedKey = null,
dropTargetKey = null,
onColumnDragStart,
onColumnDragOver,
onColumnDrop,
onColumnDragEnd
}) => {
const showSkeleton = skeletonColumnCount > 0
const loadingColumnKeySet = new Set(loadingColumnKeys)
return (
<div className='objectKanbanHeader'>
@ -23,6 +31,10 @@ const ObjectKanbanHeader = ({
<div
key={`skeleton-header-${index}`}
className='objectKanbanHeaderCell'
style={{
width: KANBAN_DEFAULT_COLUMN_WIDTH,
flex: `0 0 ${KANBAN_DEFAULT_COLUMN_WIDTH}px`
}}
>
<Skeleton.Input
active
@ -31,30 +43,75 @@ const ObjectKanbanHeader = ({
/>
</div>
))
: categoryValues.map((categoryValue) => {
const columnKey = getCategoryValueKey(categoryValue)
: columns.map(({ key: columnKey, value: categoryValue, width }) => {
const showColumnLoading =
lazyLoading || loadingColumnKeySet.has(columnKey)
const isDragging = draggedKey === columnKey
const isDropTarget =
dropTargetKey === columnKey && draggedKey !== columnKey
return (
<Flex
key={columnKey}
className='objectKanbanHeaderCell'
className={[
'objectKanbanHeaderCell',
isDragging ? 'objectKanbanHeaderCell--dragging' : '',
isDropTarget ? 'objectKanbanHeaderCell--dropTarget' : ''
]
.filter(Boolean)
.join(' ')}
align='center'
justify='space-between'
gap={8}
style={{
width,
flex: `0 0 ${width}px`
}}
onDragOver={(event) =>
onColumnDragOver?.(event, columnKey)
}
onDrop={(event) => onColumnDrop?.(event, columnKey)}
>
{categoryPropertyDef ? (
<ObjectProperty
{...categoryPropertyDef}
value={categoryValue}
objectData={{ [categoryProperty]: categoryValue }}
value={
categoryPropertyDef.type === 'tags' &&
typeof categoryValue === 'string'
? [categoryValue]
: categoryValue
}
objectData={{
[categoryProperty]:
categoryPropertyDef.type === 'tags' &&
typeof categoryValue === 'string'
? [categoryValue]
: categoryValue
}}
name={categoryProperty}
/>
) : null}
{lazyLoading && (
<LoadingOutlined
spin
style={{ flexShrink: 0, marginRight: 6 }}
/>
)}
<Flex
align='center'
gap={4}
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>
)
})}
@ -67,12 +124,25 @@ const ObjectKanbanHeader = ({
ObjectKanbanHeader.displayName = 'ObjectKanbanHeader'
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,
categoryPropertyDef: PropTypes.object,
trackRef: PropTypes.object,
skeletonColumnCount: PropTypes.number,
lazyLoading: PropTypes.bool
lazyLoading: PropTypes.bool,
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

View File

@ -60,6 +60,7 @@ import Tooltip from './Tooltip'
import { ObjectTableFilterContext } from './ObjectTableFilterContext'
import ObjectListViewContext from '../context/ObjectListViewContext'
import { isCardsView, isKanbanView, normalizeViewMode } from './viewModeUtils'
import { areValuesEqual } from '../utils/Utils'
import LoadingPlaceholder from './LoadingPlaceholder'
const logger = loglevel.getLogger('DasboardTable')
@ -96,35 +97,48 @@ const fromFilterExpression = (expr) => {
return [expr]
}
const areValuesEqual = (v1, v2) => {
const id1 = v1 && typeof v1 === 'object' && v1._id ? v1._id : v1
const id2 = v2 && typeof v2 === 'object' && v2._id ? v2._id : v2
return String(id1) === String(id2)
const idsEqual = (a, b) => {
if (a == null || b == null) return false
return String(a).toLowerCase() === String(b).toLowerCase()
}
const getChangedKeys = (existing, updated) => {
const getUpdateKeys = (updated) => {
if (!updated || typeof updated !== 'object') return []
return Object.keys(updated).filter((key) => {
if (key === '_id' || key === 'objectType') return false
if (!existing) return true
return !areValuesEqual(existing[key], updated[key])
})
return Object.keys(updated).filter(
(key) => key !== '_id' && key !== 'objectType'
)
}
const updateAffectsFilterOrSort = (
changedKeys,
existingItem,
updatedData,
filter,
masterFilter,
sorter
) => {
if (!changedKeys.length) return false
const updateKeys = getUpdateKeys(updatedData)
if (!updateKeys.length) return false
const filterKeys = new Set([
...Object.keys(filter || {}),
...Object.keys(masterFilter || {})
])
if (changedKeys.some((key) => filterKeys.has(key))) return true
if (sorter?.field && changedKeys.includes(sorter.field)) return true
return false
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 = ({
@ -992,7 +1006,7 @@ const ObjectTable = forwardRef(
const findTableItem = useCallback((id) => {
for (const page of pagesRef.current) {
const item = page.items?.find(
(entry) => String(entry._id) === String(id) && !entry.isSkeleton
(entry) => idsEqual(entry._id, id) && !entry.isSkeleton
)
if (item) return item
}
@ -1003,39 +1017,46 @@ const ObjectTable = forwardRef(
const updateEventHandler = useCallback(
(id, updatedData) => {
const existingItem = findTableItem(id)
const changedKeys = getChangedKeys(existingItem, updatedData)
// 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(
changedKeys,
existingItem,
updatedData,
subscriptionFilterRef.current,
{},
effectiveSorterRef.current
)
) {
silentReloadRef.current?.()
return
}
if (!existingItem) return
setPages((prevPages) =>
prevPages.map((page) => {
const updatedItems = page.items.map((item) => {
if (String(item._id) === String(id)) {
return { ...item, ...updatedData }
}
return item
})
return {
...page,
items: updatedItems
}
})
)
if (rowFormsRef.current[id]) {
rowFormsRef.current[id].setFieldsValue(updatedData)
}
},
[findTableItem]
@ -1099,7 +1120,7 @@ const ObjectTable = forwardRef(
effectiveSorterRef.current = effectiveSorter
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
// Cleanup subscriptions on unmount
// Cleanup subscriptions on unmount. Kanban owns its own subscriptions.
useEffect(() => {
return () => {
if (connected == true && subscribeToObjectTypeUpdatesRef.current) {
@ -1113,9 +1134,9 @@ const ObjectTable = forwardRef(
}
}, [connected])
// Subscribe to all object updates for this type
// Subscribe to all object updates for this type (list/cards only)
useEffect(() => {
if (connected !== true || !type) return
if (isKanban || connected !== true || !type) return
const unsubscribe = subscribeToAllObjectUpdates(
type,
@ -1130,10 +1151,10 @@ const ObjectTable = forwardRef(
subscribeToAllObjectUpdatesRef.current = null
}
}
}, [type, connected, subscribeToAllObjectUpdates])
}, [type, connected, subscribeToAllObjectUpdates, isKanban])
useEffect(() => {
if (connected !== true) return
if (isKanban || connected !== true) return
if (subscribedTypeRef.current === type) return
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
@ -1152,7 +1173,7 @@ const ObjectTable = forwardRef(
subscribedTypeRef.current = null
}
}
}, [type, connected])
}, [type, connected, isKanban])
const updateData = useCallback(
(id, updatedData) => {
@ -2061,6 +2082,16 @@ const ObjectTable = forwardRef(
isEditing={isEditing}
rowActions={rowActions}
renderActions={renderActions}
columns={viewMode.settings?.columns}
onColumnsChange={(columns) => {
objectListView?.setViewMode?.({
...viewMode,
settings: {
...viewMode.settings,
columns
}
})
}}
/>
</Spin>
</div>

View File

@ -15,8 +15,13 @@ import GridIcon from '../../Icons/GridIcon'
import ListIcon from '../../Icons/ListIcon'
import KanbanIcon from '../../Icons/KanbanIcon'
import SettingsIcon from '../../Icons/SettingsIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import { getModelByName } from '../../../database/ObjectModels'
import { getViewModeType, normalizeViewMode } from './viewModeUtils'
import {
getViewModeType,
isKanbanCategoryProperty,
normalizeViewMode
} from './viewModeUtils'
const VIEW_MODE_OPTIONS = [
{ type: 'list', label: 'List' },
@ -40,14 +45,18 @@ const ObjectTableViewButton = ({
const normalizedViewMode = normalizeViewMode(viewMode)
const model = getModelByName(objectType)
const stateProperties = useMemo(
const categoryProperties = useMemo(
() =>
model?.properties?.filter((property) => property.type === 'state') || [],
model?.properties?.filter((property) =>
isKanbanCategoryProperty(property)
) || [],
[model]
)
const hasKanbanOption = stateProperties.length > 0
const defaultCategoryProperty = stateProperties[0]?.name
const hasKanbanOption = categoryProperties.length > 0
const defaultCategoryProperty =
categoryProperties.find((property) => property.type === 'state')?.name ||
categoryProperties[0]?.name
const availableViewModes = useMemo(
() =>
@ -59,16 +68,22 @@ const ObjectTableViewButton = ({
const handleTypeChange = (nextType) => {
if (nextType === 'kanban') {
setViewMode({
const categoryProperty =
normalizedViewMode.type === 'kanban'
? normalizedViewMode.settings?.categoryProperty ||
defaultCategoryProperty
: defaultCategoryProperty
const nextMode = {
type: 'kanban',
settings: {
categoryProperty:
normalizedViewMode.type === 'kanban'
? normalizedViewMode.settings?.categoryProperty ||
defaultCategoryProperty
: defaultCategoryProperty
}
})
settings: { categoryProperty }
}
if (
normalizedViewMode.type === 'kanban' &&
Array.isArray(normalizedViewMode.settings?.columns)
) {
nextMode.settings.columns = normalizedViewMode.settings.columns
}
setViewMode(nextMode)
return
}
@ -160,25 +175,46 @@ const ObjectTableViewButton = ({
/>
</Popover>
<Modal
title='Kanban settings'
open={settingsOpen}
onCancel={() => setSettingsOpen(false)}
onOk={() => setSettingsOpen(false)}
destroyOnClose
destroyOnHidden
focusTriggerAfterClose={false}
footer={null}
centered
closeIcon={null}
getContainer={() => document.body}
width={520}
>
<Select
style={{ width: '100%' }}
placeholder='Select category property'
value={
normalizedViewMode.settings?.categoryProperty ||
defaultCategoryProperty
}
onChange={handleCategoryPropertyChange}
options={stateProperties.map((property) => ({
value: property.name,
label: property.label || property.name
}))}
/>
<Flex vertical gap='middle' onMouseDown={(event) => event.stopPropagation()}>
<Flex gap='middle'>
<InfoCircleIcon />
<Text strong>Kanban settings</Text>
</Flex>
<Text>
Select the property used to group cards into columns:
</Text>
<Select
style={{ width: '100%' }}
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>
</>
)

View File

@ -1,5 +1,14 @@
export const DEFAULT_VIEW_MODE = { type: 'list' }
/** Property types that can be used as kanban column categories. */
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) =>
KANBAN_CATEGORY_PROPERTY_TYPES.includes(property?.type)
export const normalizeViewMode = (value) => {
if (!value) return DEFAULT_VIEW_MODE
if (typeof value === 'string') {
@ -16,6 +25,9 @@ export const isKanbanView = (vm) => normalizeViewMode(vm).type === 'kanban'
export const getViewModeType = (vm) => normalizeViewMode(vm).type
export const toCategoryFilterValue = (value) => {
if (Array.isArray(value)) {
return value.map(toCategoryFilterValue)
}
if (value && typeof value === 'object') {
return value.type ?? value._id ?? JSON.stringify(value)
}
@ -23,9 +35,85 @@ export const toCategoryFilterValue = (value) => {
}
export const getCategoryValueKey = (value) => {
if (value && typeof value === 'object' && value.type != null) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
value.type != null
) {
return String(value.type)
}
if (value != null) return String(value)
return 'null'
}
/**
* Column keys for a category property value. Multi-value properties (e.g. tags)
* can map an item to multiple columns.
*/
export const getCategoryColumnKeys = (value) => {
if (value === undefined) return []
if (Array.isArray(value)) {
return [
...new Set(
value
.filter((entry) => entry != null && entry !== '')
.map(getCategoryValueKey)
)
]
}
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
}))

View File

@ -1,9 +1,69 @@
import get from 'lodash/get'
import isEqual from 'lodash/isEqual'
import mergeWith from 'lodash/mergeWith'
import set from 'lodash/set'
const NESTED_OBJECT_KEYS = ['_id', '_reference', 'name', 'state']
// Mirrors farmcontrol-api getChangedValues / valuesDiffer used by audit logs.
const AUDIT_IGNORED_KEYS = ['createdAt', 'updatedAt', '_id']
const isDiffableObject = (value) =>
value && typeof value === 'object' && !Array.isArray(value)
const isNumericValue = (value) =>
typeof value === 'number' ||
(value !== null &&
value !== undefined &&
!isNaN(Number(value)) &&
value !== '')
const normalizeDiffValue = (value) =>
isNumericValue(value) ? Number(value) : value
const valuesDiffer = (oldVal, newVal) =>
!isEqual(normalizeDiffValue(oldVal), normalizeDiffValue(newVal))
const getChangedValues = (oldObj, newObj) => {
const changes = {}
const combinedObj = { ...oldObj, ...newObj }
for (const key in combinedObj) {
if (AUDIT_IGNORED_KEYS.includes(key)) continue
const oldVal = oldObj ? oldObj[key] : undefined
const newVal = newObj ? newObj[key] : undefined
if (isDiffableObject(oldVal) && isDiffableObject(newVal)) {
if (oldVal?._id || newVal?._id) {
if (valuesDiffer(oldVal?._id, newVal?._id)) {
changes[key] = newVal
}
} else {
const nestedChanges = getChangedValues(oldVal, newVal)
if (Object.keys(nestedChanges).length > 0) {
changes[key] = nestedChanges
}
}
} else if (valuesDiffer(oldVal, newVal)) {
changes[key] = newVal
}
}
return changes
}
/** True when values would not be flagged as changed by audit getChangedValues. */
export function areValuesEqual(v1, v2) {
if (isDiffableObject(v1) && isDiffableObject(v2)) {
if (v1?._id || v2?._id) {
return !valuesDiffer(v1?._id, v2?._id)
}
return Object.keys(getChangedValues(v1, v2)).length === 0
}
return !valuesDiffer(v1, v2)
}
export function capitalizeFirstLetter(string) {
try {
return string[0].toUpperCase() + string.slice(1)