Compare commits
No commits in common. "c75876ab3cb6335297cfda565c19ea768ac6d757" and "cb49baaf934e3791d5ac1a2fef74e9c00dd46508" have entirely different histories.
c75876ab3c
...
cb49baaf93
@ -1451,20 +1451,11 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
|
|||||||
.objectKanbanContainerSkeletons {
|
.objectKanbanContainerSkeletons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.objectKanbanOverlayColumns {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 4;
|
|
||||||
overflow: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.objectKanbanKanbanBody {
|
.objectKanbanKanbanBody {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -1529,35 +1520,14 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.objectKanbanHeaderCell {
|
.objectKanbanHeaderCell {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 360px;
|
||||||
min-width: 200px;
|
min-width: 360px;
|
||||||
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 {
|
||||||
@ -1577,16 +1547,14 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.objectKanbanSkeletonTrack,
|
.objectKanbanSkeletonTrack {
|
||||||
.objectKanbanOverlayTrack {
|
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.objectKanbanSkeletonRow,
|
.objectKanbanSkeletonRow {
|
||||||
.objectKanbanOverlayRow {
|
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -1595,88 +1563,16 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
|
|||||||
|
|
||||||
.objectKanbanColumn,
|
.objectKanbanColumn,
|
||||||
.objectKanbanColumnSkeleton {
|
.objectKanbanColumnSkeleton {
|
||||||
position: relative;
|
flex: 0 0 360px;
|
||||||
flex: 0 0 auto;
|
min-width: 360px;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.objectKanbanColumnSkeleton {
|
||||||
border-radius: 0 0 12px 12px;
|
border-radius: 0 0 12px 12px;
|
||||||
overflow: visible;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.objectKanbanColumnCards {
|
.objectKanbanColumnCards {
|
||||||
@ -3045,8 +2941,8 @@ body.objectKanbanColumnResizing * {
|
|||||||
|
|
||||||
.list-view-tabs-color-dot {
|
.list-view-tabs-color-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 6px;
|
width: 8px;
|
||||||
height: 6px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,60 +31,42 @@ const ElipsisText = ({ children, style, className, title, code, ...rest }) => {
|
|||||||
const text = childrenToString(children)
|
const text = childrenToString(children)
|
||||||
const { start, end } = splitMiddle(text)
|
const { start, end } = splitMiddle(text)
|
||||||
|
|
||||||
const [containerEl, setContainerEl] = useState(null)
|
const containerRef = useRef(null)
|
||||||
const [measureEl, setMeasureEl] = useState(null)
|
const measureRef = useRef(null)
|
||||||
const truncatedRef = useRef(false)
|
const truncatedRef = useRef(false)
|
||||||
const [truncated, setTruncated] = useState(false)
|
const [truncated, setTruncated] = useState(false)
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!containerEl || !measureEl) return
|
const container = containerRef.current
|
||||||
|
const measure = measureRef.current
|
||||||
let raf1 = 0
|
if (!container || !measure) return
|
||||||
let raf2 = 0
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
if (cancelled) return
|
const available = container.getBoundingClientRect().width
|
||||||
|
|
||||||
const available = containerEl.getBoundingClientRect().width
|
|
||||||
if (available < 1) return
|
if (available < 1) return
|
||||||
|
|
||||||
const textWidth = measureEl.getBoundingClientRect().width
|
const textWidth = measure.getBoundingClientRect().width
|
||||||
const next = Boolean(end) && textWidth > available
|
const next = Boolean(end) && textWidth > available
|
||||||
if (truncatedRef.current === next) return
|
if (truncatedRef.current === next) return
|
||||||
|
|
||||||
truncatedRef.current = next
|
truncatedRef.current = next
|
||||||
setTruncated(next)
|
setTruncated(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
update()
|
update()
|
||||||
raf1 = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(update)
|
||||||
update()
|
|
||||||
raf2 = requestAnimationFrame(update)
|
|
||||||
})
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(update)
|
const resizeObserver = new ResizeObserver(update)
|
||||||
resizeObserver.observe(containerEl)
|
resizeObserver.observe(container)
|
||||||
|
|
||||||
const intersectionObserver = new IntersectionObserver(update)
|
const intersectionObserver = new IntersectionObserver(update)
|
||||||
intersectionObserver.observe(containerEl)
|
intersectionObserver.observe(container)
|
||||||
|
|
||||||
const onFontsReady = () => {
|
|
||||||
if (!cancelled) update()
|
|
||||||
}
|
|
||||||
const fonts = typeof document !== 'undefined' ? document.fonts : null
|
|
||||||
fonts?.ready?.then(onFontsReady)
|
|
||||||
fonts?.addEventListener?.('loadingdone', onFontsReady)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelAnimationFrame(frame)
|
||||||
cancelAnimationFrame(raf1)
|
|
||||||
cancelAnimationFrame(raf2)
|
|
||||||
resizeObserver.disconnect()
|
resizeObserver.disconnect()
|
||||||
intersectionObserver.disconnect()
|
intersectionObserver.disconnect()
|
||||||
fonts?.removeEventListener?.('loadingdone', onFontsReady)
|
|
||||||
}
|
}
|
||||||
}, [containerEl, measureEl, text, end])
|
}, [text, end])
|
||||||
|
|
||||||
const showTruncated = truncated && Boolean(end)
|
const showTruncated = truncated && Boolean(end)
|
||||||
const RootTag = code ? 'code' : 'span'
|
const RootTag = code ? 'code' : 'span'
|
||||||
@ -92,9 +74,9 @@ const ElipsisText = ({ children, style, className, title, code, ...rest }) => {
|
|||||||
const content = (
|
const content = (
|
||||||
<span
|
<span
|
||||||
className={showTruncated ? 'elipsis-text is-truncated' : 'elipsis-text'}
|
className={showTruncated ? 'elipsis-text is-truncated' : 'elipsis-text'}
|
||||||
ref={setContainerEl}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
<RootTag className='elipsis-text-measure' ref={setMeasureEl}>
|
<RootTag className='elipsis-text-measure' ref={measureRef}>
|
||||||
{text}
|
{text}
|
||||||
</RootTag>
|
</RootTag>
|
||||||
<RootTag className='elipsis-text-full'>{text}</RootTag>
|
<RootTag className='elipsis-text-full'>{text}</RootTag>
|
||||||
@ -114,7 +96,7 @@ const ElipsisText = ({ children, style, className, title, code, ...rest }) => {
|
|||||||
className={['elipsis-text-wrapper', className].filter(Boolean).join(' ')}
|
className={['elipsis-text-wrapper', className].filter(Boolean).join(' ')}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<Tooltip title={truncated ? (title ?? text) : null}>{content}</Tooltip>
|
{truncated ? <Tooltip title={title ?? text}>{content}</Tooltip> : content}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,7 +98,7 @@ const ObjectCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
styles={{ body: { padding: '14px 16px' } }}
|
styles={{ body: { padding: 18 } }}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
variant={cardStyle}
|
variant={cardStyle}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@ -11,79 +10,18 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { Empty, Flex } from 'antd'
|
import { Empty, 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 ObjectKanbanColumn from './ObjectKanbanColumn'
|
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 {
|
import { getCategoryValueKey } from './viewModeUtils'
|
||||||
getCategoryColumnKeys,
|
|
||||||
getCategoryValueKey,
|
|
||||||
KANBAN_DEFAULT_COLUMN_WIDTH,
|
|
||||||
KANBAN_MIN_COLUMN_WIDTH,
|
|
||||||
resolveKanbanColumns,
|
|
||||||
toKanbanColumnsConfig
|
|
||||||
} from './viewModeUtils'
|
|
||||||
import { areValuesEqual } from '../utils/Utils'
|
|
||||||
|
|
||||||
const KANBAN_COLUMN_WIDTH = KANBAN_DEFAULT_COLUMN_WIDTH
|
const KANBAN_COLUMN_WIDTH = 360
|
||||||
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) => {
|
|
||||||
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 getKanbanSkeletonLayout = (width, height) => {
|
||||||
const columnCount =
|
const columnCount =
|
||||||
Math.max(
|
Math.max(
|
||||||
@ -98,99 +36,6 @@ const getKanbanSkeletonLayout = (width, height) => {
|
|||||||
return { columnCount, cardCount }
|
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 = ({
|
const ObjectKanbanSkeletonColumns = ({
|
||||||
columnCount,
|
columnCount,
|
||||||
cardCount = 0,
|
cardCount = 0,
|
||||||
@ -198,51 +43,45 @@ const ObjectKanbanSkeletonColumns = ({
|
|||||||
model,
|
model,
|
||||||
modelProperties,
|
modelProperties,
|
||||||
visibleColumns,
|
visibleColumns,
|
||||||
keyPrefix = 'skeleton',
|
keyPrefix = 'skeleton'
|
||||||
columns = null,
|
}) => (
|
||||||
resizingKey = null,
|
<div className='objectKanbanSkeletonColumns objectKanbanSkeletonColumns--loading'>
|
||||||
onColumnResizeStart
|
<div className='objectKanbanSkeletonTrack' ref={trackRef}>
|
||||||
}) => {
|
<Flex gap='middle' className='objectKanbanSkeletonRow'>
|
||||||
const skeletonColumns =
|
{Array.from({ length: columnCount }).map((_, columnIndex) => (
|
||||||
columns?.length > 0
|
<div
|
||||||
? columns
|
key={`${keyPrefix}-${columnIndex}`}
|
||||||
: Array.from({ length: columnCount }).map((_, columnIndex) => ({
|
className='objectKanbanColumnSkeleton'
|
||||||
key: `${keyPrefix}-${columnIndex}`,
|
>
|
||||||
width: KANBAN_COLUMN_WIDTH
|
<div className='objectKanbanColumnCards'>
|
||||||
}))
|
{cardCount > 0 && (
|
||||||
|
<Flex
|
||||||
return (
|
vertical
|
||||||
<div className='objectKanbanSkeletonColumns objectKanbanSkeletonColumns--loading'>
|
gap='middle'
|
||||||
<div className='objectKanbanSkeletonTrack' ref={trackRef}>
|
className='objectKanbanColumnCardsInner'
|
||||||
<Flex gap='middle' className='objectKanbanSkeletonRow'>
|
>
|
||||||
{skeletonColumns.map((column, columnIndex) => (
|
{Array.from({ length: cardCount }).map((_, cardIndex) => (
|
||||||
<ObjectKanbanColumnSkeleton
|
<ObjectCard
|
||||||
key={column.key || `${keyPrefix}-${columnIndex}`}
|
key={cardIndex}
|
||||||
columnKey={column.key}
|
isSkeleton
|
||||||
width={column.width}
|
model={model}
|
||||||
cardCount={cardCount}
|
modelProperties={modelProperties}
|
||||||
model={model}
|
visibleColumns={visibleColumns}
|
||||||
modelProperties={modelProperties}
|
record={{
|
||||||
visibleColumns={visibleColumns}
|
_id: `${keyPrefix}-${columnIndex}-${cardIndex}`
|
||||||
keyPrefix={keyPrefix}
|
}}
|
||||||
columnIndex={columnIndex}
|
cardStyle='bordered'
|
||||||
isResizing={resizingKey === column.key}
|
/>
|
||||||
onResizeStart={onColumnResizeStart}
|
))}
|
||||||
/>
|
</Flex>
|
||||||
))}
|
)}
|
||||||
</Flex>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
|
|
||||||
ObjectKanbanColumnResizeOverlay.propTypes = {
|
|
||||||
columnKey: PropTypes.string,
|
|
||||||
width: PropTypes.number.isRequired,
|
|
||||||
columnIndex: PropTypes.number,
|
|
||||||
isResizing: PropTypes.bool,
|
|
||||||
onResizeStart: PropTypes.func
|
|
||||||
}
|
|
||||||
|
|
||||||
ObjectKanbanSkeletonColumns.propTypes = {
|
ObjectKanbanSkeletonColumns.propTypes = {
|
||||||
columnCount: PropTypes.number.isRequired,
|
columnCount: PropTypes.number.isRequired,
|
||||||
@ -251,10 +90,7 @@ 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,
|
|
||||||
resizingKey: PropTypes.string,
|
|
||||||
onColumnResizeStart: PropTypes.func
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ObjectKanban = forwardRef(
|
const ObjectKanban = forwardRef(
|
||||||
@ -272,22 +108,14 @@ const ObjectKanban = forwardRef(
|
|||||||
visibleColumns = {},
|
visibleColumns = {},
|
||||||
isEditing = false,
|
isEditing = false,
|
||||||
rowActions = [],
|
rowActions = [],
|
||||||
renderActions,
|
renderActions
|
||||||
columns: storedColumns = [],
|
|
||||||
onColumnsChange
|
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const {
|
const { getModelPropertyValues } = useContext(ApiServerContext)
|
||||||
getModelPropertyValues,
|
|
||||||
connected,
|
|
||||||
subscribeToAllObjectUpdates,
|
|
||||||
subscribeToObjectTypeUpdates
|
|
||||||
} = useContext(ApiServerContext)
|
|
||||||
const columnRefs = useRef({})
|
const columnRefs = useRef({})
|
||||||
const headerTrackRef = useRef(null)
|
const headerTrackRef = useRef(null)
|
||||||
const skeletonTrackRef = useRef(null)
|
const skeletonTrackRef = useRef(null)
|
||||||
const overlayTrackRef = useRef(null)
|
|
||||||
const kanbanBodyRef = useRef(null)
|
const kanbanBodyRef = useRef(null)
|
||||||
const [scrollElement, setScrollElement] = useState(null)
|
const [scrollElement, setScrollElement] = useState(null)
|
||||||
const [categoryValues, setCategoryValues] = useState([])
|
const [categoryValues, setCategoryValues] = useState([])
|
||||||
@ -298,42 +126,19 @@ const ObjectKanban = forwardRef(
|
|||||||
columnCount: 3,
|
columnCount: 3,
|
||||||
cardCount: 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 baseFilterRef = useRef(baseFilter)
|
||||||
const masterFilterRef = useRef(masterFilter)
|
const masterFilterRef = useRef(masterFilter)
|
||||||
const sorterRef = useRef(sorter)
|
const sorterRef = useRef(sorter)
|
||||||
const categoryPropertyRef = useRef(categoryProperty)
|
|
||||||
const getModelPropertyValuesRef = useRef(getModelPropertyValues)
|
const getModelPropertyValuesRef = useRef(getModelPropertyValues)
|
||||||
const lastCategoryQueryKeyRef = useRef(null)
|
const lastCategoryQueryKeyRef = useRef(null)
|
||||||
const hasLoadedRef = useRef(false)
|
const hasLoadedRef = useRef(false)
|
||||||
const loadGenerationRef = useRef(0)
|
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
|
baseFilterRef.current = baseFilter
|
||||||
masterFilterRef.current = masterFilter
|
masterFilterRef.current = masterFilter
|
||||||
sorterRef.current = sorter
|
sorterRef.current = sorter
|
||||||
categoryPropertyRef.current = categoryProperty
|
|
||||||
getModelPropertyValuesRef.current = getModelPropertyValues
|
getModelPropertyValuesRef.current = getModelPropertyValues
|
||||||
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
|
|
||||||
categoryValuesRef.current = categoryValues
|
|
||||||
|
|
||||||
const setScrollContainerRef = useCallback((node) => {
|
const setScrollContainerRef = useCallback((node) => {
|
||||||
setScrollElement(node)
|
setScrollElement(node)
|
||||||
@ -360,7 +165,6 @@ const ObjectKanban = forwardRef(
|
|||||||
const mainScroll = scrollElement
|
const mainScroll = scrollElement
|
||||||
const headerTrack = headerTrackRef.current
|
const headerTrack = headerTrackRef.current
|
||||||
const skeletonTrack = skeletonTrackRef.current
|
const skeletonTrack = skeletonTrackRef.current
|
||||||
const overlayTrack = overlayTrackRef.current
|
|
||||||
if (!mainScroll || !headerTrack) return undefined
|
if (!mainScroll || !headerTrack) return undefined
|
||||||
|
|
||||||
const handleMainScroll = () => {
|
const handleMainScroll = () => {
|
||||||
@ -369,9 +173,6 @@ const ObjectKanban = forwardRef(
|
|||||||
if (skeletonTrack) {
|
if (skeletonTrack) {
|
||||||
skeletonTrack.style.transform = offset
|
skeletonTrack.style.transform = offset
|
||||||
}
|
}
|
||||||
if (overlayTrack) {
|
|
||||||
overlayTrack.style.transform = offset
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMainScroll()
|
handleMainScroll()
|
||||||
@ -402,38 +203,14 @@ const ObjectKanban = forwardRef(
|
|||||||
[categoryProperty, type]
|
[categoryProperty, type]
|
||||||
)
|
)
|
||||||
|
|
||||||
const reloadColumns = useCallback(async (columnKeys = null) => {
|
const reloadColumns = useCallback(async () => {
|
||||||
const refs =
|
|
||||||
columnKeys == null
|
|
||||||
? Object.values(columnRefs.current)
|
|
||||||
: columnKeys.map((key) => columnRefs.current[key])
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
refs.filter(Boolean).map((columnRef) => columnRef.reload?.())
|
Object.values(columnRefs.current)
|
||||||
|
.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(
|
const load = useCallback(
|
||||||
async (filter = null, sorterArg = null, options = {}) => {
|
async (filter = null, sorterArg = null, options = {}) => {
|
||||||
const silent = options.silent === true
|
const silent = options.silent === true
|
||||||
@ -442,10 +219,9 @@ const ObjectKanban = forwardRef(
|
|||||||
if (!categoryProperty) {
|
if (!categoryProperty) {
|
||||||
lastCategoryQueryKeyRef.current = null
|
lastCategoryQueryKeyRef.current = null
|
||||||
hasLoadedRef.current = true
|
hasLoadedRef.current = true
|
||||||
categoryValuesRef.current = []
|
|
||||||
setCategoryValues([])
|
setCategoryValues([])
|
||||||
setHasLoaded(true)
|
setHasLoaded(true)
|
||||||
return []
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeFilter = filter ?? baseFilterRef.current
|
const activeFilter = filter ?? baseFilterRef.current
|
||||||
@ -458,12 +234,10 @@ const ObjectKanban = forwardRef(
|
|||||||
|
|
||||||
if (canReuseColumns) {
|
if (canReuseColumns) {
|
||||||
await reloadColumns()
|
await reloadColumns()
|
||||||
if (generation !== loadGenerationRef.current) {
|
if (generation !== loadGenerationRef.current) return
|
||||||
return categoryValuesRef.current
|
|
||||||
}
|
|
||||||
hasLoadedRef.current = true
|
hasLoadedRef.current = true
|
||||||
setHasLoaded(true)
|
setHasLoaded(true)
|
||||||
return categoryValuesRef.current
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter/sort changes keep current columns visible; objectView /
|
// Filter/sort changes keep current columns visible; objectView /
|
||||||
@ -482,26 +256,19 @@ const ObjectKanban = forwardRef(
|
|||||||
masterFilter: activeMasterFilter
|
masterFilter: activeMasterFilter
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (generation !== loadGenerationRef.current) {
|
if (generation !== loadGenerationRef.current) return
|
||||||
return categoryValuesRef.current
|
|
||||||
}
|
|
||||||
const nextValues = Array.isArray(values) ? values : []
|
const nextValues = Array.isArray(values) ? values : []
|
||||||
lastCategoryQueryKeyRef.current = queryKey
|
lastCategoryQueryKeyRef.current = queryKey
|
||||||
hasLoadedRef.current = true
|
hasLoadedRef.current = true
|
||||||
setLoadedFilter(activeFilter)
|
setLoadedFilter(activeFilter)
|
||||||
setLoadedSorter(activeSorter)
|
setLoadedSorter(activeSorter)
|
||||||
categoryValuesRef.current = nextValues
|
|
||||||
setCategoryValues(nextValues)
|
setCategoryValues(nextValues)
|
||||||
setHasLoaded(true)
|
setHasLoaded(true)
|
||||||
return nextValues
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (generation !== loadGenerationRef.current) {
|
if (generation !== loadGenerationRef.current) return
|
||||||
return categoryValuesRef.current
|
|
||||||
}
|
|
||||||
console.error('Error fetching kanban category values:', error)
|
console.error('Error fetching kanban category values:', error)
|
||||||
lastCategoryQueryKeyRef.current = null
|
lastCategoryQueryKeyRef.current = null
|
||||||
hasLoadedRef.current = true
|
hasLoadedRef.current = true
|
||||||
categoryValuesRef.current = []
|
|
||||||
setCategoryValues([])
|
setCategoryValues([])
|
||||||
setHasLoaded(true)
|
setHasLoaded(true)
|
||||||
throw error
|
throw error
|
||||||
@ -516,238 +283,6 @@ const ObjectKanban = forwardRef(
|
|||||||
await load(baseFilterRef.current, sorterRef.current)
|
await load(baseFilterRef.current, sorterRef.current)
|
||||||
}, [load])
|
}, [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(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
@ -757,164 +292,6 @@ 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%' }}>
|
||||||
@ -935,27 +312,10 @@ const ObjectKanban = forwardRef(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='objectKanbanContainerWrapper'>
|
<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}>
|
<Spin spinning={showSkeleton}>
|
||||||
<div className='objectKanbanContainerSkeletons'>
|
<div className='objectKanbanContainerSkeletons'>
|
||||||
<ObjectKanbanHeader
|
<ObjectKanbanHeader
|
||||||
columns={displayColumns}
|
categoryValues={categoryValues}
|
||||||
categoryProperty={categoryProperty}
|
categoryProperty={categoryProperty}
|
||||||
categoryPropertyDef={categoryPropertyDef}
|
categoryPropertyDef={categoryPropertyDef}
|
||||||
trackRef={headerTrackRef}
|
trackRef={headerTrackRef}
|
||||||
@ -963,13 +323,6 @@ const ObjectKanban = forwardRef(
|
|||||||
showSkeleton ? skeletonLayout.columnCount : 0
|
showSkeleton ? skeletonLayout.columnCount : 0
|
||||||
}
|
}
|
||||||
lazyLoading={showSkeleton || lazyLoading}
|
lazyLoading={showSkeleton || lazyLoading}
|
||||||
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 && (
|
||||||
@ -979,8 +332,9 @@ const ObjectKanban = forwardRef(
|
|||||||
scrollableNodeProps={{ ref: setScrollContainerRef }}
|
scrollableNodeProps={{ ref: setScrollContainerRef }}
|
||||||
>
|
>
|
||||||
<Flex gap='middle' className='objectKanban'>
|
<Flex gap='middle' className='objectKanban'>
|
||||||
{displayColumns.map(
|
{categoryValues.map((categoryValue) => {
|
||||||
({ key: columnKey, value: categoryValue, width }) => (
|
const columnKey = getCategoryValueKey(categoryValue)
|
||||||
|
return (
|
||||||
<ObjectKanbanColumn
|
<ObjectKanbanColumn
|
||||||
key={columnKey}
|
key={columnKey}
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
@ -1004,10 +358,9 @@ const ObjectKanban = forwardRef(
|
|||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
rowActions={rowActions}
|
rowActions={rowActions}
|
||||||
renderActions={renderActions}
|
renderActions={renderActions}
|
||||||
width={width}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
})}
|
||||||
</Flex>
|
</Flex>
|
||||||
</ScrollBox>
|
</ScrollBox>
|
||||||
</div>
|
</div>
|
||||||
@ -1028,18 +381,17 @@ const ObjectKanban = forwardRef(
|
|||||||
ref={skeletonTrackRef}
|
ref={skeletonTrackRef}
|
||||||
>
|
>
|
||||||
<Flex gap='middle' className='objectKanbanSkeletonRow'>
|
<Flex gap='middle' className='objectKanbanSkeletonRow'>
|
||||||
{displayColumns.map(({ key: columnKey, width }) => (
|
{categoryValues.map((categoryValue) => {
|
||||||
<ObjectKanbanColumnSkeleton
|
const columnKey = getCategoryValueKey(categoryValue)
|
||||||
key={columnKey}
|
return (
|
||||||
columnKey={columnKey}
|
<div
|
||||||
width={width}
|
key={columnKey}
|
||||||
model={model}
|
className='objectKanbanColumnSkeleton'
|
||||||
modelProperties={modelProperties}
|
>
|
||||||
visibleColumns={visibleColumns}
|
<div className='objectKanbanColumnCards' />
|
||||||
isResizing={resizingKey === columnKey}
|
</div>
|
||||||
onResizeStart={handleColumnResizeStart}
|
)
|
||||||
/>
|
})}
|
||||||
))}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1067,14 +419,7 @@ 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
|
||||||
|
|||||||
@ -14,15 +14,10 @@ import PropTypes from 'prop-types'
|
|||||||
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 { KANBAN_MIN_COLUMN_WIDTH, toCategoryFilterValue } from './viewModeUtils'
|
import { toCategoryFilterValue } from './viewModeUtils'
|
||||||
|
|
||||||
const SCROLL_THRESHOLD = 50
|
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(
|
const ObjectKanbanColumn = forwardRef(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
@ -40,8 +35,7 @@ const ObjectKanbanColumn = forwardRef(
|
|||||||
rowActions = [],
|
rowActions = [],
|
||||||
lazyLoading = false,
|
lazyLoading = false,
|
||||||
renderActions,
|
renderActions,
|
||||||
scrollElement = null,
|
scrollElement = null
|
||||||
width
|
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@ -66,10 +60,6 @@ const ObjectKanbanColumn = forwardRef(
|
|||||||
const [pages, setPages] = useState([])
|
const [pages, setPages] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
pagesRef.current = pages
|
|
||||||
}, [pages])
|
|
||||||
|
|
||||||
const columnFilter = useMemo(
|
const columnFilter = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...baseFilter,
|
...baseFilter,
|
||||||
@ -436,43 +426,9 @@ const ObjectKanbanColumn = forwardRef(
|
|||||||
}
|
}
|
||||||
}, [fetchData])
|
}, [fetchData])
|
||||||
|
|
||||||
const findItem = useCallback((id) => {
|
useImperativeHandle(ref, () => ({
|
||||||
for (const page of pagesRef.current) {
|
reload: reloadLoadedPages
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (lastColumnQueryKeyRef.current === columnQueryKey) return
|
if (lastColumnQueryKeyRef.current === columnQueryKey) return
|
||||||
@ -546,13 +502,8 @@ 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' style={columnStyle}>
|
<Flex vertical className='objectKanbanColumn'>
|
||||||
<div className='objectKanbanColumnCards' ref={containerRef}>
|
<div className='objectKanbanColumnCards' ref={containerRef}>
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
<Flex
|
<Flex
|
||||||
@ -616,8 +567,7 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ObjectKanbanColumn
|
export default ObjectKanbanColumn
|
||||||
|
|||||||
@ -1,26 +1,18 @@
|
|||||||
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 { KANBAN_DEFAULT_COLUMN_WIDTH } from './viewModeUtils'
|
import { getCategoryValueKey } from './viewModeUtils'
|
||||||
|
import { LoadingOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
const ObjectKanbanHeader = ({
|
const ObjectKanbanHeader = ({
|
||||||
columns = [],
|
categoryValues,
|
||||||
categoryProperty,
|
categoryProperty,
|
||||||
categoryPropertyDef,
|
categoryPropertyDef,
|
||||||
trackRef,
|
trackRef,
|
||||||
skeletonColumnCount = 0,
|
skeletonColumnCount = 0,
|
||||||
lazyLoading = false,
|
lazyLoading = false
|
||||||
loadingColumnKeys = [],
|
|
||||||
draggedKey = null,
|
|
||||||
dropTargetKey = null,
|
|
||||||
onColumnDragStart,
|
|
||||||
onColumnDragOver,
|
|
||||||
onColumnDrop,
|
|
||||||
onColumnDragEnd
|
|
||||||
}) => {
|
}) => {
|
||||||
const showSkeleton = skeletonColumnCount > 0
|
const showSkeleton = skeletonColumnCount > 0
|
||||||
const loadingColumnKeySet = new Set(loadingColumnKeys)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='objectKanbanHeader'>
|
<div className='objectKanbanHeader'>
|
||||||
@ -31,10 +23,6 @@ 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
|
||||||
@ -43,75 +31,30 @@ const ObjectKanbanHeader = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: columns.map(({ key: columnKey, value: categoryValue, width }) => {
|
: categoryValues.map((categoryValue) => {
|
||||||
const showColumnLoading =
|
const columnKey = getCategoryValueKey(categoryValue)
|
||||||
lazyLoading || loadingColumnKeySet.has(columnKey)
|
|
||||||
const isDragging = draggedKey === columnKey
|
|
||||||
const isDropTarget =
|
|
||||||
dropTargetKey === columnKey && draggedKey !== columnKey
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
key={columnKey}
|
key={columnKey}
|
||||||
className={[
|
className='objectKanbanHeaderCell'
|
||||||
'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
|
||||||
{...categoryPropertyDef}
|
{...categoryPropertyDef}
|
||||||
value={
|
value={categoryValue}
|
||||||
categoryPropertyDef.type === 'tags' &&
|
objectData={{ [categoryProperty]: categoryValue }}
|
||||||
typeof categoryValue === 'string'
|
|
||||||
? [categoryValue]
|
|
||||||
: categoryValue
|
|
||||||
}
|
|
||||||
objectData={{
|
|
||||||
[categoryProperty]:
|
|
||||||
categoryPropertyDef.type === 'tags' &&
|
|
||||||
typeof categoryValue === 'string'
|
|
||||||
? [categoryValue]
|
|
||||||
: categoryValue
|
|
||||||
}}
|
|
||||||
name={categoryProperty}
|
name={categoryProperty}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Flex
|
{lazyLoading && (
|
||||||
align='center'
|
<LoadingOutlined
|
||||||
gap={4}
|
spin
|
||||||
className='objectKanbanHeaderCellActions'
|
style={{ flexShrink: 0, marginRight: 6 }}
|
||||||
>
|
/>
|
||||||
{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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -124,25 +67,12 @@ const ObjectKanbanHeader = ({
|
|||||||
ObjectKanbanHeader.displayName = 'ObjectKanbanHeader'
|
ObjectKanbanHeader.displayName = 'ObjectKanbanHeader'
|
||||||
|
|
||||||
ObjectKanbanHeader.propTypes = {
|
ObjectKanbanHeader.propTypes = {
|
||||||
columns: PropTypes.arrayOf(
|
categoryValues: PropTypes.array.isRequired,
|
||||||
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),
|
|
||||||
draggedKey: PropTypes.string,
|
|
||||||
dropTargetKey: PropTypes.string,
|
|
||||||
onColumnDragStart: PropTypes.func,
|
|
||||||
onColumnDragOver: PropTypes.func,
|
|
||||||
onColumnDrop: PropTypes.func,
|
|
||||||
onColumnDragEnd: PropTypes.func
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ObjectKanbanHeader
|
export default ObjectKanbanHeader
|
||||||
|
|||||||
@ -60,7 +60,6 @@ import Tooltip from './Tooltip'
|
|||||||
import { ObjectTableFilterContext } from './ObjectTableFilterContext'
|
import { ObjectTableFilterContext } from './ObjectTableFilterContext'
|
||||||
import ObjectListViewContext from '../context/ObjectListViewContext'
|
import ObjectListViewContext from '../context/ObjectListViewContext'
|
||||||
import { isCardsView, isKanbanView, normalizeViewMode } from './viewModeUtils'
|
import { isCardsView, isKanbanView, normalizeViewMode } from './viewModeUtils'
|
||||||
import { areValuesEqual } from '../utils/Utils'
|
|
||||||
import LoadingPlaceholder from './LoadingPlaceholder'
|
import LoadingPlaceholder from './LoadingPlaceholder'
|
||||||
|
|
||||||
const logger = loglevel.getLogger('DasboardTable')
|
const logger = loglevel.getLogger('DasboardTable')
|
||||||
@ -97,48 +96,35 @@ const fromFilterExpression = (expr) => {
|
|||||||
return [expr]
|
return [expr]
|
||||||
}
|
}
|
||||||
|
|
||||||
const idsEqual = (a, b) => {
|
const areValuesEqual = (v1, v2) => {
|
||||||
if (a == null || b == null) return false
|
const id1 = v1 && typeof v1 === 'object' && v1._id ? v1._id : v1
|
||||||
return String(a).toLowerCase() === String(b).toLowerCase()
|
const id2 = v2 && typeof v2 === 'object' && v2._id ? v2._id : v2
|
||||||
|
return String(id1) === String(id2)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUpdateKeys = (updated) => {
|
const getChangedKeys = (existing, updated) => {
|
||||||
if (!updated || typeof updated !== 'object') return []
|
if (!updated || typeof updated !== 'object') return []
|
||||||
return Object.keys(updated).filter(
|
return Object.keys(updated).filter((key) => {
|
||||||
(key) => key !== '_id' && key !== 'objectType'
|
if (key === '_id' || key === 'objectType') return false
|
||||||
)
|
if (!existing) return true
|
||||||
|
return !areValuesEqual(existing[key], updated[key])
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateAffectsFilterOrSort = (
|
const updateAffectsFilterOrSort = (
|
||||||
existingItem,
|
changedKeys,
|
||||||
updatedData,
|
|
||||||
filter,
|
filter,
|
||||||
masterFilter,
|
masterFilter,
|
||||||
sorter
|
sorter
|
||||||
) => {
|
) => {
|
||||||
const updateKeys = getUpdateKeys(updatedData)
|
if (!changedKeys.length) return false
|
||||||
if (!updateKeys.length) return false
|
|
||||||
|
|
||||||
const filterKeys = new Set([
|
const filterKeys = new Set([
|
||||||
...Object.keys(filter || {}),
|
...Object.keys(filter || {}),
|
||||||
...Object.keys(masterFilter || {})
|
...Object.keys(masterFilter || {})
|
||||||
])
|
])
|
||||||
|
if (changedKeys.some((key) => filterKeys.has(key))) return true
|
||||||
const isAffectingKey = (key) =>
|
if (sorter?.field && changedKeys.includes(sorter.field)) return true
|
||||||
filterKeys.has(key) || (sorter?.field && key === sorter.field)
|
return false
|
||||||
|
|
||||||
// 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 = ({
|
const ColumnFilterDropdown = ({
|
||||||
@ -1006,7 +992,7 @@ const ObjectTable = forwardRef(
|
|||||||
const findTableItem = useCallback((id) => {
|
const findTableItem = useCallback((id) => {
|
||||||
for (const page of pagesRef.current) {
|
for (const page of pagesRef.current) {
|
||||||
const item = page.items?.find(
|
const item = page.items?.find(
|
||||||
(entry) => idsEqual(entry._id, id) && !entry.isSkeleton
|
(entry) => String(entry._id) === String(id) && !entry.isSkeleton
|
||||||
)
|
)
|
||||||
if (item) return item
|
if (item) return item
|
||||||
}
|
}
|
||||||
@ -1017,46 +1003,39 @@ const ObjectTable = forwardRef(
|
|||||||
const updateEventHandler = useCallback(
|
const updateEventHandler = useCallback(
|
||||||
(id, updatedData) => {
|
(id, updatedData) => {
|
||||||
const existingItem = findTableItem(id)
|
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 (
|
if (
|
||||||
updateAffectsFilterOrSort(
|
updateAffectsFilterOrSort(
|
||||||
existingItem,
|
changedKeys,
|
||||||
updatedData,
|
|
||||||
subscriptionFilterRef.current,
|
subscriptionFilterRef.current,
|
||||||
{},
|
{},
|
||||||
effectiveSorterRef.current
|
effectiveSorterRef.current
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
silentReloadRef.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]
|
[findTableItem]
|
||||||
@ -1120,7 +1099,7 @@ const ObjectTable = forwardRef(
|
|||||||
effectiveSorterRef.current = effectiveSorter
|
effectiveSorterRef.current = effectiveSorter
|
||||||
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
|
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
|
||||||
|
|
||||||
// Cleanup subscriptions on unmount. Kanban owns its own subscriptions.
|
// Cleanup subscriptions on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (connected == true && subscribeToObjectTypeUpdatesRef.current) {
|
if (connected == true && subscribeToObjectTypeUpdatesRef.current) {
|
||||||
@ -1134,9 +1113,9 @@ const ObjectTable = forwardRef(
|
|||||||
}
|
}
|
||||||
}, [connected])
|
}, [connected])
|
||||||
|
|
||||||
// Subscribe to all object updates for this type (list/cards only)
|
// Subscribe to all object updates for this type
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isKanban || connected !== true || !type) return
|
if (connected !== true || !type) return
|
||||||
|
|
||||||
const unsubscribe = subscribeToAllObjectUpdates(
|
const unsubscribe = subscribeToAllObjectUpdates(
|
||||||
type,
|
type,
|
||||||
@ -1151,10 +1130,10 @@ const ObjectTable = forwardRef(
|
|||||||
subscribeToAllObjectUpdatesRef.current = null
|
subscribeToAllObjectUpdatesRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [type, connected, subscribeToAllObjectUpdates, isKanban])
|
}, [type, connected, subscribeToAllObjectUpdates])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isKanban || connected !== true) return
|
if (connected !== true) return
|
||||||
if (subscribedTypeRef.current === type) return
|
if (subscribedTypeRef.current === type) return
|
||||||
|
|
||||||
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
|
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
|
||||||
@ -1173,7 +1152,7 @@ const ObjectTable = forwardRef(
|
|||||||
subscribedTypeRef.current = null
|
subscribedTypeRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [type, connected, isKanban])
|
}, [type, connected])
|
||||||
|
|
||||||
const updateData = useCallback(
|
const updateData = useCallback(
|
||||||
(id, updatedData) => {
|
(id, updatedData) => {
|
||||||
@ -2082,16 +2061,6 @@ 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>
|
||||||
|
|||||||
@ -15,13 +15,8 @@ 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, normalizeViewMode } from './viewModeUtils'
|
||||||
getViewModeType,
|
|
||||||
isKanbanCategoryProperty,
|
|
||||||
normalizeViewMode
|
|
||||||
} from './viewModeUtils'
|
|
||||||
|
|
||||||
const VIEW_MODE_OPTIONS = [
|
const VIEW_MODE_OPTIONS = [
|
||||||
{ type: 'list', label: 'List' },
|
{ type: 'list', label: 'List' },
|
||||||
@ -45,18 +40,14 @@ const ObjectTableViewButton = ({
|
|||||||
const normalizedViewMode = normalizeViewMode(viewMode)
|
const normalizedViewMode = normalizeViewMode(viewMode)
|
||||||
const model = getModelByName(objectType)
|
const model = getModelByName(objectType)
|
||||||
|
|
||||||
const categoryProperties = useMemo(
|
const stateProperties = useMemo(
|
||||||
() =>
|
() =>
|
||||||
model?.properties?.filter((property) =>
|
model?.properties?.filter((property) => property.type === 'state') || [],
|
||||||
isKanbanCategoryProperty(property)
|
|
||||||
) || [],
|
|
||||||
[model]
|
[model]
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasKanbanOption = categoryProperties.length > 0
|
const hasKanbanOption = stateProperties.length > 0
|
||||||
const defaultCategoryProperty =
|
const defaultCategoryProperty = stateProperties[0]?.name
|
||||||
categoryProperties.find((property) => property.type === 'state')?.name ||
|
|
||||||
categoryProperties[0]?.name
|
|
||||||
|
|
||||||
const availableViewModes = useMemo(
|
const availableViewModes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -68,22 +59,16 @@ const ObjectTableViewButton = ({
|
|||||||
|
|
||||||
const handleTypeChange = (nextType) => {
|
const handleTypeChange = (nextType) => {
|
||||||
if (nextType === 'kanban') {
|
if (nextType === 'kanban') {
|
||||||
const categoryProperty =
|
setViewMode({
|
||||||
normalizedViewMode.type === 'kanban'
|
|
||||||
? normalizedViewMode.settings?.categoryProperty ||
|
|
||||||
defaultCategoryProperty
|
|
||||||
: defaultCategoryProperty
|
|
||||||
const nextMode = {
|
|
||||||
type: 'kanban',
|
type: 'kanban',
|
||||||
settings: { categoryProperty }
|
settings: {
|
||||||
}
|
categoryProperty:
|
||||||
if (
|
normalizedViewMode.type === 'kanban'
|
||||||
normalizedViewMode.type === 'kanban' &&
|
? normalizedViewMode.settings?.categoryProperty ||
|
||||||
Array.isArray(normalizedViewMode.settings?.columns)
|
defaultCategoryProperty
|
||||||
) {
|
: defaultCategoryProperty
|
||||||
nextMode.settings.columns = normalizedViewMode.settings.columns
|
}
|
||||||
}
|
})
|
||||||
setViewMode(nextMode)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,46 +160,25 @@ const ObjectTableViewButton = ({
|
|||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Modal
|
<Modal
|
||||||
|
title='Kanban settings'
|
||||||
open={settingsOpen}
|
open={settingsOpen}
|
||||||
onCancel={() => setSettingsOpen(false)}
|
onCancel={() => setSettingsOpen(false)}
|
||||||
destroyOnHidden
|
onOk={() => setSettingsOpen(false)}
|
||||||
focusTriggerAfterClose={false}
|
destroyOnClose
|
||||||
footer={null}
|
|
||||||
centered
|
|
||||||
closeIcon={null}
|
|
||||||
getContainer={() => document.body}
|
|
||||||
width={520}
|
|
||||||
>
|
>
|
||||||
<Flex vertical gap='middle' onMouseDown={(event) => event.stopPropagation()}>
|
<Select
|
||||||
<Flex gap='middle'>
|
style={{ width: '100%' }}
|
||||||
<InfoCircleIcon />
|
placeholder='Select category property'
|
||||||
<Text strong>Kanban settings</Text>
|
value={
|
||||||
</Flex>
|
normalizedViewMode.settings?.categoryProperty ||
|
||||||
<Text>
|
defaultCategoryProperty
|
||||||
Select the property used to group cards into columns:
|
}
|
||||||
</Text>
|
onChange={handleCategoryPropertyChange}
|
||||||
<Select
|
options={stateProperties.map((property) => ({
|
||||||
style={{ width: '100%' }}
|
value: property.name,
|
||||||
placeholder='Select category property'
|
label: property.label || property.name
|
||||||
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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,14 +1,5 @@
|
|||||||
export const DEFAULT_VIEW_MODE = { type: 'list' }
|
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) => {
|
export const normalizeViewMode = (value) => {
|
||||||
if (!value) return DEFAULT_VIEW_MODE
|
if (!value) return DEFAULT_VIEW_MODE
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
@ -25,9 +16,6 @@ export const isKanbanView = (vm) => normalizeViewMode(vm).type === 'kanban'
|
|||||||
export const getViewModeType = (vm) => normalizeViewMode(vm).type
|
export const getViewModeType = (vm) => normalizeViewMode(vm).type
|
||||||
|
|
||||||
export const toCategoryFilterValue = (value) => {
|
export const toCategoryFilterValue = (value) => {
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map(toCategoryFilterValue)
|
|
||||||
}
|
|
||||||
if (value && typeof value === 'object') {
|
if (value && typeof value === 'object') {
|
||||||
return value.type ?? value._id ?? JSON.stringify(value)
|
return value.type ?? value._id ?? JSON.stringify(value)
|
||||||
}
|
}
|
||||||
@ -35,85 +23,9 @@ export const toCategoryFilterValue = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getCategoryValueKey = (value) => {
|
export const getCategoryValueKey = (value) => {
|
||||||
if (
|
if (value && typeof value === 'object' && value.type != null) {
|
||||||
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)
|
||||||
return 'null'
|
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
|
|
||||||
}))
|
|
||||||
|
|||||||
@ -1,69 +1,9 @@
|
|||||||
import get from 'lodash/get'
|
import get from 'lodash/get'
|
||||||
import isEqual from 'lodash/isEqual'
|
|
||||||
import mergeWith from 'lodash/mergeWith'
|
import mergeWith from 'lodash/mergeWith'
|
||||||
import set from 'lodash/set'
|
import set from 'lodash/set'
|
||||||
|
|
||||||
const NESTED_OBJECT_KEYS = ['_id', '_reference', 'name', 'state']
|
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) {
|
export function capitalizeFirstLetter(string) {
|
||||||
try {
|
try {
|
||||||
return string[0].toUpperCase() + string.slice(1)
|
return string[0].toUpperCase() + string.slice(1)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user