@@ -250,10 +496,11 @@ const ObjectSelect = ({
filterPath
}
})
+ return { treeNodes, objects }
}
// Group Nodes
- return data
+ const treeNodes = data
.map((group) => {
// Only process if it looks like a group
if (!group.property) return null
@@ -274,15 +521,15 @@ const ObjectSelect = ({
value: valueString
})
- var nodeChildren = buildTreeData(
+ const { treeNodes: nodeChildren } = buildTreeData(
children,
pIdx + 1,
parentKeys.concat(valueString),
- newFilterPath
+ newFilterPath,
+ objects
)
- if (nodeChildren.length == 0) {
- nodeChildren = undefined
- }
+ const loaded =
+ nodeChildren.length > 0 || loadedKeysRef.current.has(nodeKey)
const modelProperty = getModelProperty(type, property)
return {
title:
,
@@ -294,101 +541,211 @@ const ObjectSelect = ({
filterPath: newFilterPath,
selectable: false,
isLeaf: false,
- children: nodeChildren
+ loaded,
+ children: loaded ? nodeChildren : undefined
}
})
.filter(Boolean)
+
+ return { treeNodes, objects }
},
- [properties, type]
+ [properties, type, upsertObject]
+ )
+
+ const applyTreeFromData = useCallback(
+ (data) => {
+ const { treeNodes, objects } = buildTreeData(data)
+ setObjectList(objects)
+ objectListRef.current = objects
+ setTreeData(treeNodes)
+ treeDataRef.current = treeNodes
+
+ const syncedValue = getSelectValueFromExternal(
+ valueRef.current,
+ multiple,
+ treeNodes
+ )
+ if (multiple) {
+ if (Array.isArray(syncedValue) && syncedValue.length > 0) {
+ setTreeSelectValue(syncedValue)
+ setValueNotFound(false)
+ clearedMissingValueRef.current = false
+ }
+ } else if (syncedValue != null) {
+ setTreeSelectValue(syncedValue)
+ setValueNotFound(false)
+ clearedMissingValueRef.current = false
+ }
+
+ return { treeNodes, objects }
+ },
+ [buildTreeData, multiple]
+ )
+
+ const buildFilterFromNode = useCallback(
+ (node) => {
+ const customFilter = { ...filter }
+ if (Array.isArray(node.filterPath)) {
+ node.filterPath.forEach(({ property, value }) => {
+ customFilter[property] = value
+ })
+ }
+ customFilter[node.property] = node.filterValue
+ return customFilter
+ },
+ [filter]
)
// --- loadData for async loading on expand ---
- const loadData = async (node) => {
- // node.property is the property name, node.value is the value key
- if (!node.property) return
- if (type == 'unknown') return
- // Build filter for this node by merging all parent property-value pairs
- const customFilter = { ...filter }
- if (Array.isArray(node.filterPath)) {
- node.filterPath.forEach(({ property, value }) => {
- customFilter[property] = value
- })
- }
- // Ensure current node is in filter (should be covered by filterPath, but redundancy is safe)
- customFilter[node.property] = node.filterValue
- // Fetch children for this node
- const data = await handleFetchObjectsProperties(customFilter)
- if (!data) return
+ const loadData = useCallback(
+ async (node) => {
+ if (!node.property) return
+ if (type == 'unknown') return
+ if (node.key) loadedKeysRef.current.add(node.key)
+ await handleFetchObjectsProperties(buildFilterFromNode(node))
+ },
+ [buildFilterFromNode, handleFetchObjectsProperties, type]
+ )
- // Navigate to the specific node's children in the response
- let nodeSpecificChildren = data
+ loadDataRef.current = loadData
- if (node.filterPath && Array.isArray(node.filterPath)) {
- for (const pathItem of node.filterPath) {
- if (!Array.isArray(nodeSpecificChildren)) break
- const match = nodeSpecificChildren.find(
- (g) =>
- g.property === pathItem.property &&
- areValuesEqual(g.value, pathItem.value)
+ const reloadTree = useCallback(
+ async ({ silent = false } = {}) => {
+ if (isSearching) return
+ if (!silent) {
+ setReloading(true)
+ }
+ try {
+ let mergedData = await fetchObjectsByProperty(type, {
+ properties: properties,
+ filter: filter,
+ masterFilter
+ })
+ if (!Array.isArray(mergedData)) return
+
+ let { treeNodes } = buildTreeData(mergedData)
+
+ const keysToReload = [...expandedKeysRef.current].sort(
+ (a, b) => a.length - b.length
)
- if (match) {
- nodeSpecificChildren = match.children
- } else {
- nodeSpecificChildren = []
- break
+ for (const key of keysToReload) {
+ const node = findTreeNodeByKey(treeNodes, key)
+ if (!node?.property) continue
+
+ const branchData = await fetchObjectsByProperty(type, {
+ properties: properties,
+ filter: buildFilterFromNode(node),
+ masterFilter
+ })
+ if (!Array.isArray(branchData)) continue
+
+ const branchChildren = getBranchChildren(branchData, node.filterPath)
+ mergedData = replaceBranchChildren(
+ mergedData,
+ node.filterPath,
+ branchChildren
+ )
+ ;({ treeNodes } = buildTreeData(mergedData))
+ }
+
+ setObjectPropertiesTree(mergedData)
+ } catch (err) {
+ console.error(err)
+ } finally {
+ if (!silent) {
+ setReloading(false)
}
}
- }
+ },
+ [
+ isSearching,
+ fetchObjectsByProperty,
+ type,
+ properties,
+ filter,
+ masterFilter,
+ buildTreeData,
+ buildFilterFromNode
+ ]
+ )
- // Build new tree children only for this specific node
- const children = buildTreeData(
- nodeSpecificChildren,
- properties.indexOf(node.property) + 1,
- node.parentKeys || [],
- node.filterPath
+ const reload = useCallback(() => reloadTree(), [reloadTree])
+ const silentReload = useCallback(
+ () => reloadTree({ silent: true }),
+ [reloadTree]
+ )
+
+ reloadRef.current = reload
+ silentReloadRef.current = silentReload
+
+ updateEventHandlerRef.current = updateEventHandler
+ newEventHandlerRef.current = newEventHandler
+
+ const findObjectById = useCallback((id) => {
+ if (id == null || id === '') return null
+ return (
+ objectListRef.current.find((obj) => areValuesEqual(obj._id, id)) || null
)
-
- // Update treeData with new children for this node only
- setTreeData((prevTreeData) => {
- // Helper to recursively update the correct node
- const updateNode = (nodes) =>
- nodes.map((n) => {
- if (n.key === node.key) {
- return { ...n, children, isLeaf: children.length === 0 }
- } else if (n.children) {
- return { ...n, children: updateNode(n.children) }
- }
- return n
- })
- return updateNode(prevTreeData)
- })
- }
+ }, [])
const onTreeSelectChange = useCallback(
- (value) => {
- // Mark this as an internal change
+ (nextValue) => {
+ if (
+ getValueIdentity(nextValue) ===
+ getValueIdentity(treeSelectValueRef.current)
+ ) {
+ return
+ }
+
+ const isEmptySelection = multiple
+ ? !Array.isArray(nextValue) || nextValue.length === 0
+ : nextValue == null || nextValue === ''
+
+ const pendingId = getValueId(valueRef.current)
+ const pendingInTree =
+ pendingId != null &&
+ (isValueInTree(treeDataRef.current, pendingId) ||
+ findObjectById(pendingId) != null)
+
+ // TreeSelect emits empty/null when treeData is replaced or a controlled
+ // value is not in the tree yet. Don't wipe an external value we still own.
+ if (isEmptySelection && pendingId != null && !pendingInTree) {
+ return
+ }
+
+ setValueNotFound(false)
+ clearedMissingValueRef.current = false
if (!multiple) setIsSearching(false)
isInternalChangeRef.current = true
- // value can be a string (single) or array (multiple)
if (multiple) {
- // Multiple selection
let selectedObjects = []
- if (Array.isArray(value)) {
- selectedObjects = value
- .map((id) => objectList.find((obj) => areValuesEqual(obj._id, id)))
- .filter(Boolean)
+ if (Array.isArray(nextValue)) {
+ selectedObjects = nextValue.map(findObjectById).filter(Boolean)
}
- setTreeSelectValue(value)
+ setTreeSelectValue(nextValue)
onChange?.(selectedObjects)
- } else {
- // Single selection
- const selectedObject = objectList.find((obj) => obj._id === value)
- setTreeSelectValue(value)
- onChange?.(selectedObject)
+ return
}
+
+ if (nextValue == null || nextValue === '') {
+ setTreeSelectValue(null)
+ onChange?.(null)
+ return
+ }
+
+ const selectedObject = findObjectById(nextValue)
+ setTreeSelectValue(nextValue)
+ if (selectedObject) {
+ onChange?.(selectedObject)
+ return
+ }
+ if (pendingId != null && areValuesEqual(pendingId, nextValue)) {
+ return
+ }
+ onChange?.(null)
},
- [multiple, objectList, onChange]
+ [multiple, onChange, findObjectById, getValueIdentity]
)
const onSearch = useCallback(
@@ -398,7 +755,7 @@ const ObjectSelect = ({
if (!trimmed) {
searchRequestIdRef.current += 1
setIsSearching(false)
- setTreeData(buildTreeData(objectPropertiesTree))
+ applyTreeFromData(objectPropertiesTree)
return
}
@@ -423,7 +780,13 @@ const ObjectSelect = ({
)
: data
- setTreeData(buildTreeData(searchData, properties.length))
+ const { treeNodes, objects } = buildTreeData(
+ searchData,
+ properties.length
+ )
+ setObjectList(objects)
+ setTreeData(treeNodes)
+ treeDataRef.current = treeNodes
},
[
searchObjects,
@@ -433,7 +796,8 @@ const ObjectSelect = ({
properties.length,
multiple,
treeSelectValue,
- objectList
+ objectList,
+ applyTreeFromData
]
)
@@ -475,19 +839,134 @@ const ObjectSelect = ({
// Update treeData when objectPropertiesTree changes
useEffect(() => {
if (isSearching) return
- if (objectPropertiesTree && Object.keys(objectPropertiesTree).length > 0) {
- const newTreeData = buildTreeData(objectPropertiesTree)
- setTreeData((prev) => {
- if (JSON.stringify(prev) !== JSON.stringify(newTreeData)) {
- return newTreeData
- }
- return prev
- })
+ if (!Array.isArray(objectPropertiesTree)) return
+ if (objectPropertiesTree.length > 0) {
+ applyTreeFromData(objectPropertiesTree)
+ } else {
+ setObjectList([])
+ setTreeData([])
+ treeDataRef.current = []
}
- }, [objectPropertiesTree, properties, buildTreeData, isSearching])
+ }, [objectPropertiesTree, applyTreeFromData, isSearching])
useEffect(() => {
+ expandedKeysRef.current = expandedKeys
+ }, [expandedKeys])
+
+ useEffect(() => {
+ treeDataRef.current = treeData
+ }, [treeData])
+
+ useEffect(() => {
+ treeSelectValueRef.current = treeSelectValue
+ }, [treeSelectValue])
+
+ const prevValuesRef = useRef({ type, masterFilter })
+
+ useEffect(() => {
+ const prevValues = prevValuesRef.current
+
+ // Deep comparison for objects, simple comparison for primitives
+ const hasChanged =
+ prevValues.type !== type ||
+ JSON.stringify(prevValues.masterFilter) !== JSON.stringify(masterFilter)
+
+ if (hasChanged) {
+ loadGenerationRef.current += 1
+ searchRequestIdRef.current += 1
+ setIsSearching(false)
+ setSearchValue('')
+ setObjectPropertiesTree({})
+ setObjectList([])
+ setTreeData([])
+ treeDataRef.current = []
+ setTreeVersion((v) => v + 1)
+ setExpandedKeys([])
+ loadedKeysRef.current = new Set()
+ setInitialized(false)
+ valueRef.current = null
+ setTreeSelectValue(null)
+ setInitialLoading(true)
+ setReloading(false)
+ setError(false)
+ setValueNotFound(false)
+ clearedMissingValueRef.current = false
+ prevValuesRef.current = { type, masterFilter }
+ setCommittedSelectKey(getSelectKey(type, masterFilter))
+ }
+ }, [type, masterFilter, getSelectKey])
+
+ useEffect(() => {
+ const currentValueIdentity = getValueIdentity(value)
+ const hasValueChanged =
+ prevValueIdentityRef.current !== currentValueIdentity
+
+ if (hasValueChanged) {
+ const changeSource = isInternalChangeRef.current ? 'internal' : 'external'
+
+ if (changeSource == 'external') {
+ const nextId = getValueId(value)
+ const alreadyInTree = isValueInTree(treeDataRef.current, nextId)
+ const alreadySelected =
+ treeSelectValueRef.current != null &&
+ getValueIdentity(treeSelectValueRef.current) === currentValueIdentity
+
+ if (!alreadyInTree && !alreadySelected) {
+ loadGenerationRef.current += 1
+ }
+
+ clearedMissingValueRef.current = false
+ setValueNotFound(false)
+ }
+
+ isInternalChangeRef.current = false
+ prevValueRef.current = value
+ prevValueIdentityRef.current = currentValueIdentity
+ }
+ }, [value, getValueIdentity])
+
+ useEffect(() => {
+ if (getSelectKey(type, masterFilter) !== committedSelectKey) {
+ return
+ }
+
+ const generation = loadGenerationRef.current
const handleValue = async () => {
+ if (generation !== loadGenerationRef.current) return
+
+ const valueIdentity = getValueIdentity(value)
+ const ids = multiple
+ ? (Array.isArray(value) ? value.map(getValueId) : [])
+ : value == null
+ ? []
+ : [getValueId(value)]
+ const allInTree =
+ ids.length > 0 &&
+ ids.every(
+ (id) =>
+ id == null ||
+ id === '' ||
+ isValueInTree(treeDataRef.current, id) ||
+ findObjectById(id) != null
+ )
+
+ if (
+ value != null &&
+ type != 'unknown' &&
+ allInTree &&
+ getValueIdentity(valueRef.current) !== valueIdentity
+ ) {
+ valueRef.current = value
+ setTreeSelectValue(
+ multiple
+ ? ids.map((id) => toSelectValue(id)).filter((id) => id != null)
+ : toSelectValue(ids[0])
+ )
+ setInitialized(true)
+ setInitialLoading(false)
+ return
+ }
+
if (
multiple &&
Array.isArray(value) &&
@@ -496,12 +975,15 @@ const ObjectSelect = ({
) {
valueRef.current = value
const fullValues = await Promise.all(value.map(fetchFullObjectIfNeeded))
+ if (generation !== loadGenerationRef.current) return
const pathKeys = []
if (fullValues.length === 0) {
- handleFetchObjectsProperties()
+ const data = await handleFetchObjectsProperties()
+ if (generation !== loadGenerationRef.current) return
+ if (Array.isArray(data)) applyTreeFromData(data)
} else {
- fullValues.forEach((fullValue) => {
+ for (const fullValue of fullValues) {
const valueFilter = { ...filter }
const parentKeys = []
@@ -529,14 +1011,17 @@ const ObjectSelect = ({
parentKeys.push(valueString)
})
- handleFetchObjectsProperties(valueFilter)
- })
+ const data = await handleFetchObjectsProperties(valueFilter)
+ if (generation !== loadGenerationRef.current) return
+ if (Array.isArray(data)) applyTreeFromData(data)
+ }
}
setExpandedKeys([...new Set(pathKeys)])
+ pathKeys.forEach((key) => loadedKeysRef.current.add(key))
setTreeSelectValue(
value
- .map((item) => (item && typeof item === 'object' ? item._id : item))
+ .map((item) => toSelectValue(getValueId(item)))
.filter((id) => id != null)
)
setInitialized(true)
@@ -550,9 +1035,8 @@ const ObjectSelect = ({
type != 'unknown'
) {
valueRef.current = value
- // Check if value is a minimal object and fetch full object if needed
const fullValue = await fetchFullObjectIfNeeded(value)
- // Build a new filter from value's properties that are in the properties list
+ if (generation !== loadGenerationRef.current) return
const valueFilter = { ...filter }
const pathKeys = []
const parentKeys = []
@@ -577,7 +1061,6 @@ const ObjectSelect = ({
valueFilter[prop] = filterValue
valueString = filterValue
}
- // Build the path key for this property level
const nodeKey = parentKeys
.concat(prop + ':' + valueString)
.join('-')
@@ -585,11 +1068,12 @@ const ObjectSelect = ({
parentKeys.push(valueString)
}
})
- // Expand the path to the object
setExpandedKeys(pathKeys)
- // Fetch with the new filter
- handleFetchObjectsProperties(valueFilter)
- setTreeSelectValue(valueRef.current._id)
+ pathKeys.forEach((key) => loadedKeysRef.current.add(key))
+ const data = await handleFetchObjectsProperties(valueFilter)
+ if (generation !== loadGenerationRef.current) return
+ if (Array.isArray(data)) applyTreeFromData(data)
+ setTreeSelectValue(toSelectValue(valueRef.current._id))
setInitialized(true)
return
}
@@ -624,63 +1108,72 @@ const ObjectSelect = ({
token,
fetchFullObjectIfNeeded,
type,
+ masterFilter,
+ committedSelectKey,
+ getSelectKey,
connected,
getValueIdentity,
- multiple
+ multiple,
+ applyTreeFromData,
+ findObjectById
])
- const prevValuesRef = useRef({ type, masterFilter })
-
useEffect(() => {
- const prevValues = prevValuesRef.current
-
- // Deep comparison for objects, simple comparison for primitives
- const hasChanged =
- prevValues.type !== type ||
- JSON.stringify(prevValues.masterFilter) !== JSON.stringify(masterFilter)
-
- if (hasChanged) {
- searchRequestIdRef.current += 1
- setIsSearching(false)
- setSearchValue('')
- setObjectPropertiesTree({})
- setObjectList([])
- setTreeData([])
- setTreeVersion((v) => v + 1)
- setExpandedKeys([])
- setInitialized(false)
- onTreeSelectChange(null)
- setTreeSelectValue(null)
- setInitialLoading(true)
- setError(false)
- prevValuesRef.current = { type, masterFilter }
- }
- }, [type, masterFilter, onTreeSelectChange])
+ objectListRef.current = objectList
+ }, [objectList])
+ // Cleanup subscriptions on unmount
useEffect(() => {
- // Check if value has actually changed
- const currentValueIdentity = getValueIdentity(value)
- const hasValueChanged =
- prevValueIdentityRef.current !== currentValueIdentity
-
- if (hasValueChanged) {
- const changeSource = isInternalChangeRef.current ? 'internal' : 'external'
-
- if (changeSource == 'external') {
- setObjectPropertiesTree({})
- setTreeData([])
- setInitialized(false)
- prevValuesRef.current = { type, masterFilter }
+ return () => {
+ if (connected === true && subscribeToObjectTypeUpdatesRef.current) {
+ subscribeToObjectTypeUpdatesRef.current()
+ subscribeToObjectTypeUpdatesRef.current = null
+ }
+ if (connected === true && subscribeToAllObjectUpdatesRef.current) {
+ subscribeToAllObjectUpdatesRef.current()
+ subscribeToAllObjectUpdatesRef.current = null
}
-
- // Reset the internal change flag
- isInternalChangeRef.current = false
-
- // Update the previous value reference
- prevValueRef.current = value
- prevValueIdentityRef.current = currentValueIdentity
}
- }, [value, getValueIdentity, type, masterFilter])
+ }, [connected])
+
+ // Subscribe to all object updates for this type
+ useEffect(() => {
+ if (connected !== true || type === 'unknown' || type == null) 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])
+
+ // Subscribe to type-level updates (new/changed objects matching filter)
+ useEffect(() => {
+ if (connected !== true) return
+ if (subscribedTypeRef.current === type) return
+
+ const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
+ type,
+ subscriptionFilterRef.current,
+ () => newEventHandlerRef.current()
+ )
+ 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])
useEffect(() => {
if (initialLoading == false) {
@@ -692,14 +1185,49 @@ const ObjectSelect = ({
}
}, [initialLoading])
+ const modelLabel = useMemo(() => getModelByName(type).label, [type])
+
const placeholder = useMemo(
() =>
type == 'unknown' || type == undefined
? 'n/a'
- : `Select a ${getModelByName(type).label.toLowerCase()}...`,
- [type]
+ : `Select a ${modelLabel.toLowerCase()}...`,
+ [type, modelLabel]
)
+ const valueReady =
+ initialized &&
+ !initialLoading &&
+ !delayedInitialLoading &&
+ !reloading &&
+ !isSearching
+
+ useEffect(() => {
+ const missing = isExternalValueMissing(
+ value,
+ multiple,
+ objectList,
+ treeData,
+ valueReady
+ )
+
+ if (missing) {
+ setValueNotFound(true)
+ return
+ }
+
+ clearedMissingValueRef.current = false
+ if (value != null) {
+ setValueNotFound(false)
+ }
+ }, [value, multiple, objectList, treeData, valueReady])
+
+ const displayPlaceholder = valueNotFound
+ ? `${modelLabel.charAt(0).toUpperCase()}${modelLabel.slice(1).toLowerCase()} not found.`
+ : placeholder
+
+ const displayValue = valueNotFound ? (multiple ? [] : null) : treeSelectValue
+
// --- Error UI ---
if (error) {
return (
@@ -733,15 +1261,17 @@ const ObjectSelect = ({
multiple={multiple}
loadData={isSearching ? undefined : loadData}
showCheckedStrategy={SHOW_CHILD}
- placeholder={placeholder}
{...treeSelectProps}
{...rest}
- value={treeSelectValue}
+ placeholder={displayPlaceholder}
+ status={valueNotFound ? 'warning' : treeSelectProps.status}
+ value={displayValue}
onChange={onTreeSelectChange}
onSearch={onSearch}
onInputKeyDown={onInputKeyDown}
searchValue={searchValue}
disabled={disabled || type == 'unknown' || type == undefined}
+ loading={reloading}
style={{ opacity: delayedInitialLoading ? 0 : 1 }}
className={multiple ? 'object-select-multiple' : 'object-select'}
/>
diff --git a/src/components/Dashboard/common/ObjectTable.jsx b/src/components/Dashboard/common/ObjectTable.jsx
index 6d3f6662..108a3555 100644
--- a/src/components/Dashboard/common/ObjectTable.jsx
+++ b/src/components/Dashboard/common/ObjectTable.jsx
@@ -15,13 +15,12 @@ import {
Row,
Col,
Flex,
- Spin,
Button,
- Input,
Space,
- Tooltip,
Form,
- Splitter
+ Splitter,
+ Card,
+ Checkbox
} from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types'
@@ -36,8 +35,10 @@ import {
} from '../../../database/ObjectModels'
import ObjectProperty from './ObjectProperty'
import ObjectCard from './ObjectCard'
+import ObjectKanban from './ObjectKanban'
+import ObjectTimeline from './ObjectTimeline'
import FilterSidebar from './FilterSidebar'
-import XMarkIcon from '../../Icons/XMarkIcon'
+import SortSidebar from './SortSidebar'
import CheckIcon from '../../Icons/CheckIcon'
import { useLocation } from 'react-router-dom'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
@@ -47,16 +48,38 @@ import { useActions } from '../context/ActionsContext'
import ActionsIcon from '../../Icons/ActionsIcon'
import FilterIcon from '../../Icons/FilterIcon'
import ScrollBox from './ScrollBox'
+import Spin from './Spin'
+import SimplePropertyFilter from './SimplePropertyFilter'
+import QuickPropertyFilters from './QuickPropertyFilters'
+import FilterInput from './FilterInput'
import {
getActiveFilterValues,
+ useTableState,
useTableStatePersistence
} from '../context/TableStateContext'
+import { hasActionPermission } from '../../../database/permissions'
+import Tooltip from './Tooltip'
+import { ObjectTableFilterContext } from './ObjectTableFilterContext'
+import ObjectListViewContext from '../context/ObjectListViewContext'
+import {
+ isCardsView,
+ isKanbanView,
+ isTimelineView,
+ normalizeViewMode
+} from './viewModeUtils'
+import { areValuesEqual } from '../utils/Utils'
+import MissingPlaceholder from './MissingPlaceholder'
+import LoadingPlaceholder from './LoadingPlaceholder'
+import useSidebarWidth from '../hooks/useSidebarWidth'
+
+import classNames from 'classnames'
const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel)
const SCROLL_THRESHOLD = 50
const SKELETON_HEIGHT = 49.5
+const EMPTY_MASTER_FILTER = {}
const getCardColSpan = (containerWidth) => {
if (containerWidth >= 2980) return 2
@@ -66,6 +89,168 @@ const getCardColSpan = (containerWidth) => {
return 24
}
+const toFilterExpression = (values) => {
+ if (!values?.length) return undefined
+ const parts = values.map((value) => {
+ if (value && typeof value === 'object') {
+ return String(value._id ?? value.type ?? JSON.stringify(value))
+ }
+ return String(value)
+ })
+ return parts.length === 1 ? parts[0] : parts.join('|')
+}
+
+const fromFilterExpression = (expr) => {
+ if (expr === undefined || expr === null || expr === '') return null
+ if (typeof expr === 'string' && expr.includes('|')) {
+ return expr.split('|').filter((part) => part !== '')
+ }
+ return [expr]
+}
+
+const idsEqual = (a, b) => {
+ if (a == null || b == null) return false
+ return String(a).toLowerCase() === String(b).toLowerCase()
+}
+
+const getUpdateKeys = (updated) => {
+ if (!updated || typeof updated !== 'object') return []
+ return Object.keys(updated).filter(
+ (key) => key !== '_id' && key !== 'objectType'
+ )
+}
+
+const updateAffectsFilterOrSort = (
+ existingItem,
+ updatedData,
+ filter,
+ masterFilter,
+ sorter
+) => {
+ const updateKeys = getUpdateKeys(updatedData)
+ if (!updateKeys.length) return false
+
+ const filterKeys = new Set([
+ ...Object.keys(filter || {}),
+ ...Object.keys(masterFilter || {})
+ ])
+
+ 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 = ({
+ setSelectedKeys,
+ selectedKeys,
+ confirm,
+ clearFilters,
+ visible,
+ propertyName,
+ propertyLabel,
+ modelType,
+ filter = {},
+ masterFilter = {}
+}) => {
+ const [expression, setExpression] = useState('')
+ const [draft, setDraft] = useState(selectedKeys || [])
+ const selectedKeysRef = useRef(selectedKeys)
+ selectedKeysRef.current = selectedKeys
+
+ // Re-sync from the applied sidebar/column filter each time the dropdown opens
+ useEffect(() => {
+ if (!visible) return
+ const keys = selectedKeysRef.current || []
+ setDraft(keys)
+ setExpression(toFilterExpression(keys) ?? '')
+ }, [visible])
+
+ const handleDraftChange = (next) => {
+ setDraft(next)
+ setExpression(toFilterExpression(next) ?? '')
+ }
+
+ const handleExpressionChange = (text) => {
+ setExpression(text)
+ setDraft(fromFilterExpression(text) || [])
+ }
+
+ const applyFilter = () => {
+ const trimmed = expression.trim()
+ if (!trimmed) {
+ clearFilters()
+ } else if (draft?.length) {
+ setSelectedKeys(draft)
+ } else {
+ setSelectedKeys([trimmed])
+ }
+ confirm()
+ }
+
+ return (
+
+
+
+
+ } />
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+ColumnFilterDropdown.propTypes = {
+ setSelectedKeys: PropTypes.func,
+ selectedKeys: PropTypes.array,
+ confirm: PropTypes.func,
+ clearFilters: PropTypes.func,
+ visible: PropTypes.bool,
+ propertyName: PropTypes.string,
+ propertyLabel: PropTypes.string,
+ modelType: PropTypes.string,
+ filter: PropTypes.object,
+ masterFilter: PropTypes.object
+}
+
const RowForm = ({ record, isEditing, onRegister, children }) => {
const [form] = Form.useForm()
useEffect(() => {
@@ -114,12 +299,14 @@ const ObjectTable = forwardRef(
scrollHeight = 'calc(var(--unit-100vh) - 258px)',
onDataChange,
initialPage = 1,
+ viewMode: viewModeProp,
cards = false,
visibleColumns = {},
- masterFilter = {},
+ masterFilter,
size = 'middle',
onStateChange,
showFilterSidebar = false,
+ showSortSidebar = false,
expandHeight = false,
showActions = true,
saveFilterInSession = false,
@@ -134,9 +321,29 @@ const ObjectTable = forwardRef(
},
ref
) => {
+ const objectListView = useContext(ObjectListViewContext)
+ const viewMode = normalizeViewMode(
+ viewModeProp ??
+ objectListView?.viewMode ??
+ (cards ? { type: 'cards' } : { type: 'list' })
+ )
+ const isCards = isCardsView(viewMode)
+ const isKanban = isKanbanView(viewMode)
+ const isTimeline = isTimelineView(viewMode)
+ const kanbanRef = useRef(null)
+ const prevViewModeKeyRef = useRef(null)
const { token, userProfile } = useContext(AuthContext)
const { isElectron } = useContext(ElectronContext)
const { callAction } = useActions()
+ const resolvedMasterFilter = masterFilter ?? EMPTY_MASTER_FILTER
+ const listViewId = objectListView?.listViewId ?? null
+ const listViewFilter = objectListView?.listViewFilter ?? null
+ const listViewSort = objectListView?.listViewSort ?? null
+ const activeObjectView = objectListView?.activeObjectView ?? null
+ const userOverridesRef = objectListView?.userOverridesRef ?? null
+ const onObjectViewFilterSortChange = objectListView?.isEditing
+ ? objectListView.handleFilterSortChange
+ : null
const onStateChangeRef = useRef(onStateChange)
useEffect(() => {
onStateChangeRef.current = onStateChange
@@ -144,7 +351,7 @@ const ObjectTable = forwardRef(
const {
fetchObjects,
connected,
- subscribeToObjectUpdates,
+ subscribeToAllObjectUpdates,
subscribeToObjectTypeUpdates,
updateMultipleObjects,
setObjectActivity,
@@ -155,11 +362,19 @@ const ObjectTable = forwardRef(
const {
getPersistedFilter,
getPersistedSorter,
+ getAllTabPersistedFilter,
+ getAllTabPersistedSorter,
persistFilter,
+ persistSort,
persistTableState,
- registerPageFilter
+ persistAllTabTableState,
+ registerPageFilter,
+ registerPageSorter,
+ registerObjectListFilter,
+ registerObjectListSorter
} = useTableStatePersistence({
scope: type,
+ viewId: listViewId,
pagePath: location.pathname,
saveFilterInSession,
saveFilterInUrl,
@@ -170,11 +385,12 @@ const ObjectTable = forwardRef(
useSortInSession,
useSortInUrl
})
+ const { getViewFromUrl } = useTableState()
var adjustedScrollHeight = scrollHeight
if (isMobile) {
- adjustedScrollHeight = 'calc(var(--unit-100vh) - 298px)'
+ adjustedScrollHeight = 'calc(var(--unit-100vh) - 348px)'
}
- if (cards) {
+ if (isCards || isKanban || isTimeline) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 210px)'
}
if (isElectron) {
@@ -183,24 +399,80 @@ const ObjectTable = forwardRef(
if (isMobile && isElectron) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 282px)'
}
- if (cards && isElectron) {
+ if ((isCards || isKanban || isTimeline) && isElectron) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 258px)'
}
const tableRef = useRef(null)
const model = getModelByName(type)
+ const bulkActions =
+ model.actions?.filter(
+ (action) =>
+ action.bulk === true &&
+ hasActionPermission(userProfile, model, action.name)
+ ) || []
+ const canBulkSelect = bulkActions.length > 0 && objectListView != null
const activeFilterRef = useRef({})
const tableSorterRef = useRef({})
+ const listViewIdRef = useRef(listViewId)
+ const listViewFilterRef = useRef(listViewFilter)
+ const sidebarViewIdRef = useRef(listViewId)
+ const activeObjectViewRef = useRef(activeObjectView)
+ activeObjectViewRef.current = activeObjectView
+ const onObjectViewFilterSortChangeRef = useRef(onObjectViewFilterSortChange)
+ onObjectViewFilterSortChangeRef.current = onObjectViewFilterSortChange
const [sidebarFilter, setSidebarFilter] = useState({})
const [tableSorter, setTableSorter] = useState({})
const [initialized, setInitialized] = useState(false)
+ const [sidebarWidth, setSidebarWidth] = useSidebarWidth(type)
+
+ const [verticalScrollVisible, setVerticalScrollVisible] = useState(false)
+ const tableContentRef = useRef(null)
+
+ // Keep refs in sync with props. applyViewState may update them earlier in
+ // the same tick; props remain the source of truth after commit.
+ useEffect(() => {
+ listViewIdRef.current = listViewId
+ listViewFilterRef.current = listViewFilter || {}
+ }, [listViewId, listViewFilter])
+
+ const assignSidebarToView = useCallback((viewId) => {
+ sidebarViewIdRef.current = viewId ?? null
+ }, [])
+
+ const lastAppliedViewKeyRef = useRef(null)
+
+ const buildEffectiveFilter = useCallback(
+ (userFilter, viewFilter = listViewFilterRef.current) => {
+ const active = getActiveFilterValues(userFilter)
+ const viewId = listViewIdRef.current
+ // While editing a view, the sidebar IS the view filter. Merging the
+ // previous definition would put removed keys back.
+ if (!viewId || activeObjectViewRef.current) return active
+ return {
+ ...getActiveFilterValues(viewFilter || {}),
+ ...active
+ }
+ },
+ []
+ )
// Table state
const [pages, setPages] = useState([])
const pagesRef = useRef(pages)
- const tableData = useMemo(
- () => pages.flatMap((page) => page.items),
- [pages]
- )
+ const tableData = useMemo(() => {
+ const items = pages.flatMap((page) => page.items)
+ const seen = new Set()
+ return items.filter((item) => {
+ const id = item?._id
+ if (id == null) return true
+ if (seen.has(id)) {
+ logger.warn('Duplicate table row omitted:', id)
+ return false
+ }
+ seen.add(id)
+ return true
+ })
+ }, [pages])
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
@@ -219,18 +491,31 @@ const ObjectTable = forwardRef(
onStateChangeRef.current?.({ isEditing, editLoading })
}, [isEditing, editLoading])
- const subscribedIdsRef = useRef([])
- // const [typeSubscribed, setTypeSubscribed] = useState(false)
- const unsubscribesRef = useRef([])
const updateEventHandlerRef = useRef()
const subscribeToObjectTypeUpdatesRef = useRef(null)
const subscribedTypeRef = useRef(null)
+ const subscribeToAllObjectUpdatesRef = useRef(null)
const newEventHandlerRef = useRef()
const subscriptionFilterRef = useRef()
+ const effectiveSorterRef = useRef({})
+ const reloadRef = useRef(null)
+ const silentReloadRef = useRef(null)
const subscribeToObjectTypeUpdatesFnRef = useRef(
subscribeToObjectTypeUpdates
)
- const prevValuesRef = useRef({ type, masterFilter })
+ const prevValuesRef = useRef({ type, masterFilter: resolvedMasterFilter })
+
+ const getMasterFilter = useCallback(
+ () => resolvedMasterFilter || EMPTY_MASTER_FILTER,
+ [resolvedMasterFilter]
+ )
+
+ const resolveSorter = useCallback((userSorter) => {
+ if (userSorter?.field && userSorter?.order) {
+ return { field: userSorter.field, order: userSorter.order }
+ }
+ return {}
+ }, [])
const rowActions =
model.actions?.filter((action) => action.row == true) || []
@@ -258,24 +543,86 @@ const ObjectTable = forwardRef(
const loadingPagesRef = useRef(new Set())
const pendingScrollAnchorRef = useRef(null)
+ const dataLoadGenerationRef = useRef(0)
+ const [tableListKey, setTableListKey] = useState(0)
+ // Suppress Ant Design Table onChange while we remount / set filter+sort
+ // programmatically. Remounting with controlled filteredValue often fires
+ // onChange with all-null filters (sorter intact) and would wipe the view.
+ const isInternalTableChangeRef = useRef(false)
+ const internalTableChangeClearRafRef = useRef(null)
- const renderActions = (objectData) => {
+ const suppressTableChange = useCallback(() => {
+ isInternalTableChangeRef.current = true
+ if (internalTableChangeClearRafRef.current != null) {
+ cancelAnimationFrame(internalTableChangeClearRafRef.current)
+ }
+ // Double rAF: wait until after the remounted Table commits and any
+ // synchronous/mount onChange from Ant Design has run.
+ internalTableChangeClearRafRef.current = requestAnimationFrame(() => {
+ internalTableChangeClearRafRef.current = requestAnimationFrame(() => {
+ internalTableChangeClearRafRef.current = null
+ isInternalTableChangeRef.current = false
+ })
+ })
+ }, [])
+
+ const beginDataReload = useCallback(() => {
+ dataLoadGenerationRef.current += 1
+ loadingPagesRef.current.clear()
+ pendingScrollAnchorRef.current = null
+ pagesRef.current = []
+ suppressTableChange()
+ setTableListKey((key) => key + 1)
+ return dataLoadGenerationRef.current
+ }, [suppressTableChange])
+
+ const isStaleDataLoad = useCallback((generation) => {
+ return generation !== dataLoadGenerationRef.current
+ }, [])
+
+ const clearTablePages = useCallback(() => {
+ pagesRef.current = []
+ setPages([])
+ }, [])
+
+ const setTablePages = useCallback((nextPages) => {
+ pagesRef.current = nextPages
+ setPages(nextPages)
+ }, [])
+
+ const mergeLoadedPage = useCallback((currentPages, loadedPage) => {
+ const withoutSkeletons = currentPages.filter(
+ (page) => !page.isSkeletonPage
+ )
+ const withoutDuplicate = withoutSkeletons.filter(
+ (page) => page.pageNum !== loadedPage.pageNum
+ )
+ return [...withoutDuplicate, loadedPage].sort(
+ (a, b) => a.pageNum - b.pageNum
+ )
+ }, [])
+
+ const renderActions = (objectData, actionsDisabled = false) => {
return (
{rowActions.map((action, index) => {
- var disabled = false
+ const denied = !hasActionPermission(userProfile, model, action.name)
+ var disabled = denied
if (action.disabled) {
if (typeof action.disabled === 'function') {
- disabled = action.disabled({
- ...objectData,
- _user: userProfile
- })
+ disabled =
+ denied ||
+ action.disabled({
+ ...objectData,
+ _user: userProfile
+ }) ||
+ actionsDisabled
} else {
- disabled = action.disabled
+ disabled = denied || action.disabled || actionsDisabled
}
}
return (
-
+
)
}
diff --git a/src/components/Dashboard/common/ObjectTableViewButton.jsx b/src/components/Dashboard/common/ObjectTableViewButton.jsx
index ebf72e7b..7baebb94 100644
--- a/src/components/Dashboard/common/ObjectTableViewButton.jsx
+++ b/src/components/Dashboard/common/ObjectTableViewButton.jsx
@@ -1,20 +1,350 @@
import PropTypes from 'prop-types'
-import { Button } from 'antd'
+import { useContext, useMemo, useState } from 'react'
+import ObjectListViewContext from '../context/ObjectListViewContext'
+import {
+ Button,
+ Divider,
+ Flex,
+ Modal,
+ Popover,
+ Radio,
+ Select,
+ Typography
+} from 'antd'
import GridIcon from '../../Icons/GridIcon'
import ListIcon from '../../Icons/ListIcon'
+import KanbanIcon from '../../Icons/KanbanIcon'
+import TimelineIcon from '../../Icons/TimelineIcon'
+import SettingsIcon from '../../Icons/SettingsIcon'
+import InfoCircleIcon from '../../Icons/InfoCircleIcon'
+import { getModelByName } from '../../../database/ObjectModels'
+import {
+ getViewModeType,
+ isKanbanCategoryProperty,
+ isTimelineDateProperty,
+ isTimelineColorProperty,
+ normalizeViewMode
+} from './viewModeUtils'
-const ObjectTableViewButton = ({ viewMode, setViewMode, ...buttonProps }) => (
-
:
}
- onClick={() => setViewMode(viewMode === 'cards' ? 'list' : 'cards')}
- title={viewMode === 'cards' ? 'Switch to list view' : 'Switch to card view'}
- {...buttonProps}
- />
-)
+const VIEW_MODE_OPTIONS = [
+ { type: 'list', label: 'List' },
+ { type: 'cards', label: 'Cards' },
+ { type: 'kanban', label: 'Kanban' },
+ { type: 'timeline', label: 'Timeline' }
+]
+
+const { Text } = Typography
+
+const ObjectTableViewButton = ({
+ objectType,
+ viewMode: viewModeProp,
+ setViewMode: setViewModeProp,
+ showEndingDivider = false,
+ ...buttonProps
+}) => {
+ const objectListView = useContext(ObjectListViewContext)
+ const viewMode = viewModeProp ?? objectListView?.viewMode
+ const setViewMode = setViewModeProp ?? objectListView?.setViewMode
+ const [settingsOpen, setSettingsOpen] = useState(false)
+ const normalizedViewMode = normalizeViewMode(viewMode)
+ const model = getModelByName(objectType)
+
+ const categoryProperties = useMemo(
+ () =>
+ model?.properties?.filter((property) =>
+ isKanbanCategoryProperty(property)
+ ) || [],
+ [model]
+ )
+ const dateProperties = useMemo(
+ () => model?.properties?.filter(isTimelineDateProperty) || [],
+ [model]
+ )
+
+ const colorProperties = useMemo(
+ () => model?.properties?.filter(isTimelineColorProperty) || [],
+ [model]
+ )
+
+ const hasKanbanOption = categoryProperties.length > 0
+ const hasTimelineOption = dateProperties.length > 0
+ const defaultCategoryProperty =
+ categoryProperties.find((property) => property.type === 'state')?.name ||
+ categoryProperties[0]?.name
+ const defaultStartDate = dateProperties[0]?.name
+ const defaultColorProperty =
+ colorProperties[0]?.color || colorProperties[0]?.state
+
+ const availableViewModes = useMemo(
+ () =>
+ VIEW_MODE_OPTIONS.filter(
+ (option) =>
+ (option.type !== 'kanban' || hasKanbanOption) &&
+ (option.type !== 'timeline' || hasTimelineOption)
+ ).map((option) => option.type),
+ [hasKanbanOption, hasTimelineOption]
+ )
+
+ const handleTypeChange = (nextType) => {
+ if (nextType === 'kanban') {
+ const categoryProperty =
+ normalizedViewMode.type === 'kanban'
+ ? normalizedViewMode.settings?.categoryProperty ||
+ defaultCategoryProperty
+ : defaultCategoryProperty
+ const nextMode = {
+ type: 'kanban',
+ settings: { categoryProperty }
+ }
+ if (
+ normalizedViewMode.type === 'kanban' &&
+ Array.isArray(normalizedViewMode.settings?.columns)
+ ) {
+ nextMode.settings.columns = normalizedViewMode.settings.columns
+ }
+ setViewMode(nextMode)
+ return
+ }
+
+ if (nextType === 'timeline') {
+ const existingSettings =
+ normalizedViewMode.type === 'timeline'
+ ? normalizedViewMode.settings
+ : undefined
+ setViewMode({
+ type: 'timeline',
+ settings: {
+ startDate: existingSettings?.startDate || defaultStartDate,
+ ...(existingSettings?.endDate
+ ? { endDate: existingSettings.endDate }
+ : {})
+ }
+ })
+ return
+ }
+
+ setViewMode({ type: nextType })
+ }
+
+ const handleCategoryPropertyChange = (categoryProperty) => {
+ setViewMode({
+ type: 'kanban',
+ settings: { categoryProperty }
+ })
+ }
+
+ const handleTimelineSettingChange = (name, value) => {
+ const settings = {
+ ...normalizedViewMode.settings,
+ [name]: value
+ }
+ if (!settings.endDate) delete settings.endDate
+ if (!settings.colorProperty) delete settings.colorProperty
+ setViewMode({ type: 'timeline', settings })
+ }
+
+ const { onClick: buttonOnClick, ...restButtonProps } = buttonProps
+
+ const handleCycleView = (event) => {
+ buttonOnClick?.(event)
+
+ const currentType = getViewModeType(normalizedViewMode)
+ const currentIndex = availableViewModes.indexOf(currentType)
+ const nextIndex =
+ currentIndex === -1 ? 0 : (currentIndex + 1) % availableViewModes.length
+
+ handleTypeChange(availableViewModes[nextIndex])
+ }
+
+ const activeIcon =
+ normalizedViewMode.type === 'cards' ? (
+
+ ) : normalizedViewMode.type === 'kanban' ? (
+
+ ) : normalizedViewMode.type === 'timeline' ? (
+
+ ) : (
+
+ )
+
+ const content = (
+ <>
+
+ handleTypeChange(e.target.value)}
+ >
+
+ {VIEW_MODE_OPTIONS.filter(
+ (option) =>
+ (option.type !== 'kanban' || hasKanbanOption) &&
+ (option.type !== 'timeline' || hasTimelineOption)
+ ).map((option) => (
+
+
+ {option.label}
+ {(option.type === 'kanban' ||
+ option.type === 'timeline') && (
+
+ }
+ disabled={normalizedViewMode.type !== option.type}
+ onClick={(event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ setSettingsOpen(true)
+ }}
+ />
+ )}
+
+
+ ))}
+
+
+
+ }
+ placement='bottomLeft'
+ arrow={false}
+ trigger='hover'
+ >
+
+
+
setSettingsOpen(false)}
+ destroyOnHidden
+ focusTriggerAfterClose={false}
+ footer={null}
+ centered
+ closeIcon={null}
+ getContainer={() => document.body}
+ width={520}
+ >
+ event.stopPropagation()}
+ >
+
+
+
+ {normalizedViewMode.type === 'timeline'
+ ? 'Timeline settings'
+ : 'Kanban settings'}
+
+
+ {normalizedViewMode.type === 'timeline' ? (
+ <>
+
+ Select the required start property and an optional end property:
+
+
+
+ >
+ )
+
+ if (showEndingDivider) {
+ return (
+
+
+ {content}
+
+ )
+ }
+ return content
+}
ObjectTableViewButton.propTypes = {
- viewMode: PropTypes.oneOf(['list', 'cards']).isRequired,
- setViewMode: PropTypes.func.isRequired
+ objectType: PropTypes.string.isRequired,
+ viewMode: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ setViewMode: PropTypes.func,
+ showEndingDivider: PropTypes.bool
}
export default ObjectTableViewButton
diff --git a/src/components/Dashboard/common/ObjectTimeline.jsx b/src/components/Dashboard/common/ObjectTimeline.jsx
new file mode 100644
index 00000000..0461c3c6
--- /dev/null
+++ b/src/components/Dashboard/common/ObjectTimeline.jsx
@@ -0,0 +1,354 @@
+import {
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ useCallback
+} from 'react'
+import PropTypes from 'prop-types'
+import { Flex, Typography, Card, Divider } from 'antd'
+import { ApiServerContext } from '../context/ApiServerContext'
+import ScrollBox from './ScrollBox'
+import MissingPlaceholder from './MissingPlaceholder'
+import LoadingPlaceholder from './LoadingPlaceholder'
+import ObjectTimelineRow from './ObjectTimelineRow'
+import { getTimelineRangeFromValues, getTimelineTicks } from './timelineUtils'
+import cn from 'classnames'
+import { getStateTagInfo } from '../utils/Utils'
+import { AuthContext } from '../context/AuthContext'
+
+const { Text } = Typography
+const LABEL_WIDTH = 226
+const SHADOW_PADDING = 12
+const MIN_TICK_SECTION_WIDTH = 140
+
+const ObjectTimeline = ({
+ type,
+ records,
+ model,
+ startDate,
+ endDate,
+ colorProperty,
+ filter = {},
+ masterFilter = {},
+ isEditing = false,
+ rowActions = [],
+ renderActions,
+ lazyLoading = false,
+ loading = false,
+ skeletonBoundaryIds = {},
+ onScroll,
+ rowWrapper
+}) => {
+ const { getModelPropertyValues, connected } = useContext(ApiServerContext)
+ const { token } = useContext(AuthContext)
+ const getModelPropertyValuesRef = useRef(getModelPropertyValues)
+ getModelPropertyValuesRef.current = getModelPropertyValues
+
+ const [startValues, setStartValues] = useState([])
+ const [endValues, setEndValues] = useState([])
+
+ const rangeQueryKey = useMemo(
+ () =>
+ JSON.stringify({
+ type,
+ startDate,
+ endDate,
+ filter,
+ masterFilter
+ }),
+ [type, startDate, endDate, filter, masterFilter]
+ )
+
+ useEffect(() => {
+ if (connected !== true || !token) return
+
+ if (!type || !startDate) {
+ setStartValues([])
+ setEndValues([])
+ return undefined
+ }
+
+ let cancelled = false
+ const { filter: activeFilter, masterFilter: activeMasterFilter } =
+ JSON.parse(rangeQueryKey)
+
+ const loadRangeValues = async () => {
+ try {
+ const startPromise = getModelPropertyValuesRef.current(
+ type,
+ startDate,
+ {
+ filter: activeFilter,
+ masterFilter: activeMasterFilter
+ }
+ )
+ const endPromise = endDate
+ ? getModelPropertyValuesRef.current(type, endDate, {
+ filter: activeFilter,
+ masterFilter: activeMasterFilter
+ })
+ : Promise.resolve([])
+ const [nextStartValues, nextEndValues] = await Promise.all([
+ startPromise,
+ endPromise
+ ])
+ if (cancelled) return
+ setStartValues(Array.isArray(nextStartValues) ? nextStartValues : [])
+ setEndValues(Array.isArray(nextEndValues) ? nextEndValues : [])
+ } catch (error) {
+ if (cancelled) return
+ console.error('Error fetching timeline date values:', error)
+ setStartValues([])
+ setEndValues([])
+ }
+ }
+
+ loadRangeValues()
+ return () => {
+ cancelled = true
+ }
+ }, [endDate, rangeQueryKey, startDate, type, connected, token])
+
+ const [showShadow, setShowShadow] = useState(false)
+
+ const handleScroll = useCallback(
+ (event) => {
+ const { scrollLeft } = event.target
+ setShowShadow(scrollLeft > 0)
+ onScroll?.(event)
+ },
+ [onScroll]
+ )
+
+ const timelineRange = useMemo(
+ () => getTimelineRangeFromValues(startValues, endValues),
+ [endValues, startValues]
+ )
+ const lastTimelineRangeRef = useRef(null)
+ if (timelineRange) {
+ lastTimelineRangeRef.current = timelineRange
+ }
+
+ const range = useMemo(
+ () =>
+ timelineRange ||
+ lastTimelineRangeRef.current ||
+ getTimelineRangeFromValues([new Date()]),
+ [timelineRange]
+ )
+
+ const calculatedLabelWidth = useMemo(() => {
+ return LABEL_WIDTH + rowActions.length * 32
+ }, [rowActions.length])
+
+ const ticks = useMemo(() => getTimelineTicks(range.start, range.end), [range])
+ const tickCount = Math.max(1, ticks.length - 1)
+ const minTrackWidth = MIN_TICK_SECTION_WIDTH * tickCount
+ const minContentWidth = calculatedLabelWidth + minTrackWidth
+
+ const nameProperty = model.properties?.find(
+ (property) => property.name === 'name'
+ )
+
+ const referenceProperty = model.properties?.find(
+ (property) => property.name === '_reference'
+ )
+
+ const colorPropertyObject = model.properties?.find(
+ (property) => property.name === colorProperty
+ )
+
+ if (!startDate) {
+ return (
+
+ )
+ }
+
+ if (!loading && !lazyLoading && records.length === 0) {
+ return (
+
+ )
+ }
+
+ if (records.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {nameProperty?.label
+ ? nameProperty?.label
+ : referenceProperty?.label}
+
+
+
+
+ {ticks.map((tick, index) => {
+ return (
+
+ {tick.label}
+
+ )
+ })}
+
+
+ {records.map((record) => {
+ const skeletonBoundary =
+ record._id === skeletonBoundaryIds.next
+ ? 'next'
+ : record._id === skeletonBoundaryIds.previous
+ ? 'previous'
+ : undefined
+ var color = 'var(--color-primary, #1677ff)'
+ if (colorPropertyObject) {
+ if (
+ colorPropertyObject.type === 'state' &&
+ record[colorPropertyObject.name]?.type
+ ) {
+ console.log('GOT STATE')
+ color = `var(--color-${getStateTagInfo(record[colorPropertyObject.name].type).status}, #1677ff)`
+ } else {
+ color = record[colorPropertyObject.name]
+ }
+ }
+ const row = (
+
+ )
+ return rowWrapper ? rowWrapper(record, row) : row
+ })}
+
+
+
+
+ )
+}
+
+ObjectTimeline.propTypes = {
+ type: PropTypes.string,
+ records: PropTypes.array.isRequired,
+ model: PropTypes.object.isRequired,
+ startDate: PropTypes.string,
+ endDate: PropTypes.string,
+ colorProperty: PropTypes.string,
+ filter: PropTypes.object,
+ masterFilter: PropTypes.object,
+ isEditing: PropTypes.bool,
+ rowActions: PropTypes.array,
+ renderActions: PropTypes.func.isRequired,
+ lazyLoading: PropTypes.bool,
+ loading: PropTypes.bool,
+ skeletonBoundaryIds: PropTypes.object,
+ onScroll: PropTypes.func,
+ rowWrapper: PropTypes.func
+}
+
+export default ObjectTimeline
diff --git a/src/components/Dashboard/common/ObjectTimelineItem.jsx b/src/components/Dashboard/common/ObjectTimelineItem.jsx
new file mode 100644
index 00000000..31c004e8
--- /dev/null
+++ b/src/components/Dashboard/common/ObjectTimelineItem.jsx
@@ -0,0 +1,119 @@
+import PropTypes from 'prop-types'
+import dayjs from 'dayjs'
+import { Flex, Typography } from 'antd'
+import classNames from 'classnames'
+import { getTimelineItemPosition } from './timelineUtils'
+
+const { Text } = Typography
+
+const ObjectTimelineItem = ({
+ label,
+ startValue,
+ endValue,
+ rangeStart,
+ rangeEnd,
+ labelIsReference = false,
+ isSkeleton = false,
+ color = 'var(--color-primary, #1677ff)'
+}) => {
+ if (isSkeleton) {
+ return (
+
+ )
+ }
+
+ const position = getTimelineItemPosition(
+ startValue,
+ endValue,
+ rangeStart,
+ rangeEnd
+ )
+ if (!position) {
+ return
No valid start date
+ }
+
+ const startLabel = dayjs(startValue).format('YYYY-MM-DD HH:mm')
+ const endLabel =
+ !position.isPoint && endValue && dayjs(endValue).isValid()
+ ? dayjs(endValue).format('YYYY-MM-DD HH:mm')
+ : null
+
+ return (
+
+
+
+ {label}
+
+
+
+ )
+}
+
+ObjectTimelineItem.propTypes = {
+ label: PropTypes.string,
+ color: PropTypes.string,
+ startValue: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.instanceOf(Date)
+ ]),
+ endValue: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]),
+ rangeStart: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number,
+ PropTypes.instanceOf(Date)
+ ]).isRequired,
+ rangeEnd: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number,
+ PropTypes.instanceOf(Date)
+ ]).isRequired,
+ isSkeleton: PropTypes.bool,
+ labelIsReference: PropTypes.bool
+}
+
+export default ObjectTimelineItem
diff --git a/src/components/Dashboard/common/ObjectTimelineRow.jsx b/src/components/Dashboard/common/ObjectTimelineRow.jsx
new file mode 100644
index 00000000..c22a9d0e
--- /dev/null
+++ b/src/components/Dashboard/common/ObjectTimelineRow.jsx
@@ -0,0 +1,160 @@
+import { createElement } from 'react'
+import PropTypes from 'prop-types'
+import { Flex, Skeleton } from 'antd'
+import { getPropertyValue } from '../../../database/ObjectModels'
+import ObjectProperty from './ObjectProperty'
+import ObjectTimelineItem from './ObjectTimelineItem'
+
+const ObjectTimelineRow = ({
+ model,
+ record,
+ startDate,
+ endDate,
+ rangeStart,
+ rangeEnd,
+ labelWidth,
+ minTrackWidth,
+ tickCount,
+ isEditing = false,
+ rowActions = [],
+ renderActions,
+ lazyLoading = false,
+ skeletonBoundary,
+ color = 'var(--color-primary, #1677ff)',
+ shadowPadding = 12
+}) => {
+ const isSkeleton = record?.isSkeleton === true
+ const nameProperty = model.properties?.find(
+ (property) => property.name === 'name'
+ )
+ const referenceProperty = model.properties?.find(
+ (property) => property.name === '_reference'
+ )
+ const nameValue = getPropertyValue(record, 'name')
+ const modelPrefix = model.prefix
+ const label =
+ typeof nameValue === 'string'
+ ? nameValue
+ : modelPrefix + ':' + String(record?._reference || '')
+ const startValue = getPropertyValue(record, startDate)
+ const endValue = endDate ? getPropertyValue(record, endDate) : undefined
+ const actions =
+ !isSkeleton && rowActions.length > 0
+ ? renderActions(record, lazyLoading)
+ : null
+
+ return (
+
+
+
+ {isSkeleton ? (
+
+ ) : (
+ <>
+
+ {createElement(model.icon, {
+ style: { fontSize: 14, flex: '0 0 auto' }
+ })}
+
+
+ {nameProperty ? (
+
+ ) : (
+
+ )}
+
+ {actions}
+ >
+ )}
+
+
+
+
+
+
+ )
+}
+
+ObjectTimelineRow.propTypes = {
+ model: PropTypes.object.isRequired,
+ record: PropTypes.object.isRequired,
+ color: PropTypes.string,
+ startDate: PropTypes.string.isRequired,
+ endDate: PropTypes.string,
+ rangeStart: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
+ .isRequired,
+ rangeEnd: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
+ .isRequired,
+ labelWidth: PropTypes.number.isRequired,
+ minTrackWidth: PropTypes.number.isRequired,
+ tickCount: PropTypes.number.isRequired,
+ isEditing: PropTypes.bool,
+ rowActions: PropTypes.array,
+ renderActions: PropTypes.func.isRequired,
+ lazyLoading: PropTypes.bool,
+ skeletonBoundary: PropTypes.string,
+ shadowPadding: PropTypes.number
+}
+
+export default ObjectTimelineRow
diff --git a/src/components/Dashboard/common/PDFPreview.jsx b/src/components/Dashboard/common/PDFPreview.jsx
new file mode 100644
index 00000000..aa58a94b
--- /dev/null
+++ b/src/components/Dashboard/common/PDFPreview.jsx
@@ -0,0 +1,87 @@
+import { useState } from 'react'
+import PropTypes from 'prop-types'
+import { Flex, Button } from 'antd'
+import PlusIcon from '../../Icons/PlusIcon.jsx'
+import MinusIcon from '../../Icons/MinusIcon.jsx'
+import PanIcon from '../../Icons/PanIcon.jsx'
+import PanFilledIcon from '../../Icons/PanFilledIcon.jsx'
+import PDFViewer from './PDFViewer.jsx'
+import { clampPreviewScale } from '../hooks/usePinchZoom.js'
+
+const PDFPreview = ({ file, loading = false, style }) => {
+ const [previewScale, setPreviewScale] = useState(1)
+ const [panMode, setPanMode] = useState(false)
+
+ return (
+
+
+
+ ) : (
+
+ )
+ }
+ disabled={loading}
+ onClick={() => {
+ setPanMode((prev) => !prev)
+ }}
+ />
+ }
+ onClick={() => {
+ setPreviewScale((prev) => clampPreviewScale(prev + 0.05))
+ }}
+ disabled={loading}
+ />
+ {
+ setPreviewScale(1)
+ }}
+ >
+ {previewScale.toFixed(2)}x
+
+ }
+ onClick={() => {
+ setPreviewScale((prev) => clampPreviewScale(prev - 0.05))
+ }}
+ disabled={loading}
+ />
+
+
+
+
+ )
+}
+
+PDFPreview.propTypes = {
+ file: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.instanceOf(Blob),
+ PropTypes.instanceOf(ArrayBuffer),
+ PropTypes.object
+ ]),
+ loading: PropTypes.bool,
+ style: PropTypes.object
+}
+
+export default PDFPreview
diff --git a/src/components/Dashboard/common/PDFViewer.jsx b/src/components/Dashboard/common/PDFViewer.jsx
new file mode 100644
index 00000000..74dba829
--- /dev/null
+++ b/src/components/Dashboard/common/PDFViewer.jsx
@@ -0,0 +1,324 @@
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState
+} from 'react'
+import PropTypes from 'prop-types'
+import { Document, Page, pdfjs } from 'react-pdf'
+import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
+import 'react-pdf/dist/Page/AnnotationLayer.css'
+import 'react-pdf/dist/Page/TextLayer.css'
+import LoadingPlaceholder from './LoadingPlaceholder.jsx'
+import ScrollBox from './ScrollBox.jsx'
+import usePreserveZoomScroll from '../hooks/usePreserveZoomScroll.js'
+import usePinchZoom from '../hooks/usePinchZoom.js'
+
+pdfjs.GlobalWorkerOptions.workerSrc = pdfWorker
+
+const noop = () => {}
+
+const PDFViewer = ({
+ file,
+ scale = 1,
+ panMode = false,
+ loading = false,
+ onScaleChange,
+ onLoadSuccess = noop,
+ onLoadError = noop
+}) => {
+ const containerRef = useRef(null)
+ const scrollElementRef = useRef(null)
+ const zoomContentRef = useRef(null)
+ const pdfPagesRef = useRef(null)
+ const panStartRef = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 })
+ const pdfDocumentRef = useRef(null)
+ const prevFileRef = useRef(file)
+ const documentKeyRef = useRef(0)
+ const [numPages, setNumPages] = useState(0)
+ const [isPanning, setIsPanning] = useState(false)
+ const [hasLoadError, setHasLoadError] = useState(false)
+ const [pdfSize, setPdfSize] = useState({ width: 0, height: 0 })
+ const [renderedScale, setRenderedScale] = useState(scale)
+
+ const activeFile = loading == true ? null : file
+ if (prevFileRef.current !== activeFile) {
+ prevFileRef.current = activeFile
+ documentKeyRef.current += 1
+ }
+
+ const destroyPdfDocument = useCallback(() => {
+ const pdf = pdfDocumentRef.current
+ pdfDocumentRef.current = null
+ if (pdf && typeof pdf.destroy === 'function') {
+ pdf.destroy()
+ }
+ }, [])
+
+ const handleDocumentLoadSuccess = useCallback(
+ (pdf) => {
+ pdfDocumentRef.current = pdf
+ setHasLoadError(false)
+ setNumPages(pdf.numPages)
+ onLoadSuccess(pdf)
+ },
+ [onLoadSuccess]
+ )
+
+ const handleDocumentLoadError = useCallback(
+ (error) => {
+ destroyPdfDocument()
+ setHasLoadError(true)
+ setNumPages(0)
+ onLoadError(error)
+ },
+ [destroyPdfDocument, onLoadError]
+ )
+
+ useEffect(() => {
+ setNumPages(0)
+ setHasLoadError(false)
+ setPdfSize({ width: 0, height: 0 })
+ return () => {
+ destroyPdfDocument()
+ }
+ }, [activeFile, destroyPdfDocument])
+
+ useEffect(() => {
+ if (scale === renderedScale) {
+ return undefined
+ }
+
+ const timeout = window.setTimeout(() => {
+ setRenderedScale(scale)
+ }, 500)
+ return () => window.clearTimeout(timeout)
+ }, [renderedScale, scale])
+
+ useLayoutEffect(() => {
+ const pages = pdfPagesRef.current
+ if (!pages || typeof ResizeObserver !== 'function') {
+ return undefined
+ }
+
+ const updatePdfSize = () => {
+ const width = pages.offsetWidth / renderedScale
+ const height = pages.offsetHeight / renderedScale
+ if (width <= 0 && height <= 0) {
+ return
+ }
+ setPdfSize((prev) =>
+ prev.width === width && prev.height === height
+ ? prev
+ : { width, height }
+ )
+ }
+
+ updatePdfSize()
+ const observer = new ResizeObserver(updatePdfSize)
+ observer.observe(pages)
+ return () => observer.disconnect()
+ }, [activeFile, numPages, renderedScale])
+
+ useEffect(() => {
+ if (!panMode) {
+ setIsPanning(false)
+ }
+ }, [panMode])
+
+ useEffect(() => {
+ const scrollEl = scrollElementRef.current
+ if (!panMode || !scrollEl) {
+ return
+ }
+
+ const handlePointerDown = (event) => {
+ if (event.pointerType === 'mouse' && event.button !== 0) {
+ return
+ }
+ event.preventDefault()
+ panStartRef.current = {
+ x: event.clientX,
+ y: event.clientY,
+ scrollLeft: scrollEl.scrollLeft,
+ scrollTop: scrollEl.scrollTop
+ }
+ setIsPanning(true)
+ }
+
+ scrollEl.addEventListener('pointerdown', handlePointerDown)
+ return () => {
+ scrollEl.removeEventListener('pointerdown', handlePointerDown)
+ }
+ }, [panMode, activeFile])
+
+ useEffect(() => {
+ if (!isPanning) {
+ return
+ }
+
+ const previousCursor = document.body.style.cursor
+ const previousUserSelect = document.body.style.userSelect
+ document.body.style.cursor = 'grabbing'
+ document.body.style.userSelect = 'none'
+
+ const handlePointerMove = (event) => {
+ const scrollEl = scrollElementRef.current
+ if (!scrollEl) {
+ return
+ }
+ const { x, y, scrollLeft, scrollTop } = panStartRef.current
+ scrollEl.scrollLeft = scrollLeft - (event.clientX - x)
+ scrollEl.scrollTop = scrollTop - (event.clientY - y)
+ }
+
+ const handlePointerUp = () => {
+ setIsPanning(false)
+ }
+
+ window.addEventListener('pointermove', handlePointerMove)
+ window.addEventListener('pointerup', handlePointerUp)
+ window.addEventListener('pointercancel', handlePointerUp)
+ return () => {
+ document.body.style.cursor = previousCursor
+ document.body.style.userSelect = previousUserSelect
+ window.removeEventListener('pointermove', handlePointerMove)
+ window.removeEventListener('pointerup', handlePointerUp)
+ window.removeEventListener('pointercancel', handlePointerUp)
+ }
+ }, [isPanning])
+
+ const { captureAtClientPoint } = usePreserveZoomScroll(
+ scrollElementRef,
+ zoomContentRef,
+ scale,
+ `${documentKeyRef.current}:${numPages}`
+ )
+
+ usePinchZoom({
+ containerRef,
+ scale,
+ onScaleChange,
+ onBeforeScaleChange: captureAtClientPoint,
+ enabled: typeof onScaleChange === 'function' && loading != true
+ })
+
+ const isDocumentLoading =
+ Boolean(activeFile) && numPages === 0 && hasLoadError != true
+ const showLoadingPlaceholder = loading == true || isDocumentLoading
+ const scaledPdfWidth = pdfSize.width * scale
+ const scaledPdfHeight = pdfSize.height * scale
+ const liveTransformScale = scale / renderedScale
+
+ return (
+
+ {showLoadingPlaceholder ? (
+
+
+
+ ) : null}
+ {activeFile ? (
+
+
+
+ Failed to load PDF.
+
+ }
+ onLoadSuccess={handleDocumentLoadSuccess}
+ onLoadError={handleDocumentLoadError}
+ >
+
+
+ {Array.from(new Array(numPages), (_el, index) => (
+
+ ))}
+
+
+
+
+
+ ) : null}
+
+ )
+}
+
+PDFViewer.propTypes = {
+ file: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.instanceOf(Blob),
+ PropTypes.instanceOf(ArrayBuffer),
+ PropTypes.object
+ ]),
+ scale: PropTypes.number,
+ panMode: PropTypes.bool,
+ loading: PropTypes.bool,
+ onScaleChange: PropTypes.func,
+ onLoadSuccess: PropTypes.func,
+ onLoadError: PropTypes.func
+}
+
+export default PDFViewer
diff --git a/src/components/Dashboard/common/PermissionCheckbox.jsx b/src/components/Dashboard/common/PermissionCheckbox.jsx
new file mode 100644
index 00000000..d831c72a
--- /dev/null
+++ b/src/components/Dashboard/common/PermissionCheckbox.jsx
@@ -0,0 +1,74 @@
+import PropTypes from 'prop-types'
+import { theme } from 'antd'
+import CheckIcon from '../../Icons/CheckIcon'
+import XMarkIcon from '../../Icons/XMarkIcon'
+
+const ICON_STYLE = {
+ fontSize: 7,
+ color: '#ffffff'
+}
+
+const PermissionCheckbox = ({
+ checked = false,
+ indeterminate = false,
+ mixed = false,
+ disabled = false,
+ onChange
+}) => {
+ const { token } = theme.useToken()
+ const state = mixed
+ ? 'mixed'
+ : indeterminate
+ ? 'inherit'
+ : checked
+ ? 'allow'
+ : 'deny'
+
+ return (
+