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:
parent
212400c283
commit
18252ab854
346 changed files with 642 additions and 644 deletions
3
app/react/components/buttons/AddButton.module.css
Normal file
3
app/react/components/buttons/AddButton.module.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.add-button {
|
||||
border: none;
|
||||
}
|
20
app/react/components/buttons/AddButton.stories.tsx
Normal file
20
app/react/components/buttons/AddButton.stories.tsx
Normal 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!');
|
||||
},
|
||||
};
|
22
app/react/components/buttons/AddButton.test.tsx
Normal file
22
app/react/components/buttons/AddButton.test.tsx
Normal 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();
|
||||
});
|
27
app/react/components/buttons/AddButton.tsx
Normal file
27
app/react/components/buttons/AddButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
105
app/react/components/buttons/Button.stories.tsx
Normal file
105
app/react/components/buttons/Button.stories.tsx
Normal 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>
|
||||
);
|
||||
}
|
37
app/react/components/buttons/Button.test.tsx
Normal file
37
app/react/components/buttons/Button.test.tsx
Normal 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();
|
||||
});
|
56
app/react/components/buttons/Button.tsx
Normal file
56
app/react/components/buttons/Button.tsx
Normal 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';
|
||||
}
|
||||
}
|
117
app/react/components/buttons/ButtonGroup.stories.tsx
Normal file
117
app/react/components/buttons/ButtonGroup.stories.tsx
Normal 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>
|
||||
);
|
||||
}
|
18
app/react/components/buttons/ButtonGroup.test.tsx
Normal file
18
app/react/components/buttons/ButtonGroup.test.tsx
Normal 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();
|
||||
});
|
31
app/react/components/buttons/ButtonGroup.tsx
Normal file
31
app/react/components/buttons/ButtonGroup.tsx
Normal 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 '';
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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: '',
|
||||
};
|
37
app/react/components/buttons/CopyButton/CopyButton.test.tsx
Normal file
37
app/react/components/buttons/CopyButton/CopyButton.test.tsx
Normal 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();
|
||||
});
|
49
app/react/components/buttons/CopyButton/CopyButton.tsx
Normal file
49
app/react/components/buttons/CopyButton/CopyButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
1
app/react/components/buttons/CopyButton/index.ts
Normal file
1
app/react/components/buttons/CopyButton/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { CopyButton } from './CopyButton';
|
36
app/react/components/buttons/CopyButton/useCopy.ts
Normal file
36
app/react/components/buttons/CopyButton/useCopy.ts
Normal 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 };
|
||||
}
|
36
app/react/components/buttons/LoadingButton.stories.tsx
Normal file
36
app/react/components/buttons/LoadingButton.stories.tsx
Normal 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>
|
||||
);
|
||||
}
|
43
app/react/components/buttons/LoadingButton.test.tsx
Normal file
43
app/react/components/buttons/LoadingButton.test.tsx
Normal 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();
|
||||
});
|
39
app/react/components/buttons/LoadingButton.tsx
Normal file
39
app/react/components/buttons/LoadingButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
5
app/react/components/buttons/index.ts
Normal file
5
app/react/components/buttons/index.ts
Normal 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';
|
Loading…
Add table
Add a link
Reference in a new issue