-
Notifications
You must be signed in to change notification settings - Fork 228
[UI] Add DashboardLayout and WidgetPicker components #1706
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
8ce6b10
c1fd1f8
7d0b48d
5a65f9d
7acdc32
eaba5c9
6aa7ca3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import React, { useState, useEffect } from 'react'; | ||
| import { Box } from '../../base'; | ||
| import { useTheme, useMediaQuery } from '../../theme'; | ||
| import { SwipeableDrawer } from '@mui/material'; | ||
|
|
||
| export interface DashboardLayoutProps { | ||
| /** The main dashboard content (typically the React-Grid-Layout) */ | ||
| children: React.ReactNode; | ||
|
|
||
| /** Whether the right-hand sidebar should be visible */ | ||
| isSidebarOpen: boolean; | ||
|
|
||
| /** The content to render inside the sidebar (e.g., Widget Gallery) */ | ||
| sidebarContent: React.ReactNode; | ||
|
|
||
| /** Optional custom width for the sidebar. Defaults to responsive width. */ | ||
| sidebarWidth?: string | number | Partial<Record<'xs' | 'sm' | 'md' | 'lg' | 'xl', string | number>>; | ||
|
|
||
| /** Optional sticky top offset for the sidebar (useful if page has a top navbar) */ | ||
| sidebarTopOffset?: string | number; | ||
|
|
||
| /** Optional fixed height for the sticky sidebar. Defaults to 100vh */ | ||
| sidebarHeight?: string | number; | ||
|
|
||
|
|
||
| } | ||
|
|
||
| export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ | ||
| children, | ||
| isSidebarOpen, | ||
| sidebarContent, | ||
| sidebarWidth = { xs: '100%', md: '350px' }, | ||
| sidebarTopOffset = '0', | ||
| sidebarHeight = '100vh', | ||
| }) => { | ||
| const theme = useTheme(); | ||
| // We use the 'md' breakpoint (900px default) to switch between mobile and desktop layout | ||
| const isMobile = useMediaQuery(theme.breakpoints.down('md')); | ||
|
|
||
| const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false); | ||
| const drawerBleeding = 56; | ||
|
|
||
| useEffect(() => { | ||
| if (isSidebarOpen) { | ||
| setIsMobileDrawerOpen(true); | ||
| } else { | ||
| setIsMobileDrawerOpen(false); | ||
| } | ||
| }, [isSidebarOpen]); | ||
|
Comment on lines
+40
to
+49
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From my understanding, the
Could this lead to state inconsistencies or bugs in the future? For example, the drawer could be closed from within
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Katotodan normally you'd be absolutely right about the state desync, but in this specific case, it's actually intentional. In the parent component, isSidebarOpen is tied to isEditMode. On mobile, we need the user to be able to swipe down and minimize the drawer so they can see the dashboard and drag widgets around. If we kept a strict single source of truth and fired onClose when swiping down, |
||
|
|
||
| return ( | ||
| <Box sx={{ display: 'flex', flexDirection: 'row', gap: '1rem', width: '100%' }}> | ||
| <Box sx={{ flex: 1, padding: 0, minWidth: 0 }}> | ||
| {children} | ||
| </Box> | ||
|
|
||
| {isMobile && ( | ||
| <> | ||
| <SwipeableDrawer | ||
| anchor="bottom" | ||
| open={isMobileDrawerOpen} | ||
| onClose={() => { | ||
| setIsMobileDrawerOpen(false); | ||
| }} | ||
| onOpen={() => setIsMobileDrawerOpen(true)} | ||
|
NSTKrishna marked this conversation as resolved.
|
||
| swipeAreaWidth={isSidebarOpen ? drawerBleeding : 0} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe isMobileDrawerOpen should be used instead |
||
| disableSwipeToOpen={false} | ||
| ModalProps={{ | ||
| keepMounted: true, | ||
| }} | ||
| sx={{ | ||
| '& .MuiPaper-root': { | ||
| height: `calc(50% - ${drawerBleeding}px)`, | ||
| overflow: 'visible', | ||
| }, | ||
| '& .MuiDrawer-paper': { | ||
| borderTopLeftRadius: '16px', | ||
| borderTopRightRadius: '16px', | ||
| }, | ||
| }} | ||
| > | ||
| <Box | ||
| sx={{ | ||
| position: 'absolute', | ||
| top: -drawerBleeding, | ||
| borderTopLeftRadius: 16, | ||
| borderTopRightRadius: 16, | ||
| visibility: 'visible', | ||
| right: 0, | ||
| left: 0, | ||
| backgroundColor: theme.palette.background.paper, | ||
| height: drawerBleeding, | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| justifyContent: 'center', | ||
| borderBottom: `1px solid ${theme.palette.divider}`, | ||
| cursor: 'pointer', | ||
| }} | ||
| onClick={() => { | ||
| const nextState = !isMobileDrawerOpen; | ||
| setIsMobileDrawerOpen(nextState); | ||
| }} | ||
| > | ||
| <Box | ||
| sx={{ | ||
| width: 30, | ||
| height: 6, | ||
| backgroundColor: theme.palette.mode === 'light' ? '#e0e0e0' : '#424242', | ||
| borderRadius: 3, | ||
| }} | ||
| /> | ||
| </Box> | ||
| <Box sx={{ px: 2, pb: 2, height: '100%', overflow: 'auto' }}> | ||
| {sidebarContent} | ||
| </Box> | ||
| </SwipeableDrawer> | ||
| </> | ||
| )} | ||
|
|
||
| {isSidebarOpen && !isMobile && ( | ||
| <Box | ||
| sx={{ | ||
| width: sidebarWidth, | ||
| flexShrink: 0, | ||
| position: 'sticky', | ||
| top: sidebarTopOffset, | ||
| alignSelf: 'flex-start', | ||
| height: sidebarHeight, | ||
| maxHeight: sidebarHeight, | ||
| }} | ||
| > | ||
| {sidebarContent} | ||
| </Box> | ||
| )} | ||
| </Box> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| import React from 'react'; | ||
| import { Box, IconButton, Stack, Typography } from '../../base'; | ||
| import { AddIcon, CloseIcon } from '../../icons'; | ||
| import { useTheme } from '../../theme'; | ||
| import { SxProps, Theme } from '@mui/material'; | ||
|
|
||
| export interface WidgetItem { | ||
| key: string; | ||
| title: string; | ||
| thumbnail?: string; | ||
| [key: string]: unknown; // Allow passing extra widget properties | ||
| } | ||
|
|
||
| export interface WidgetPickerProps { | ||
| /** The list of widgets available to add */ | ||
| widgetsToAdd: WidgetItem[]; | ||
|
|
||
| /** Callback when a widget is clicked to be added */ | ||
| onAddWidget: (widget: Omit<WidgetItem, 'key'>, key: string) => void; | ||
|
|
||
| /** Optional callback to close the picker (renders a Close icon if provided) */ | ||
| onClose?: () => void; | ||
|
|
||
| /** Custom background color for the header. Defaults to theme.palette.background.default */ | ||
| headerBackgroundColor?: string; | ||
|
|
||
| /** Custom text color for the header. Defaults to theme.palette.text.primary */ | ||
| headerTextColor?: string; | ||
|
|
||
| /** Custom styles for the outer container (e.g. for custom box shadows or borders) */ | ||
| containerSx?: SxProps<Theme>; | ||
| } | ||
|
|
||
| export const WidgetPicker: React.FC<WidgetPickerProps> = ({ | ||
| widgetsToAdd, | ||
| onAddWidget, | ||
| onClose, | ||
| headerBackgroundColor, | ||
| headerTextColor, | ||
| containerSx = {}, | ||
| }) => { | ||
| const theme = useTheme(); | ||
|
|
||
| return ( | ||
| <Box | ||
| sx={{ | ||
| display: 'flex', | ||
| flexDirection: 'column', | ||
| height: '100%', | ||
| width: '100%', | ||
| backgroundColor: theme.palette.background.paper, | ||
| ...containerSx, | ||
| }} | ||
| > | ||
| <Stack | ||
| direction="row" | ||
| sx={{ | ||
| alignItems: 'center', | ||
| justifyContent: 'space-between', | ||
| px: 2, | ||
| py: 1.5, | ||
| borderBottom: `1px solid ${theme.palette.divider}`, | ||
| background: headerBackgroundColor || theme.palette.background.default, | ||
| flexShrink: 0, | ||
| zIndex: 1, | ||
| }} | ||
| > | ||
| <Typography variant="h5" sx={{ color: headerTextColor || theme.palette.text.primary }}> | ||
| Widgets | ||
| </Typography> | ||
| {onClose && ( | ||
| <IconButton aria-label="Close widget picker" onClick={onClose} size="small"> | ||
| <CloseIcon fill={headerTextColor || theme.palette.text.primary} width="20" /> | ||
| </IconButton> | ||
| )} | ||
| </Stack> | ||
|
|
||
| <Box | ||
| sx={{ | ||
| display: 'flex', | ||
| flexDirection: 'column', | ||
| gap: '0.75rem', | ||
| p: 2, | ||
| overflowY: 'auto', | ||
| flex: 1, | ||
| minHeight: 0, | ||
| scrollbarWidth: 'thin', | ||
| scrollbarColor: `${theme.palette.divider} transparent`, | ||
| '&::-webkit-scrollbar': { | ||
| width: '8px', | ||
| height: '8px', | ||
| }, | ||
| '&::-webkit-scrollbar-track': { | ||
| background: 'transparent', | ||
| }, | ||
| '&::-webkit-scrollbar-thumb': { | ||
| backgroundColor: theme.palette.divider, | ||
| borderRadius: '4px', | ||
| border: '2px solid transparent', | ||
| backgroundClip: 'content-box', | ||
| '&:hover': { | ||
| backgroundColor: theme.palette.text.secondary, | ||
| }, | ||
| }, | ||
| }} | ||
| > | ||
| {widgetsToAdd.length === 0 && ( | ||
| <Typography variant="body2" sx={{ color: theme.palette.text.primary, textAlign: 'center', mt: 2 }}> | ||
| All widgets added to the layout. | ||
| </Typography> | ||
| )} | ||
|
|
||
| {widgetsToAdd.map(({ key, ...widget }) => ( | ||
| <Box | ||
| key={key} | ||
| sx={{ | ||
| width: '100%', | ||
| minWidth: '16rem', | ||
| p: 1.5, | ||
| height: '18rem', | ||
| flexShrink: 0, | ||
| backgroundColor: theme.palette.background.secondary, | ||
| boxShadow: theme.shadows[1], | ||
| borderRadius: 1, | ||
| }} | ||
| > | ||
| <Stack | ||
| direction="row" | ||
| sx={{ alignItems: "center", justifyContent: "space-between" }} | ||
| > | ||
| <Typography variant="button">{widget.title}</Typography> | ||
| <IconButton | ||
| aria-label={`Add ${widget.title} widget`} | ||
| onClick={() => onAddWidget(widget, key)} | ||
| > | ||
| <AddIcon fill={theme.palette.text.primary} width="30" /> | ||
| </IconButton> | ||
| </Stack> | ||
| {widget.thumbnail && ( | ||
| <img | ||
| src={widget.thumbnail} | ||
| alt={widget.title} | ||
| style={{ | ||
| width: '100%', | ||
| height: 'auto', | ||
| maxHeight: '180px', | ||
| objectFit: 'contain', | ||
| marginTop: '0.5rem', | ||
| }} | ||
| /> | ||
| )} | ||
| </Box> | ||
| ))} | ||
| </Box> | ||
| </Box> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from './WidgetPicker'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Export WidgetPicker as a named export instead |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,9 @@ | ||
| // Export all custom components | ||
| export * from './CustomTooltip'; | ||
| export * from './DashboardLayout'; | ||
| export * from './HelperTextPopover'; | ||
| export * from './Markdown'; | ||
| export * from './Modal'; | ||
| export * from './RJSFFormWrapper'; | ||
| export * from './StyledAccordion'; | ||
| export * from './WidgetPicker'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Abstract DashboardLayout into it's own file and only use the index to import it and export it as a named export instead