1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +02:00

refactor(app): move react components to react codebase [EE-3179] (#6971)

This commit is contained in:
Chaim Lev-Ari 2022-06-17 19:18:42 +03:00 committed by GitHub
parent 212400c283
commit 18252ab854
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
346 changed files with 642 additions and 644 deletions

View file

@ -0,0 +1,3 @@
.add-button {
border: none;
}

View file

@ -0,0 +1,20 @@
import { Meta, Story } from '@storybook/react';
import { AddButton, Props } from './AddButton';
export default {
component: AddButton,
title: 'Components/Buttons/AddButton',
} as Meta;
function Template({ label, onClick }: JSX.IntrinsicAttributes & Props) {
return <AddButton label={label} onClick={onClick} />;
}
export const Primary: Story<Props> = Template.bind({});
Primary.args = {
label: 'Create new container',
onClick: () => {
alert('Hello AddButton!');
},
};

View file

@ -0,0 +1,22 @@
import { fireEvent, render } from '@testing-library/react';
import { AddButton, Props } from './AddButton';
function renderDefault({
label = 'default label',
onClick = () => {},
}: Partial<Props> = {}) {
return render(<AddButton label={label} onClick={onClick} />);
}
test('should display a AddButton component and allow onClick', async () => {
const label = 'test label';
const onClick = jest.fn();
const { findByText } = renderDefault({ label, onClick });
const buttonLabel = await findByText(label);
expect(buttonLabel).toBeTruthy();
fireEvent.click(buttonLabel);
expect(onClick).toHaveBeenCalled();
});

View file

@ -0,0 +1,27 @@
import clsx from 'clsx';
import styles from './AddButton.module.css';
export interface Props {
className?: string;
label: string;
onClick: () => void;
}
export function AddButton({ label, onClick, className }: Props) {
return (
<button
className={clsx(
className,
'label',
'label-default',
'interactive',
styles.addButton
)}
type="button"
onClick={onClick}
>
<i className="fa fa-plus-circle space-right" aria-hidden="true" /> {label}
</button>
);
}

View file

@ -0,0 +1,105 @@
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { Button, Props } from './Button';
export default {
component: Button,
title: 'Components/Buttons/Button',
} as Meta;
function Template({
onClick,
color,
size,
disabled,
}: JSX.IntrinsicAttributes & PropsWithChildren<Props>) {
return (
<Button onClick={onClick} color={color} size={size} disabled={disabled}>
<i className="fa fa-download" aria-hidden="true" /> Primary Button
</Button>
);
}
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
color: 'primary',
size: 'small',
disabled: false,
onClick: () => {
alert('Hello Button!');
},
};
export function Disabled() {
return (
<Button color="primary" onClick={() => {}} disabled>
Disabled Button
</Button>
);
}
export function Warning() {
return (
<Button color="warning" onClick={() => {}}>
Warning Button
</Button>
);
}
export function Success() {
return (
<Button color="success" onClick={() => {}}>
Success Button
</Button>
);
}
export function Danger() {
return (
<Button color="danger" onClick={() => {}}>
Danger Button
</Button>
);
}
export function Default() {
return (
<Button color="default" onClick={() => {}}>
<i className="fa fa-plus-circle" aria-hidden="true" /> Add an environment
variable
</Button>
);
}
export function Link() {
return (
<Button color="link" onClick={() => {}}>
Link Button
</Button>
);
}
export function XSmall() {
return (
<Button color="primary" onClick={() => {}} size="xsmall">
XSmall Button
</Button>
);
}
export function Small() {
return (
<Button color="primary" onClick={() => {}} size="small">
Small Button
</Button>
);
}
export function Large() {
return (
<Button color="primary" onClick={() => {}} size="large">
Large Button
</Button>
);
}

View file

@ -0,0 +1,37 @@
import { fireEvent, render } from '@testing-library/react';
import { PropsWithChildren } from 'react';
import { Button, Props } from './Button';
function renderDefault({
type = 'button',
color = 'primary',
size = 'small',
disabled = false,
onClick = () => {},
children = null,
}: Partial<PropsWithChildren<Props>> = {}) {
return render(
<Button
type={type}
color={color}
size={size}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
);
}
test('should display a Button component and allow onClick', async () => {
const children = 'test label';
const onClick = jest.fn();
const { findByText } = renderDefault({ children, onClick });
const buttonLabel = await findByText(children);
expect(buttonLabel).toBeTruthy();
fireEvent.click(buttonLabel);
expect(onClick).toHaveBeenCalled();
});

View file

@ -0,0 +1,56 @@
import { MouseEventHandler, PropsWithChildren } from 'react';
import clsx from 'clsx';
type Type = 'submit' | 'button' | 'reset';
type Color = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'link';
type Size = 'xsmall' | 'small' | 'medium' | 'large';
export interface Props {
color?: Color;
size?: Size;
disabled?: boolean;
title?: string;
className?: string;
dataCy?: string;
type?: Type;
onClick?: MouseEventHandler<HTMLButtonElement>;
}
export function Button({
type = 'button',
color = 'primary',
size = 'small',
disabled = false,
className,
dataCy,
onClick,
title,
children,
}: PropsWithChildren<Props>) {
return (
<button
data-cy={dataCy}
/* eslint-disable-next-line react/button-has-type */
type={type}
disabled={disabled}
className={clsx('btn', `btn-${color}`, sizeClass(size), className)}
onClick={onClick}
title={title}
>
{children}
</button>
);
}
function sizeClass(size?: Size) {
switch (size) {
case 'large':
return 'btn-lg';
case 'medium':
return 'btn-md';
case 'xsmall':
return 'btn-xs';
default:
return 'btn-sm';
}
}

View file

@ -0,0 +1,117 @@
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { Button } from './Button';
import { ButtonGroup, Props } from './ButtonGroup';
export default {
component: ButtonGroup,
title: 'Components/Buttons/ButtonGroup',
} as Meta;
function Template({
size,
}: JSX.IntrinsicAttributes & PropsWithChildren<Props>) {
return (
<ButtonGroup size={size}>
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="danger" onClick={() => {}}>
<i className="fa fa-stop space-right" aria-hidden="true" />
Stop
</Button>
<Button color="danger" onClick={() => {}}>
<i className="fa fa-bomb space-right" aria-hidden="true" />
Kill
</Button>
<Button color="primary" onClick={() => {}}>
<i className="fa fa-sync space-right" aria-hidden="true" />
Restart
</Button>
<Button color="primary" disabled onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Resume
</Button>
<Button color="danger" onClick={() => {}}>
<i className="fa fa-trash-alt space-right" aria-hidden="true" />
Remove
</Button>
</ButtonGroup>
);
}
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
size: 'small',
};
export function Xsmall() {
return (
<ButtonGroup size="xsmall">
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="danger" onClick={() => {}}>
<i className="fa fa-stop space-right" aria-hidden="true" />
Stop
</Button>
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="primary" onClick={() => {}}>
<i className="fa fa-sync space-right" aria-hidden="true" />
Restart
</Button>
</ButtonGroup>
);
}
export function Small() {
return (
<ButtonGroup size="small">
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="danger" onClick={() => {}}>
<i className="fa fa-stop space-right" aria-hidden="true" />
Stop
</Button>
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="primary" onClick={() => {}}>
<i className="fa fa-sync space-right" aria-hidden="true" />
Restart
</Button>
</ButtonGroup>
);
}
export function Large() {
return (
<ButtonGroup size="large">
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="danger" onClick={() => {}}>
<i className="fa fa-stop space-right" aria-hidden="true" />
Stop
</Button>
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="primary" onClick={() => {}}>
<i className="fa fa-sync space-right" aria-hidden="true" />
Restart
</Button>
</ButtonGroup>
);
}

View file

@ -0,0 +1,18 @@
import { render } from '@testing-library/react';
import { PropsWithChildren } from 'react';
import { ButtonGroup, Props } from './ButtonGroup';
function renderDefault({
size = 'small',
children = 'null',
}: Partial<PropsWithChildren<Props>> = {}) {
return render(<ButtonGroup size={size}>{children}</ButtonGroup>);
}
test('should display a ButtonGroup component', async () => {
const { findByRole } = renderDefault({});
const element = await findByRole('group');
expect(element).toBeTruthy();
});

View file

@ -0,0 +1,31 @@
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
export type Size = 'xsmall' | 'small' | 'large';
export interface Props {
size?: Size;
className?: string;
}
export function ButtonGroup({
size = 'small',
children,
className,
}: PropsWithChildren<Props>) {
return (
<div className={clsx('btn-group', sizeClass(size), className)} role="group">
{children}
</div>
);
}
function sizeClass(size: Size | undefined) {
switch (size) {
case 'xsmall':
return 'btn-group-xs';
case 'large':
return 'btn-group-lg';
default:
return '';
}
}

View file

@ -0,0 +1,45 @@
.fadeout {
animation: fadeOut 2.5s;
animation-fill-mode: forwards;
}
.container {
display: flex;
align-items: baseline;
margin-top: 10px;
}
.display-text {
opacity: 0;
margin-left: 7px;
color: #23ae89;
}
@-webkit-keyframes fadeOut {
0% {
opacity: 1;
}
50% {
opacity: 0.8;
}
99% {
opacity: 0.01;
}
100% {
opacity: 0;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
50% {
opacity: 0.8;
}
99% {
opacity: 0.01;
}
100% {
opacity: 0;
}
}

View file

@ -0,0 +1,34 @@
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { CopyButton, Props } from './CopyButton';
export default {
component: CopyButton,
title: 'Components/Buttons/CopyButton',
} as Meta;
function Template({
copyText,
displayText,
children,
}: JSX.IntrinsicAttributes & PropsWithChildren<Props>) {
return (
<CopyButton copyText={copyText} displayText={displayText}>
{children}
</CopyButton>
);
}
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
children: 'Copy to clipboard',
copyText: 'this will be copied to clipboard',
};
export const NoCopyText: Story<PropsWithChildren<Props>> = Template.bind({});
NoCopyText.args = {
children: 'Copy to clipboard without copied text',
copyText: 'clipboard override',
displayText: '',
};

View file

@ -0,0 +1,37 @@
import { fireEvent, render } from '@testing-library/react';
import { CopyButton } from './CopyButton';
test('should display a CopyButton with children', async () => {
const children = 'test button children';
const { findByText } = render(
<CopyButton copyText="">{children}</CopyButton>
);
const button = await findByText(children);
expect(button).toBeTruthy();
});
test('CopyButton should copy text to clipboard', async () => {
// override navigator.clipboard.writeText (to test copy to clipboard functionality)
let clipboardText = '';
const writeText = jest.fn((text) => {
clipboardText = text;
});
Object.assign(navigator, {
clipboard: { writeText },
});
const children = 'button';
const copyText = 'text successfully copied to clipboard';
const { findByText } = render(
<CopyButton copyText={copyText}>{children}</CopyButton>
);
const button = await findByText(children);
expect(button).toBeTruthy();
fireEvent.click(button);
expect(clipboardText).toBe(copyText);
expect(writeText).toHaveBeenCalled();
});

View file

@ -0,0 +1,49 @@
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { Button } from '../Button';
import styles from './CopyButton.module.css';
import { useCopy } from './useCopy';
export interface Props {
copyText: string;
fadeDelay?: number;
displayText?: string;
className?: string;
}
export function CopyButton({
copyText,
fadeDelay = 1000,
displayText = 'copied',
className,
children,
}: PropsWithChildren<Props>) {
const { handleCopy, copiedSuccessfully } = useCopy(copyText, fadeDelay);
return (
<div className={styles.container}>
<Button
className={className}
size="small"
onClick={handleCopy}
title="Copy Value"
type="button"
>
<i className="fa fa-copy space-right" aria-hidden="true" /> {children}
</Button>
<span
className={clsx(
copiedSuccessfully && styles.fadeout,
styles.displayText,
'space-left'
)}
>
<i className="fa fa-check" aria-hidden="true" />
{displayText && <span className="space-left">{displayText}</span>}
</span>
</div>
);
}

View file

@ -0,0 +1 @@
export { CopyButton } from './CopyButton';

View file

@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
export function useCopy(copyText: string, fadeDelay = 1000) {
const [copiedSuccessfully, setCopiedSuccessfully] = useState(false);
useEffect(() => {
const fadeoutTime = setTimeout(
() => setCopiedSuccessfully(false),
fadeDelay
);
// clear timeout when component unmounts
return () => {
clearTimeout(fadeoutTime);
};
}, [copiedSuccessfully, fadeDelay]);
function handleCopy() {
// https://developer.mozilla.org/en-US/docs/Web/API/Clipboard
// https://caniuse.com/?search=clipboard
if (navigator.clipboard) {
navigator.clipboard.writeText(copyText);
} else {
// https://stackoverflow.com/a/57192718
const inputEl = document.createElement('textarea');
inputEl.value = copyText;
document.body.appendChild(inputEl);
inputEl.select();
document.execCommand('copy');
inputEl.hidden = true;
document.body.removeChild(inputEl);
}
setCopiedSuccessfully(true);
}
return { handleCopy, copiedSuccessfully };
}

View file

@ -0,0 +1,36 @@
import { Meta } from '@storybook/react';
import { LoadingButton } from './LoadingButton';
export default {
component: LoadingButton,
title: 'Components/Buttons/LoadingButton',
} as Meta;
interface Args {
loadingText: string;
isLoading: boolean;
}
function Template({ loadingText, isLoading }: Args) {
return (
<LoadingButton loadingText={loadingText} isLoading={isLoading}>
<i className="fa fa-download" aria-hidden="true" /> Download
</LoadingButton>
);
}
Template.args = {
loadingText: 'loading',
isLoading: false,
};
export const Example = Template.bind({});
export function IsLoading() {
return (
<LoadingButton loadingText="loading" isLoading>
<i className="fa fa-download" aria-hidden="true" /> Download
</LoadingButton>
);
}

View file

@ -0,0 +1,43 @@
import { render } from '@/react-tools/test-utils';
import { LoadingButton } from './LoadingButton';
test('when isLoading is true should show spinner and loading text', async () => {
const loadingText = 'loading';
const children = 'not visible';
const { findByLabelText, queryByText, findByText } = render(
<LoadingButton loadingText={loadingText} isLoading>
{children}
</LoadingButton>
);
const buttonLabel = queryByText(children);
expect(buttonLabel).toBeNull();
const spinner = await findByLabelText('loading');
expect(spinner).toBeVisible();
const loadingTextElem = await findByText(loadingText);
expect(loadingTextElem).toBeVisible();
});
test('should show children when false', async () => {
const loadingText = 'loading';
const children = 'visible';
const { queryByLabelText, queryByText } = render(
<LoadingButton loadingText={loadingText} isLoading={false}>
{children}
</LoadingButton>
);
const buttonLabel = queryByText(children);
expect(buttonLabel).toBeVisible();
const spinner = queryByLabelText('loading');
expect(spinner).toBeNull();
const loadingTextElem = queryByText(loadingText);
expect(loadingTextElem).toBeNull();
});

View file

@ -0,0 +1,39 @@
import { PropsWithChildren } from 'react';
import { type Props as ButtonProps, Button } from './Button';
interface Props extends ButtonProps {
loadingText: string;
isLoading: boolean;
}
export function LoadingButton({
loadingText,
isLoading,
disabled,
type = 'submit',
children,
...buttonProps
}: PropsWithChildren<Props>) {
return (
<Button
// eslint-disable-next-line react/jsx-props-no-spreading
{...buttonProps}
type={type}
disabled={disabled || isLoading}
>
{isLoading ? (
<>
<i
className="fa fa-circle-notch fa-spin space-right"
aria-label="loading"
aria-hidden="true"
/>
{loadingText}
</>
) : (
children
)}
</Button>
);
}

View file

@ -0,0 +1,5 @@
export { Button } from './Button';
export { AddButton } from './AddButton';
export { ButtonGroup } from './ButtonGroup';
export { LoadingButton } from './LoadingButton';
export { CopyButton } from './CopyButton';