All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated DashboardLayout to utilize Electron context for dynamic spacing adjustments. - Enhanced About and Settings components to include icon keys for improved navigation clarity. - Refined DashboardOverviewPage to generate dynamic icon keys based on page names. - Improved DashboardTabs to support sidebar icons, enhancing visual consistency. - Expanded NavigationTabsContext to manage icon keys for tabs, streamlining tab interactions. - Updated sidebarIconMap to utilize component references for better performance and maintainability.
1023 lines
31 KiB
JavaScript
1023 lines
31 KiB
JavaScript
import {
|
|
cloneElement,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState
|
|
} from 'react'
|
|
import { Flex, Space } from 'antd'
|
|
import PropTypes from 'prop-types'
|
|
import { HolderOutlined } from '@ant-design/icons'
|
|
import ActionsButton from './ActionsButton'
|
|
import ViewButton from './ViewButton'
|
|
import EditButtons from './EditButtons'
|
|
import ScrollBox from './ScrollBox'
|
|
import InfoCollapse from './InfoCollapse'
|
|
import StatsDisplay, { StatsViewButton } from './StatsDisplay'
|
|
import ModelHistoryDisplay from './ModelHistoryDisplay'
|
|
import LazyWhenVisible from './LazyWhenVisible'
|
|
import ObjectTable from './ObjectTable'
|
|
import DashboardOverviewFlexColumns from './DashboardOverviewFlexColumns'
|
|
import useCollapseState from '../hooks/useCollapseState'
|
|
import usePageLayout, {
|
|
mergeOrder,
|
|
normalizeColumnProportions
|
|
} from '../hooks/usePageLayout'
|
|
import { OverviewLayoutContext } from '../context/OverviewLayoutContext'
|
|
import { useNavigationTabPage } from '../context/NavigationTabsContext'
|
|
|
|
const flattenOverviewSections = (sectionList = []) => {
|
|
const leaves = {}
|
|
const defaultFlexColumns = {}
|
|
const defaultSectionOrder = []
|
|
|
|
for (const section of sectionList) {
|
|
if (section?.type === 'row') {
|
|
const childKeys = []
|
|
for (const column of section.columns || []) {
|
|
for (const item of column) {
|
|
if (item?.key) {
|
|
leaves[item.key] = item
|
|
childKeys.push(item.key)
|
|
}
|
|
}
|
|
}
|
|
defaultFlexColumns[section.key] = childKeys
|
|
defaultSectionOrder.push(section.key)
|
|
} else if (section?.key) {
|
|
leaves[section.key] = section
|
|
defaultSectionOrder.push(section.key)
|
|
}
|
|
}
|
|
|
|
return { leaves, defaultSectionOrder, defaultFlexColumns }
|
|
}
|
|
|
|
const collectViewItems = (sectionList = []) => {
|
|
const items = []
|
|
const visit = (section) => {
|
|
if (section?.type === 'row') {
|
|
section.columns?.forEach((column) => column.forEach(visit))
|
|
return
|
|
}
|
|
if (section?.key && section?.title) {
|
|
items.push({ key: section.key, label: section.title })
|
|
}
|
|
}
|
|
sectionList.forEach(visit)
|
|
return items
|
|
}
|
|
|
|
const copyLayout = (layout) => ({
|
|
sectionOrder: [...(layout?.sectionOrder || [])],
|
|
flexColumns: Object.fromEntries(
|
|
Object.entries(layout?.flexColumns || {}).map(([key, value]) => [
|
|
key,
|
|
Array.isArray(value) ? [...value] : []
|
|
])
|
|
),
|
|
statsOrder: Object.fromEntries(
|
|
Object.entries(layout?.statsOrder || {}).map(([key, value]) => [
|
|
key,
|
|
Array.isArray(value) ? [...value] : value
|
|
])
|
|
),
|
|
statsVisibility: Object.fromEntries(
|
|
Object.entries(layout?.statsVisibility || {}).map(([key, value]) => [
|
|
key,
|
|
value && typeof value === 'object' && !Array.isArray(value)
|
|
? { ...value }
|
|
: {}
|
|
])
|
|
),
|
|
sectionHeights: { ...(layout?.sectionHeights || {}) },
|
|
flexColumnProportions: Object.fromEntries(
|
|
Object.entries(layout?.flexColumnProportions || {}).map(([key, value]) => [
|
|
key,
|
|
Array.isArray(value) ? [...value] : []
|
|
])
|
|
)
|
|
})
|
|
|
|
const createFlexColumnsId = () =>
|
|
`flexColumns-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
|
|
const DEFAULT_HISTORY_HEIGHT = 400
|
|
const DEFAULT_TABLE_HEIGHT = 360
|
|
const EMPTY_ORDER = []
|
|
const EMPTY_COLUMNS = {}
|
|
|
|
const getVerticalEdgeInsert = (element, clientY) => {
|
|
const rect = element.getBoundingClientRect()
|
|
const edge = Math.min(48, rect.height / 3)
|
|
const y = clientY - rect.top
|
|
if (y <= edge) return { insertBefore: true }
|
|
if (y >= rect.height - edge) return { insertBefore: false }
|
|
return null
|
|
}
|
|
|
|
const DashboardOverviewPage = ({
|
|
pageName,
|
|
collapseDefaults = {},
|
|
viewItems,
|
|
actionMenu,
|
|
onOpenActionsModal,
|
|
sections = []
|
|
}) => {
|
|
const overviewSection = pageName.endsWith('Overview')
|
|
? pageName.slice(0, -'Overview'.length)
|
|
: ''
|
|
const overviewTitle = overviewSection
|
|
? `Overview - ${overviewSection}`
|
|
: pageName
|
|
const overviewIconKey = overviewSection
|
|
? `${overviewSection.charAt(0).toLowerCase()}${overviewSection.slice(1)}`
|
|
: undefined
|
|
useNavigationTabPage({ title: overviewTitle, iconKey: overviewIconKey })
|
|
const [savedCollapseState, updateCollapseState] = useCollapseState(
|
|
pageName,
|
|
collapseDefaults
|
|
)
|
|
const collapseState = useMemo(
|
|
() => ({ ...collapseDefaults, ...savedCollapseState }),
|
|
[collapseDefaults, savedCollapseState]
|
|
)
|
|
|
|
const { leaves, defaultSectionOrder, defaultFlexColumns } = useMemo(
|
|
() => flattenOverviewSections(sections),
|
|
[sections]
|
|
)
|
|
const [savedLayout, updatePageLayout] = usePageLayout(pageName, {
|
|
sectionOrder: defaultSectionOrder,
|
|
flexColumns: defaultFlexColumns,
|
|
statsOrder: {},
|
|
statsVisibility: {}
|
|
})
|
|
|
|
const [isLayoutEditing, setIsLayoutEditing] = useState(false)
|
|
const [draftLayout, setDraftLayout] = useState(null)
|
|
const [draggedItem, setDraggedItem] = useState(null)
|
|
const [dropTarget, setDropTarget] = useState(null)
|
|
const [liveHeights, setLiveHeights] = useState({})
|
|
const scrollRef = useRef(null)
|
|
const dragYRef = useRef(null)
|
|
const scrollRafRef = useRef(0)
|
|
|
|
const displayLayout =
|
|
isLayoutEditing && draftLayout ? draftLayout : savedLayout
|
|
const resolvedViewItems = viewItems || collectViewItems(sections)
|
|
const sectionsByKey = leaves
|
|
const orderedSectionKeys = useMemo(
|
|
() => displayLayout.sectionOrder || EMPTY_ORDER,
|
|
[displayLayout.sectionOrder]
|
|
)
|
|
const flexColumns = useMemo(
|
|
() => displayLayout.flexColumns || EMPTY_COLUMNS,
|
|
[displayLayout.flexColumns]
|
|
)
|
|
const isGroupKey = (key) =>
|
|
Object.prototype.hasOwnProperty.call(flexColumns, key)
|
|
|
|
const startEditing = useCallback(() => {
|
|
setDraftLayout(copyLayout(savedLayout))
|
|
setIsLayoutEditing(true)
|
|
}, [savedLayout])
|
|
|
|
const clearDragState = useCallback(() => {
|
|
setDraggedItem(null)
|
|
setDropTarget(null)
|
|
}, [])
|
|
|
|
const getSectionHeight = useCallback(
|
|
(key, fallback) => {
|
|
if (liveHeights[key] != null) return liveHeights[key]
|
|
const saved = savedLayout.sectionHeights?.[key]
|
|
return typeof saved === 'number' ? saved : fallback
|
|
},
|
|
[liveHeights, savedLayout.sectionHeights]
|
|
)
|
|
|
|
const handleSectionHeightChange = useCallback(
|
|
(key, height, persist) => {
|
|
setLiveHeights((current) => ({ ...current, [key]: height }))
|
|
if (!persist) return
|
|
|
|
const nextHeights = {
|
|
...(savedLayout.sectionHeights || {}),
|
|
[key]: height
|
|
}
|
|
setDraftLayout((current) => {
|
|
if (!current) return current
|
|
return { ...current, sectionHeights: nextHeights }
|
|
})
|
|
updatePageLayout({
|
|
...savedLayout,
|
|
sectionHeights: nextHeights
|
|
})
|
|
},
|
|
[savedLayout, updatePageLayout]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!draggedItem) return undefined
|
|
|
|
const edgePx = 72
|
|
const maxSpeed = 28
|
|
|
|
const stopScroll = () => {
|
|
if (scrollRafRef.current) {
|
|
cancelAnimationFrame(scrollRafRef.current)
|
|
scrollRafRef.current = 0
|
|
}
|
|
}
|
|
|
|
const tick = () => {
|
|
scrollRafRef.current = 0
|
|
const y = dragYRef.current
|
|
const scrollEl = scrollRef.current
|
|
if (y == null || !scrollEl) return
|
|
|
|
const rect = scrollEl.getBoundingClientRect()
|
|
let dy = 0
|
|
if (y >= rect.bottom - edgePx) {
|
|
dy = Math.min(maxSpeed, Math.ceil((y - (rect.bottom - edgePx)) / 2) + 6)
|
|
} else if (y <= rect.top + edgePx) {
|
|
dy = -Math.min(maxSpeed, Math.ceil((rect.top + edgePx - y) / 2) + 6)
|
|
}
|
|
|
|
if (dy === 0) return
|
|
|
|
const maxScroll = scrollEl.scrollHeight - scrollEl.clientHeight
|
|
const next = Math.max(0, Math.min(maxScroll, scrollEl.scrollTop + dy))
|
|
if (next !== scrollEl.scrollTop) {
|
|
scrollEl.scrollTop = next
|
|
scrollRafRef.current = requestAnimationFrame(tick)
|
|
}
|
|
}
|
|
|
|
const onDragOver = (event) => {
|
|
dragYRef.current = event.clientY
|
|
if (!scrollRafRef.current) {
|
|
scrollRafRef.current = requestAnimationFrame(tick)
|
|
}
|
|
}
|
|
|
|
const onDragEnd = () => {
|
|
dragYRef.current = null
|
|
stopScroll()
|
|
}
|
|
|
|
document.addEventListener('dragover', onDragOver)
|
|
document.addEventListener('dragend', onDragEnd)
|
|
document.addEventListener('drop', onDragEnd)
|
|
|
|
return () => {
|
|
document.removeEventListener('dragover', onDragOver)
|
|
document.removeEventListener('dragend', onDragEnd)
|
|
document.removeEventListener('drop', onDragEnd)
|
|
onDragEnd()
|
|
}
|
|
}, [draggedItem])
|
|
|
|
const cancelEditing = useCallback(() => {
|
|
setDraftLayout(null)
|
|
setIsLayoutEditing(false)
|
|
clearDragState()
|
|
}, [clearDragState])
|
|
|
|
const handleUpdate = useCallback(() => {
|
|
if (draftLayout) {
|
|
updatePageLayout(draftLayout)
|
|
}
|
|
setDraftLayout(null)
|
|
setIsLayoutEditing(false)
|
|
clearDragState()
|
|
}, [draftLayout, updatePageLayout, clearDragState])
|
|
|
|
const reorderStats = useCallback(
|
|
(objectType, newOrder) => {
|
|
if (!isLayoutEditing) return
|
|
setDraftLayout((current) => {
|
|
const base = current || copyLayout(savedLayout)
|
|
return {
|
|
...base,
|
|
statsOrder: {
|
|
...base.statsOrder,
|
|
[objectType]: newOrder
|
|
}
|
|
}
|
|
})
|
|
},
|
|
[isLayoutEditing, savedLayout]
|
|
)
|
|
|
|
const updateStatsVisibility = useCallback(
|
|
(objectType, key, value) => {
|
|
if (!isLayoutEditing) return
|
|
setDraftLayout((current) => {
|
|
const base = current || copyLayout(savedLayout)
|
|
return {
|
|
...base,
|
|
statsVisibility: {
|
|
...base.statsVisibility,
|
|
[objectType]: {
|
|
...(base.statsVisibility?.[objectType] || {}),
|
|
[key]: value
|
|
}
|
|
}
|
|
}
|
|
})
|
|
},
|
|
[isLayoutEditing, savedLayout]
|
|
)
|
|
|
|
const layoutContextValue = useMemo(
|
|
() => ({
|
|
isLayoutEditing,
|
|
getStatsOrder: (objectType, defaultOrder = []) =>
|
|
mergeOrder(displayLayout.statsOrder?.[objectType] || [], defaultOrder),
|
|
reorderStats,
|
|
getStatsVisibility: (objectType, defaultNames = []) => {
|
|
const saved = displayLayout.statsVisibility?.[objectType] || {}
|
|
return Object.fromEntries(
|
|
defaultNames.map((name) => [name, saved[name] !== false])
|
|
)
|
|
},
|
|
updateStatsVisibility
|
|
}),
|
|
[isLayoutEditing, displayLayout, reorderStats, updateStatsVisibility]
|
|
)
|
|
|
|
const applyMove = useCallback(
|
|
(dest) => {
|
|
if (!draggedItem || !dest) return
|
|
if (draggedItem.kind === 'flexGroup' && dest.parent !== 'root') return
|
|
|
|
setDraftLayout((current) => {
|
|
const base = current || copyLayout(savedLayout)
|
|
const originalSourceList =
|
|
draggedItem.sourceParent === 'root'
|
|
? base.sectionOrder
|
|
: base.flexColumns?.[draggedItem.sourceParent] || []
|
|
const sourceIndex = originalSourceList.indexOf(draggedItem.key)
|
|
if (sourceIndex === -1) return current || base
|
|
|
|
const originalDestList =
|
|
dest.parent === 'root'
|
|
? base.sectionOrder
|
|
: base.flexColumns?.[dest.parent] || []
|
|
|
|
let destIndex = dest.insertBefore ? dest.index : dest.index + 1
|
|
if (
|
|
draggedItem.sourceParent === dest.parent &&
|
|
sourceIndex < destIndex
|
|
) {
|
|
destIndex -= 1
|
|
}
|
|
destIndex = Math.max(0, Math.min(destIndex, originalDestList.length))
|
|
if (
|
|
draggedItem.sourceParent === dest.parent &&
|
|
destIndex === sourceIndex
|
|
) {
|
|
return current || base
|
|
}
|
|
|
|
const sectionOrder = [...(base.sectionOrder || [])]
|
|
const nextFlexColumns = Object.fromEntries(
|
|
Object.entries(base.flexColumns || {}).map(([id, items]) => [
|
|
id,
|
|
[...items]
|
|
])
|
|
)
|
|
|
|
if (draggedItem.sourceParent === 'root') {
|
|
const from = sectionOrder.indexOf(draggedItem.key)
|
|
if (from !== -1) sectionOrder.splice(from, 1)
|
|
} else {
|
|
nextFlexColumns[draggedItem.sourceParent] = (
|
|
nextFlexColumns[draggedItem.sourceParent] || []
|
|
).filter((key) => key !== draggedItem.key)
|
|
}
|
|
|
|
if (dest.parent === 'root') {
|
|
const insertAt = Math.max(0, Math.min(destIndex, sectionOrder.length))
|
|
sectionOrder.splice(insertAt, 0, draggedItem.key)
|
|
} else {
|
|
const destList = [...(nextFlexColumns[dest.parent] || [])]
|
|
const insertAt = Math.max(0, Math.min(destIndex, destList.length))
|
|
destList.splice(insertAt, 0, draggedItem.key)
|
|
nextFlexColumns[dest.parent] = destList
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
sectionOrder,
|
|
flexColumns: nextFlexColumns,
|
|
flexColumnProportions: Object.fromEntries(
|
|
Object.entries(nextFlexColumns).map(([id, items]) => [
|
|
id,
|
|
normalizeColumnProportions(
|
|
items.length,
|
|
base.flexColumnProportions?.[id]
|
|
)
|
|
])
|
|
)
|
|
}
|
|
})
|
|
},
|
|
[draggedItem, savedLayout]
|
|
)
|
|
|
|
const handleDragStart = useCallback(
|
|
(e, item) => {
|
|
if (!isLayoutEditing) return
|
|
e.stopPropagation()
|
|
const selector =
|
|
item.sourceParent === 'root'
|
|
? '.overview-section-wrapper'
|
|
: '.overview-flex-columns-item'
|
|
const wrapper = e.currentTarget.closest(selector)
|
|
if (wrapper) {
|
|
const rect = wrapper.getBoundingClientRect()
|
|
e.dataTransfer.setDragImage(
|
|
wrapper,
|
|
e.clientX - rect.left,
|
|
e.clientY - rect.top
|
|
)
|
|
}
|
|
setDraggedItem(item)
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
e.dataTransfer.setData('text/html', '')
|
|
},
|
|
[isLayoutEditing]
|
|
)
|
|
|
|
const setRootDropFromGroupEdge = useCallback(
|
|
(e, groupId) => {
|
|
const wrapper = e.currentTarget.closest('.overview-section-wrapper')
|
|
if (!wrapper) return false
|
|
const edge = getVerticalEdgeInsert(wrapper, e.clientY)
|
|
if (!edge) return false
|
|
const rootIndex = orderedSectionKeys.indexOf(groupId)
|
|
if (rootIndex === -1) return false
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
setDropTarget({
|
|
parent: 'root',
|
|
index: rootIndex,
|
|
insertBefore: edge.insertBefore
|
|
})
|
|
return true
|
|
},
|
|
[orderedSectionKeys]
|
|
)
|
|
|
|
const handleRootDragOver = useCallback(
|
|
(e, index, key) => {
|
|
if (!isLayoutEditing || !draggedItem) return
|
|
const isGroup = Object.prototype.hasOwnProperty.call(flexColumns, key)
|
|
if (draggedItem.kind === 'section' && isGroup) {
|
|
const edge = getVerticalEdgeInsert(e.currentTarget, e.clientY)
|
|
if (edge) {
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
setDropTarget({
|
|
parent: 'root',
|
|
index,
|
|
insertBefore: edge.insertBefore
|
|
})
|
|
}
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
setDropTarget({
|
|
parent: 'root',
|
|
index,
|
|
insertBefore: e.clientY < rect.top + rect.height / 2
|
|
})
|
|
},
|
|
[isLayoutEditing, draggedItem, flexColumns]
|
|
)
|
|
|
|
const handleRootListDragOver = useCallback(
|
|
(e) => {
|
|
if (!isLayoutEditing || !draggedItem) return
|
|
if (
|
|
e.target instanceof Element &&
|
|
e.target.closest('.overview-section-wrapper')
|
|
) {
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
const wrappers = [...e.currentTarget.children].filter((el) =>
|
|
el.classList.contains('overview-section-wrapper')
|
|
)
|
|
if (wrappers.length === 0) {
|
|
setDropTarget({ parent: 'root', index: 0, insertBefore: true })
|
|
return
|
|
}
|
|
const y = e.clientY
|
|
for (let i = 0; i < wrappers.length; i++) {
|
|
const rect = wrappers[i].getBoundingClientRect()
|
|
if (y < rect.top + rect.height / 2) {
|
|
const key = wrappers[i].dataset.overviewKey
|
|
const nextIndex = orderedSectionKeys.indexOf(key)
|
|
setDropTarget({
|
|
parent: 'root',
|
|
index: nextIndex === -1 ? i : nextIndex,
|
|
insertBefore: true
|
|
})
|
|
return
|
|
}
|
|
}
|
|
const lastKey = wrappers[wrappers.length - 1].dataset.overviewKey
|
|
const lastIndex = orderedSectionKeys.indexOf(lastKey)
|
|
setDropTarget({
|
|
parent: 'root',
|
|
index: lastIndex === -1 ? wrappers.length - 1 : lastIndex,
|
|
insertBefore: false
|
|
})
|
|
},
|
|
[isLayoutEditing, draggedItem, orderedSectionKeys]
|
|
)
|
|
|
|
const handleGroupItemDragOver = useCallback(
|
|
(e, groupId, index) => {
|
|
if (!isLayoutEditing || !draggedItem || draggedItem.kind !== 'section') {
|
|
return
|
|
}
|
|
if (setRootDropFromGroupEdge(e, groupId)) return
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
setDropTarget({
|
|
parent: groupId,
|
|
index,
|
|
insertBefore: true
|
|
})
|
|
},
|
|
[isLayoutEditing, draggedItem, setRootDropFromGroupEdge]
|
|
)
|
|
|
|
const handleGroupContainerDragOver = useCallback(
|
|
(e, groupId) => {
|
|
if (!isLayoutEditing || !draggedItem || draggedItem.kind !== 'section') {
|
|
return
|
|
}
|
|
if (setRootDropFromGroupEdge(e, groupId)) return
|
|
if (
|
|
e.target instanceof Element &&
|
|
e.target.closest('.overview-flex-columns-item')
|
|
) {
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
const items = flexColumns[groupId] || []
|
|
setDropTarget({
|
|
parent: groupId,
|
|
index: items.length,
|
|
insertBefore: true
|
|
})
|
|
},
|
|
[isLayoutEditing, draggedItem, flexColumns, setRootDropFromGroupEdge]
|
|
)
|
|
|
|
const handleDragLeave = useCallback((e) => {
|
|
if (
|
|
e.relatedTarget instanceof Node &&
|
|
e.currentTarget.contains(e.relatedTarget)
|
|
) {
|
|
return
|
|
}
|
|
setDropTarget(null)
|
|
}, [])
|
|
|
|
const handleDrop = useCallback(
|
|
(e, fallbackParent, fallbackIndex) => {
|
|
if (!isLayoutEditing || !draggedItem) return
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const dest = dropTarget || {
|
|
parent: fallbackParent,
|
|
index: fallbackIndex,
|
|
insertBefore: true
|
|
}
|
|
applyMove(dest)
|
|
clearDragState()
|
|
},
|
|
[isLayoutEditing, draggedItem, dropTarget, applyMove, clearDragState]
|
|
)
|
|
|
|
const handleDragEnd = useCallback(() => {
|
|
clearDragState()
|
|
}, [clearDragState])
|
|
|
|
const addColumns = useCallback(() => {
|
|
setIsLayoutEditing(true)
|
|
setDraftLayout((current) => {
|
|
const base = current || copyLayout(savedLayout)
|
|
const id = createFlexColumnsId()
|
|
return {
|
|
...base,
|
|
sectionOrder: [...(base.sectionOrder || []), id],
|
|
flexColumns: {
|
|
...(base.flexColumns || {}),
|
|
[id]: []
|
|
}
|
|
}
|
|
})
|
|
}, [savedLayout])
|
|
|
|
const removeGroup = useCallback(
|
|
(groupId) => {
|
|
setDraftLayout((current) => {
|
|
const base = current || copyLayout(savedLayout)
|
|
const children = base.flexColumns?.[groupId] || []
|
|
const sectionOrder = [...(base.sectionOrder || [])]
|
|
const at = sectionOrder.indexOf(groupId)
|
|
if (at === -1) return current || base
|
|
sectionOrder.splice(at, 1, ...children)
|
|
const nextFlexColumns = { ...(base.flexColumns || {}) }
|
|
delete nextFlexColumns[groupId]
|
|
const nextFlexColumnProportions = {
|
|
...(base.flexColumnProportions || {})
|
|
}
|
|
delete nextFlexColumnProportions[groupId]
|
|
return {
|
|
...base,
|
|
sectionOrder,
|
|
flexColumns: nextFlexColumns,
|
|
flexColumnProportions: nextFlexColumnProportions
|
|
}
|
|
})
|
|
},
|
|
[savedLayout]
|
|
)
|
|
|
|
const updateFlexColumnProportions = useCallback(
|
|
(groupId, nextProportions) => {
|
|
if (!isLayoutEditing) return
|
|
setDraftLayout((current) => {
|
|
const base = current || copyLayout(savedLayout)
|
|
return {
|
|
...base,
|
|
flexColumnProportions: {
|
|
...(base.flexColumnProportions || {}),
|
|
[groupId]: nextProportions
|
|
}
|
|
}
|
|
})
|
|
},
|
|
[isLayoutEditing, savedLayout]
|
|
)
|
|
|
|
const renderDragHandle = (item) => (
|
|
<span
|
|
className='overview-drag-handle'
|
|
draggable
|
|
onClick={(e) => e.stopPropagation()}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onDragStart={(e) => handleDragStart(e, item)}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<HolderOutlined />
|
|
</span>
|
|
)
|
|
|
|
const isItemVisible = (key) => isLayoutEditing || collapseState[key] !== false
|
|
|
|
const wouldMove = (item, dest) => {
|
|
if (!item || !dest) return false
|
|
if (item.key === orderedSectionKeys[dest.index] && dest.parent === 'root') {
|
|
const insertIndex = dest.insertBefore ? dest.index : dest.index + 1
|
|
let targetIndex = insertIndex
|
|
if (item.sourceParent === 'root' && item.sourceIndex < targetIndex) {
|
|
targetIndex -= 1
|
|
}
|
|
return targetIndex !== item.sourceIndex
|
|
}
|
|
if (item.sourceParent === dest.parent) {
|
|
const insertIndex = dest.insertBefore ? dest.index : dest.index + 1
|
|
let targetIndex = insertIndex
|
|
if (item.sourceIndex < targetIndex) {
|
|
targetIndex -= 1
|
|
}
|
|
return targetIndex !== item.sourceIndex
|
|
}
|
|
return true
|
|
}
|
|
|
|
const getRootWrapperClass = (index, key) => {
|
|
const isDragging =
|
|
draggedItem?.sourceParent === 'root' && draggedItem.key === key
|
|
let insertClass = ''
|
|
if (
|
|
dropTarget?.parent === 'root' &&
|
|
dropTarget.index === index &&
|
|
draggedItem &&
|
|
draggedItem.key !== key &&
|
|
wouldMove(draggedItem, dropTarget)
|
|
) {
|
|
insertClass = dropTarget.insertBefore
|
|
? 'overview-insert-before'
|
|
: 'overview-insert-after'
|
|
}
|
|
return `overview-section-wrapper ${isDragging ? 'overview-dragging' : ''} ${insertClass}`
|
|
}
|
|
|
|
const getGroupItemClass = (groupId, index, key) => {
|
|
const isDragging =
|
|
draggedItem?.sourceParent === groupId && draggedItem.key === key
|
|
const isDragOver =
|
|
dropTarget?.parent === groupId &&
|
|
dropTarget.index === index &&
|
|
draggedItem?.kind === 'section' &&
|
|
draggedItem.key !== key
|
|
return `overview-flex-columns-item ${isDragging ? 'overview-dragging' : ''} ${
|
|
isDragOver ? 'overview-drag-over' : ''
|
|
}`
|
|
}
|
|
|
|
const renderCollapse = (section, dragHandle = null) => {
|
|
const isStats = section.content?.type === StatsDisplay
|
|
const statsObjectType = isStats ? section.content.props?.objectType : null
|
|
const isHistory = section.content?.type === ModelHistoryDisplay
|
|
const isTable = section.content?.type === ObjectTable
|
|
const isLazyContent = isStats || isHistory || isTable
|
|
const resizable = isHistory || isTable
|
|
const defaultHeight = isHistory
|
|
? DEFAULT_HISTORY_HEIGHT
|
|
: DEFAULT_TABLE_HEIGHT
|
|
const contentHeight = getSectionHeight(section.key, defaultHeight)
|
|
const extra =
|
|
isLayoutEditing && statsObjectType ? (
|
|
<StatsViewButton objectType={statsObjectType} />
|
|
) : null
|
|
|
|
let content = section.content
|
|
if (resizable && content) {
|
|
content = isHistory
|
|
? cloneElement(content, { height: contentHeight })
|
|
: cloneElement(content, { scrollHeight: `${contentHeight}px` })
|
|
}
|
|
|
|
if (isLazyContent && content) {
|
|
content = (
|
|
<LazyWhenVisible
|
|
scrollRootRef={scrollRef}
|
|
minHeight={
|
|
isHistory || isTable ? contentHeight : 120
|
|
}
|
|
>
|
|
{content}
|
|
</LazyWhenVisible>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<InfoCollapse
|
|
title={section.title}
|
|
icon={section.icon ?? null}
|
|
canCollapse={false}
|
|
active={collapseState[section.key] !== false}
|
|
onToggle={(isActive) => updateCollapseState(section.key, isActive)}
|
|
className={section.className || ''}
|
|
collapseKey={section.key}
|
|
dragHandle={dragHandle}
|
|
extra={extra}
|
|
resizable={resizable}
|
|
contentHeight={contentHeight}
|
|
onContentHeightChange={
|
|
resizable
|
|
? (height, persist) =>
|
|
handleSectionHeightChange(section.key, height, persist)
|
|
: undefined
|
|
}
|
|
>
|
|
{content}
|
|
</InfoCollapse>
|
|
)
|
|
}
|
|
|
|
const actionMenuItems = useMemo(() => {
|
|
const layoutItems = [
|
|
{
|
|
key: 'add-columns',
|
|
label: 'Add columns',
|
|
onClick: addColumns
|
|
}
|
|
]
|
|
const extraItems = actionMenu?.items || []
|
|
if (extraItems.length === 0) {
|
|
return layoutItems
|
|
}
|
|
return [...layoutItems, { type: 'divider' }, ...extraItems]
|
|
}, [actionMenu, addColumns])
|
|
|
|
return (
|
|
<OverviewLayoutContext.Provider value={layoutContextValue}>
|
|
<Flex
|
|
gap='large'
|
|
vertical
|
|
style={{
|
|
maxHeight: '100%',
|
|
minHeight: 0
|
|
}}
|
|
>
|
|
<Flex justify='space-between'>
|
|
<Space size='small'>
|
|
<ActionsButton
|
|
menu={{ items: actionMenuItems }}
|
|
onOpenModal={onOpenActionsModal}
|
|
disabled={false}
|
|
/>
|
|
<ViewButton
|
|
items={resolvedViewItems}
|
|
visibleState={collapseState}
|
|
updateVisibleState={updateCollapseState}
|
|
/>
|
|
</Space>
|
|
<Space>
|
|
<EditButtons
|
|
isEditing={isLayoutEditing}
|
|
handleUpdate={handleUpdate}
|
|
cancelEditing={cancelEditing}
|
|
startEditing={startEditing}
|
|
formValid={true}
|
|
loading={false}
|
|
requirePermission={false}
|
|
/>
|
|
</Space>
|
|
</Flex>
|
|
<ScrollBox scrollableNodeProps={{ ref: scrollRef }}>
|
|
<Flex
|
|
vertical
|
|
gap='large'
|
|
style={isLayoutEditing ? { paddingTop: 13.5 } : undefined}
|
|
onDragOver={handleRootListDragOver}
|
|
onDrop={(e) => handleDrop(e, 'root', 0)}
|
|
>
|
|
{orderedSectionKeys.map((key, index) => {
|
|
if (isGroupKey(key)) {
|
|
const groupItems = flexColumns[key] || []
|
|
const itemsToRender = isLayoutEditing
|
|
? groupItems
|
|
: groupItems.filter((childKey) => isItemVisible(childKey))
|
|
if (itemsToRender.length === 0 && !isLayoutEditing) {
|
|
return null
|
|
}
|
|
const groupProportions = normalizeColumnProportions(
|
|
groupItems.length,
|
|
displayLayout.flexColumnProportions?.[key]
|
|
)
|
|
const renderProportions = itemsToRender.map((childKey) => {
|
|
const itemIndex = groupItems.indexOf(childKey)
|
|
return itemIndex === -1
|
|
? undefined
|
|
: groupProportions[itemIndex]
|
|
})
|
|
|
|
return (
|
|
<div
|
|
key={key}
|
|
data-overview-key={key}
|
|
onDragOver={(e) => handleRootDragOver(e, index, key)}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={(e) => handleDrop(e, 'root', index)}
|
|
className={getRootWrapperClass(index, key)}
|
|
>
|
|
<DashboardOverviewFlexColumns
|
|
isLayoutEditing={isLayoutEditing}
|
|
isDragOver={
|
|
dropTarget?.parent === key &&
|
|
draggedItem?.kind === 'section' &&
|
|
dropTarget.index >= groupItems.length
|
|
}
|
|
dragHandle={
|
|
isLayoutEditing
|
|
? renderDragHandle({
|
|
kind: 'flexGroup',
|
|
key,
|
|
sourceParent: 'root',
|
|
sourceIndex: index
|
|
})
|
|
: null
|
|
}
|
|
onRemove={
|
|
isLayoutEditing ? () => removeGroup(key) : undefined
|
|
}
|
|
onDragOver={(e) => handleGroupContainerDragOver(e, key)}
|
|
onDrop={(e) => handleDrop(e, key, groupItems.length)}
|
|
onDragLeave={handleDragLeave}
|
|
proportions={renderProportions}
|
|
onProportionsChange={
|
|
isLayoutEditing
|
|
? (next) => updateFlexColumnProportions(key, next)
|
|
: undefined
|
|
}
|
|
>
|
|
{itemsToRender.map((childKey, childIndex) => {
|
|
const section = sectionsByKey[childKey]
|
|
if (!section) return null
|
|
return (
|
|
<div
|
|
key={childKey}
|
|
onDragOver={(e) =>
|
|
handleGroupItemDragOver(e, key, childIndex)
|
|
}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={(e) => handleDrop(e, key, childIndex)}
|
|
className={getGroupItemClass(
|
|
key,
|
|
childIndex,
|
|
childKey
|
|
)}
|
|
>
|
|
{renderCollapse(
|
|
section,
|
|
isLayoutEditing
|
|
? renderDragHandle({
|
|
kind: 'section',
|
|
key: childKey,
|
|
sourceParent: key,
|
|
sourceIndex: childIndex
|
|
})
|
|
: null
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</DashboardOverviewFlexColumns>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const section = sectionsByKey[key]
|
|
if (!section) return null
|
|
if (!isItemVisible(section.key)) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={key}
|
|
data-overview-key={key}
|
|
onDragOver={(e) => handleRootDragOver(e, index, key)}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={(e) => handleDrop(e, 'root', index)}
|
|
className={getRootWrapperClass(index, key)}
|
|
>
|
|
{renderCollapse(
|
|
section,
|
|
isLayoutEditing
|
|
? renderDragHandle({
|
|
kind: 'section',
|
|
key,
|
|
sourceParent: 'root',
|
|
sourceIndex: index
|
|
})
|
|
: null
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</Flex>
|
|
</ScrollBox>
|
|
</Flex>
|
|
</OverviewLayoutContext.Provider>
|
|
)
|
|
}
|
|
|
|
const sectionShape = PropTypes.shape({
|
|
key: PropTypes.string.isRequired,
|
|
title: PropTypes.string,
|
|
icon: PropTypes.node,
|
|
className: PropTypes.string,
|
|
content: PropTypes.node,
|
|
type: PropTypes.oneOf(['row']),
|
|
columns: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object))
|
|
})
|
|
|
|
DashboardOverviewPage.propTypes = {
|
|
pageName: PropTypes.string.isRequired,
|
|
collapseDefaults: PropTypes.object,
|
|
viewItems: PropTypes.arrayOf(
|
|
PropTypes.shape({
|
|
key: PropTypes.string.isRequired,
|
|
label: PropTypes.string.isRequired
|
|
})
|
|
),
|
|
actionMenu: PropTypes.object,
|
|
onOpenActionsModal: PropTypes.func,
|
|
sections: PropTypes.arrayOf(sectionShape)
|
|
}
|
|
|
|
export default DashboardOverviewPage
|