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) => ( e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onDragStart={(e) => handleDragStart(e, item)} onDragEnd={handleDragEnd} > ) 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 ? ( ) : null let content = section.content if (resizable && content) { content = isHistory ? cloneElement(content, { height: contentHeight }) : cloneElement(content, { scrollHeight: `${contentHeight}px` }) } if (isLazyContent && content) { content = ( {content} ) } return ( 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} ) } 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 ( 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 (
handleRootDragOver(e, index, key)} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, 'root', index)} className={getRootWrapperClass(index, key)} > = 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 (
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 )}
) })}
) } const section = sectionsByKey[key] if (!section) return null if (!isItemVisible(section.key)) { return null } return (
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 )}
) })}
) } 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