1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +02:00

refactor(wizard): migrate to react [EE-2305] (#6957)

This commit is contained in:
Chaim Lev-Ari 2022-05-23 17:32:51 +03:00 committed by GitHub
parent 3aacaa7caf
commit 01dc9066b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
125 changed files with 2994 additions and 1744 deletions

View file

@ -0,0 +1,93 @@
.stepper-wrapper {
width: 60%;
margin-top: auto;
display: flex;
justify-content: space-between;
margin-bottom: 20px;
margin-left: 10px;
}
.step-wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
flex: 1;
}
.step-wrapper::before {
position: absolute;
content: '';
width: 100%;
top: 20px;
left: -100%;
z-index: 2;
border-bottom: 5px solid var(--bg-stepper-item-counter);
}
.step-wrapper::after {
position: absolute;
content: '';
border-bottom: 5px solid var(--bg-stepper-item-counter);
width: 100%;
top: 20px;
left: 0;
z-index: 2;
}
.step .step-name {
position: absolute;
bottom: -25px;
min-width: max-content;
}
.step-wrapper .step {
position: relative;
z-index: 5;
display: flex;
justify-content: center;
align-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-stepper-item-counter);
margin-bottom: 6px;
}
.step-wrapper.active {
font-weight: bold;
content: none;
}
.step-wrapper.active .step {
background: #337ab7;
}
.step-wrapper.active .step-counter {
color: #fff;
}
.step-wrapper.completed .step {
background-color: #48b400;
}
.step-wrapper.completed .step-counter {
color: #fff;
}
.step-wrapper.completed::after {
position: absolute;
content: '';
border-bottom: 5px solid #48b400;
width: 100%;
top: 20px;
left: 0;
z-index: 3;
}
.step-wrapper:first-child::before {
content: none;
}
.step-wrapper:last-child::after {
content: none;
}

View file

@ -0,0 +1,43 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { Button } from '@/portainer/components/Button';
import { Step, Stepper } from './Stepper';
export default {
component: Stepper,
title: 'Components/Stepper',
} as Meta;
interface Args {
totalSteps: number;
}
function Template({ totalSteps = 5 }: Args) {
const steps: Step[] = Array.from({ length: totalSteps }).map((_, index) => ({
title: `step ${index + 1}`,
}));
const [currentStep, setCurrentStep] = useState(1);
return (
<>
<Stepper currentStep={currentStep} steps={steps} />
<Button
onClick={() => setCurrentStep(currentStep - 1)}
disabled={currentStep <= 1}
>
Previous
</Button>
<Button
onClick={() => setCurrentStep(currentStep + 1)}
disabled={currentStep >= steps.length}
>
Next
</Button>
</>
);
}
export { Template };

View file

@ -0,0 +1,33 @@
import clsx from 'clsx';
import styles from './Stepper.module.css';
export interface Step {
title: string;
}
interface Props {
currentStep: number;
steps: Step[];
}
export function Stepper({ currentStep, steps }: Props) {
return (
<div className={styles.stepperWrapper}>
{steps.map((step, index) => (
<div
key={step.title}
className={clsx(styles.stepWrapper, {
[styles.active]: index + 1 === currentStep,
[styles.completed]: index + 1 < currentStep,
})}
>
<div className={styles.step}>
<div className={styles.stepCounter}>{index + 1}</div>
<div className={styles.stepName}>{step.title}</div>
</div>
</div>
))}
</div>
);
}

View file

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

View file

@ -0,0 +1,4 @@
.remove-tag-btn {
border: 0;
background: transparent;
}

View file

@ -0,0 +1,25 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { TagId } from '@/portainer/tags/types';
import { TagSelector } from './TagSelector';
export default {
component: TagSelector,
title: 'Components/TagSelector',
} as Meta;
function Example() {
const [value, setValue] = useState<TagId[]>([]);
return <TagSelector value={value} onChange={setValue} />;
}
function ExampleWithCreate() {
const [value, setValue] = useState<TagId[]>([]);
return <TagSelector value={value} onChange={setValue} allowCreate />;
}
export { Example, ExampleWithCreate };

View file

@ -0,0 +1,61 @@
import { Tag, TagId } from '@/portainer/tags/types';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { server, rest } from '@/setup-tests/server';
import { TagSelector } from './TagSelector';
test('should show a message when no tags and allowCreate is false', async () => {
const { getByText } = await renderComponent({ allowCreate: false }, []);
expect(
getByText('No tags available. Head over to the', {
exact: false,
})
).toBeInTheDocument();
});
test('should show the selected tags', async () => {
const tags: Tag[] = [
{
ID: 1,
Name: 'tag1',
},
{
ID: 2,
Name: 'tag2',
},
];
const selectedTags = [tags[1]];
const { getByText } = await renderComponent(
{ value: selectedTags.map((t) => t.ID) },
tags
);
expect(getByText(selectedTags[0].Name)).toBeInTheDocument();
});
async function renderComponent(
{
value = [],
allowCreate = false,
onChange = jest.fn(),
}: {
value?: TagId[];
allowCreate?: boolean;
onChange?: jest.Mock;
} = {},
tags: Tag[] = []
) {
server.use(rest.get('/api/tags', (_req, res, ctx) => res(ctx.json(tags))));
const queries = renderWithQueryClient(
<TagSelector value={value} allowCreate={allowCreate} onChange={onChange} />
);
const tagElement = await queries.findAllByText('tags', { exact: false });
expect(tagElement.length).toBeGreaterThanOrEqual(1);
return queries;
}

View file

@ -0,0 +1,118 @@
import clsx from 'clsx';
import _ from 'lodash';
import { TagId } from '@/portainer/tags/types';
import {
Creatable,
Select,
} from '@/portainer/components/form-components/ReactSelect';
import { useCreateTagMutation, useTags } from '@/portainer/tags/queries';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Link } from '@/portainer/components/Link';
import styles from './TagSelector.module.css';
interface Props {
value: TagId[];
allowCreate?: boolean;
onChange(value: TagId[]): void;
}
interface Option {
value: TagId;
label: string;
}
export function TagSelector({ value, allowCreate = false, onChange }: Props) {
// change the struct because react-select has a bug with Creatable (https://github.com/JedWatson/react-select/issues/3417#issuecomment-461868989)
const tagsQuery = useTags((tags) =>
tags.map((opt) => ({ label: opt.Name, value: opt.ID }))
);
const createTagMutation = useCreateTagMutation();
if (!tagsQuery.tags) {
return null;
}
const { tags } = tagsQuery;
const selectedTags = _.compact(
value.map((id) => tags.find((tag) => tag.value === id))
);
const SelectComponent = allowCreate ? Creatable : Select;
if (!tags.length && !allowCreate) {
return (
<div className="form-group">
<div className="col-sm-12 small text-muted">
No tags available. Head over to the
<Link to="portainer.tags" className="space-right space-left">
Tags view
</Link>
to add tags
</div>
</div>
);
}
return (
<>
{value.length > 0 && (
<FormControl label="Selected tags">
{selectedTags.map((tag) => (
<span className="tag space-right interactive" key={tag.value}>
{tag.label}
<button
type="button"
title="Remove tag"
className={clsx(styles.removeTagBtn, 'space-left')}
onClick={() => handleRemove(tag.value)}
>
<i className="fa fa-trash-alt white-icon" aria-hidden="true" />
</button>
</span>
))}
</FormControl>
)}
<FormControl label="Tags" inputId="tags-selector">
<SelectComponent
inputId="tags-selector"
value={[] as { label: string; value: number }[]}
hideSelectedOptions
options={tags.filter((tag) => !value.includes(tag.value))}
closeMenuOnSelect={false}
onChange={handleAdd}
noOptionsMessage={() => 'No tags available'}
formatCreateLabel={(inputValue) => `Create "${inputValue}"`}
onCreateOption={handleCreateOption}
/>
</FormControl>
</>
);
function handleAdd(tag?: Option | null) {
if (!tag) {
return;
}
onChange([...value, tag.value]);
}
function handleRemove(tagId: TagId) {
onChange(value.filter((id) => id !== tagId));
}
function handleCreateOption(inputValue: string) {
if (!allowCreate) {
return;
}
createTagMutation.mutate(inputValue, {
onSuccess(tag) {
handleAdd({ label: tag.Name, value: tag.ID });
},
});
}
}

View file

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