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)