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.
This commit is contained in:
parent
59f603d05d
commit
65e25ae077
@ -2,6 +2,7 @@ import {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@ -16,12 +17,54 @@ 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 { getCategoryValueKey } from './viewModeUtils'
|
import { getCategoryColumnKeys, getCategoryValueKey } from './viewModeUtils'
|
||||||
|
import { areValuesEqual } from '../utils/Utils'
|
||||||
|
|
||||||
const KANBAN_COLUMN_WIDTH = 360
|
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 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(
|
||||||
@ -112,7 +155,12 @@ const ObjectKanban = forwardRef(
|
|||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const { getModelPropertyValues } = useContext(ApiServerContext)
|
const {
|
||||||
|
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)
|
||||||
@ -126,19 +174,37 @@ const ObjectKanban = forwardRef(
|
|||||||
columnCount: 3,
|
columnCount: 3,
|
||||||
cardCount: 3
|
cardCount: 3
|
||||||
})
|
})
|
||||||
|
const [reloadingColumnKeys, setReloadingColumnKeys] = useState([])
|
||||||
|
|
||||||
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)
|
||||||
@ -203,14 +269,38 @@ const ObjectKanban = forwardRef(
|
|||||||
[categoryProperty, type]
|
[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(
|
await Promise.all(
|
||||||
Object.values(columnRefs.current)
|
refs.filter(Boolean).map((columnRef) => columnRef.reload?.())
|
||||||
.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
|
||||||
@ -219,9 +309,10 @@ 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
|
||||||
@ -234,10 +325,12 @@ const ObjectKanban = forwardRef(
|
|||||||
|
|
||||||
if (canReuseColumns) {
|
if (canReuseColumns) {
|
||||||
await reloadColumns()
|
await reloadColumns()
|
||||||
if (generation !== loadGenerationRef.current) return
|
if (generation !== loadGenerationRef.current) {
|
||||||
|
return categoryValuesRef.current
|
||||||
|
}
|
||||||
hasLoadedRef.current = true
|
hasLoadedRef.current = true
|
||||||
setHasLoaded(true)
|
setHasLoaded(true)
|
||||||
return
|
return categoryValuesRef.current
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter/sort changes keep current columns visible; objectView /
|
// Filter/sort changes keep current columns visible; objectView /
|
||||||
@ -256,19 +349,26 @@ const ObjectKanban = forwardRef(
|
|||||||
masterFilter: activeMasterFilter
|
masterFilter: activeMasterFilter
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (generation !== loadGenerationRef.current) return
|
if (generation !== loadGenerationRef.current) {
|
||||||
|
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) return
|
if (generation !== loadGenerationRef.current) {
|
||||||
|
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
|
||||||
@ -283,6 +383,236 @@ 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,
|
||||||
() => ({
|
() => ({
|
||||||
@ -323,6 +653,7 @@ const ObjectKanban = forwardRef(
|
|||||||
showSkeleton ? skeletonLayout.columnCount : 0
|
showSkeleton ? skeletonLayout.columnCount : 0
|
||||||
}
|
}
|
||||||
lazyLoading={showSkeleton || lazyLoading}
|
lazyLoading={showSkeleton || lazyLoading}
|
||||||
|
loadingColumnKeys={reloadingColumnKeys}
|
||||||
/>
|
/>
|
||||||
<div className='objectKanbanKanbanBody' ref={kanbanBodyRef}>
|
<div className='objectKanbanKanbanBody' ref={kanbanBodyRef}>
|
||||||
{!showSkeleton && (
|
{!showSkeleton && (
|
||||||
|
|||||||
@ -18,6 +18,11 @@ 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(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
@ -60,6 +65,10 @@ 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,
|
||||||
@ -426,9 +435,43 @@ const ObjectKanbanColumn = forwardRef(
|
|||||||
}
|
}
|
||||||
}, [fetchData])
|
}, [fetchData])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
const findItem = useCallback((id) => {
|
||||||
reload: reloadLoadedPages
|
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(() => {
|
useEffect(() => {
|
||||||
if (lastColumnQueryKeyRef.current === columnQueryKey) return
|
if (lastColumnQueryKeyRef.current === columnQueryKey) return
|
||||||
|
|||||||
@ -10,9 +10,11 @@ const ObjectKanbanHeader = ({
|
|||||||
categoryPropertyDef,
|
categoryPropertyDef,
|
||||||
trackRef,
|
trackRef,
|
||||||
skeletonColumnCount = 0,
|
skeletonColumnCount = 0,
|
||||||
lazyLoading = false
|
lazyLoading = false,
|
||||||
|
loadingColumnKeys = []
|
||||||
}) => {
|
}) => {
|
||||||
const showSkeleton = skeletonColumnCount > 0
|
const showSkeleton = skeletonColumnCount > 0
|
||||||
|
const loadingColumnKeySet = new Set(loadingColumnKeys)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='objectKanbanHeader'>
|
<div className='objectKanbanHeader'>
|
||||||
@ -33,6 +35,8 @@ const ObjectKanbanHeader = ({
|
|||||||
))
|
))
|
||||||
: categoryValues.map((categoryValue) => {
|
: categoryValues.map((categoryValue) => {
|
||||||
const columnKey = getCategoryValueKey(categoryValue)
|
const columnKey = getCategoryValueKey(categoryValue)
|
||||||
|
const showColumnLoading =
|
||||||
|
lazyLoading || loadingColumnKeySet.has(columnKey)
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
key={columnKey}
|
key={columnKey}
|
||||||
@ -44,12 +48,23 @@ const ObjectKanbanHeader = ({
|
|||||||
{categoryPropertyDef ? (
|
{categoryPropertyDef ? (
|
||||||
<ObjectProperty
|
<ObjectProperty
|
||||||
{...categoryPropertyDef}
|
{...categoryPropertyDef}
|
||||||
value={categoryValue}
|
value={
|
||||||
objectData={{ [categoryProperty]: categoryValue }}
|
categoryPropertyDef.type === 'tags' &&
|
||||||
|
typeof categoryValue === 'string'
|
||||||
|
? [categoryValue]
|
||||||
|
: categoryValue
|
||||||
|
}
|
||||||
|
objectData={{
|
||||||
|
[categoryProperty]:
|
||||||
|
categoryPropertyDef.type === 'tags' &&
|
||||||
|
typeof categoryValue === 'string'
|
||||||
|
? [categoryValue]
|
||||||
|
: categoryValue
|
||||||
|
}}
|
||||||
name={categoryProperty}
|
name={categoryProperty}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{lazyLoading && (
|
{showColumnLoading && (
|
||||||
<LoadingOutlined
|
<LoadingOutlined
|
||||||
spin
|
spin
|
||||||
style={{ flexShrink: 0, marginRight: 6 }}
|
style={{ flexShrink: 0, marginRight: 6 }}
|
||||||
@ -72,7 +87,8 @@ ObjectKanbanHeader.propTypes = {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ObjectKanbanHeader
|
export default ObjectKanbanHeader
|
||||||
|
|||||||
@ -60,6 +60,7 @@ 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')
|
||||||
@ -96,35 +97,48 @@ const fromFilterExpression = (expr) => {
|
|||||||
return [expr]
|
return [expr]
|
||||||
}
|
}
|
||||||
|
|
||||||
const areValuesEqual = (v1, v2) => {
|
const idsEqual = (a, b) => {
|
||||||
const id1 = v1 && typeof v1 === 'object' && v1._id ? v1._id : v1
|
if (a == null || b == null) return false
|
||||||
const id2 = v2 && typeof v2 === 'object' && v2._id ? v2._id : v2
|
return String(a).toLowerCase() === String(b).toLowerCase()
|
||||||
return String(id1) === String(id2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getChangedKeys = (existing, updated) => {
|
const getUpdateKeys = (updated) => {
|
||||||
if (!updated || typeof updated !== 'object') return []
|
if (!updated || typeof updated !== 'object') return []
|
||||||
return Object.keys(updated).filter((key) => {
|
return Object.keys(updated).filter(
|
||||||
if (key === '_id' || key === 'objectType') return false
|
(key) => key !== '_id' && key !== 'objectType'
|
||||||
if (!existing) return true
|
)
|
||||||
return !areValuesEqual(existing[key], updated[key])
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateAffectsFilterOrSort = (
|
const updateAffectsFilterOrSort = (
|
||||||
changedKeys,
|
existingItem,
|
||||||
|
updatedData,
|
||||||
filter,
|
filter,
|
||||||
masterFilter,
|
masterFilter,
|
||||||
sorter
|
sorter
|
||||||
) => {
|
) => {
|
||||||
if (!changedKeys.length) return false
|
const updateKeys = getUpdateKeys(updatedData)
|
||||||
|
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
|
|
||||||
if (sorter?.field && changedKeys.includes(sorter.field)) return true
|
const isAffectingKey = (key) =>
|
||||||
return false
|
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 = ({
|
const ColumnFilterDropdown = ({
|
||||||
@ -992,7 +1006,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) => String(entry._id) === String(id) && !entry.isSkeleton
|
(entry) => idsEqual(entry._id, id) && !entry.isSkeleton
|
||||||
)
|
)
|
||||||
if (item) return item
|
if (item) return item
|
||||||
}
|
}
|
||||||
@ -1003,39 +1017,46 @@ 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(
|
||||||
changedKeys,
|
existingItem,
|
||||||
|
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]
|
||||||
@ -1099,7 +1120,7 @@ const ObjectTable = forwardRef(
|
|||||||
effectiveSorterRef.current = effectiveSorter
|
effectiveSorterRef.current = effectiveSorter
|
||||||
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
|
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
|
||||||
|
|
||||||
// Cleanup subscriptions on unmount
|
// Cleanup subscriptions on unmount. Kanban owns its own subscriptions.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (connected == true && subscribeToObjectTypeUpdatesRef.current) {
|
if (connected == true && subscribeToObjectTypeUpdatesRef.current) {
|
||||||
@ -1113,9 +1134,9 @@ const ObjectTable = forwardRef(
|
|||||||
}
|
}
|
||||||
}, [connected])
|
}, [connected])
|
||||||
|
|
||||||
// Subscribe to all object updates for this type
|
// Subscribe to all object updates for this type (list/cards only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connected !== true || !type) return
|
if (isKanban || connected !== true || !type) return
|
||||||
|
|
||||||
const unsubscribe = subscribeToAllObjectUpdates(
|
const unsubscribe = subscribeToAllObjectUpdates(
|
||||||
type,
|
type,
|
||||||
@ -1130,10 +1151,10 @@ const ObjectTable = forwardRef(
|
|||||||
subscribeToAllObjectUpdatesRef.current = null
|
subscribeToAllObjectUpdatesRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [type, connected, subscribeToAllObjectUpdates])
|
}, [type, connected, subscribeToAllObjectUpdates, isKanban])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connected !== true) return
|
if (isKanban || connected !== true) return
|
||||||
if (subscribedTypeRef.current === type) return
|
if (subscribedTypeRef.current === type) return
|
||||||
|
|
||||||
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
|
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
|
||||||
@ -1152,7 +1173,7 @@ const ObjectTable = forwardRef(
|
|||||||
subscribedTypeRef.current = null
|
subscribedTypeRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [type, connected])
|
}, [type, connected, isKanban])
|
||||||
|
|
||||||
const updateData = useCallback(
|
const updateData = useCallback(
|
||||||
(id, updatedData) => {
|
(id, updatedData) => {
|
||||||
|
|||||||
@ -16,7 +16,11 @@ 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 { getModelByName } from '../../../database/ObjectModels'
|
import { getModelByName } from '../../../database/ObjectModels'
|
||||||
import { getViewModeType, normalizeViewMode } from './viewModeUtils'
|
import {
|
||||||
|
getViewModeType,
|
||||||
|
isKanbanCategoryProperty,
|
||||||
|
normalizeViewMode
|
||||||
|
} from './viewModeUtils'
|
||||||
|
|
||||||
const VIEW_MODE_OPTIONS = [
|
const VIEW_MODE_OPTIONS = [
|
||||||
{ type: 'list', label: 'List' },
|
{ type: 'list', label: 'List' },
|
||||||
@ -40,14 +44,18 @@ const ObjectTableViewButton = ({
|
|||||||
const normalizedViewMode = normalizeViewMode(viewMode)
|
const normalizedViewMode = normalizeViewMode(viewMode)
|
||||||
const model = getModelByName(objectType)
|
const model = getModelByName(objectType)
|
||||||
|
|
||||||
const stateProperties = useMemo(
|
const categoryProperties = useMemo(
|
||||||
() =>
|
() =>
|
||||||
model?.properties?.filter((property) => property.type === 'state') || [],
|
model?.properties?.filter((property) =>
|
||||||
|
isKanbanCategoryProperty(property)
|
||||||
|
) || [],
|
||||||
[model]
|
[model]
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasKanbanOption = stateProperties.length > 0
|
const hasKanbanOption = categoryProperties.length > 0
|
||||||
const defaultCategoryProperty = stateProperties[0]?.name
|
const defaultCategoryProperty =
|
||||||
|
categoryProperties.find((property) => property.type === 'state')?.name ||
|
||||||
|
categoryProperties[0]?.name
|
||||||
|
|
||||||
const availableViewModes = useMemo(
|
const availableViewModes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -174,7 +182,7 @@ const ObjectTableViewButton = ({
|
|||||||
defaultCategoryProperty
|
defaultCategoryProperty
|
||||||
}
|
}
|
||||||
onChange={handleCategoryPropertyChange}
|
onChange={handleCategoryPropertyChange}
|
||||||
options={stateProperties.map((property) => ({
|
options={categoryProperties.map((property) => ({
|
||||||
value: property.name,
|
value: property.name,
|
||||||
label: property.label || property.name
|
label: property.label || property.name
|
||||||
}))}
|
}))}
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
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 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') {
|
||||||
@ -16,6 +22,9 @@ 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)
|
||||||
}
|
}
|
||||||
@ -23,9 +32,27 @@ export const toCategoryFilterValue = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getCategoryValueKey = (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)
|
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)]
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,69 @@
|
|||||||
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