Compare commits

...

3 Commits

Author SHA1 Message Date
5e46de8b60 Refactor Dashboard Layout and Sidebar Components for Improved Responsiveness
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new DashboardSidebarSplitter component to manage sidebar resizing and collapsing behavior, enhancing user experience on different screen sizes.
- Updated DashboardSidebar to support dynamic collapsed width and improved styling for better visual consistency.
- Refactored DashboardLayout to utilize the new splitter, streamlining the layout structure and improving maintainability.
- Enhanced CSS styles for sidebar components to ensure consistent behavior and appearance across various states.
2026-08-20 21:36:02 +01:00
4ea6d1e7a3 Enhance DashboardSidebar with CollapsedItemIcon and Tooltip integration
- Introduced a new CollapsedItemIcon component to improve the display of sidebar items when collapsed, providing additional context through tooltips.
- Updated the DashboardSidebar to utilize the CollapsedItemIcon for better user experience and visual clarity when navigating collapsed sidebar items.
- Enhanced code readability by organizing the new component and its prop types within the DashboardSidebar file.
2026-08-20 21:05:45 +01:00
d52a8eb570 Refactor ElipsisText component to integrate Tooltip for truncated text
- Updated the ElipsisText component to conditionally wrap truncated text in a Tooltip, enhancing user experience by providing additional context on hover.
- Simplified the rendering logic by consolidating the content structure, improving code readability and maintainability.
2026-08-20 21:03:32 +01:00
5 changed files with 344 additions and 47 deletions

View File

@ -253,8 +253,8 @@ html.macos-vibrancy .dark-mode .ant-layout.main-content-layout .ant-card {
}
.electron-sider.ant-layout-sider-collapsed {
max-width: 55px !important;
min-width: 55px !important;
min-width: var(--dashboard-sidebar-collapsed-width, 55px) !important;
max-width: var(--dashboard-sidebar-collapsed-width, 55px) !important;
}
.loading-modal .ant-modal-footer {
@ -362,8 +362,10 @@ code {
}
.cursor-tooltip .ant-card {
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
}
/* --- Start of src/index.css --- */
@ -412,9 +414,21 @@ body {
/* --- Start of src/components/Dashboard/common/DashboardSidebar.css --- */
.ant-layout-sider {
width: 250px; /* Adjust the width as per your preference */
min-width: 250px; /* Ensure a minimum width to avoid collapsing */
max-width: 400px; /* Optionally set a maximum width */
height: 100%;
}
.dashboard-sider.ant-layout-sider {
width: 100% !important;
min-width: 0 !important;
max-width: 100% !important;
flex: 0 0 100% !important;
}
.dashboard-sider.ant-layout-sider-collapsed {
width: var(--dashboard-sidebar-collapsed-width, 80px) !important;
min-width: var(--dashboard-sidebar-collapsed-width, 80px) !important;
max-width: var(--dashboard-sidebar-collapsed-width, 80px) !important;
flex: 0 0 var(--dashboard-sidebar-collapsed-width, 80px) !important;
}
/* --- End of src/components/Dashboard/common/DashboardSidebar.css --- */
@ -995,6 +1009,11 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
min-width: 0;
}
.farmcontrol-splitter.ant-splitter-horizontal.is-size-transitioning
.ant-splitter-panel {
transition: flex-basis 0.15s ease-in-out;
}
.farmcontrol-splitter
.ant-splitter-panel:has(~ .ant-splitter-panel)
.info-collapse.ant-collapse.ant-collapse-icon-position-end
@ -1355,7 +1374,8 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
1,
abs(
calc(
var(--permissions-col, 0) - var(--permissions-hovered-col, -1)
var(--permissions-col, 0) -
var(--permissions-hovered-col, -1)
)
)
)
@ -1512,3 +1532,19 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
stroke: currentColor;
stroke-width: 8px;
}
.dashboard-splitter.farmcontrol-splitter > .ant-splitter-bar {
margin: 0;
}
.dashboard-splitter.farmcontrol-splitter
> .ant-splitter-bar
.ant-splitter-bar-dragger::before {
background: transparent;
}
.dashboard-splitter.farmcontrol-splitter
> .ant-splitter-bar
.ant-splitter-bar-dragger::after {
content: none;
}

View File

@ -10,6 +10,7 @@ import ManagementSidebar from './Management/ManagementSidebar'
import DashboardNavigation from './common/DashboardNavigation'
import DashboardBreadcrumb from './common/DashboardBreadcrumb'
import DeveloperSidebar from './Developer/DeveloperSidebar'
import DashboardSidebarSplitter from './common/DashboardSidebarSplitter'
import { useThemeContext } from './context/ThemeContext'
import { MessageProvider } from './context/MessageContext'
@ -26,6 +27,22 @@ const DashboardLayout = ({ children }) => {
const { isDarkMode } = useThemeContext()
const sidebar = isProduction ? (
<ProductionSidebar />
) : isInventory ? (
<InventorySidebar />
) : isFinance ? (
<FinanceSidebar />
) : isSales ? (
<SalesSidebar />
) : isManagement ? (
<ManagementSidebar />
) : isDeveloper ? (
<DeveloperSidebar />
) : (
<ProductionSidebar />
)
return (
<MessageProvider>
<Layout
@ -33,34 +50,18 @@ const DashboardLayout = ({ children }) => {
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout`}
>
<DashboardNavigation />
<Layout>
{isProduction ? (
<ProductionSidebar />
) : isInventory ? (
<InventorySidebar />
) : isFinance ? (
<FinanceSidebar />
) : isSales ? (
<SalesSidebar />
) : isManagement ? (
<ManagementSidebar />
) : isDeveloper ? (
<DeveloperSidebar />
) : (
<ProductionSidebar /> // Default to production sidebar
)}
<DashboardSidebarSplitter sidebar={sidebar}>
<Layout style={{ padding: '24px' }} className='main-content-layout'>
<Content>
<Flex vertical style={{ height: '100%' }} gap='20px'>
<Flex justify='space-between'>
<DashboardBreadcrumb style={{ margin: '16px 0' }} />
</Flex>
{children}
</Flex>
</Content>
</Layout>
</Layout>
</DashboardSidebarSplitter>
</Layout>
</MessageProvider>
)

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useContext } from 'react'
import { cloneElement, isValidElement, useState, useEffect, useContext } from 'react'
import { Layout, Menu, Flex, Button } from 'antd'
import { CaretDownFilled } from '@ant-design/icons'
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
@ -12,13 +12,35 @@ import { getSidebarIconNode } from '../../Icons/sidebarIconMap'
import SimpleBar from 'simplebar-react'
import { useThemeContext } from '../context/ThemeContext'
import { filterSidebarItemsByListPermission } from '../../../database/Sidebars'
import Tooltip from './Tooltip'
const { Sider } = Layout
const CollapsedItemIcon = ({ className, icon, title }) => {
const iconNode = isValidElement(icon)
? cloneElement(icon, {
className: [icon.props.className, className].filter(Boolean).join(' ')
})
: icon
return (
<Tooltip title={title}>
{iconNode ?? <span className={className} />}
</Tooltip>
)
}
CollapsedItemIcon.propTypes = {
className: PropTypes.string,
icon: PropTypes.node,
title: PropTypes.node
}
const DashboardSidebar = ({
items = [],
selectedKey = '',
onCollapse,
collapsed: collapsedProp,
collapsedWidth: collapsedWidthProp,
key = 'DashboardSidebar_collapseState'
}) => {
const [collapsed, setCollapsed] = useState(() => {
@ -30,6 +52,7 @@ const DashboardSidebar = ({
const navigate = useNavigate()
const { isDarkMode } = useThemeContext()
const { isElectron } = useContext(ElectronContext)
const collapsedWidth = collapsedWidthProp ?? (isElectron ? 55 : 80)
const { userProfile } = useContext(AuthContext)
const allowedItems = filterSidebarItemsByListPermission(items, userProfile)
@ -52,9 +75,15 @@ const DashboardSidebar = ({
return item
}
const icon = item.icon || getSidebarIconNode(item.iconKey)
const mappedItem = {
key: item.key,
icon: item.icon || getSidebarIconNode(item.iconKey),
icon:
collapsed && !isMobile ? (
<CollapsedItemIcon title={item.label} icon={icon} />
) : (
icon
),
label: item.label
}
@ -90,10 +119,14 @@ const DashboardSidebar = ({
return (
<Sider
width={250}
width='100%'
collapsedWidth={collapsedWidth}
theme='light'
collapsed={collapsed}
className={isElectron ? 'electron-sider' : ''}
className={`dashboard-sider${isElectron ? ' electron-sider' : ''}`}
style={{
'--dashboard-sidebar-collapsed-width': `${collapsedWidth}px`
}}
>
<Flex
style={{ height: '100%', minHeight: 0 }}
@ -132,6 +165,7 @@ DashboardSidebar.propTypes = {
selectedKey: PropTypes.string,
onCollapse: PropTypes.func,
collapsed: PropTypes.bool,
collapsedWidth: PropTypes.number,
key: PropTypes.string
}

View File

@ -0,0 +1,222 @@
import {
cloneElement,
isValidElement,
useCallback,
useContext,
useEffect,
useRef,
useState
} from 'react'
import PropTypes from 'prop-types'
import { Layout, Splitter } from 'antd'
import { useMediaQuery } from 'react-responsive'
import { ElectronContext } from '../context/ElectronContext'
const COLLAPSE_BELOW_PX = 150
const DEFAULT_SIDEBAR_PX = 300
const MAX_SIDEBAR_PX = 400
const COLLAPSED_WIDTH_PX = 80
const ELECTRON_COLLAPSED_WIDTH_PX = 55
const COLLAPSE_STORAGE_KEY = 'DashboardSidebar_collapseState'
const SIZE_STORAGE_KEY = 'DashboardSidebar_width'
const readStoredBoolean = (key, fallback) => {
try {
const saved = sessionStorage.getItem(key)
return saved ? JSON.parse(saved) : fallback
} catch {
return fallback
}
}
const readStoredNumber = (key, fallback) => {
try {
const saved = sessionStorage.getItem(key)
const value = saved ? JSON.parse(saved) : fallback
return typeof value === 'number' && !Number.isNaN(value) ? value : fallback
} catch {
return fallback
}
}
const clampExpandedSize = (size) =>
Math.min(MAX_SIDEBAR_PX, Math.max(size, COLLAPSE_BELOW_PX))
const DashboardSidebarSplitter = ({ sidebar, children }) => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const { isElectron } = useContext(ElectronContext)
const collapsedWidth = isElectron
? ELECTRON_COLLAPSED_WIDTH_PX
: COLLAPSED_WIDTH_PX
const [collapsed, setCollapsed] = useState(() =>
readStoredBoolean(COLLAPSE_STORAGE_KEY, false)
)
const [sidebarSize, setSidebarSize] = useState(() => {
if (readStoredBoolean(COLLAPSE_STORAGE_KEY, false)) {
return isElectron ? ELECTRON_COLLAPSED_WIDTH_PX : COLLAPSED_WIDTH_PX
}
return clampExpandedSize(
readStoredNumber(SIZE_STORAGE_KEY, DEFAULT_SIDEBAR_PX)
)
})
const collapsedRef = useRef(collapsed)
collapsedRef.current = collapsed
const lastExpandedSizeRef = useRef(
clampExpandedSize(readStoredNumber(SIZE_STORAGE_KEY, DEFAULT_SIDEBAR_PX))
)
const [sizeTransitioning, setSizeTransitioning] = useState(false)
const sizeTransitionTimeoutRef = useRef(null)
const startSizeTransition = useCallback(() => {
setSizeTransitioning(true)
if (sizeTransitionTimeoutRef.current) {
clearTimeout(sizeTransitionTimeoutRef.current)
}
sizeTransitionTimeoutRef.current = setTimeout(() => {
setSizeTransitioning(false)
sizeTransitionTimeoutRef.current = null
}, 150)
}, [])
useEffect(
() => () => {
if (sizeTransitionTimeoutRef.current) {
clearTimeout(sizeTransitionTimeoutRef.current)
}
},
[]
)
const persistCollapsed = (nextCollapsed) => {
sessionStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(nextCollapsed))
}
const persistSize = (size) => {
sessionStorage.setItem(SIZE_STORAGE_KEY, JSON.stringify(size))
}
const collapseSidebar = useCallback(
(expandedSize) => {
if (expandedSize >= COLLAPSE_BELOW_PX) {
lastExpandedSizeRef.current = expandedSize
persistSize(expandedSize)
}
collapsedRef.current = true
setCollapsed(true)
persistCollapsed(true)
startSizeTransition()
setSidebarSize(collapsedWidth)
},
[collapsedWidth, startSizeTransition]
)
const expandSidebar = useCallback(() => {
collapsedRef.current = false
setCollapsed(false)
persistCollapsed(false)
startSizeTransition()
setSidebarSize(DEFAULT_SIDEBAR_PX)
persistSize(DEFAULT_SIDEBAR_PX)
lastExpandedSizeRef.current = DEFAULT_SIDEBAR_PX
}, [startSizeTransition])
const handleSidebarCollapse = useCallback(
(nextCollapsed) => {
if (nextCollapsed) {
collapseSidebar(sidebarSize)
} else {
expandSidebar()
}
},
[collapseSidebar, expandSidebar, sidebarSize]
)
const handleResize = useCallback(
(sizes) => {
if (collapsedRef.current) {
setSidebarSize(collapsedWidth)
return
}
const size = sizes[0]
if (size < COLLAPSE_BELOW_PX) {
collapseSidebar(lastExpandedSizeRef.current)
return
}
setSidebarSize(size)
lastExpandedSizeRef.current = clampExpandedSize(size)
},
[collapseSidebar, collapsedWidth]
)
const handleResizeEnd = useCallback(
(sizes) => {
if (collapsedRef.current) {
setSidebarSize(collapsedWidth)
return
}
const next = clampExpandedSize(sizes[0])
lastExpandedSizeRef.current = next
setSidebarSize(next)
persistSize(next)
},
[collapsedWidth]
)
const sidebarNode = isValidElement(sidebar)
? cloneElement(sidebar, {
collapsed,
collapsedWidth,
onCollapse: handleSidebarCollapse
})
: sidebar
if (isMobile) {
return (
<Layout>
{sidebarNode}
{children}
</Layout>
)
}
return (
<Layout style={{ height: '100%', minHeight: 0 }}>
<Splitter
className={`farmcontrol-splitter dashboard-splitter${
sizeTransitioning ? ' is-size-transitioning' : ''
}`}
style={{ height: '100%' }}
onResize={handleResize}
onResizeEnd={handleResizeEnd}
>
<Splitter.Panel
size={sidebarSize}
min={collapsedWidth}
max={collapsed ? collapsedWidth : MAX_SIDEBAR_PX}
resizable={!collapsed}
style={{
overflow: 'hidden',
minWidth: collapsedWidth,
maxWidth:
collapsed && !sizeTransitioning ? collapsedWidth : undefined
}}
>
{sidebarNode}
</Splitter.Panel>
<Splitter.Panel min={320}>{children}</Splitter.Panel>
</Splitter>
</Layout>
)
}
DashboardSidebarSplitter.propTypes = {
sidebar: PropTypes.node.isRequired,
children: PropTypes.node.isRequired
}
export default DashboardSidebarSplitter

View File

@ -1,6 +1,7 @@
import { useLayoutEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types'
import { Typography } from 'antd'
import Tooltip from './Tooltip'
const { Text } = Typography
@ -70,29 +71,32 @@ const ElipsisText = ({ children, style, className, title, code, ...rest }) => {
const showTruncated = truncated && Boolean(end)
const RootTag = code ? 'code' : 'span'
const content = (
<span
className={showTruncated ? 'elipsis-text is-truncated' : 'elipsis-text'}
ref={containerRef}
>
<RootTag className='elipsis-text-measure' ref={measureRef}>
{text}
</RootTag>
<RootTag className='elipsis-text-full'>{text}</RootTag>
<RootTag className='elipsis-text-truncated'>
<span className='elipsis-text-start'>{start}</span>
<span className='elipsis-text-dots'>...</span>
<span className='elipsis-text-end'>
<span className='elipsis-text-end-inner'>{end}</span>
</span>
</RootTag>
</span>
)
return (
<Text
{...rest}
className={['elipsis-text-wrapper', className].filter(Boolean).join(' ')}
title={title ?? text}
style={style}
>
<span
className={showTruncated ? 'elipsis-text is-truncated' : 'elipsis-text'}
ref={containerRef}
>
<RootTag className='elipsis-text-measure' ref={measureRef}>
{text}
</RootTag>
<RootTag className='elipsis-text-full'>{text}</RootTag>
<RootTag className='elipsis-text-truncated'>
<span className='elipsis-text-start'>{start}</span>
<span className='elipsis-text-dots'>...</span>
<span className='elipsis-text-end'>
<span className='elipsis-text-end-inner'>{end}</span>
</span>
</RootTag>
</span>
{truncated ? <Tooltip title={title ?? text}>{content}</Tooltip> : content}
</Text>
)
}