This commit is contained in:
hamid
2026-06-16 01:32:43 +03:30
commit 69bbd28bb0
298 changed files with 24728 additions and 0 deletions
@@ -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;
+10
View File
@@ -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 };