diff --git a/.gitignore b/.gitignore
index bec73737e..60bed44fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ bower_components
dist
portainer-checksum.txt
api/cmd/portainer/portainer*
+storybook-static
.tmp
**/.vscode/settings.json
**/.vscode/tasks.json
diff --git a/app/assets/css/app.css b/app/assets/css/app.css
index 32b6d01ca..7f348fd09 100644
--- a/app/assets/css/app.css
+++ b/app/assets/css/app.css
@@ -600,7 +600,7 @@ a[ng-click] {
padding-top: 7px;
}
-.tag {
+.tag:not(.token) {
padding: 2px 6px;
color: white;
background-color: var(--blue-2);
diff --git a/app/portainer/components/Button/AddButton.module.css b/app/portainer/components/Button/AddButton.module.css
new file mode 100644
index 000000000..a46fb8a6d
--- /dev/null
+++ b/app/portainer/components/Button/AddButton.module.css
@@ -0,0 +1,3 @@
+.add-button {
+ border: none;
+}
diff --git a/app/portainer/components/Button/AddButton.stories.tsx b/app/portainer/components/Button/AddButton.stories.tsx
new file mode 100644
index 000000000..d03344058
--- /dev/null
+++ b/app/portainer/components/Button/AddButton.stories.tsx
@@ -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 ;
+}
+
+export const Primary: Story = Template.bind({});
+Primary.args = {
+ label: 'Create new container',
+ onClick: () => {
+ alert('Hello AddButton!');
+ },
+};
diff --git a/app/portainer/components/Button/AddButton.test.tsx b/app/portainer/components/Button/AddButton.test.tsx
new file mode 100644
index 000000000..dd7c2b917
--- /dev/null
+++ b/app/portainer/components/Button/AddButton.test.tsx
@@ -0,0 +1,22 @@
+import { fireEvent, render } from '@testing-library/react';
+
+import { AddButton, Props } from './AddButton';
+
+function renderDefault({
+ label = 'default label',
+ onClick = () => {},
+}: Partial = {}) {
+ return render();
+}
+
+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();
+});
diff --git a/app/portainer/components/Button/AddButton.tsx b/app/portainer/components/Button/AddButton.tsx
new file mode 100644
index 000000000..e95718ddc
--- /dev/null
+++ b/app/portainer/components/Button/AddButton.tsx
@@ -0,0 +1,25 @@
+import clsx from 'clsx';
+
+import styles from './AddButton.module.css';
+
+export interface Props {
+ label: string;
+ onClick: () => void;
+}
+
+export function AddButton({ label, onClick }: Props) {
+ return (
+
+ );
+}
diff --git a/app/portainer/components/Button/Button.stories.tsx b/app/portainer/components/Button/Button.stories.tsx
new file mode 100644
index 000000000..a2baee9a4
--- /dev/null
+++ b/app/portainer/components/Button/Button.stories.tsx
@@ -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) {
+ return (
+
+ );
+}
+
+export const Primary: Story> = Template.bind({});
+Primary.args = {
+ color: 'primary',
+ size: 'small',
+ disabled: false,
+ onClick: () => {
+ alert('Hello Button!');
+ },
+};
+
+export function Disabled() {
+ return (
+
+ );
+}
+
+export function Warning() {
+ return (
+
+ );
+}
+
+export function Success() {
+ return (
+
+ );
+}
+
+export function Danger() {
+ return (
+
+ );
+}
+
+export function Default() {
+ return (
+
+ );
+}
+
+export function Link() {
+ return (
+
+ );
+}
+
+export function XSmall() {
+ return (
+
+ );
+}
+
+export function Small() {
+ return (
+
+ );
+}
+
+export function Large() {
+ return (
+
+ );
+}
diff --git a/app/portainer/components/Button/Button.test.tsx b/app/portainer/components/Button/Button.test.tsx
new file mode 100644
index 000000000..9cdc9914f
--- /dev/null
+++ b/app/portainer/components/Button/Button.test.tsx
@@ -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> = {}) {
+ return render(
+
+ );
+}
+
+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();
+});
diff --git a/app/portainer/components/Button/Button.tsx b/app/portainer/components/Button/Button.tsx
new file mode 100644
index 000000000..efd21a1f7
--- /dev/null
+++ b/app/portainer/components/Button/Button.tsx
@@ -0,0 +1,45 @@
+import { PropsWithChildren } from 'react';
+import clsx from 'clsx';
+
+type Type = 'submit' | 'reset' | 'button';
+type Color = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'link';
+type Size = 'xsmall' | 'small' | 'large';
+export interface Props {
+ type?: Type;
+ color?: Color;
+ size?: Size;
+ disabled?: boolean;
+ onClick: () => void;
+}
+
+export function Button({
+ type = 'button',
+ color = 'primary',
+ size = 'small',
+ disabled = false,
+ onClick,
+ children,
+}: PropsWithChildren) {
+ return (
+
+ );
+}
+
+function sizeClass(size?: Size) {
+ switch (size) {
+ case 'large':
+ return 'btn-lg';
+ case 'xsmall':
+ return 'btn-xs';
+ default:
+ return 'btn-sm';
+ }
+}
diff --git a/app/portainer/components/Button/ButtonGroup.stories.tsx b/app/portainer/components/Button/ButtonGroup.stories.tsx
new file mode 100644
index 000000000..cd2b0cabd
--- /dev/null
+++ b/app/portainer/components/Button/ButtonGroup.stories.tsx
@@ -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) {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export const Primary: Story> = Template.bind({});
+Primary.args = {
+ size: 'small',
+};
+
+export function Xsmall() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function Small() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function Large() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/app/portainer/components/Button/ButtonGroup.test.tsx b/app/portainer/components/Button/ButtonGroup.test.tsx
new file mode 100644
index 000000000..597fdb441
--- /dev/null
+++ b/app/portainer/components/Button/ButtonGroup.test.tsx
@@ -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> = {}) {
+ return render({children});
+}
+
+test('should display a ButtonGroup component', async () => {
+ const { findByRole } = renderDefault({});
+
+ const element = await findByRole('group');
+ expect(element).toBeTruthy();
+});
diff --git a/app/portainer/components/Button/ButtonGroup.tsx b/app/portainer/components/Button/ButtonGroup.tsx
new file mode 100644
index 000000000..89b966fb7
--- /dev/null
+++ b/app/portainer/components/Button/ButtonGroup.tsx
@@ -0,0 +1,29 @@
+import { PropsWithChildren } from 'react';
+import clsx from 'clsx';
+
+type Size = 'xsmall' | 'small' | 'large';
+export interface Props {
+ size?: Size;
+}
+
+export function ButtonGroup({
+ size = 'small',
+ children,
+}: PropsWithChildren) {
+ return (
+
+ {children}
+
+ );
+}
+
+function sizeClass(size: Size | undefined) {
+ switch (size) {
+ case 'xsmall':
+ return 'btn-group-xs';
+ case 'large':
+ return 'btn-group-lg';
+ default:
+ return 'btn-group-sm';
+ }
+}
diff --git a/app/portainer/components/Button/index.ts b/app/portainer/components/Button/index.ts
new file mode 100644
index 000000000..1ba16921f
--- /dev/null
+++ b/app/portainer/components/Button/index.ts
@@ -0,0 +1,7 @@
+import { Button } from './Button';
+import { AddButton } from './AddButton';
+import { ButtonGroup } from './ButtonGroup';
+
+export { Button, AddButton, ButtonGroup };
+
+export default Button;
diff --git a/package.json b/package.json
index 24018cb1a..bbdf0501a 100644
--- a/package.json
+++ b/package.json
@@ -91,6 +91,7 @@
"bootstrap": "^3.4.0",
"chardet": "^1.3.0",
"chart.js": "~2.7.0",
+ "clsx": "^1.1.1",
"codemirror": "~5.30.0",
"core-js": "^3.16.3",
"fast-json-patch": "^3.0.0-1",
@@ -214,4 +215,4 @@
"pre-commit": "lint-staged"
}
}
-}
\ No newline at end of file
+}