Skip to content

Commit be4d9d7

Browse files
authored
SIMSBIOHUB-879: Improve UI Consistency for Shared Components (#336)
* buttons * tabs * inputs * chip * dialog * data grid * page header * linter * code smells * remove console logs * cleanup * fix test
1 parent 2fd76f0 commit be4d9d7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1453
-824
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import AddIcon from '@mui/icons-material/Add';
2+
import { render } from 'test-helpers/test-utils';
3+
import { DangerButton } from './DangerButton';
4+
5+
describe('DangerButton', () => {
6+
it('applies danger defaults', () => {
7+
const { getByRole } = render(<DangerButton>Delete</DangerButton>);
8+
9+
const button = getByRole('button', { name: 'Delete' });
10+
11+
expect(button.className).toContain('MuiButton-contained');
12+
expect(button.className).toContain('MuiButton-containedError');
13+
expect(button.className).toContain('MuiButton-sizeMedium');
14+
});
15+
16+
it('allows overriding defaults', () => {
17+
const { getByRole } = render(
18+
<DangerButton color="primary" size="small">
19+
Delete
20+
</DangerButton>
21+
);
22+
23+
const button = getByRole('button', { name: 'Delete' });
24+
25+
expect(button.className).toContain('MuiButton-containedPrimary');
26+
expect(button.className).toContain('MuiButton-sizeSmall');
27+
});
28+
29+
it('renders end icon', () => {
30+
const { getByTestId } = render(<DangerButton endIcon={<AddIcon data-testid="end-icon" />}>Delete</DangerButton>);
31+
32+
expect(getByTestId('end-icon')).toBeVisible();
33+
});
34+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Button, { ButtonProps } from '@mui/material/Button';
2+
3+
/**
4+
* Semantic destructive action button.
5+
*
6+
* @param {ButtonProps} props
7+
* @return {*}
8+
*/
9+
export const DangerButton = (props: ButtonProps) => {
10+
return <Button variant="contained" color="error" size="medium" {...props} />;
11+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import AddIcon from '@mui/icons-material/Add';
2+
import { render } from 'test-helpers/test-utils';
3+
import { PrimaryButton } from './PrimaryButton';
4+
5+
describe('PrimaryButton', () => {
6+
it('applies primary defaults', () => {
7+
const { getByRole } = render(<PrimaryButton>Create</PrimaryButton>);
8+
9+
const button = getByRole('button', { name: 'Create' });
10+
11+
expect(button.className).toContain('MuiButton-contained');
12+
expect(button.className).toContain('MuiButton-containedPrimary');
13+
expect(button.className).toContain('MuiButton-sizeMedium');
14+
});
15+
16+
it('allows overriding defaults', () => {
17+
const { getByRole } = render(
18+
<PrimaryButton variant="text" size="small">
19+
Create
20+
</PrimaryButton>
21+
);
22+
23+
const button = getByRole('button', { name: 'Create' });
24+
25+
expect(button.className).toContain('MuiButton-text');
26+
expect(button.className).toContain('MuiButton-sizeSmall');
27+
});
28+
29+
it('renders start and end icons', () => {
30+
const { getByTestId } = render(
31+
<PrimaryButton startIcon={<AddIcon data-testid="start-icon" />} endIcon={<AddIcon data-testid="end-icon" />}>
32+
Create
33+
</PrimaryButton>
34+
);
35+
36+
expect(getByTestId('start-icon')).toBeVisible();
37+
expect(getByTestId('end-icon')).toBeVisible();
38+
});
39+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Button, { ButtonProps } from '@mui/material/Button';
2+
3+
/**
4+
* Semantic primary call-to-action button.
5+
*
6+
* @param {ButtonProps} props
7+
* @return {*}
8+
*/
9+
export const PrimaryButton = (props: ButtonProps) => {
10+
return <Button variant="contained" color="primary" size="medium" {...props} />;
11+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import AddIcon from '@mui/icons-material/Add';
2+
import { render } from 'test-helpers/test-utils';
3+
import { SecondaryButton } from './SecondaryButton';
4+
5+
describe('SecondaryButton', () => {
6+
it('applies secondary defaults', () => {
7+
const { getByRole } = render(<SecondaryButton>View</SecondaryButton>);
8+
9+
const button = getByRole('button', { name: 'View' });
10+
11+
expect(button.className).toContain('MuiButton-outlined');
12+
expect(button.className).toContain('MuiButton-outlinedPrimary');
13+
expect(button.className).toContain('MuiButton-sizeMedium');
14+
});
15+
16+
it('allows overriding defaults', () => {
17+
const { getByRole } = render(
18+
<SecondaryButton variant="contained" size="small">
19+
View
20+
</SecondaryButton>
21+
);
22+
23+
const button = getByRole('button', { name: 'View' });
24+
25+
expect(button.className).toContain('MuiButton-contained');
26+
expect(button.className).toContain('MuiButton-sizeSmall');
27+
});
28+
29+
it('renders start icon', () => {
30+
const { getByTestId } = render(
31+
<SecondaryButton startIcon={<AddIcon data-testid="start-icon" />}>View</SecondaryButton>
32+
);
33+
34+
expect(getByTestId('start-icon')).toBeVisible();
35+
});
36+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Button, { ButtonProps } from '@mui/material/Button';
2+
3+
/**
4+
* Semantic secondary action button.
5+
*
6+
* @param {ButtonProps} props
7+
* @return {*}
8+
*/
9+
export const SecondaryButton = (props: ButtonProps) => {
10+
return <Button variant="outlined" color="primary" size="medium" {...props} />;
11+
};
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { grey } from '@mui/material/colors';
2+
import { alpha } from '@mui/material/styles';
3+
import { DataGrid, type DataGridProps, type GridValidRowModel } from '@mui/x-data-grid';
4+
import { SkeletonTable } from 'components/loading/SkeletonLoaders';
5+
import React, { useCallback } from 'react';
6+
import StyledDataGridOverlay from './StyledDataGridOverlay';
7+
8+
export type ICustomDataGridProps<R extends GridValidRowModel = GridValidRowModel> = DataGridProps<R> & {
9+
noRowsMessage?: string;
10+
noRowsOverlay?: React.ReactElement;
11+
};
12+
13+
/**
14+
* Standardized DataGrid wrapper that applies shared table styles while preserving full MUI DataGrid API support.
15+
*/
16+
const CustomDataGrid = <R extends GridValidRowModel = GridValidRowModel>(props: ICustomDataGridProps<R>) => {
17+
const { sx, noRowsMessage, noRowsOverlay, slots, ...rest } = props;
18+
19+
const loadingOverlay = () => <SkeletonTable />;
20+
21+
const defaultNoRowsOverlay = useCallback(
22+
() => noRowsOverlay ?? <StyledDataGridOverlay message={noRowsMessage} />,
23+
[noRowsMessage, noRowsOverlay]
24+
);
25+
26+
return (
27+
<DataGrid
28+
{...rest}
29+
disableColumnMenu={true}
30+
disableColumnResize={true}
31+
slots={{
32+
loadingOverlay,
33+
noRowsOverlay: defaultNoRowsOverlay,
34+
...slots
35+
}}
36+
sx={[
37+
(theme) => {
38+
return {
39+
height: '100%',
40+
bgcolor: theme.palette.background.paper,
41+
display: 'flex',
42+
flexDirection: 'column',
43+
borderRadius: 2,
44+
border: 'none !important',
45+
46+
'& *:focus-within': {
47+
outline: 'none !important'
48+
},
49+
50+
'& .MuiDataGrid-columnHeaders': {
51+
bgcolor: theme.palette.background.paper,
52+
minHeight: 56,
53+
height: 56,
54+
maxHeight: 56
55+
},
56+
'& .MuiDataGrid-columnHeader': {
57+
bgcolor: theme.palette.background.paper,
58+
borderBottom: `1px solid ${theme.palette.divider} !important`,
59+
minHeight: 56,
60+
height: 56,
61+
maxHeight: 56,
62+
px: 2,
63+
alignItems: 'center'
64+
},
65+
'& .MuiDataGrid-columnHeaderTitleContainer': {
66+
minHeight: 56,
67+
height: 56,
68+
maxHeight: 56,
69+
minWidth: 72
70+
},
71+
'& .MuiDataGrid-columnHeaderTitleContainerContent': {
72+
justifyContent: 'flex-start',
73+
width: '100%',
74+
minWidth: 0
75+
},
76+
'& .MuiDataGrid-columnHeaderTitle': {
77+
textTransform: 'uppercase',
78+
fontWeight: 700,
79+
fontSize: '0.7rem',
80+
color: theme.palette.text.secondary
81+
},
82+
'& .MuiDataGrid-columnSeparator': {
83+
display: 'none'
84+
},
85+
'& .MuiDataGrid-sortIcon, & .MuiDataGrid-iconButtonContainer .MuiSvgIcon-root': {
86+
color: grey[500]
87+
},
88+
89+
'& .MuiDataGrid-cell:not(.MuiDataGrid-cellCheckbox) > *': {
90+
minWidth: 0,
91+
overflow: 'hidden',
92+
whiteSpace: 'nowrap',
93+
textOverflow: 'ellipsis'
94+
},
95+
96+
'& .MuiDataGrid-row': {
97+
bgcolor: theme.palette.background.paper,
98+
borderBottom: `1px solid ${theme.palette.divider}`,
99+
borderTop: 'none',
100+
borderRight: `1px solid ${theme.palette.divider}`,
101+
cursor: rest.onRowClick ? 'pointer' : 'default'
102+
},
103+
'& .MuiDataGrid-row.Mui-selected': {
104+
bgcolor: `${alpha(theme.palette.primary.main, 0.14)} !important`
105+
},
106+
'& .MuiDataGrid-row.Mui-selected:hover': {
107+
bgcolor: `${alpha(theme.palette.primary.main, 0.18)} !important`
108+
},
109+
'& .MuiDataGrid-row:hover:not(.Mui-selected)': {
110+
bgcolor: theme.palette.action.hover
111+
},
112+
113+
'& .MuiDataGrid-cell': {
114+
bgcolor: 'inherit',
115+
border: 'none',
116+
display: 'flex',
117+
alignItems: 'center',
118+
justifyContent: 'flex-start',
119+
textAlign: 'left',
120+
px: 2,
121+
fontSize: '0.85rem !important',
122+
minHeight: '0 !important',
123+
overflow: 'hidden'
124+
},
125+
126+
'& .MuiDataGrid-cellContent': {
127+
display: 'flex',
128+
alignItems: 'center',
129+
justifyContent: 'flex-start',
130+
textAlign: 'left',
131+
width: '100%',
132+
minWidth: 0,
133+
overflow: 'hidden',
134+
whiteSpace: 'nowrap',
135+
textOverflow: 'ellipsis'
136+
},
137+
138+
'& .MuiDataGrid-cell .MuiTypography-root': {
139+
fontSize: '0.85rem !important',
140+
minWidth: 0,
141+
overflow: 'hidden',
142+
whiteSpace: 'nowrap',
143+
textOverflow: 'ellipsis'
144+
},
145+
146+
'& .MuiDataGrid-cellEmpty': {
147+
padding: '0 !important'
148+
},
149+
150+
'& .MuiDataGrid-footerContainer': {
151+
bgcolor: theme.palette.background.paper,
152+
borderTop: `1px solid ${theme.palette.divider}`
153+
},
154+
'& .MuiDataGrid-virtualScroller': {
155+
bgcolor: theme.palette.background.paper
156+
},
157+
'& .MuiDataGrid-main': {
158+
overflow: 'hidden'
159+
},
160+
'& .MuiDataGrid-row:last-of-type .MuiDataGrid-cell': {
161+
borderBottom: 'none'
162+
},
163+
164+
'&.MuiDataGrid-root--densityCompact .MuiDataGrid-row': {
165+
padding: '4px',
166+
minHeight: 100
167+
}
168+
};
169+
},
170+
...(Array.isArray(sx) ? sx : [sx])
171+
]}
172+
/>
173+
);
174+
};
175+
176+
export default CustomDataGrid;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Box from '@mui/material/Box';
2+
import Typography from '@mui/material/Typography';
3+
4+
export interface IStyledDataGridOverlayProps {
5+
message?: string;
6+
}
7+
8+
/**
9+
* Default no-rows overlay for data grids.
10+
*
11+
* @param {IStyledDataGridOverlayProps} props
12+
* @return {*}
13+
*/
14+
const StyledDataGridOverlay: React.FC<IStyledDataGridOverlayProps> = (props) => {
15+
return (
16+
<Box
17+
sx={{
18+
width: '100%',
19+
minHeight: 160,
20+
display: 'flex',
21+
alignItems: 'center',
22+
justifyContent: 'center',
23+
px: 2
24+
}}>
25+
<Typography align="center" color="text.secondary">
26+
{props.message || 'No records to display'}
27+
</Typography>
28+
</Box>
29+
);
30+
};
31+
32+
export default StyledDataGridOverlay;

app/src/components/dialog/EditDialog.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { fireEvent, waitFor } from '@testing-library/react';
22
import EditDialog from 'components/dialog/EditDialog';
3-
import CustomTextField from 'components/fields/CustomTextField';
3+
import CustomTextFieldFormik from 'components/fields/CustomTextFieldFormik';
44
import { useFormikContext } from 'formik';
55
import { render } from 'test-helpers/test-utils';
66
import yup from 'utils/YupSchema';
@@ -20,7 +20,7 @@ const SampleFormikForm = () => {
2020

2121
return (
2222
<form onSubmit={handleSubmit}>
23-
<CustomTextField name="testField" label="Test Field" other={{ multiline: true, required: true, rows: 4 }} />
23+
<CustomTextFieldFormik name="testField" label="Test Field" multiline required rows={4} />
2424
</form>
2525
);
2626
};

app/src/components/dialog/EditDialog.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,7 @@ export const EditDialog = <T extends FormikValues>(props: React.PropsWithChildre
9191
props.onSave(values);
9292
}}>
9393
{(formikProps) => (
94-
<Dialog
95-
fullWidth
96-
maxWidth="md"
97-
open={props.open}
98-
aria-labelledby="edit-dialog-title"
99-
aria-describedby="edit-dialog-description">
94+
<Dialog open={props.open} aria-labelledby="edit-dialog-title" aria-describedby="edit-dialog-description">
10095
<DialogTitle id="edit-dialog-title">{props.dialogTitle}</DialogTitle>
10196
<DialogContent>{props.component.element}</DialogContent>
10297
<DialogActions>

0 commit comments

Comments
 (0)