Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions src/custom/DashboardLayout/DashboardLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import React, { useState, useEffect, useRef } from 'react';
import { Box, Fab } from '../../base';
import { AddIcon } from '../../icons/Add';
import { useTheme, useMediaQuery } from '../../theme';
import { BottomSheet } from '../BottomSheet';

export interface DashboardLayoutProps {
/** The main dashboard content (typically the React-Grid-Layout) */
children: React.ReactNode;

/** Whether Edit Mode is active (controls sidebar visibility). When this
* transitions from false → true the mobile sheet auto-opens. */
isSidebarOpen: boolean;

/** The content to render inside the sidebar (e.g., Widget Gallery) */
sidebarContent: React.ReactNode;

/** Accessible title for the mobile bottom sheet (used as aria-labelledby on the Dialog).
* Defaults to 'Widget Picker'. */
sidebarTitle?: string;

/** 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,
sidebarTitle = 'Widget Picker',
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'));

// isSheetVisible is independently owned by DashboardLayout:
// - resets to true whenever Edit Mode (isSidebarOpen) transitions OFF → ON
// - can be set to false by the user dismissing the sheet (FAB appears instead)
// - set to false when Edit Mode turns OFF
// This two-dimension model prevents the sheet from re-opening on every
// isSidebarOpen change after the user has intentionally minimized it.
const [isSheetVisible, setIsSheetVisible] = useState(isSidebarOpen);
const prevIsSidebarOpen = useRef(isSidebarOpen);

useEffect(() => {
if (isSidebarOpen && !prevIsSidebarOpen.current) {
// Edit Mode just turned ON → pop the sheet open
setIsSheetVisible(true);
}
if (!isSidebarOpen) {
// Edit Mode turned OFF → close the sheet and hide the FAB
setIsSheetVisible(false);
}
prevIsSidebarOpen.current = isSidebarOpen;
}, [isSidebarOpen]);

return (
<Box sx={{ display: 'flex', flexDirection: 'row', gap: '1rem', width: '100%' }}>
<Box sx={{ flex: 1, padding: 0, minWidth: 0 }}>
{children}
</Box>

{isSidebarOpen && isMobile && (
<>
<BottomSheet
open={isSheetVisible}
onClose={() => setIsSheetVisible(false)}
title={sidebarTitle}
maxHeight="50vh"
>
{sidebarContent}
</BottomSheet>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{/* FAB appears when Edit Mode is active but the sheet has been minimized,
letting users rearrange the dashboard and pull the picker back up. */}
{!isSheetVisible && (
<Fab
color="primary"
aria-label="Open Widget Picker"
onClick={() => setIsSheetVisible(true)}
sx={(fabTheme) => ({
position: 'fixed',
bottom: 24,
right: 24,
zIndex: fabTheme.zIndex.drawer,
})}
>
<AddIcon fill={theme.palette.primary.contrastText} />
</Fab>
)}
</>
)}

{isSidebarOpen && !isMobile && (
<Box
sx={{
width: sidebarWidth,
flexShrink: 0,
position: 'sticky',
top: sidebarTopOffset,
alignSelf: 'flex-start',
height: sidebarHeight,
maxHeight: sidebarHeight,
}}
>
{sidebarContent}
</Box>
)}
</Box>
);
};
2 changes: 2 additions & 0 deletions src/custom/DashboardLayout/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { DashboardLayout } from './DashboardLayout';
export type { DashboardLayoutProps } from './DashboardLayout';
43 changes: 24 additions & 19 deletions src/custom/DashboardWidgets/PlainCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Box, Card, CardContent, Link, Typography } from '../../base';
import { OpenInNewIcon } from '../../icons';
import { styled } from '../../theme';
import { WidgetEmptyState } from '../WidgetEmptyState';

const StyledCard = styled(Card)(({ theme }) => ({
height: '100%',
Expand Down Expand Up @@ -72,27 +73,31 @@ export const PlainCard = ({ title, icon, resources }: PlainCardProps): JSX.Eleme
</StyledTitleBox>

<StyledContentBox>
<StyledResourceList>
{resources.map((item) => (
<ResourceListItem key={item.link}>
<Box sx={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>{item.icon}</Box>
{resources.length === 0 ? (
<WidgetEmptyState message="No resources available" />
) : (
<StyledResourceList>
{resources.map((item) => (
<ResourceListItem key={item.link}>
<Box sx={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>{item.icon}</Box>

<StyledResourceLink
href={item.link}
target={item.external ? '_blank' : '_self'}
rel={item.external ? 'noopener noreferrer' : ''}
>
{item.name}
</StyledResourceLink>
<StyledResourceLink
href={item.link}
target={item.external ? '_blank' : '_self'}
rel={item.external ? 'noopener noreferrer' : ''}
>
{item.name}
</StyledResourceLink>

{item.external && (
<sup>
<OpenInNewIcon width="12px" height="12px" fill="currentColor" />
</sup>
)}
</ResourceListItem>
))}
</StyledResourceList>
{item.external && (
<sup>
<OpenInNewIcon width="12px" height="12px" fill="currentColor" />
</sup>
)}
</ResourceListItem>
))}
</StyledResourceList>
)}
</StyledContentBox>
</CardContent>
</StyledCard>
Expand Down
20 changes: 18 additions & 2 deletions src/custom/ResponsiveDataTable.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import MUIDataTable, { MUIDataTableColumn } from '@sistent/mui-datatables';
import MUIDataTable, { MUIDataTableColumn, MUIDataTableOptions } from '@sistent/mui-datatables';
import React, { useCallback } from 'react';
import { Checkbox, Collapse, ListItemIcon, ListItemText, Menu, MenuItem } from '../base';
import { ShareIcon } from '../icons';
Expand All @@ -8,6 +8,7 @@ import { styled, useTheme } from './../theme';
import { ColView } from './Helpers/ResponsiveColumns/responsive-coulmns.tsx';
import { TableAction } from './TableActions';
import { TooltipIcon } from './TooltipIconButton';
import { WidgetEmptyState } from './WidgetEmptyState';

export const IconWrapper = styled('div', {
shouldForwardProp: (prop) => prop !== 'disabled'
Expand Down Expand Up @@ -141,7 +142,7 @@ export interface Column {
export interface ResponsiveDataTableProps {
data: string[][];
columns: MUIDataTableColumn[];
options?: object;
options?: MUIDataTableOptions;
tableCols?: MUIDataTableColumn[];
updateCols?: ((columns: MUIDataTableColumn[]) => void) | undefined;
columnVisibility: Record<string, boolean> | undefined;
Expand All @@ -158,8 +159,23 @@ const ResponsiveDataTable = ({
rowsPerPageOptions = [10, 25, 50, 100],
...props
}: ResponsiveDataTableProps): JSX.Element => {
const textLabels = options?.textLabels || {};
const bodyTextLabels = textLabels.body || {};

const noMatchMessage =
typeof bodyTextLabels.noMatch === 'string'
? bodyTextLabels.noMatch
: 'No data available';

const updatedOptions = {
...options,
textLabels: {
...textLabels,
body: {
...bodyTextLabels,
noMatch: <WidgetEmptyState message={noMatchMessage} />
}
},
print: false,
download: false,
search: false,
Expand Down
87 changes: 87 additions & 0 deletions src/custom/WidgetEmptyState/WidgetEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React from 'react';
import { Box, Typography, Button, Stack } from '../../base';
import { useTheme } from '../../theme';

export interface WidgetEmptyStateProps {
/** The message to display when no data is available */
message?: string;

/** Optional icon to display above the message */
icon?: React.ReactNode;

/** Optional action button configuration */
action?: {
label: string;
onClick: () => void;
};
}

export const WidgetEmptyState: React.FC<WidgetEmptyStateProps> = ({
message = 'No data available',
icon,
action,
}) => {
const theme = useTheme();

return (
// Outer container is a plain presentational box — role="status" is scoped
// only to the message Typography below so interactive descendants (icon, Button)
// are not degraded by the live-region semantics.
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%',
minHeight: '120px',
p: 3,
}}
>
<Stack spacing={1.5} sx={{ alignItems: 'center' }}>
{icon && (
<Box
sx={{
color: theme.palette.text.secondary,
opacity: 0.6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& svg': {
width: 48,
height: 48,
},
}}
>
{icon}
</Box>
)}
{/* role="status" + aria-live scoped only to the message text, not the
interactive siblings — per ARIA spec, live regions must not contain
interactive elements or AT may hide/degrade their semantics. */}
<Typography
role="status"
aria-live="polite"
variant="body2"
sx={{
color: theme.palette.text.secondary,
textAlign: 'center',
maxWidth: '280px',
}}
>
{message}
</Typography>
{action && (
<Button
variant="outlined"
size="small"
onClick={action.onClick}
sx={{ mt: 0.5 }}
>
{action.label}
</Button>
)}
</Stack>
</Box>
);
};
2 changes: 2 additions & 0 deletions src/custom/WidgetEmptyState/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { WidgetEmptyState } from './WidgetEmptyState';
export type { WidgetEmptyStateProps } from './WidgetEmptyState';
Loading
Loading