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:
parent
3aacaa7caf
commit
01dc9066b7
125 changed files with 2994 additions and 1744 deletions
93
app/react/components/Stepper/Stepper.module.css
Normal file
93
app/react/components/Stepper/Stepper.module.css
Normal 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;
|
||||
}
|
43
app/react/components/Stepper/Stepper.stories.tsx
Normal file
43
app/react/components/Stepper/Stepper.stories.tsx
Normal 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 };
|
33
app/react/components/Stepper/Stepper.tsx
Normal file
33
app/react/components/Stepper/Stepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
1
app/react/components/Stepper/index.ts
Normal file
1
app/react/components/Stepper/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Stepper } from './Stepper';
|
4
app/react/components/TagSelector/TagSelector.module.css
Normal file
4
app/react/components/TagSelector/TagSelector.module.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.remove-tag-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
25
app/react/components/TagSelector/TagSelector.stories.tsx
Normal file
25
app/react/components/TagSelector/TagSelector.stories.tsx
Normal 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 };
|
61
app/react/components/TagSelector/TagSelector.test.tsx
Normal file
61
app/react/components/TagSelector/TagSelector.test.tsx
Normal 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;
|
||||
}
|
118
app/react/components/TagSelector/TagSelector.tsx
Normal file
118
app/react/components/TagSelector/TagSelector.tsx
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
1
app/react/components/TagSelector/index.ts
Normal file
1
app/react/components/TagSelector/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { TagSelector } from './TagSelector';
|
Loading…
Add table
Add a link
Reference in a new issue