diff --git a/src/components/Dashboard/common/ObjectKanban.jsx b/src/components/Dashboard/common/ObjectKanban.jsx
index 37903dc2..eb8a549e 100644
--- a/src/components/Dashboard/common/ObjectKanban.jsx
+++ b/src/components/Dashboard/common/ObjectKanban.jsx
@@ -2,6 +2,7 @@ import {
forwardRef,
useCallback,
useContext,
+ useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
@@ -16,12 +17,54 @@ import ObjectKanbanColumn from './ObjectKanbanColumn'
import ObjectKanbanHeader from './ObjectKanbanHeader'
import ScrollBox from './ScrollBox'
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_GAP = 16
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 columnCount =
Math.max(
@@ -112,7 +155,12 @@ const ObjectKanban = forwardRef(
},
ref
) => {
- const { getModelPropertyValues } = useContext(ApiServerContext)
+ const {
+ getModelPropertyValues,
+ connected,
+ subscribeToAllObjectUpdates,
+ subscribeToObjectTypeUpdates
+ } = useContext(ApiServerContext)
const columnRefs = useRef({})
const headerTrackRef = useRef(null)
const skeletonTrackRef = useRef(null)
@@ -126,19 +174,37 @@ const ObjectKanban = forwardRef(
columnCount: 3,
cardCount: 3
})
+ const [reloadingColumnKeys, setReloadingColumnKeys] = useState([])
const baseFilterRef = useRef(baseFilter)
const masterFilterRef = useRef(masterFilter)
const sorterRef = useRef(sorter)
+ const categoryPropertyRef = useRef(categoryProperty)
const getModelPropertyValuesRef = useRef(getModelPropertyValues)
const lastCategoryQueryKeyRef = useRef(null)
const hasLoadedRef = useRef(false)
const loadGenerationRef = useRef(0)
+ const updateEventHandlerRef = useRef()
+ const newEventHandlerRef = useRef()
+ const silentReloadRef = useRef(null)
+ const silentReloadItemColumnsRef = useRef(null)
+ const silentReloadCategoryColumnsRef = useRef(null)
+ const categoryValuesRef = useRef([])
+ const subscriptionFilterRef = useRef({})
+ const subscribeToObjectTypeUpdatesRef = useRef(null)
+ const subscribeToAllObjectUpdatesRef = useRef(null)
+ const subscribedTypeRef = useRef(null)
+ const subscribeToObjectTypeUpdatesFnRef = useRef(
+ subscribeToObjectTypeUpdates
+ )
baseFilterRef.current = baseFilter
masterFilterRef.current = masterFilter
sorterRef.current = sorter
+ categoryPropertyRef.current = categoryProperty
getModelPropertyValuesRef.current = getModelPropertyValues
+ subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
+ categoryValuesRef.current = categoryValues
const setScrollContainerRef = useCallback((node) => {
setScrollElement(node)
@@ -203,14 +269,38 @@ const ObjectKanban = forwardRef(
[categoryProperty, type]
)
- const reloadColumns = useCallback(async () => {
+ const reloadColumns = useCallback(async (columnKeys = null) => {
+ const refs =
+ columnKeys == null
+ ? Object.values(columnRefs.current)
+ : columnKeys.map((key) => columnRefs.current[key])
await Promise.all(
- Object.values(columnRefs.current)
- .filter(Boolean)
- .map((columnRef) => columnRef.reload?.())
+ refs.filter(Boolean).map((columnRef) => columnRef.reload?.())
)
}, [])
+ const findItem = useCallback((id) => {
+ for (const columnRef of Object.values(columnRefs.current)) {
+ const item = columnRef?.findItem?.(id)
+ if (item) return item
+ }
+ return null
+ }, [])
+
+ const findItemColumnKeys = useCallback((id) => {
+ return Object.entries(columnRefs.current)
+ .filter(([, columnRef]) => columnRef?.findItem?.(id))
+ .map(([key]) => key)
+ }, [])
+
+ const updateItem = useCallback((id, updatedData) => {
+ for (const columnRef of Object.values(columnRefs.current)) {
+ if (columnRef?.findItem?.(id)) {
+ columnRef.updateItem?.(id, updatedData)
+ }
+ }
+ }, [])
+
const load = useCallback(
async (filter = null, sorterArg = null, options = {}) => {
const silent = options.silent === true
@@ -219,9 +309,10 @@ const ObjectKanban = forwardRef(
if (!categoryProperty) {
lastCategoryQueryKeyRef.current = null
hasLoadedRef.current = true
+ categoryValuesRef.current = []
setCategoryValues([])
setHasLoaded(true)
- return
+ return []
}
const activeFilter = filter ?? baseFilterRef.current
@@ -234,10 +325,12 @@ const ObjectKanban = forwardRef(
if (canReuseColumns) {
await reloadColumns()
- if (generation !== loadGenerationRef.current) return
+ if (generation !== loadGenerationRef.current) {
+ return categoryValuesRef.current
+ }
hasLoadedRef.current = true
setHasLoaded(true)
- return
+ return categoryValuesRef.current
}
// Filter/sort changes keep current columns visible; objectView /
@@ -256,19 +349,26 @@ const ObjectKanban = forwardRef(
masterFilter: activeMasterFilter
}
)
- if (generation !== loadGenerationRef.current) return
+ if (generation !== loadGenerationRef.current) {
+ return categoryValuesRef.current
+ }
const nextValues = Array.isArray(values) ? values : []
lastCategoryQueryKeyRef.current = queryKey
hasLoadedRef.current = true
setLoadedFilter(activeFilter)
setLoadedSorter(activeSorter)
+ categoryValuesRef.current = nextValues
setCategoryValues(nextValues)
setHasLoaded(true)
+ return nextValues
} catch (error) {
- if (generation !== loadGenerationRef.current) return
+ if (generation !== loadGenerationRef.current) {
+ return categoryValuesRef.current
+ }
console.error('Error fetching kanban category values:', error)
lastCategoryQueryKeyRef.current = null
hasLoadedRef.current = true
+ categoryValuesRef.current = []
setCategoryValues([])
setHasLoaded(true)
throw error
@@ -283,6 +383,236 @@ const ObjectKanban = forwardRef(
await load(baseFilterRef.current, sorterRef.current)
}, [load])
+ const silentReload = useCallback(async () => {
+ const previousKeys = categoryValuesRef.current.map(getCategoryValueKey)
+ setReloadingColumnKeys(previousKeys)
+ try {
+ lastCategoryQueryKeyRef.current = null
+ const nextValues = await load(baseFilterRef.current, sorterRef.current, {
+ silent: true
+ })
+ const nextKeySet = new Set(
+ (nextValues || []).map(getCategoryValueKey)
+ )
+ // Only reload columns that existed before; newly created columns
+ // mount and load themselves.
+ const keysToReload = previousKeys.filter((key) => nextKeySet.has(key))
+ setReloadingColumnKeys(keysToReload)
+ await reloadColumns(keysToReload)
+ } finally {
+ setReloadingColumnKeys([])
+ }
+ }, [load, reloadColumns])
+
+ const silentReloadItemColumns = useCallback(
+ async (id) => {
+ const keysToReload = findItemColumnKeys(id)
+ if (!keysToReload.length) {
+ await silentReload()
+ return
+ }
+ setReloadingColumnKeys(keysToReload)
+ try {
+ await reloadColumns(keysToReload)
+ } finally {
+ setReloadingColumnKeys([])
+ }
+ },
+ [findItemColumnKeys, reloadColumns, silentReload]
+ )
+
+ const silentReloadCategoryColumns = useCallback(
+ async (existingItem, updatedData) => {
+ const categoryPropertyName = categoryPropertyRef.current
+ const previousKeys = new Set(
+ categoryValuesRef.current.map(getCategoryValueKey)
+ )
+
+ const oldKeys =
+ existingItem && categoryPropertyName
+ ? getCategoryColumnKeys(existingItem[categoryPropertyName])
+ : []
+ const updatedKeys =
+ categoryPropertyName &&
+ updatedData &&
+ Object.prototype.hasOwnProperty.call(
+ updatedData,
+ categoryPropertyName
+ )
+ ? getCategoryColumnKeys(
+ updatedData[categoryPropertyName]
+ ).filter((key) => previousKeys.has(key))
+ : []
+
+ const tentativeKeys = [...new Set([...oldKeys, ...updatedKeys])]
+ setReloadingColumnKeys(tentativeKeys)
+ try {
+ lastCategoryQueryKeyRef.current = null
+ const nextValues = await load(
+ baseFilterRef.current,
+ sorterRef.current,
+ { silent: true }
+ )
+ const nextKeySet = new Set(
+ (nextValues || []).map(getCategoryValueKey)
+ )
+
+ const keysToReload = []
+
+ for (const oldColumnKey of oldKeys) {
+ if (nextKeySet.has(oldColumnKey)) {
+ keysToReload.push(oldColumnKey)
+ }
+ }
+
+ if (
+ categoryPropertyName &&
+ updatedData &&
+ Object.prototype.hasOwnProperty.call(
+ updatedData,
+ categoryPropertyName
+ )
+ ) {
+ for (const newColumnKey of getCategoryColumnKeys(
+ updatedData[categoryPropertyName]
+ )) {
+ // Newly created columns mount and fetch themselves — skip reload.
+ if (
+ previousKeys.has(newColumnKey) &&
+ nextKeySet.has(newColumnKey)
+ ) {
+ keysToReload.push(newColumnKey)
+ }
+ }
+ }
+
+ const uniqueKeys = [...new Set(keysToReload)]
+ setReloadingColumnKeys(uniqueKeys)
+ await reloadColumns(uniqueKeys)
+ } finally {
+ setReloadingColumnKeys([])
+ }
+ },
+ [load, reloadColumns]
+ )
+
+ silentReloadRef.current = silentReload
+ silentReloadItemColumnsRef.current = silentReloadItemColumns
+ silentReloadCategoryColumnsRef.current = silentReloadCategoryColumns
+
+ const subscriptionFilter = useMemo(
+ () => ({ ...masterFilter, ...baseFilter }),
+ [baseFilter, masterFilter]
+ )
+ subscriptionFilterRef.current = subscriptionFilter
+
+ const updateEventHandler = useCallback(
+ (id, updatedData) => {
+ const existingItem = findItem(id)
+
+ // Always merge into the local item when it already exists.
+ if (existingItem) {
+ updateItem(id, updatedData)
+ }
+
+ const affectingKeys = getAffectingFilterOrSortKeys(
+ existingItem,
+ updatedData,
+ subscriptionFilterRef.current,
+ {},
+ sorterRef.current,
+ categoryPropertyRef.current
+ )
+
+ if (!affectingKeys.length) return
+
+ const categoryPropertyName = categoryPropertyRef.current
+ if (
+ categoryPropertyName &&
+ affectingKeys.includes(categoryPropertyName)
+ ) {
+ silentReloadCategoryColumnsRef.current?.(existingItem, updatedData)
+ return
+ }
+
+ const sorterField = sorterRef.current?.field
+ const onlySortKeyChanged =
+ existingItem &&
+ sorterField &&
+ affectingKeys.length === 1 &&
+ affectingKeys[0] === sorterField
+
+ if (onlySortKeyChanged) {
+ silentReloadItemColumnsRef.current?.(id)
+ return
+ }
+
+ silentReloadRef.current?.()
+ },
+ [findItem, updateItem]
+ )
+
+ updateEventHandlerRef.current = updateEventHandler
+
+ const newEventHandler = useCallback(() => {
+ silentReloadRef.current?.()
+ }, [])
+
+ newEventHandlerRef.current = newEventHandler
+
+ useEffect(() => {
+ return () => {
+ if (connected == true && subscribeToObjectTypeUpdatesRef.current) {
+ subscribeToObjectTypeUpdatesRef.current()
+ subscribeToObjectTypeUpdatesRef.current = null
+ }
+ if (connected == true && subscribeToAllObjectUpdatesRef.current) {
+ subscribeToAllObjectUpdatesRef.current()
+ subscribeToAllObjectUpdatesRef.current = null
+ }
+ }
+ }, [connected])
+
+ useEffect(() => {
+ if (connected !== true || !type) return
+
+ const unsubscribe = subscribeToAllObjectUpdates(
+ type,
+ (id, updateData) => {
+ updateEventHandlerRef.current(id, updateData)
+ }
+ )
+ subscribeToAllObjectUpdatesRef.current = unsubscribe
+ return () => {
+ if (unsubscribe) unsubscribe()
+ if (subscribeToAllObjectUpdatesRef.current === unsubscribe) {
+ subscribeToAllObjectUpdatesRef.current = null
+ }
+ }
+ }, [type, connected, subscribeToAllObjectUpdates])
+
+ useEffect(() => {
+ if (connected !== true) return
+ if (subscribedTypeRef.current === type) return
+
+ const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
+ type,
+ subscriptionFilterRef.current,
+ (params) => newEventHandlerRef.current(params)
+ )
+ subscribeToObjectTypeUpdatesRef.current = unsubscribe
+ subscribedTypeRef.current = type
+ return () => {
+ if (unsubscribe) unsubscribe()
+ if (subscribeToObjectTypeUpdatesRef.current === unsubscribe) {
+ subscribeToObjectTypeUpdatesRef.current = null
+ }
+ if (subscribedTypeRef.current === type) {
+ subscribedTypeRef.current = null
+ }
+ }
+ }, [type, connected])
+
useImperativeHandle(
ref,
() => ({
@@ -323,6 +653,7 @@ const ObjectKanban = forwardRef(
showSkeleton ? skeletonLayout.columnCount : 0
}
lazyLoading={showSkeleton || lazyLoading}
+ loadingColumnKeys={reloadingColumnKeys}
/>
{!showSkeleton && (
diff --git a/src/components/Dashboard/common/ObjectKanbanColumn.jsx b/src/components/Dashboard/common/ObjectKanbanColumn.jsx
index 8aa7a287..fe0def8d 100644
--- a/src/components/Dashboard/common/ObjectKanbanColumn.jsx
+++ b/src/components/Dashboard/common/ObjectKanbanColumn.jsx
@@ -18,6 +18,11 @@ import { toCategoryFilterValue } from './viewModeUtils'
const SCROLL_THRESHOLD = 50
+const idsEqual = (a, b) => {
+ if (a == null || b == null) return false
+ return String(a).toLowerCase() === String(b).toLowerCase()
+}
+
const ObjectKanbanColumn = forwardRef(
(
{
@@ -60,6 +65,10 @@ const ObjectKanbanColumn = forwardRef(
const [pages, setPages] = useState([])
const [loading, setLoading] = useState(true)
+ useEffect(() => {
+ pagesRef.current = pages
+ }, [pages])
+
const columnFilter = useMemo(
() => ({
...baseFilter,
@@ -426,9 +435,43 @@ const ObjectKanbanColumn = forwardRef(
}
}, [fetchData])
- useImperativeHandle(ref, () => ({
- reload: reloadLoadedPages
- }))
+ const findItem = useCallback((id) => {
+ for (const page of pagesRef.current) {
+ const item = page.items?.find(
+ (entry) => idsEqual(entry._id, id) && !entry.isSkeleton
+ )
+ if (item) return item
+ }
+ return null
+ }, [])
+
+ const updateItem = useCallback((id, updatedData) => {
+ setPages((prevPages) => {
+ const nextPages = prevPages.map((page) => {
+ let changed = false
+ const updatedItems = page.items.map((item) => {
+ if (idsEqual(item._id, id) && !item.isSkeleton) {
+ changed = true
+ return { ...item, ...updatedData, _id: item._id }
+ }
+ return item
+ })
+ return changed ? { ...page, items: updatedItems } : page
+ })
+ pagesRef.current = nextPages
+ return nextPages
+ })
+ }, [])
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ reload: reloadLoadedPages,
+ findItem,
+ updateItem
+ }),
+ [findItem, reloadLoadedPages, updateItem]
+ )
useEffect(() => {
if (lastColumnQueryKeyRef.current === columnQueryKey) return
diff --git a/src/components/Dashboard/common/ObjectKanbanHeader.jsx b/src/components/Dashboard/common/ObjectKanbanHeader.jsx
index 6e3d37d1..34b6bb6b 100644
--- a/src/components/Dashboard/common/ObjectKanbanHeader.jsx
+++ b/src/components/Dashboard/common/ObjectKanbanHeader.jsx
@@ -10,9 +10,11 @@ const ObjectKanbanHeader = ({
categoryPropertyDef,
trackRef,
skeletonColumnCount = 0,
- lazyLoading = false
+ lazyLoading = false,
+ loadingColumnKeys = []
}) => {
const showSkeleton = skeletonColumnCount > 0
+ const loadingColumnKeySet = new Set(loadingColumnKeys)
return (
@@ -33,6 +35,8 @@ const ObjectKanbanHeader = ({
))
: categoryValues.map((categoryValue) => {
const columnKey = getCategoryValueKey(categoryValue)
+ const showColumnLoading =
+ lazyLoading || loadingColumnKeySet.has(columnKey)
return (
) : null}
- {lazyLoading && (
+ {showColumnLoading && (
{
return [expr]
}
-const areValuesEqual = (v1, v2) => {
- const id1 = v1 && typeof v1 === 'object' && v1._id ? v1._id : v1
- const id2 = v2 && typeof v2 === 'object' && v2._id ? v2._id : v2
- return String(id1) === String(id2)
+const idsEqual = (a, b) => {
+ if (a == null || b == null) return false
+ return String(a).toLowerCase() === String(b).toLowerCase()
}
-const getChangedKeys = (existing, updated) => {
+const getUpdateKeys = (updated) => {
if (!updated || typeof updated !== 'object') return []
- return Object.keys(updated).filter((key) => {
- if (key === '_id' || key === 'objectType') return false
- if (!existing) return true
- return !areValuesEqual(existing[key], updated[key])
- })
+ return Object.keys(updated).filter(
+ (key) => key !== '_id' && key !== 'objectType'
+ )
}
const updateAffectsFilterOrSort = (
- changedKeys,
+ existingItem,
+ updatedData,
filter,
masterFilter,
sorter
) => {
- if (!changedKeys.length) return false
+ const updateKeys = getUpdateKeys(updatedData)
+ if (!updateKeys.length) return false
+
const filterKeys = new Set([
...Object.keys(filter || {}),
...Object.keys(masterFilter || {})
])
- if (changedKeys.some((key) => filterKeys.has(key))) return true
- if (sorter?.field && changedKeys.includes(sorter.field)) return true
- return false
+
+ const isAffectingKey = (key) =>
+ filterKeys.has(key) || (sorter?.field && key === sorter.field)
+
+ // Item not in the current fetch: any filter/sort key in the update
+ // may change whether it belongs in this view.
+ if (!existingItem) {
+ return updateKeys.some(isAffectingKey)
+ }
+
+ // Item already fetched: reload when a filter or sort key's value changed.
+ return updateKeys.some(
+ (key) =>
+ isAffectingKey(key) &&
+ !areValuesEqual(existingItem[key], updatedData[key])
+ )
}
const ColumnFilterDropdown = ({
@@ -992,7 +1006,7 @@ const ObjectTable = forwardRef(
const findTableItem = useCallback((id) => {
for (const page of pagesRef.current) {
const item = page.items?.find(
- (entry) => String(entry._id) === String(id) && !entry.isSkeleton
+ (entry) => idsEqual(entry._id, id) && !entry.isSkeleton
)
if (item) return item
}
@@ -1003,39 +1017,46 @@ const ObjectTable = forwardRef(
const updateEventHandler = useCallback(
(id, updatedData) => {
const existingItem = findTableItem(id)
- const changedKeys = getChangedKeys(existingItem, updatedData)
+
+ // Always merge into the local item when it already exists.
+ if (existingItem) {
+ setPages((prevPages) =>
+ prevPages.map((page) => {
+ const updatedItems = page.items.map((item) => {
+ if (idsEqual(item._id, id)) {
+ // Keep the row's original _id casing; update payloads often
+ // arrive lowercased from the NATS subject.
+ return { ...item, ...updatedData, _id: item._id }
+ }
+ return item
+ })
+ return {
+ ...page,
+ items: updatedItems
+ }
+ })
+ )
+
+ if (rowFormsRef.current[id]) {
+ rowFormsRef.current[id].setFieldsValue(updatedData)
+ } else if (
+ existingItem._id != null &&
+ rowFormsRef.current[existingItem._id]
+ ) {
+ rowFormsRef.current[existingItem._id].setFieldsValue(updatedData)
+ }
+ }
if (
updateAffectsFilterOrSort(
- changedKeys,
+ existingItem,
+ updatedData,
subscriptionFilterRef.current,
{},
effectiveSorterRef.current
)
) {
silentReloadRef.current?.()
- return
- }
-
- if (!existingItem) return
-
- setPages((prevPages) =>
- prevPages.map((page) => {
- const updatedItems = page.items.map((item) => {
- if (String(item._id) === String(id)) {
- return { ...item, ...updatedData }
- }
- return item
- })
- return {
- ...page,
- items: updatedItems
- }
- })
- )
-
- if (rowFormsRef.current[id]) {
- rowFormsRef.current[id].setFieldsValue(updatedData)
}
},
[findTableItem]
@@ -1099,7 +1120,7 @@ const ObjectTable = forwardRef(
effectiveSorterRef.current = effectiveSorter
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
- // Cleanup subscriptions on unmount
+ // Cleanup subscriptions on unmount. Kanban owns its own subscriptions.
useEffect(() => {
return () => {
if (connected == true && subscribeToObjectTypeUpdatesRef.current) {
@@ -1113,9 +1134,9 @@ const ObjectTable = forwardRef(
}
}, [connected])
- // Subscribe to all object updates for this type
+ // Subscribe to all object updates for this type (list/cards only)
useEffect(() => {
- if (connected !== true || !type) return
+ if (isKanban || connected !== true || !type) return
const unsubscribe = subscribeToAllObjectUpdates(
type,
@@ -1130,10 +1151,10 @@ const ObjectTable = forwardRef(
subscribeToAllObjectUpdatesRef.current = null
}
}
- }, [type, connected, subscribeToAllObjectUpdates])
+ }, [type, connected, subscribeToAllObjectUpdates, isKanban])
useEffect(() => {
- if (connected !== true) return
+ if (isKanban || connected !== true) return
if (subscribedTypeRef.current === type) return
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
@@ -1152,7 +1173,7 @@ const ObjectTable = forwardRef(
subscribedTypeRef.current = null
}
}
- }, [type, connected])
+ }, [type, connected, isKanban])
const updateData = useCallback(
(id, updatedData) => {
diff --git a/src/components/Dashboard/common/ObjectTableViewButton.jsx b/src/components/Dashboard/common/ObjectTableViewButton.jsx
index c8983b5c..960ad54c 100644
--- a/src/components/Dashboard/common/ObjectTableViewButton.jsx
+++ b/src/components/Dashboard/common/ObjectTableViewButton.jsx
@@ -16,7 +16,11 @@ import ListIcon from '../../Icons/ListIcon'
import KanbanIcon from '../../Icons/KanbanIcon'
import SettingsIcon from '../../Icons/SettingsIcon'
import { getModelByName } from '../../../database/ObjectModels'
-import { getViewModeType, normalizeViewMode } from './viewModeUtils'
+import {
+ getViewModeType,
+ isKanbanCategoryProperty,
+ normalizeViewMode
+} from './viewModeUtils'
const VIEW_MODE_OPTIONS = [
{ type: 'list', label: 'List' },
@@ -40,14 +44,18 @@ const ObjectTableViewButton = ({
const normalizedViewMode = normalizeViewMode(viewMode)
const model = getModelByName(objectType)
- const stateProperties = useMemo(
+ const categoryProperties = useMemo(
() =>
- model?.properties?.filter((property) => property.type === 'state') || [],
+ model?.properties?.filter((property) =>
+ isKanbanCategoryProperty(property)
+ ) || [],
[model]
)
- const hasKanbanOption = stateProperties.length > 0
- const defaultCategoryProperty = stateProperties[0]?.name
+ const hasKanbanOption = categoryProperties.length > 0
+ const defaultCategoryProperty =
+ categoryProperties.find((property) => property.type === 'state')?.name ||
+ categoryProperties[0]?.name
const availableViewModes = useMemo(
() =>
@@ -174,7 +182,7 @@ const ObjectTableViewButton = ({
defaultCategoryProperty
}
onChange={handleCategoryPropertyChange}
- options={stateProperties.map((property) => ({
+ options={categoryProperties.map((property) => ({
value: property.name,
label: property.label || property.name
}))}
diff --git a/src/components/Dashboard/common/viewModeUtils.js b/src/components/Dashboard/common/viewModeUtils.js
index 2cafa5f2..9234b17d 100644
--- a/src/components/Dashboard/common/viewModeUtils.js
+++ b/src/components/Dashboard/common/viewModeUtils.js
@@ -1,5 +1,11 @@
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) => {
if (!value) return DEFAULT_VIEW_MODE
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 toCategoryFilterValue = (value) => {
+ if (Array.isArray(value)) {
+ return value.map(toCategoryFilterValue)
+ }
if (value && typeof value === 'object') {
return value.type ?? value._id ?? JSON.stringify(value)
}
@@ -23,9 +32,27 @@ export const toCategoryFilterValue = (value) => {
}
export const getCategoryValueKey = (value) => {
- if (value && typeof value === 'object' && value.type != null) {
+ if (value && typeof value === 'object' && !Array.isArray(value) && value.type != null) {
return String(value.type)
}
if (value != null) return String(value)
return 'null'
}
+
+/**
+ * Column keys for a category property value. Multi-value properties (e.g. tags)
+ * can map an item to multiple columns.
+ */
+export const getCategoryColumnKeys = (value) => {
+ if (value === undefined) return []
+ if (Array.isArray(value)) {
+ return [
+ ...new Set(
+ value
+ .filter((entry) => entry != null && entry !== '')
+ .map(getCategoryValueKey)
+ )
+ ]
+ }
+ return [getCategoryValueKey(value)]
+}
diff --git a/src/components/Dashboard/utils/Utils.js b/src/components/Dashboard/utils/Utils.js
index 81bdb411..8db3561b 100644
--- a/src/components/Dashboard/utils/Utils.js
+++ b/src/components/Dashboard/utils/Utils.js
@@ -1,9 +1,69 @@
import get from 'lodash/get'
+import isEqual from 'lodash/isEqual'
import mergeWith from 'lodash/mergeWith'
import set from 'lodash/set'
const NESTED_OBJECT_KEYS = ['_id', '_reference', 'name', 'state']
+// Mirrors farmcontrol-api getChangedValues / valuesDiffer used by audit logs.
+const AUDIT_IGNORED_KEYS = ['createdAt', 'updatedAt', '_id']
+
+const isDiffableObject = (value) =>
+ value && typeof value === 'object' && !Array.isArray(value)
+
+const isNumericValue = (value) =>
+ typeof value === 'number' ||
+ (value !== null &&
+ value !== undefined &&
+ !isNaN(Number(value)) &&
+ value !== '')
+
+const normalizeDiffValue = (value) =>
+ isNumericValue(value) ? Number(value) : value
+
+const valuesDiffer = (oldVal, newVal) =>
+ !isEqual(normalizeDiffValue(oldVal), normalizeDiffValue(newVal))
+
+const getChangedValues = (oldObj, newObj) => {
+ const changes = {}
+ const combinedObj = { ...oldObj, ...newObj }
+
+ for (const key in combinedObj) {
+ if (AUDIT_IGNORED_KEYS.includes(key)) continue
+
+ const oldVal = oldObj ? oldObj[key] : undefined
+ const newVal = newObj ? newObj[key] : undefined
+
+ if (isDiffableObject(oldVal) && isDiffableObject(newVal)) {
+ if (oldVal?._id || newVal?._id) {
+ if (valuesDiffer(oldVal?._id, newVal?._id)) {
+ changes[key] = newVal
+ }
+ } else {
+ const nestedChanges = getChangedValues(oldVal, newVal)
+ if (Object.keys(nestedChanges).length > 0) {
+ changes[key] = nestedChanges
+ }
+ }
+ } else if (valuesDiffer(oldVal, newVal)) {
+ changes[key] = newVal
+ }
+ }
+
+ return changes
+}
+
+/** True when values would not be flagged as changed by audit getChangedValues. */
+export function areValuesEqual(v1, v2) {
+ if (isDiffableObject(v1) && isDiffableObject(v2)) {
+ if (v1?._id || v2?._id) {
+ return !valuesDiffer(v1?._id, v2?._id)
+ }
+ return Object.keys(getChangedValues(v1, v2)).length === 0
+ }
+ return !valuesDiffer(v1, v2)
+}
+
export function capitalizeFirstLetter(string) {
try {
return string[0].toUpperCase() + string.slice(1)