init
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { Avatar, Stack, Typography } from '@mui/material';
|
||||
import { AppLink } from '../common';
|
||||
|
||||
interface UserInfoProps {
|
||||
className?: string;
|
||||
showAvatar?: boolean;
|
||||
user?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders User info with Avatar
|
||||
* @component UserInfo
|
||||
* @param {boolean} [showAvatar] - user's avatar picture is shown when true
|
||||
* @param {object} [user] - logged user data {name, email, avatar...}
|
||||
*/
|
||||
const UserInfo = ({ showAvatar = false, user, ...restOfProps }: UserInfoProps) => {
|
||||
const fullName = user?.name || [user?.nameFirst || '', user?.nameLast || ''].join(' ').trim();
|
||||
const srcAvatar = user?.avatar ? user?.avatar : undefined;
|
||||
const userPhoneOrEmail = user?.phone || (user?.email as string);
|
||||
|
||||
return (
|
||||
<Stack alignItems="center" minHeight="fit-content" marginBottom={2} {...restOfProps}>
|
||||
{showAvatar ? (
|
||||
<AppLink to="/me" underline="none">
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
fontSize: '3rem',
|
||||
}}
|
||||
alt={fullName || 'User Avatar'}
|
||||
src={srcAvatar}
|
||||
/>
|
||||
</AppLink>
|
||||
) : null}
|
||||
<Typography sx={{ mt: 1 }} variant="h6">
|
||||
{fullName || 'Current User'}
|
||||
</Typography>
|
||||
<Typography variant="body2">{userPhoneOrEmail || 'Loading...'}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserInfo;
|
||||
@@ -0,0 +1,4 @@
|
||||
import UserInfo from './UserInfo';
|
||||
|
||||
export { UserInfo };
|
||||
export default UserInfo;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import AppAlert from './AppAlert';
|
||||
import { capitalize, randomText } from '@/utils';
|
||||
import { AlertProps } from '@mui/material';
|
||||
|
||||
const ComponentToTest = AppAlert;
|
||||
|
||||
/**
|
||||
* Tests for <AppAlert/> component
|
||||
*/
|
||||
describe('<AppAlert/> component', () => {
|
||||
it('renders itself', () => {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} />);
|
||||
const alert = screen.getByTestId(testId);
|
||||
expect(alert).toBeDefined();
|
||||
expect(alert).toHaveAttribute('role', 'alert');
|
||||
expect(alert).toHaveClass('MuiAlert-root');
|
||||
});
|
||||
|
||||
it('supports .severity property', () => {
|
||||
const SEVERITIES = ['error', 'info', 'success', 'warning'];
|
||||
for (const severity of SEVERITIES) {
|
||||
const testId = randomText(8);
|
||||
const severity = 'success';
|
||||
render(
|
||||
<ComponentToTest
|
||||
data-testid={testId}
|
||||
severity={severity}
|
||||
variant="filled" // Needed to verify exact MUI classes
|
||||
/>
|
||||
);
|
||||
const alert = screen.getByTestId(testId);
|
||||
expect(alert).toBeDefined();
|
||||
expect(alert).toHaveClass(`MuiAlert-filled${capitalize(severity)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('supports .variant property', () => {
|
||||
const VARIANTS = ['filled', 'outlined', 'standard'];
|
||||
for (const variant of VARIANTS) {
|
||||
const testId = randomText(8);
|
||||
render(
|
||||
<ComponentToTest
|
||||
data-testid={testId}
|
||||
variant={variant as AlertProps['variant']}
|
||||
severity="warning" // Needed to verify exact MUI classes
|
||||
/>
|
||||
);
|
||||
const alert = screen.getByTestId(testId);
|
||||
expect(alert).toBeDefined();
|
||||
expect(alert).toHaveClass(`MuiAlert-${variant}Warning`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import MuiAlert, { AlertProps as MuiAlertProps } from '@mui/material/Alert';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { APP_ALERT_SEVERITY, APP_ALERT_VARIANT } from '../../config';
|
||||
|
||||
/**
|
||||
* Application styled Alert component
|
||||
* @component AppAlert
|
||||
*/
|
||||
const AppAlert: FunctionComponent<MuiAlertProps> = ({
|
||||
severity = APP_ALERT_SEVERITY,
|
||||
variant = APP_ALERT_VARIANT,
|
||||
onClose,
|
||||
...restOfProps
|
||||
}) => {
|
||||
return <MuiAlert severity={severity} sx={{ marginY: 1 }} variant={variant} onClose={onClose} {...restOfProps} />;
|
||||
};
|
||||
|
||||
export default AppAlert;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AppAlert from './AppAlert';
|
||||
|
||||
export { AppAlert as default, AppAlert };
|
||||
@@ -0,0 +1,155 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import AppButton, { AppButtonProps } from './AppButton';
|
||||
import DefaultIcon from '@mui/icons-material/MoreHoriz';
|
||||
import { randomText, capitalize } from '@/utils';
|
||||
|
||||
/**
|
||||
* AppButton wrapped with Theme Provider
|
||||
*/
|
||||
const ComponentToTest: FunctionComponent<AppButtonProps> = (props) => (
|
||||
<ThemeProvider>
|
||||
<AppButton {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
/**
|
||||
* Test specific color for AppButton
|
||||
* @param {string} colorName - name of the color, one of ColorName type
|
||||
* @param {string} [expectedClassName] - optional value to be found in className (color "true" may use "success" class name)
|
||||
* @param {boolean} [ignoreClassName] - optional flag to ignore className (color "inherit" doesn't use any class name)
|
||||
*/
|
||||
function testButtonColor(colorName: string, ignoreClassName = false, expectedClassName = colorName) {
|
||||
it(`supports "${colorName}" color`, () => {
|
||||
const testId = randomText(8);
|
||||
let text = `${colorName} button`;
|
||||
render(
|
||||
<ComponentToTest
|
||||
color={colorName}
|
||||
data-testid={testId}
|
||||
variant="contained" // Required to get correct CSS class name
|
||||
>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
|
||||
let button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
// console.log('button.className:', button?.className);
|
||||
if (!ignoreClassName) {
|
||||
expect(button?.className?.includes('MuiButton-root')).toBeTruthy();
|
||||
expect(button?.className?.includes('MuiButton-contained')).toBeTruthy();
|
||||
expect(button?.className?.includes(`MuiButton-contained${capitalize(expectedClassName)}`)).toBeTruthy(); // Check for "MuiButton-contained[Primary| Secondary |...]" class
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('<AppButton/> component', () => {
|
||||
// beforeEach(() => {});
|
||||
|
||||
it('renders itself', () => {
|
||||
let text = 'sample button';
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId}>{text}</ComponentToTest>);
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveAttribute('role', 'button');
|
||||
expect(button).toHaveAttribute('type', 'button'); // not "submit" or "input" by default
|
||||
});
|
||||
|
||||
it('has .margin style by default', () => {
|
||||
let text = 'button with default margin';
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId}>{text}</ComponentToTest>);
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveStyle('margin: 8px'); // Actually it is theme.spacing(1) value
|
||||
});
|
||||
|
||||
it('supports .className property', () => {
|
||||
let text = 'button with specific class';
|
||||
let className = 'someClassName';
|
||||
const testId = randomText(8);
|
||||
render(
|
||||
<ComponentToTest data-testid={testId} className={className}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveClass(className);
|
||||
});
|
||||
|
||||
it('supports .label property', () => {
|
||||
let text = 'button with label';
|
||||
render(<ComponentToTest label={text} />);
|
||||
let span = screen.getByText(text);
|
||||
expect(span).toBeDefined();
|
||||
let button = span.closest('button'); // parent <button> element
|
||||
expect(button).toBeDefined();
|
||||
});
|
||||
|
||||
it('supports .text property', () => {
|
||||
let text = 'button with text';
|
||||
render(<ComponentToTest text={text} />);
|
||||
let span = screen.getByText(text);
|
||||
expect(span).toBeDefined();
|
||||
let button = span.closest('button'); // parent <button> element
|
||||
expect(button).toBeDefined();
|
||||
});
|
||||
|
||||
it('supports .startIcon property as <svg/>', () => {
|
||||
let text = 'button with start icon';
|
||||
render(<ComponentToTest text={text} startIcon={<DefaultIcon data-testid="startIcon" />} />);
|
||||
let button = screen.getByText(text);
|
||||
let icon = within(button).getByTestId('startIcon');
|
||||
expect(icon).toBeDefined();
|
||||
let span = icon.closest('span');
|
||||
expect(span).toHaveClass('MuiButton-startIcon');
|
||||
});
|
||||
|
||||
it('supports .endIcon property', () => {
|
||||
let text = 'button with end icon as <svg/>';
|
||||
render(<ComponentToTest text={text} endIcon={<DefaultIcon data-testid="endIcon" />} />);
|
||||
let button = screen.getByText(text);
|
||||
let icon = within(button).getByTestId('endIcon');
|
||||
expect(icon).toBeDefined();
|
||||
let span = icon.closest('span');
|
||||
expect(span).toHaveClass('MuiButton-endIcon');
|
||||
});
|
||||
|
||||
it('supports .startIcon property as string', () => {
|
||||
let text = 'button with start icon';
|
||||
render(<ComponentToTest text={text} startIcon="default" />);
|
||||
let button = screen.getByText(text);
|
||||
let icon = within(button).getByTestId('MoreHorizIcon'); //Note: this is valid only when "default" icon is <MoreHorizIcon />
|
||||
expect(icon).toBeDefined();
|
||||
let span = icon.closest('span');
|
||||
expect(span).toHaveClass('MuiButton-startIcon');
|
||||
});
|
||||
|
||||
it('supports .endIcon property', () => {
|
||||
let text = 'button with end icon as string';
|
||||
render(<ComponentToTest text={text} endIcon="default" />);
|
||||
let button = screen.getByText(text);
|
||||
let icon = within(button).getByTestId('MoreHorizIcon'); //Note: this is valid only when "default" icon is <MoreHorizIcon />
|
||||
expect(icon).toBeDefined();
|
||||
let span = icon.closest('span');
|
||||
expect(span).toHaveClass('MuiButton-endIcon');
|
||||
});
|
||||
|
||||
// MUI colors
|
||||
testButtonColor('inherit');
|
||||
testButtonColor('primary');
|
||||
testButtonColor('secondary');
|
||||
testButtonColor('error');
|
||||
testButtonColor('warning');
|
||||
testButtonColor('info');
|
||||
testButtonColor('success');
|
||||
|
||||
// Non-MUI colors
|
||||
testButtonColor('green', true);
|
||||
testButtonColor('#FF00FF', true);
|
||||
testButtonColor('rgba(255, 0, 0, 0.5)', true);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ElementType, FunctionComponent, ReactNode, useMemo } from 'react';
|
||||
import Button, { ButtonProps } from '@mui/material/Button';
|
||||
import AppIcon from '../AppIcon';
|
||||
import AppLink from '../AppLink';
|
||||
import { APP_BUTTON_VARIANT } from '../../config';
|
||||
|
||||
const MUI_BUTTON_COLORS = ['inherit', 'primary', 'secondary', 'success', 'error', 'info', 'warning'];
|
||||
|
||||
const DEFAULT_SX_VALUES = {
|
||||
margin: 1, // By default the AppButton has theme.spacing(1) margin on all sides
|
||||
};
|
||||
|
||||
export interface AppButtonProps extends Omit<ButtonProps, 'color' | 'endIcon' | 'startIcon'> {
|
||||
color?: string; // Not only 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning',
|
||||
endIcon?: string | ReactNode;
|
||||
label?: string; // Alternate to .text
|
||||
text?: string; // Alternate to .label
|
||||
startIcon?: string | ReactNode;
|
||||
// Missing props
|
||||
component?: ElementType; // Could be RouterLink, AppLink, <a>, etc.
|
||||
to?: string; // Link prop
|
||||
href?: string; // Link prop
|
||||
openInNewTab?: boolean; // Link prop
|
||||
underline?: 'none' | 'hover' | 'always'; // Link prop
|
||||
}
|
||||
|
||||
/**
|
||||
* Application styled Material UI Button with Box around to specify margins using props
|
||||
* @component AppButton
|
||||
* @param {string} [color] - when passing MUI value ('primary', 'secondary', and so on), it is color of the button body, otherwise it is color of text and icons
|
||||
* @param {string} [children] - content to render, overrides .label and .text props
|
||||
* @param {string | ReactNode} [endIcon] - name of AppIcon or ReactNode to show after the button label
|
||||
* @param {string} [href] - external link URI
|
||||
* @param {string} [label] - text to render, alternate to .text
|
||||
* @param {boolean} [openInNewTab] - link will be opened in new tab when true
|
||||
* @param {string | ReactNode} [startIcon] - name of AppIcon or ReactNode to show before the button label
|
||||
* @param {Array<func| object| bool> | func | object} [sx] - additional CSS styles to apply to the button
|
||||
* @param {string} [text] - text to render, alternate to .label
|
||||
* @param {string} [to] - internal link URI
|
||||
* @param {string} [underline] - controls underline style when button used as link, one of 'none', 'hover', or 'always'
|
||||
* @param {string} [variant] - MUI variant of the button, one of 'text', 'outlined', or 'contained'
|
||||
*/
|
||||
const AppButton: FunctionComponent<AppButtonProps> = ({
|
||||
children,
|
||||
color: propColor = 'inherit',
|
||||
component: propComponent,
|
||||
endIcon,
|
||||
label,
|
||||
startIcon,
|
||||
sx: propSx = DEFAULT_SX_VALUES,
|
||||
text,
|
||||
underline = 'none',
|
||||
variant = APP_BUTTON_VARIANT,
|
||||
...restOfProps
|
||||
}) => {
|
||||
const iconStart: ReactNode = useMemo(
|
||||
() => (!startIcon ? undefined : typeof startIcon === 'string' ? <AppIcon icon={String(startIcon)} /> : startIcon),
|
||||
[startIcon]
|
||||
);
|
||||
|
||||
const iconEnd: ReactNode = useMemo(
|
||||
() => (!endIcon ? undefined : typeof endIcon === 'string' ? <AppIcon icon={String(endIcon)} /> : endIcon),
|
||||
[endIcon]
|
||||
);
|
||||
|
||||
const isMuiColor = useMemo(() => MUI_BUTTON_COLORS.includes(propColor), [propColor]);
|
||||
|
||||
const componentToRender =
|
||||
!propComponent && (restOfProps?.href || restOfProps?.to) ? AppLink : propComponent ?? Button;
|
||||
|
||||
const colorToRender = isMuiColor ? (propColor as ButtonProps['color']) : 'inherit';
|
||||
const sxToRender = {
|
||||
...propSx,
|
||||
...(isMuiColor ? {} : { color: propColor }),
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
component={componentToRender}
|
||||
color={colorToRender}
|
||||
endIcon={iconEnd}
|
||||
startIcon={iconStart}
|
||||
sx={sxToRender}
|
||||
variant={variant}
|
||||
{...{ ...restOfProps, underline }}
|
||||
>
|
||||
{children || label || text}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppButton;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AppButton from './AppButton';
|
||||
|
||||
export { AppButton as default, AppButton };
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import AppIcon from './AppIcon';
|
||||
import { APP_ICON_SIZE } from '../../config';
|
||||
import { randomColor, randomText } from '@/utils';
|
||||
import { ICONS } from './config';
|
||||
|
||||
const ComponentToTest = AppIcon;
|
||||
|
||||
/**
|
||||
* Tests for <AppIcon/> component
|
||||
*/
|
||||
describe('<AppIcon/> component', () => {
|
||||
it('renders itself', () => {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} />);
|
||||
const svg = screen.getByTestId(testId);
|
||||
expect(svg).toBeDefined();
|
||||
expect(svg).toHaveAttribute('data-icon', 'default');
|
||||
expect(svg).toHaveAttribute('size', String(APP_ICON_SIZE)); // default size
|
||||
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set
|
||||
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se
|
||||
});
|
||||
|
||||
it('supports .color property', () => {
|
||||
const testId = randomText(8);
|
||||
const color = randomColor(); // Note: 'rgb(255, 128, 0)' format is used by react-icons npm, so tests may fail
|
||||
render(<ComponentToTest data-testid={testId} color={color} />);
|
||||
const svg = screen.getByTestId(testId);
|
||||
expect(svg).toHaveAttribute('data-icon', 'default');
|
||||
// expect(svg).toHaveAttribute('color', color); // TODO: Looks like MUI Icons exclude .color property from <svg> rendering
|
||||
expect(svg).toHaveStyle(`color: ${color}`);
|
||||
expect(svg).toHaveAttribute('fill', 'currentColor'); // .fill must be 'currentColor' when .color property is set
|
||||
});
|
||||
|
||||
it('supports .icon property', () => {
|
||||
// Verify that all icons are supported
|
||||
for (const icon of Object.keys(ICONS)) {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} icon={icon} />);
|
||||
const svg = screen.getByTestId(testId);
|
||||
expect(svg).toBeDefined();
|
||||
expect(svg).toHaveAttribute('data-icon', icon.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
it('supports .size property', () => {
|
||||
const testId = randomText(8);
|
||||
const size = Math.floor(Math.random() * 128) + 1;
|
||||
render(<ComponentToTest data-testid={testId} size={size} />);
|
||||
const svg = screen.getByTestId(testId);
|
||||
expect(svg).toHaveAttribute('size', String(size));
|
||||
expect(svg).toHaveAttribute('height', String(size));
|
||||
expect(svg).toHaveAttribute('width', String(size));
|
||||
});
|
||||
|
||||
it('supports .title property', () => {
|
||||
const testId = randomText(8);
|
||||
const title = randomText(16);
|
||||
render(<ComponentToTest data-testid={testId} title={title} />);
|
||||
const svg = screen.getByTestId(testId);
|
||||
expect(svg).toBeDefined();
|
||||
expect(svg).toHaveAttribute('title', title);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ComponentType, FunctionComponent, SVGAttributes } from 'react';
|
||||
import { APP_ICON_SIZE } from '../../config';
|
||||
import { IconName, ICONS } from './config';
|
||||
|
||||
/**
|
||||
* Props of the AppIcon component, also can be used for SVG icons
|
||||
*/
|
||||
export interface Props extends SVGAttributes<SVGElement> {
|
||||
color?: string;
|
||||
icon?: IconName | string;
|
||||
size?: string | number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders SVG icon by given Icon name
|
||||
* @component AppIcon
|
||||
* @param {string} [color] - color of the icon as a CSS color value
|
||||
* @param {string} [icon] - name of the Icon to render
|
||||
* @param {string} [title] - title/hint to show when the cursor hovers the icon
|
||||
* @param {string | number} [size] - size of the icon, default is ICON_SIZE
|
||||
*/
|
||||
const AppIcon: FunctionComponent<Props> = ({
|
||||
color,
|
||||
icon = 'default',
|
||||
size = APP_ICON_SIZE,
|
||||
style,
|
||||
...restOfProps
|
||||
}) => {
|
||||
const iconName = (icon || 'default').trim().toLowerCase() as IconName;
|
||||
|
||||
let ComponentToRender: ComponentType = ICONS[iconName];
|
||||
if (!ComponentToRender) {
|
||||
console.warn(`AppIcon: icon "${iconName}" is not found!`);
|
||||
ComponentToRender = ICONS.default; // ICONS['default'];
|
||||
}
|
||||
|
||||
const propsToRender = {
|
||||
height: size,
|
||||
color,
|
||||
fill: color && 'currentColor',
|
||||
size,
|
||||
style: { ...style, color },
|
||||
width: size,
|
||||
...restOfProps,
|
||||
};
|
||||
|
||||
return <ComponentToRender data-icon={iconName} {...propsToRender} />;
|
||||
};
|
||||
|
||||
export default AppIcon;
|
||||
@@ -0,0 +1,56 @@
|
||||
// SVG assets
|
||||
import PencilIcon from './icons/PencilIcon';
|
||||
// MUI Icons
|
||||
import DefaultIcon from '@mui/icons-material/MoreHoriz';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DayNightIcon from '@mui/icons-material/Brightness4';
|
||||
import NightIcon from '@mui/icons-material/Brightness3';
|
||||
import DayIcon from '@mui/icons-material/Brightness5';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import AccountCircle from '@mui/icons-material/AccountCircle';
|
||||
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import ExitToAppIcon from '@mui/icons-material/ExitToApp';
|
||||
import NotificationsIcon from '@mui/icons-material/NotificationsOutlined';
|
||||
import DangerousIcon from '@mui/icons-material/Dangerous';
|
||||
|
||||
/**
|
||||
* List of all available Icon names
|
||||
*/
|
||||
export type IconName = keyof typeof ICONS;
|
||||
|
||||
/**
|
||||
* How to use:
|
||||
* 1. Import all required React, MUI or other SVG icons into this file.
|
||||
* 2. Add icons with "unique lowercase names" into ICONS object. Lowercase is a must!
|
||||
* 3. Use icons everywhere in the App by their names in <Icon icon="xxx" /> component
|
||||
* Important: properties of ICONS object MUST be lowercase!
|
||||
* Note: You can use camelCase or UPPERCASE in the <Icon icon="someIconByName" /> component
|
||||
*/
|
||||
export const ICONS /* Note: Setting type disables property autocomplete :( was - : Record<string, ComponentType> */ = {
|
||||
default: DefaultIcon,
|
||||
logo: PencilIcon,
|
||||
close: CloseIcon,
|
||||
menu: MenuIcon,
|
||||
settings: SettingsIcon,
|
||||
visibilityon: VisibilityIcon,
|
||||
visibilityoff: VisibilityOffIcon,
|
||||
daynight: DayNightIcon,
|
||||
night: NightIcon,
|
||||
day: DayIcon,
|
||||
search: SearchIcon,
|
||||
info: InfoIcon,
|
||||
home: HomeIcon,
|
||||
account: AccountCircle,
|
||||
signup: PersonAddIcon,
|
||||
login: PersonIcon,
|
||||
logout: ExitToAppIcon,
|
||||
notifications: NotificationsIcon,
|
||||
error: DangerousIcon,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { IconProps } from '../utils';
|
||||
|
||||
const CurrencyIcon: FunctionComponent<IconProps> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 36 36" {...props}>
|
||||
<path
|
||||
fill="#D99E82"
|
||||
d="M35.222 33.598c-.647-2.101-1.705-6.059-2.325-7.566-.501-1.216-.969-2.438-1.544-3.014-.575-.575-1.553-.53-2.143.058 0 0-2.469 1.675-3.354 2.783-1.108.882-2.785 3.357-2.785 3.357-.59.59-.635 1.567-.06 2.143.576.575 1.798 1.043 3.015 1.544 1.506.62 5.465 1.676 7.566 2.325.359.11 1.74-1.271 1.63-1.63z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA596E"
|
||||
d="M13.643 5.308c1.151 1.151 1.151 3.016 0 4.167l-4.167 4.168c-1.151 1.15-3.018 1.15-4.167 0L1.141 9.475c-1.15-1.151-1.15-3.016 0-4.167l4.167-4.167c1.15-1.151 3.016-1.151 4.167 0l4.168 4.167z"
|
||||
/>
|
||||
<path fill="#FFCC4D" d="M31.353 23.018l-4.17 4.17-4.163 4.165L7.392 15.726l8.335-8.334 15.626 15.626z" />
|
||||
<path
|
||||
fill="#292F33"
|
||||
d="M32.078 34.763s2.709 1.489 3.441.757c.732-.732-.765-3.435-.765-3.435s-2.566.048-2.676 2.678z"
|
||||
/>
|
||||
<path fill="#CCD6DD" d="M2.183 10.517l8.335-8.335 5.208 5.209-8.334 8.335z" />
|
||||
<path
|
||||
fill="#99AAB5"
|
||||
d="M3.225 11.558l8.334-8.334 1.042 1.042L4.267 12.6zm2.083 2.086l8.335-8.335 1.042 1.042-8.335 8.334z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrencyIcon;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { IconProps } from '../utils';
|
||||
|
||||
const PencilIcon: FunctionComponent<IconProps> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 36 36" {...props}>
|
||||
<path
|
||||
fill="#D99E82"
|
||||
d="M35.222 33.598c-.647-2.101-1.705-6.059-2.325-7.566-.501-1.216-.969-2.438-1.544-3.014-.575-.575-1.553-.53-2.143.058 0 0-2.469 1.675-3.354 2.783-1.108.882-2.785 3.357-2.785 3.357-.59.59-.635 1.567-.06 2.143.576.575 1.798 1.043 3.015 1.544 1.506.62 5.465 1.676 7.566 2.325.359.11 1.74-1.271 1.63-1.63z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA596E"
|
||||
d="M13.643 5.308c1.151 1.151 1.151 3.016 0 4.167l-4.167 4.168c-1.151 1.15-3.018 1.15-4.167 0L1.141 9.475c-1.15-1.151-1.15-3.016 0-4.167l4.167-4.167c1.15-1.151 3.016-1.151 4.167 0l4.168 4.167z"
|
||||
/>
|
||||
<path fill="#FFCC4D" d="M31.353 23.018l-4.17 4.17-4.163 4.165L7.392 15.726l8.335-8.334 15.626 15.626z" />
|
||||
<path
|
||||
fill="#292F33"
|
||||
d="M32.078 34.763s2.709 1.489 3.441.757c.732-.732-.765-3.435-.765-3.435s-2.566.048-2.676 2.678z"
|
||||
/>
|
||||
<path fill="#CCD6DD" d="M2.183 10.517l8.335-8.335 5.208 5.209-8.334 8.335z" />
|
||||
<path
|
||||
fill="#99AAB5"
|
||||
d="M3.225 11.558l8.334-8.334 1.042 1.042L4.267 12.6zm2.083 2.086l8.335-8.335 1.042 1.042-8.335 8.334z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default PencilIcon;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { IconProps } from '../utils';
|
||||
|
||||
const YellowPlaneIcon: FunctionComponent<IconProps> = (props) => {
|
||||
const styleOpacityAndEnableBackground = {
|
||||
opacity: 0.2,
|
||||
// enableBackground: 'new'
|
||||
};
|
||||
|
||||
return (
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" xmlSpace="preserve" {...props}>
|
||||
<path
|
||||
style={{ fill: '#FFCE00' }}
|
||||
d="M142.21,493.991c12.991,12.991,34.057,12.991,47.05,0c12.991-12.991,12.991-34.057,0-47.048
|
||||
L65.057,322.742c-12.993-12.992-34.059-12.992-47.05,0s-12.989,34.055,0,47.048L142.21,493.991z"
|
||||
/>
|
||||
<circle style={{ fill: '#7D868C' }} cx="386.857" cy="125.141" r="35.932" />
|
||||
<path
|
||||
style={styleOpacityAndEnableBackground}
|
||||
d="M391.846,120.154c-9.556-9.556-18.727-17.187-27.59-22.948
|
||||
c-0.969,0.785-1.908,1.627-2.807,2.527c-14.033,14.034-14.033,36.786,0,50.816c14.031,14.034,36.786,14.034,50.816,0
|
||||
c0.908-0.907,1.754-1.853,2.546-2.829C409.07,138.902,401.453,129.759,391.846,120.154z"
|
||||
/>
|
||||
<path
|
||||
style={styleOpacityAndEnableBackground}
|
||||
d="M75.949,333.636c-15.914,18.935-28.002,33.038-32.3,37.337
|
||||
c-4.221,4.221-7.777,8.86-10.672,13.785l94.264,94.266c4.925-2.894,9.565-6.449,13.789-10.672
|
||||
c4.298-4.301,18.399-16.384,37.334-32.303L75.949,333.636z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: '#333E48' }}
|
||||
d="M384.037,127.964c-30.663-30.663-63.439-47.604-94.099-16.941
|
||||
C254.182,146.782,79.164,363.201,57.52,384.842c-19.227,19.231-19.23,50.409,0,69.636c19.23,19.233,50.409,19.228,69.636,0
|
||||
c21.644-21.641,238.063-196.657,273.82-232.416C431.639,191.4,414.698,158.626,384.037,127.964z"
|
||||
/>
|
||||
<circle style={{ fill: '#7D868C' }} cx="218.905" cy="293.104" r="35.93" />
|
||||
<path
|
||||
style={styleOpacityAndEnableBackground}
|
||||
d="M278.708,123.019c-25.32,28.083-74.009,86.548-119.486,141.296
|
||||
l88.463,88.463c54.748-45.477,113.215-94.162,141.296-119.487L278.708,123.019z"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
style={{ fill: '#FFCE00' }}
|
||||
d="M384.46,458.665c27.283,27.281,71.513,27.279,98.795-0.003c27.28-27.279,27.283-71.511,0-98.793
|
||||
L152.13,28.746c-27.283-27.283-71.513-27.283-98.793,0c-27.283,27.281-27.285,71.514-0.002,98.793L384.46,458.665z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: '#FFCE00' }}
|
||||
d="M84.341,435.945c-2.121,0-4.241-0.809-5.857-2.426c-3.236-3.235-3.236-8.48-0.002-11.716
|
||||
l50.812-50.814c3.236-3.236,8.483-3.235,11.716-0.001c3.236,3.235,3.236,8.48,0,11.714l-50.812,50.814
|
||||
C88.58,435.136,86.459,435.945,84.341,435.945z"
|
||||
/>
|
||||
</g>
|
||||
<rect
|
||||
x="174.379"
|
||||
y="88.222"
|
||||
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 200.0443 399.0237)"
|
||||
style={styleOpacityAndEnableBackground}
|
||||
width="16.568"
|
||||
height="139.719"
|
||||
/>
|
||||
<rect
|
||||
x="117.601"
|
||||
y="31.432"
|
||||
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 143.2742 261.929)"
|
||||
style={styleOpacityAndEnableBackground}
|
||||
width="16.568"
|
||||
height="139.719"
|
||||
/>
|
||||
<rect
|
||||
x="345.637"
|
||||
y="259.464"
|
||||
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 371.313 812.4501)"
|
||||
style={styleOpacityAndEnableBackground}
|
||||
width="16.568"
|
||||
height="139.719"
|
||||
/>
|
||||
<rect
|
||||
x="402.427"
|
||||
y="316.259"
|
||||
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 428.1006 949.5629)"
|
||||
style={styleOpacityAndEnableBackground}
|
||||
width="16.568"
|
||||
height="139.719"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: '#1E252B' }}
|
||||
d="M489.114,354.011L383.944,248.841c10.747-9.425,18.276-16.308,22.89-20.921
|
||||
c16.43-16.43,22.038-34.95,16.673-55.044c-1.363-5.108-3.454-10.308-6.267-15.628c0.296-0.281,0.596-0.553,0.885-0.842
|
||||
c2.167-2.167,4.076-4.523,5.724-7.024l41.209,41.209c1.618,1.617,3.739,2.426,5.858,2.426c2.12,0,4.24-0.808,5.858-2.426
|
||||
c3.235-3.236,3.235-8.48,0-11.716l-46.323-46.323c0.406-2.427,0.626-4.902,0.626-7.411c0-11.811-4.599-22.914-12.95-31.265
|
||||
c-8.351-8.352-19.454-12.953-31.265-12.953c-2.511,0-4.987,0.22-7.415,0.627L333.126,35.23c-3.235-3.235-8.48-3.236-11.716-0.001
|
||||
c-3.236,3.235-3.236,8.48-0.001,11.714l41.209,41.21c-2.503,1.647-4.858,3.555-7.025,5.722c-0.288,0.288-0.562,0.59-0.843,0.886
|
||||
c-5.319-2.812-10.518-4.902-15.627-6.267c-20.099-5.369-38.615,0.243-55.044,16.673c-4.61,4.611-11.492,12.142-20.921,22.893
|
||||
L157.989,22.89C143.229,8.129,123.605,0,102.733,0S62.237,8.129,47.48,22.888c-14.76,14.76-22.89,34.383-22.89,55.254
|
||||
c-0.001,20.873,8.127,40.497,22.887,55.254l114.564,114.564c-6.337,7.624-12.648,15.223-18.86,22.703
|
||||
c-19.346,23.293-38.189,45.983-53.839,64.649l-18.428-18.429c-7.848-7.848-18.284-12.17-29.383-12.17s-21.534,4.322-29.382,12.172
|
||||
c-16.198,16.199-16.198,42.559,0,58.761l25.683,25.684c-6.704,20.045-2.103,43.073,13.83,59.004
|
||||
c10.865,10.867,25.311,16.85,40.677,16.85c6.339,0,12.515-1.034,18.354-2.994l25.658,25.658c8.102,8.101,18.74,12.15,29.381,12.15
|
||||
c10.64,0,21.284-4.051,29.384-12.15c16.201-16.201,16.201-42.562-0.001-58.763l-18.43-18.43
|
||||
c18.659-15.643,41.335-34.476,64.615-53.811c7.49-6.221,15.101-12.541,22.736-18.887l114.566,114.564
|
||||
c14.757,14.756,34.379,22.885,55.251,22.887c0.002,0,0.002,0,0.004,0c20.87,0,40.495-8.129,55.254-22.89
|
||||
c14.758-14.759,22.887-34.381,22.888-55.253C512.002,388.394,503.872,368.77,489.114,354.011z M386.86,97.491
|
||||
c7.385,0,14.328,2.876,19.55,8.099c5.222,5.222,8.097,12.165,8.097,19.55c0,6.56-2.273,12.766-6.438,17.731
|
||||
c-4.96-6.686-10.995-13.587-18.174-20.765c-7.179-7.179-14.08-13.215-20.767-18.176C374.093,99.765,380.301,97.491,386.86,97.491z
|
||||
M295.795,116.881c12.28-12.282,24.689-16.218,39.053-12.38c12.86,3.434,27.034,13.026,43.329,29.323
|
||||
c16.296,16.295,25.886,30.468,29.32,43.329c3.836,14.362-0.098,26.772-12.382,39.054c-4.413,4.414-12.113,11.434-22.915,20.895
|
||||
l-97.303-97.303C284.365,128.993,291.385,121.29,295.795,116.881z M23.866,363.932c-9.74-9.742-9.74-25.593,0-35.333
|
||||
c4.717-4.718,10.992-7.317,17.666-7.317c6.675,0,12.949,2.599,17.668,7.317l19.438,19.441C65.414,363.702,55.648,375,51.662,378.986
|
||||
c-2.168,2.168-4.12,4.47-5.868,6.876L23.866,363.932z M183.401,452.801c9.742,9.741,9.742,25.59,0.001,35.331
|
||||
c-9.742,9.741-25.594,9.742-35.336,0l-21.927-21.926c2.417-1.763,4.717-3.717,6.873-5.872c3.985-3.985,15.285-13.751,30.947-26.976
|
||||
L183.401,452.801z M230.718,356.101c-53.485,44.418-99.676,82.779-109.419,92.521c-7.735,7.735-18.02,11.995-28.96,11.996
|
||||
c-10.939,0-21.225-4.26-28.961-11.997c-15.968-15.966-15.968-41.949,0-57.92c9.744-9.743,48.118-55.949,92.55-109.452
|
||||
c5.891-7.094,11.87-14.293,17.879-21.523l78.468,78.468C245.033,344.21,237.822,350.2,230.718,356.101z M477.397,452.804
|
||||
c-11.631,11.632-27.093,18.038-43.539,18.038h-0.003c-16.447-0.001-31.909-6.406-43.538-18.034L59.192,121.681
|
||||
c-11.631-11.628-18.036-27.09-18.034-43.538c0-16.447,6.406-31.91,18.037-43.541c11.631-11.629,27.093-18.034,43.539-18.034
|
||||
c16.447,0,31.91,6.405,43.54,18.036l331.124,331.123c11.63,11.629,18.036,27.093,18.036,43.54
|
||||
C495.434,425.713,489.029,441.175,477.397,452.804z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default YellowPlaneIcon;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AppIcon from './AppIcon';
|
||||
|
||||
export { AppIcon as default, AppIcon };
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SVGAttributes } from 'react';
|
||||
|
||||
/**
|
||||
* Props to use with custom SVG icons, similar to AppIcon's Props
|
||||
*/
|
||||
export interface IconProps extends SVGAttributes<SVGElement> {
|
||||
color?: string;
|
||||
icon?: string;
|
||||
size?: string | number;
|
||||
title?: string;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import AppIconButton, { MUI_ICON_BUTTON_COLORS } from './AppIconButton';
|
||||
import { APP_ICON_SIZE } from '../../config';
|
||||
import { capitalize, randomColor, randomText } from '@/utils';
|
||||
import { ICONS } from '../AppIcon/config';
|
||||
|
||||
const ComponentToTest = AppIconButton;
|
||||
|
||||
function randomPropertyName(obj: object): string {
|
||||
const objectProperties = Object.keys(obj);
|
||||
const propertyName = objectProperties[Math.floor(Math.random() * objectProperties.length)];
|
||||
return propertyName;
|
||||
}
|
||||
|
||||
// function randomPropertyValue(obj: object): unknown {
|
||||
// const propertyName = randomPropertyName(obj);
|
||||
// return (obj as ObjectPropByName)[propertyName];
|
||||
// }
|
||||
|
||||
/**
|
||||
* Tests for <AppIconButton/> component
|
||||
*/
|
||||
describe('<AppIconButton/> component', () => {
|
||||
it('renders itself', () => {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} />);
|
||||
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveAttribute('role', 'button');
|
||||
expect(button).toHaveAttribute('type', 'button');
|
||||
|
||||
// Icon
|
||||
const svg = button.querySelector('svg');
|
||||
expect(svg).toBeDefined();
|
||||
expect(svg).toHaveAttribute('data-icon', 'default'); // default icon
|
||||
expect(svg).toHaveAttribute('size', String(APP_ICON_SIZE)); // default size
|
||||
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set
|
||||
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se
|
||||
});
|
||||
|
||||
it('supports .color property', () => {
|
||||
for (const color of [...MUI_ICON_BUTTON_COLORS, randomColor(), randomColor(), randomColor()]) {
|
||||
const testId = randomText(8);
|
||||
const icon = randomPropertyName(ICONS) as string;
|
||||
render(<ComponentToTest data-testid={testId} color={color} icon={icon} />);
|
||||
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
|
||||
if (color == 'default') {
|
||||
return; // Nothing to test for default color
|
||||
}
|
||||
|
||||
if (MUI_ICON_BUTTON_COLORS.includes(color)) {
|
||||
expect(button).toHaveClass(`MuiIconButton-color${capitalize(color)}`);
|
||||
} else {
|
||||
expect(button).toHaveStyle({ color: color });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('supports .disable property', () => {
|
||||
const testId = randomText(8);
|
||||
const title = randomText(16);
|
||||
render(<ComponentToTest data-testid={testId} disabled />);
|
||||
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(button).toHaveClass('Mui-disabled');
|
||||
});
|
||||
|
||||
it('supports .icon property', () => {
|
||||
// Verify that all icons are supported
|
||||
for (const icon of Object.keys(ICONS)) {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} icon={icon} />);
|
||||
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
|
||||
// Icon
|
||||
const svg = button.querySelector('svg');
|
||||
expect(button).toBeDefined();
|
||||
expect(svg).toHaveAttribute('data-icon', icon.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
it('supports .size property', () => {
|
||||
const sizes = ['small', 'medium', 'large'] as const; // as IconButtonProps['size'][];
|
||||
for (const size of sizes) {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} size={size} />);
|
||||
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveClass(`MuiIconButton-size${capitalize(size)}`); // MuiIconButton-sizeSmall | MuiIconButton-sizeMedium | MuiIconButton-sizeLarge
|
||||
}
|
||||
});
|
||||
|
||||
it('supports .title property', async () => {
|
||||
const testId = randomText(8);
|
||||
const title = randomText(16);
|
||||
render(<ComponentToTest data-testid={testId} title={title} />);
|
||||
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveAttribute('aria-label', title);
|
||||
|
||||
// Emulate mouseover event to show tooltip
|
||||
await fireEvent(button, new MouseEvent('mouseover', { bubbles: true }));
|
||||
|
||||
// Tooltip is rendered in a separate div, so we need to find it by role
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeDefined();
|
||||
expect(tooltip).toHaveTextContent(title);
|
||||
expect(tooltip).toHaveClass('MuiTooltip-popper');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ElementType, FunctionComponent, useMemo } from 'react';
|
||||
import { Tooltip, IconButton, IconButtonProps, TooltipProps } from '@mui/material';
|
||||
import AppIcon from '../AppIcon';
|
||||
import AppLink from '../AppLink';
|
||||
import { alpha } from '@mui/material';
|
||||
import { Props } from '../AppIcon/AppIcon';
|
||||
import { IconName } from '../AppIcon/config';
|
||||
|
||||
export const MUI_ICON_BUTTON_COLORS = [
|
||||
'inherit',
|
||||
'default',
|
||||
'primary',
|
||||
'secondary',
|
||||
'success',
|
||||
'error',
|
||||
'info',
|
||||
'warning',
|
||||
];
|
||||
|
||||
export interface AppIconButtonProps extends Omit<IconButtonProps, 'color'> {
|
||||
color?: string; // Not only 'inherit' | 'default' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning',
|
||||
icon?: IconName | string;
|
||||
iconProps?: Partial<Props>;
|
||||
// Missing props
|
||||
component?: ElementType; // Could be RouterLink, AppLink, <a>, etc.
|
||||
to?: string; // Link prop
|
||||
href?: string; // Link prop
|
||||
openInNewTab?: boolean; // Link prop
|
||||
tooltipProps?: Partial<TooltipProps>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders MUI IconButton with SVG image by given Icon name
|
||||
* @param {string} [color] - color of background and hover effect. Non MUI values is also accepted.
|
||||
* @param {boolean} [disabled] - the IconButton is not active when true, also the Tooltip is not rendered.
|
||||
* @param {string} [href] - external link URI
|
||||
* @param {string} [icon] - name of Icon to render inside the IconButton
|
||||
* @param {object} [iconProps] - additional props to pass into the AppIcon component
|
||||
* @param {boolean} [openInNewTab] - link will be opened in new tab when true
|
||||
* @param {string} [size] - size of the button: 'small', 'medium' or 'large'
|
||||
* @param {Array<func| object| bool> | func | object} [sx] - additional CSS styles to apply to the button
|
||||
* @param {string} [title] - when set, the IconButton is rendered inside Tooltip with this text
|
||||
* @param {string} [to] - internal link URI
|
||||
* @param {object} [tooltipProps] - additional props to pass into the Tooltip component
|
||||
*/
|
||||
const AppIconButton: FunctionComponent<AppIconButtonProps> = ({
|
||||
color = 'default',
|
||||
component,
|
||||
children,
|
||||
disabled,
|
||||
icon,
|
||||
iconProps,
|
||||
sx,
|
||||
title,
|
||||
tooltipProps,
|
||||
...restOfProps
|
||||
}) => {
|
||||
const componentToRender = !component && (restOfProps?.href || restOfProps?.to) ? AppLink : component ?? IconButton;
|
||||
|
||||
const isMuiColor = useMemo(() => MUI_ICON_BUTTON_COLORS.includes(color), [color]);
|
||||
|
||||
const iconButtonToRender = useMemo(() => {
|
||||
const colorToRender = isMuiColor ? (color as IconButtonProps['color']) : 'default';
|
||||
const sxToRender = {
|
||||
...sx,
|
||||
...(!isMuiColor && {
|
||||
color: color,
|
||||
':hover': {
|
||||
backgroundColor: alpha(color, 0.04),
|
||||
},
|
||||
}),
|
||||
};
|
||||
return (
|
||||
<IconButton
|
||||
component={componentToRender}
|
||||
color={colorToRender}
|
||||
disabled={disabled}
|
||||
sx={sxToRender}
|
||||
{...restOfProps}
|
||||
>
|
||||
<AppIcon icon={icon} {...iconProps} />
|
||||
{children}
|
||||
</IconButton>
|
||||
);
|
||||
}, [color, componentToRender, children, disabled, icon, isMuiColor, sx, iconProps, restOfProps]);
|
||||
|
||||
// When title is set, wrap the IconButton with Tooltip.
|
||||
// Note: when IconButton is disabled the Tooltip is not working, so we don't need it
|
||||
return title && !disabled ? (
|
||||
<Tooltip title={title} {...tooltipProps}>
|
||||
{iconButtonToRender}
|
||||
</Tooltip>
|
||||
) : (
|
||||
iconButtonToRender
|
||||
);
|
||||
};
|
||||
|
||||
export default AppIconButton;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AppIconButton from './AppIconButton';
|
||||
|
||||
export { AppIconButton as default, AppIconButton };
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { randomText } from '@/utils';
|
||||
import AppImage from './AppImage';
|
||||
|
||||
const ComponentToTest = AppImage;
|
||||
|
||||
/**
|
||||
* Tests for <AppImage/> component
|
||||
*/
|
||||
describe('<AppImage/> component', () => {
|
||||
const src = 'https:/domain.com/image.jpg';
|
||||
|
||||
it('renders itself', () => {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} src={src} />);
|
||||
const image = screen.getByTestId(testId);
|
||||
expect(image).toBeDefined();
|
||||
expect(image).toHaveAttribute('src', src);
|
||||
expect(image).toHaveAttribute('alt', 'Image'); // Default prop value
|
||||
expect(image).toHaveAttribute('height', '256'); // Default prop value
|
||||
expect(image).toHaveAttribute('width', '256'); // Default prop value
|
||||
});
|
||||
|
||||
it('supports .width and .height props', () => {
|
||||
const testId = randomText(8);
|
||||
const height = 345;
|
||||
const width = 123;
|
||||
render(<ComponentToTest data-testid={testId} height={height} src={src} width={width} />);
|
||||
const image = screen.getByTestId(testId);
|
||||
expect(image).toBeDefined();
|
||||
expect(image).toHaveAttribute('height', String(height));
|
||||
expect(image).toHaveAttribute('width', String(width));
|
||||
});
|
||||
|
||||
it('supports .title property', () => {
|
||||
const testId = randomText(8);
|
||||
const title = randomText(16);
|
||||
render(<ComponentToTest data-testid={testId} src={src} title={title} />);
|
||||
const image = screen.getByTestId(testId);
|
||||
expect(image).toBeDefined();
|
||||
expect(image).toHaveAttribute('title', title);
|
||||
expect(image).toHaveAttribute('alt', title); // When title is provided, it is used as alt
|
||||
});
|
||||
|
||||
it('supports .alt property even when .title is provided', () => {
|
||||
const testId = randomText(8);
|
||||
const title = randomText(16);
|
||||
const alt = randomText(32);
|
||||
render(<ComponentToTest alt={alt} data-testid={testId} src={src} title={title} />);
|
||||
const image = screen.getByTestId(testId);
|
||||
expect(image).toBeDefined();
|
||||
expect(image).toHaveAttribute('alt', alt);
|
||||
expect(image).toHaveAttribute('title', title);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import NextImage, { ImageProps } from 'next/image';
|
||||
|
||||
interface AppImageProps extends Omit<ImageProps, 'alt'> {
|
||||
alt?: string; // Make property optional as it was before NextJs v13
|
||||
}
|
||||
|
||||
/**
|
||||
* Application wrapper around NextJS image with some default props
|
||||
* @component AppImage
|
||||
*/
|
||||
const AppImage: FunctionComponent<AppImageProps> = ({
|
||||
title, // Note: value has be destructed before usage as default value for other property
|
||||
alt = title ?? 'Image',
|
||||
height = 256,
|
||||
width = 256,
|
||||
...restOfProps
|
||||
}) => {
|
||||
// Uses custom loader + unoptimized="true" to avoid NextImage warning https://nextjs.org/docs/api-reference/next/image#unoptimized
|
||||
return <NextImage alt={alt} height={height} title={title} unoptimized={true} width={width} {...restOfProps} />;
|
||||
};
|
||||
|
||||
export default AppImage;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AppImage from './AppImage';
|
||||
|
||||
export { AppImage as default, AppImage };
|
||||
@@ -0,0 +1,229 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import mockRouter from 'next-router-mock';
|
||||
/* IMPORTANT! To get 'next/router' working with tests, add into "jest.setup.js" file following:
|
||||
---
|
||||
jest.mock('next/router', () => require('next-router-mock'));
|
||||
---
|
||||
*/
|
||||
import AppLink from '.';
|
||||
import { capitalize, randomColor } from '@/utils';
|
||||
|
||||
jest.mock('next/navigation', () => {
|
||||
const result = {
|
||||
...require('next-router-mock'),
|
||||
// useSearchParams: () => jest.fn(),
|
||||
usePathname: () => {
|
||||
const router = mockRouter;
|
||||
return router.asPath;
|
||||
},
|
||||
};
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* AppLink wrapped with Mocked Router
|
||||
*/
|
||||
const ComponentToTest = AppLink;
|
||||
|
||||
/**
|
||||
* Tests for <AppLink/> component
|
||||
*/
|
||||
describe('<AppLink/> component', () => {
|
||||
it('renders itself', () => {
|
||||
const text = 'sample text';
|
||||
const url = 'https://example.com/';
|
||||
render(<ComponentToTest href={url}>{text}</ComponentToTest>);
|
||||
const link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it('supports external link', () => {
|
||||
const text = 'external link';
|
||||
const url = 'https://example.com/';
|
||||
render(<ComponentToTest href={url}>{text}</ComponentToTest>);
|
||||
const link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
expect(link).toHaveAttribute('target', '_blank'); // Open external links in new Tab by default
|
||||
expect(link).toHaveAttribute('rel'); // For links opened in new Tab rel="noreferrer noopener" is required
|
||||
const rel = (link as any)?.rel;
|
||||
expect(rel.includes('noreferrer')).toBeTruthy(); // ref="noreferrer" check
|
||||
expect(rel.includes('noopener')).toBeTruthy(); // rel="noreferrer check
|
||||
});
|
||||
|
||||
it('supports internal link', () => {
|
||||
const text = 'internal link';
|
||||
const url = '/internal-link';
|
||||
render(<ComponentToTest to={url}>{text}</ComponentToTest>);
|
||||
const link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
expect(link).not.toHaveAttribute('target');
|
||||
expect(link).not.toHaveAttribute('rel');
|
||||
});
|
||||
|
||||
it('supports .openInNewTab property', () => {
|
||||
// External link with openInNewTab={false}
|
||||
let text = 'external link in same tab';
|
||||
let url = 'https://example.com/';
|
||||
render(
|
||||
<ComponentToTest href={url} openInNewTab={false}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
expect(link).not.toHaveAttribute('target');
|
||||
expect(link).not.toHaveAttribute('rel');
|
||||
|
||||
// Internal link with openInNewTab={true}
|
||||
text = 'internal link in new tab';
|
||||
url = '/internal-link-in-new-tab';
|
||||
render(
|
||||
<ComponentToTest to={url} openInNewTab>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
expect(link).toHaveAttribute('target', '_blank'); // Open links in new Tab
|
||||
expect(link).toHaveAttribute('rel'); // For links opened in new Tab rel="noreferrer noopener" is required
|
||||
const rel = (link as any)?.rel;
|
||||
expect(rel.includes('noreferrer')).toBeTruthy(); // ref="noreferrer" check
|
||||
expect(rel.includes('noopener')).toBeTruthy(); // rel="noreferrer check
|
||||
});
|
||||
|
||||
it('supports .className property', () => {
|
||||
let text = 'internal link with specific class';
|
||||
let url = '/internal-link-with-class';
|
||||
let className = 'someClassName';
|
||||
render(
|
||||
<ComponentToTest to={url} className={className}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveClass(className);
|
||||
});
|
||||
|
||||
it('supports .activeClassName property in pair with .to property', () => {
|
||||
let link;
|
||||
let textActive = 'internal link with activeClassName';
|
||||
let textPassive = 'internal link without activeClassName';
|
||||
let url = '/internal-link';
|
||||
let activeClassName = 'someClassName';
|
||||
|
||||
// router.pathhname doesn't match .to prop
|
||||
mockRouter.push('not-' + url);
|
||||
render(
|
||||
<ComponentToTest to={url} activeClassName={activeClassName}>
|
||||
{textPassive}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(textPassive);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).not.toHaveClass(activeClassName);
|
||||
|
||||
// router.pathhname matches .to prop
|
||||
mockRouter.push(url);
|
||||
render(
|
||||
<ComponentToTest to={url} activeClassName={activeClassName}>
|
||||
{textActive}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(textActive);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveClass(activeClassName);
|
||||
});
|
||||
|
||||
it('supports .activeClassName property in pair with .href property', () => {
|
||||
let link;
|
||||
let textActive = 'external link with activeClassName';
|
||||
let textPassive = 'external link without activeClassName';
|
||||
let url = '/external-link.com';
|
||||
let activeClassName = 'someClassName';
|
||||
|
||||
// router.pathhname doesn't match .href prop
|
||||
mockRouter.push('not-' + url);
|
||||
render(
|
||||
<ComponentToTest href={url} activeClassName={activeClassName}>
|
||||
{textPassive}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(textPassive);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).not.toHaveClass(activeClassName);
|
||||
|
||||
// router.pathhname matches .href prop
|
||||
mockRouter.push(url);
|
||||
render(
|
||||
<ComponentToTest href={url} activeClassName={activeClassName}>
|
||||
{textActive}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(textActive);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveClass(activeClassName);
|
||||
});
|
||||
|
||||
it('supports .color property', () => {
|
||||
// Check several times with random colors
|
||||
for (let i = 1; i < 5; i++) {
|
||||
let text = `link #${i} with .color property`;
|
||||
let url = '/internal-link-with-color';
|
||||
let color = randomColor();
|
||||
render(
|
||||
<ComponentToTest to={url} color={color}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveStyle(`color: ${color}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('supports .underline property', () => {
|
||||
// Enumerate all possible values
|
||||
['hover', 'always', 'none'].forEach((underline) => {
|
||||
let text = `link with .underline == "${underline}"`;
|
||||
let url = '/internal-link-with-underline';
|
||||
render(
|
||||
<ComponentToTest to={url} underline={underline as any}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
underline === 'none'
|
||||
? expect(link).toHaveStyle('text-decoration: none')
|
||||
: expect(link).toHaveStyle('text-decoration: underline');
|
||||
// TODO: make "hover" test with "mouse moving"
|
||||
|
||||
expect(link).toHaveClass(`MuiLink-underline${capitalize(underline)}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('supports .noLinkStyle property', () => {
|
||||
let text = 'internal link noLinkStyle';
|
||||
let url = '/internal-link-no-style';
|
||||
let noLinkStyle = true;
|
||||
render(
|
||||
<ComponentToTest to={url} noLinkStyle={noLinkStyle}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).not.toHaveClass('MuiLink-root');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
// See: https://github.com/mui-org/material-ui/blob/6b18675c7e6204b77f4c469e113f62ee8be39178/examples/nextjs-with-typescript/src/Link.tsx
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import { AnchorHTMLAttributes, forwardRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import NextLink, { LinkProps as NextLinkProps } from 'next/link';
|
||||
import MuiLink, { LinkProps as MuiLinkProps } from '@mui/material/Link';
|
||||
import { APP_LINK_COLOR, APP_LINK_UNDERLINE } from '../../config';
|
||||
|
||||
export const EXTERNAL_LINK_PROPS = {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for NextLinkComposed component
|
||||
*/
|
||||
interface NextLinkComposedProps
|
||||
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>,
|
||||
Omit<NextLinkProps, 'href' | 'as' | 'onClick' | 'onMouseEnter'> {
|
||||
to: NextLinkProps['href'];
|
||||
linkAs?: NextLinkProps['as'];
|
||||
href?: NextLinkProps['href'];
|
||||
}
|
||||
|
||||
/**
|
||||
* NextJS composed link to use with Material UI
|
||||
* @NextLinkComposed NextLinkComposed
|
||||
*/
|
||||
const NextLinkComposed = forwardRef<HTMLAnchorElement, NextLinkComposedProps>(function NextLinkComposed(
|
||||
{ to, linkAs, href, replace, scroll, passHref, shallow, prefetch, ...restOfProps },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<NextLink
|
||||
legacyBehavior={true} // TODO: Remove when MUI become compatible with NextJs 13+
|
||||
href={to}
|
||||
prefetch={prefetch}
|
||||
as={linkAs}
|
||||
replace={replace}
|
||||
scroll={scroll}
|
||||
shallow={shallow}
|
||||
passHref={passHref}
|
||||
>
|
||||
<a ref={ref} {...restOfProps} />
|
||||
</NextLink>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for AppLinkForNext component
|
||||
*/
|
||||
export type AppLinkForNextProps = {
|
||||
activeClassName?: string;
|
||||
as?: NextLinkProps['as'];
|
||||
href?: string | NextLinkProps['href'];
|
||||
noLinkStyle?: boolean;
|
||||
to?: string | NextLinkProps['href'];
|
||||
openInNewTab?: boolean;
|
||||
} & Omit<NextLinkComposedProps, 'to' | 'linkAs' | 'href'> &
|
||||
Omit<MuiLinkProps, 'href'>;
|
||||
|
||||
/**
|
||||
* Material UI link for NextJS
|
||||
* A styled version of the Next.js Link component: https://nextjs.org/docs/#with-link
|
||||
* @component AppLinkForNext
|
||||
* @param {string} [activeClassName] - class name for active link, applied when the router.pathname matches .href or .to props
|
||||
* @param {string} [as] - passed to NextJS Link component in .as prop
|
||||
* @param {string} [className] - class name for <a> tag or NextJS Link component
|
||||
* @param {object|function} children - content to wrap with <a> tag
|
||||
* @param {string} [color] - color of the link
|
||||
* @param {boolean} [noLinkStyle] - when true, link will not have MUI styles
|
||||
* @param {string} [to] - internal link URI
|
||||
* @param {string} [href] - external link URI
|
||||
* @param {boolean} [openInNewTab] - link will be opened in new tab when true
|
||||
* @param {string} [underline] - controls "underline" style of the MUI link: 'hover' | 'always' | 'none'
|
||||
*/
|
||||
const AppLinkForNext = forwardRef<HTMLAnchorElement, AppLinkForNextProps>(function Link(props, ref) {
|
||||
const {
|
||||
activeClassName = 'active', // This class is applied to the Link component when the router.pathname matches the href/to prop
|
||||
as: linkAs,
|
||||
className: classNameProps,
|
||||
href,
|
||||
noLinkStyle,
|
||||
role, // Link don't have roles, so just exclude it from ...restOfProps
|
||||
color = APP_LINK_COLOR,
|
||||
underline = APP_LINK_UNDERLINE,
|
||||
to,
|
||||
sx,
|
||||
openInNewTab = Boolean(href), // Open external links in new Tab by default
|
||||
...restOfProps
|
||||
} = props;
|
||||
const currentPath = usePathname();
|
||||
const destination = to ?? href ?? '';
|
||||
const pathname = typeof destination === 'string' ? destination : destination.pathname;
|
||||
const className = clsx(classNameProps, {
|
||||
[activeClassName]: pathname == currentPath && activeClassName,
|
||||
});
|
||||
|
||||
const isExternal =
|
||||
typeof destination === 'string' && (destination.startsWith('http') || destination.startsWith('mailto:'));
|
||||
|
||||
const propsToRender = {
|
||||
color,
|
||||
underline, // 'hover' | 'always' | 'none'
|
||||
...(openInNewTab && EXTERNAL_LINK_PROPS),
|
||||
...restOfProps,
|
||||
};
|
||||
|
||||
if (isExternal) {
|
||||
if (noLinkStyle) {
|
||||
return <a className={className} href={destination as string} ref={ref as any} {...propsToRender} />;
|
||||
}
|
||||
|
||||
return <MuiLink className={className} href={destination as string} ref={ref} sx={sx} {...propsToRender} />;
|
||||
}
|
||||
|
||||
if (noLinkStyle) {
|
||||
return <NextLinkComposed className={className} ref={ref as any} to={destination} {...propsToRender} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MuiLink
|
||||
component={NextLinkComposed}
|
||||
linkAs={linkAs}
|
||||
className={className}
|
||||
ref={ref}
|
||||
to={destination}
|
||||
sx={sx}
|
||||
{...propsToRender}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default AppLinkForNext;
|
||||
@@ -0,0 +1,4 @@
|
||||
import AppLink, { AppLinkForNextProps as AppLinkProps } from './AppLinkNextNavigation';
|
||||
|
||||
export type { AppLinkProps };
|
||||
export { AppLink as default, AppLink };
|
||||
@@ -0,0 +1,36 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { CircularProgress, CircularProgressProps, LinearProgress, Stack, StackProps } from '@mui/material';
|
||||
import { APP_LOADING_COLOR, APP_LOADING_SIZE, APP_LOADING_TYPE } from '@/components/config';
|
||||
|
||||
interface Props extends StackProps {
|
||||
color?: CircularProgressProps['color'];
|
||||
size?: number | string;
|
||||
type?: 'circular' | 'linear';
|
||||
value?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders MI circular progress centered inside Stack
|
||||
* @component AppLoading
|
||||
* @prop {string} [size] - size of the progress component. Numbers means pixels, string can be '2.5rem'
|
||||
*/
|
||||
const AppLoading: FunctionComponent<Props> = ({
|
||||
color = APP_LOADING_COLOR,
|
||||
size = APP_LOADING_SIZE,
|
||||
type = APP_LOADING_TYPE,
|
||||
value,
|
||||
...restOfProps
|
||||
}) => {
|
||||
const alignItems = type === 'linear' ? undefined : 'center';
|
||||
return (
|
||||
<Stack my={2} alignItems={alignItems} {...restOfProps}>
|
||||
{type === 'linear' ? (
|
||||
<LinearProgress color={color} value={value} />
|
||||
) : (
|
||||
<CircularProgress color={color} size={size} value={value} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLoading;
|
||||
@@ -0,0 +1,4 @@
|
||||
import AppLoading from './AppLoading';
|
||||
|
||||
export { AppLoading };
|
||||
export default AppLoading;
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: ErrorInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary wrapper to save Application parts from falling
|
||||
* @component ErrorBoundary
|
||||
* @param {string} [props.name] - name of the wrapped segment, "Error Boundary" by default
|
||||
*/
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
static defaultProps = {
|
||||
name: 'Error Boundary',
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
// The next render will show the Error UI
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Save information to help render Error UI
|
||||
this.setState({ error, errorInfo });
|
||||
// TODO: Add log error messages to an error reporting service here
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Error UI rendering
|
||||
return (
|
||||
<div>
|
||||
<h2>{this.props.name} - Something went wrong</h2>
|
||||
<details style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{this.state?.error?.toString()}
|
||||
<br />
|
||||
{this.state?.errorInfo?.componentStack}
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal UI rendering
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,10 @@
|
||||
import AppAlert from './AppAlert';
|
||||
import AppButton from './AppButton';
|
||||
import AppIcon from './AppIcon';
|
||||
import AppIconButton from './AppIconButton';
|
||||
import AppImage from './AppImage';
|
||||
import AppLink from './AppLink';
|
||||
import AppLoading from './AppLoading';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
|
||||
export { ErrorBoundary, AppAlert, AppButton, AppIcon, AppIconButton, AppImage, AppLink, AppLoading };
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Components configuration
|
||||
*/
|
||||
export const CONTENT_MAX_WIDTH = 800;
|
||||
export const CONTENT_MIN_WIDTH = 320; // CONTENT_MAX_WIDTH - Sidebar width
|
||||
|
||||
/**
|
||||
* AppAlert and AppSnackBarAlert components
|
||||
*/
|
||||
export const APP_ALERT_SEVERITY = 'error'; // 'error' | 'info'| 'success' | 'warning'
|
||||
export const APP_ALERT_VARIANT = 'filled'; // 'filled' | 'outlined' | 'standard'
|
||||
|
||||
/**
|
||||
* AppButton component
|
||||
*/
|
||||
export const APP_BUTTON_VARIANT = 'contained'; // | 'text' | 'outlined'
|
||||
export const APP_BUTTON_MARGIN = 1;
|
||||
|
||||
/**
|
||||
* AppIcon component
|
||||
*/
|
||||
export const APP_ICON_SIZE = 24;
|
||||
|
||||
/**
|
||||
* AppLink component
|
||||
*/
|
||||
export const APP_LINK_COLOR = 'textSecondary'; // 'primary' // 'secondary'
|
||||
export const APP_LINK_UNDERLINE = 'hover'; // 'always
|
||||
|
||||
/**
|
||||
* AppLoading component
|
||||
*/
|
||||
export const APP_LOADING_COLOR = 'primary'; // 'secondary'
|
||||
export const APP_LOADING_SIZE = '3rem'; // 40
|
||||
export const APP_LOADING_TYPE = 'circular'; // 'linear'; // 'circular'
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './common';
|
||||
|
||||
import UserInfo from './UserInfo';
|
||||
|
||||
export { UserInfo };
|
||||
Reference in New Issue
Block a user