feat(policies): UI stepper in policy create and environment wizard [R8S-718] (#1672)

This commit is contained in:
Ali
2026-01-21 09:37:39 +13:00
committed by GitHub
parent 4f5073cd9e
commit f535c814d9
17 changed files with 736 additions and 199 deletions

View File

@@ -67,7 +67,7 @@ dev: ## Run both the client and server in development mode
make dev-client
dev-client: ## Run the client in development mode
pnpm run dev
pnpm install && pnpm run dev
dev-server: build-server ## Run the server in development mode
@./dev/run_container.sh

View File

@@ -211,6 +211,7 @@ export const ngModule = angular
'size',
'loadingMessage',
'getOptionValue',
'onBlur',
])
)
.component(

View File

@@ -0,0 +1,163 @@
import clsx from 'clsx';
import { Check } from 'lucide-react';
import { Icon } from '@@/Icon';
export interface StepData {
label: string;
}
interface Props {
step: StepData;
index: number;
currentStepIndex: number;
isLast: boolean;
onStepClick?: (stepIndex: number) => void;
}
type StepStateLabel = 'completed' | 'current' | 'upcoming';
interface StepState {
isActive: boolean;
isCompleted: boolean;
isClickable?: boolean;
}
export function Step({
step,
index,
currentStepIndex,
isLast,
onStepClick,
}: Props) {
const isActive = index === currentStepIndex;
const isCompleted = index < currentStepIndex;
const isClickable = !!onStepClick && isCompleted;
const displayNumber = index + 1;
const stepState = getStepState({ isActive, isCompleted });
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => isClickable && onStepClick?.(index)}
disabled={!isClickable}
className={getButtonClasses({
isActive,
isCompleted,
isClickable,
})}
aria-label={`Step ${displayNumber}: ${step.label}, ${stepState}`}
aria-current={isActive ? 'step' : undefined}
data-cy={`stepper-step-${index}`}
data-step-state={stepState}
>
<span className={getBadgeClasses({ isCompleted })} aria-hidden="true">
{isCompleted ? <Icon icon={Check} size="xs" /> : displayNumber}
</span>
<span className="whitespace-nowrap text-sm">{step.label}</span>
</button>
{!isLast && (
<div
className={getConnectorClasses({ isCompleted })}
aria-hidden="true"
/>
)}
</div>
);
}
function getStepState({
isActive,
isCompleted,
}: Pick<StepState, 'isActive' | 'isCompleted'>): StepStateLabel {
if (isCompleted) {
return 'completed';
}
if (isActive) {
return 'current';
}
return 'upcoming';
}
function getButtonClasses({ isActive, isCompleted, isClickable }: StepState) {
return clsx(
'flex items-center gap-2 rounded-lg border border-solid px-3 py-2 transition-all text-inherit font-medium',
getButtonStateClasses({ isActive, isCompleted }),
isClickable
? clsx(
'cursor-pointer hover:border-blue-6 hover:bg-blue-3',
'th-dark:hover:border-blue-5 th-dark:hover:bg-blue-9/30',
'th-highcontrast:hover:border-blue-4 th-highcontrast:hover:bg-blue-9/40'
)
: 'cursor-default'
);
}
function getButtonStateClasses({
isActive,
isCompleted,
}: Omit<StepState, 'isClickable'>) {
if (isActive) {
return clsx(
'border-blue-6 bg-blue-2',
'th-dark:border-blue-7 th-dark:bg-blue-10',
'th-highcontrast:border-blue-7 th-highcontrast:bg-blue-10'
);
}
if (isCompleted) {
return clsx(
'border-graphite-700 bg-graphite-700/5',
'th-dark:border-gray-warm-5 th-dark:bg-gray-warm-9',
'th-highcontrast:border-gray-4 th-highcontrast:bg-gray-9'
);
}
return clsx(
'border-gray-5 bg-white',
'th-dark:border-gray-warm-7 th-dark:bg-gray-iron-10',
'th-highcontrast:border-gray-2 th-highcontrast:bg-black'
);
}
function getBadgeClasses({ isCompleted }: Pick<StepState, 'isCompleted'>) {
const base = 'flex h-6 w-6 items-center justify-center rounded-full text-xs';
if (isCompleted) {
return clsx(
base,
'bg-graphite-700 text-mist-100',
'th-dark:bg-gray-warm-5 th-dark:text-gray-iron-10',
'th-highcontrast:bg-gray-4 th-highcontrast:text-black'
);
}
return clsx(
base,
'bg-gray-4 text-gray-7',
'th-dark:bg-gray-warm-7 th-dark:text-white',
'th-highcontrast:bg-gray-8 th-highcontrast:text-white'
);
}
function getConnectorClasses({ isCompleted }: Pick<StepState, 'isCompleted'>) {
const base = 'h-0.5 w-8 transition-colors';
if (isCompleted) {
return clsx(
base,
'bg-graphite-700',
'th-dark:bg-gray-warm-5',
'th-highcontrast:bg-gray-4'
);
}
return clsx(
base,
'bg-gray-5',
'th-dark:bg-gray-warm-7',
'th-highcontrast:bg-gray-6'
);
}

View File

@@ -1,97 +0,0 @@
.stepper-wrapper {
width: 60%;
margin-top: auto;
display: flex;
justify-content: space-between;
margin-bottom: 20px;
margin-left: 50px;
}
.step-wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
flex: 1;
}
.step-wrapper::before {
position: absolute;
content: '';
width: 100%;
top: 15px;
left: -100%;
z-index: 2;
border-bottom: 3px solid var(--border-stepper-color);
}
.step-wrapper::after {
position: absolute;
content: '';
border-bottom: 3px solid var(--border-stepper-color);
width: 100%;
top: 15px;
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: 30px;
height: 30px;
border-radius: 50%;
/* background: var(--ui-white); */
background: var(--bg-stepper-color);
margin-bottom: 6px;
border: 1px solid var(--ui-gray-6);
}
.step-wrapper.active {
font-weight: bold;
content: none;
}
.step-wrapper.active .step {
background: var(--bg-stepper-active-color);
border: 2px solid var(--ui-blue-8);
}
.step-wrapper.active .step-counter {
color: var(--text-stepper-active-color);
}
.step-wrapper.completed .step {
background: var(--bg-stepper-active-color);
border: 2px solid var(--ui-blue-8);
}
.step-wrapper.completed .step-counter {
color: var(--text-stepper-active-color);
}
.step-wrapper.completed::after {
position: absolute;
content: '';
border-bottom: 3px solid var(--ui-blue-8);
width: 100%;
top: 15px;
left: 0;
z-index: 3;
}
.step-wrapper:first-child::before {
content: none;
}
.step-wrapper:last-child::after {
content: none;
}

View File

@@ -1,4 +1,4 @@
import { Meta } from '@storybook/react';
import { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { Button } from '@@/buttons';
@@ -15,31 +15,67 @@ interface Args {
}
function Template({ totalSteps = 5 }: Args) {
const steps: Step[] = Array.from({ length: totalSteps }).map((_, index) => ({
label: `step ${index + 1}`,
}));
const steps: Array<Step> = Array.from({ length: totalSteps }).map(
(_, index) => ({
label: `Step ${index + 1}`,
})
);
const [currentStep, setCurrentStep] = useState(1);
const [currentStepIndex, setCurrentStepIndex] = useState(0);
return (
<>
<Stepper currentStep={currentStep} steps={steps} />
<div className="flex flex-col gap-4">
<Stepper currentStepIndex={currentStepIndex} steps={steps} />
<div className="flex gap-2">
<Button
onClick={() => setCurrentStep(currentStep - 1)}
onClick={() => setCurrentStepIndex(currentStepIndex - 1)}
data-cy="previous-button"
disabled={currentStep <= 1}
disabled={currentStepIndex <= 0}
>
Previous
</Button>
<Button
onClick={() => setCurrentStep(currentStep + 1)}
onClick={() => setCurrentStepIndex(currentStepIndex + 1)}
data-cy="next-button"
disabled={currentStep >= steps.length}
disabled={currentStepIndex >= steps.length - 1}
>
Next
</Button>
</>
</div>
</div>
);
}
export { Template };
function ClickableTemplate({ totalSteps = 4 }: Args) {
const steps: Array<Step> = [
{ label: 'Select Environment' },
{ label: 'Configure' },
{ label: 'Review' },
{ label: 'Deploy' },
].slice(0, totalSteps);
const [currentStepIndex, setCurrentStepIndex] = useState(2);
return (
<div className="flex flex-col gap-4">
<Stepper
currentStepIndex={currentStepIndex}
steps={steps}
onStepClick={(stepIndex) => setCurrentStepIndex(stepIndex)}
/>
<p className="text-sm text-gray-6">
Click on completed or current steps to navigate. Current step:{' '}
{currentStepIndex + 1}
</p>
</div>
);
}
export const Clickable: StoryObj<Args> = {
render: ClickableTemplate,
args: {
totalSteps: 4,
},
};

View File

@@ -1,33 +1,31 @@
import clsx from 'clsx';
import { Step, StepData } from './Step';
import styles from './Stepper.module.css';
export interface Step {
label: string;
}
export type { StepData as Step };
interface Props {
currentStep: number;
steps: Step[];
/** 0-based index of the current step */
currentStepIndex: number;
steps: Array<StepData>;
/** Callback with 0-based step index */
onStepClick?: (stepIndex: number) => void;
}
export function Stepper({ currentStep, steps }: Props) {
export function Stepper({ currentStepIndex, steps, onStepClick }: Props) {
return (
<div className={styles.stepperWrapper}>
{steps.map((step, index) => (
<div
key={step.label}
className={clsx(styles.stepWrapper, {
[styles.active]: index + 1 === currentStep,
[styles.completed]: index + 1 < currentStep,
})}
<nav
aria-label="Progress steps"
className="flex flex-wrap items-center gap-2"
>
<div className={styles.step}>
<div className={styles.stepCounter}>{index + 1}</div>
<div className={styles.stepName}>{step.label}</div>
</div>
</div>
{steps.map((step, index) => (
<Step
key={step.label}
step={step}
index={index}
currentStepIndex={currentStepIndex}
isLast={index === steps.length - 1}
onStepClick={onStepClick}
/>
))}
</div>
</nav>
);
}

View File

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

View File

@@ -0,0 +1,149 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useWizardSteps, StepConfig } from './useWizardSteps';
const testSteps: Array<StepConfig> = [
{ id: 'step-1', label: 'First Step' },
{ id: 'step-2', label: 'Second Step' },
{ id: 'step-3', label: 'Third Step' },
];
describe('useWizardSteps', () => {
describe('initial state', () => {
it('should start at the first step by default', () => {
const { result } = renderHook(() => useWizardSteps({ steps: testSteps }));
expect(result.current.currentStep).toEqual(testSteps[0]);
expect(result.current.currentStepIndex).toBe(0);
expect(result.current.isFirstStep).toBe(true);
expect(result.current.isLastStep).toBe(false);
expect(result.current.canGoBack).toBe(false);
expect(result.current.canGoForward).toBe(true);
});
it('should start at the specified initial step', () => {
const { result } = renderHook(() =>
useWizardSteps({ steps: testSteps, initialStepId: 'step-2' })
);
expect(result.current.currentStep).toEqual(testSteps[1]);
expect(result.current.currentStepIndex).toBe(1);
expect(result.current.isFirstStep).toBe(false);
expect(result.current.isLastStep).toBe(false);
});
it('should default to first step if initialStepId is invalid', () => {
const { result } = renderHook(() =>
useWizardSteps({ steps: testSteps, initialStepId: 'invalid-id' })
);
expect(result.current.currentStepIndex).toBe(0);
expect(result.current.currentStep).toEqual(testSteps[0]);
});
});
describe('goToNextStep', () => {
it('should advance to the next step', () => {
const { result } = renderHook(() => useWizardSteps({ steps: testSteps }));
act(() => {
result.current.goToNextStep();
});
expect(result.current.currentStepIndex).toBe(1);
expect(result.current.currentStep).toEqual(testSteps[1]);
});
it('should not advance past the last step', () => {
const { result } = renderHook(() =>
useWizardSteps({ steps: testSteps, initialStepId: 'step-3' })
);
expect(result.current.isLastStep).toBe(true);
act(() => {
result.current.goToNextStep();
});
expect(result.current.currentStepIndex).toBe(2);
expect(result.current.isLastStep).toBe(true);
});
});
describe('goToPreviousStep', () => {
it('should go back to the previous step', () => {
const { result } = renderHook(() =>
useWizardSteps({ steps: testSteps, initialStepId: 'step-2' })
);
act(() => {
result.current.goToPreviousStep();
});
expect(result.current.currentStepIndex).toBe(0);
expect(result.current.currentStep).toEqual(testSteps[0]);
});
it('should not go back before the first step', () => {
const { result } = renderHook(() => useWizardSteps({ steps: testSteps }));
act(() => {
result.current.goToPreviousStep();
});
expect(result.current.currentStepIndex).toBe(0);
expect(result.current.isFirstStep).toBe(true);
});
});
describe('goToStep', () => {
it('should navigate to a step by id', () => {
const { result } = renderHook(() => useWizardSteps({ steps: testSteps }));
act(() => {
result.current.goToStep('step-3');
});
expect(result.current.currentStepIndex).toBe(2);
expect(result.current.currentStep).toEqual(testSteps[2]);
});
it('should not change step if id is invalid', () => {
const { result } = renderHook(() => useWizardSteps({ steps: testSteps }));
act(() => {
result.current.goToStep('invalid-id');
});
expect(result.current.currentStepIndex).toBe(0);
});
});
describe('goToStepByIndex', () => {
it('should navigate to a step by index', () => {
const { result } = renderHook(() => useWizardSteps({ steps: testSteps }));
act(() => {
result.current.goToStepByIndex(2);
});
expect(result.current.currentStepIndex).toBe(2);
expect(result.current.currentStep).toEqual(testSteps[2]);
});
it('should not change step if index is out of bounds', () => {
const { result } = renderHook(() => useWizardSteps({ steps: testSteps }));
act(() => {
result.current.goToStepByIndex(10);
});
expect(result.current.currentStepIndex).toBe(0);
act(() => {
result.current.goToStepByIndex(-1);
});
expect(result.current.currentStepIndex).toBe(0);
});
});
});

View File

@@ -0,0 +1,81 @@
import { useState, useCallback, useMemo } from 'react';
export interface StepConfig {
id: string;
label: string;
}
interface UseWizardStepsOptions {
steps: Array<StepConfig>;
initialStepId?: string;
}
interface WizardStepState {
currentStep: StepConfig;
currentStepIndex: number;
isFirstStep: boolean;
isLastStep: boolean;
canGoBack: boolean;
canGoForward: boolean;
goToNextStep: () => void;
goToPreviousStep: () => void;
goToStep: (stepId: string) => void;
goToStepByIndex: (index: number) => void;
}
export function useWizardSteps({
steps,
initialStepId,
}: UseWizardStepsOptions): WizardStepState {
const initialIndex = useMemo(() => {
if (!initialStepId) return 0;
const index = steps.findIndex((s) => s.id === initialStepId);
return Math.max(0, index);
}, [initialStepId, steps]);
const [stepIndex, setStepIndex] = useState(initialIndex);
const currentStep = steps[stepIndex];
const isFirstStep = stepIndex === 0;
const isLastStep = stepIndex === steps.length - 1;
const goToNextStep = useCallback(() => {
setStepIndex((i) => (i < steps.length - 1 ? i + 1 : i));
}, [steps.length]);
const goToPreviousStep = useCallback(() => {
setStepIndex((i) => (i > 0 ? i - 1 : i));
}, []);
const goToStep = useCallback(
(stepId: string) => {
const index = steps.findIndex((s) => s.id === stepId);
if (index !== -1) {
setStepIndex(index);
}
},
[steps]
);
const goToStepByIndex = useCallback(
(index: number) => {
if (index >= 0 && index < steps.length) {
setStepIndex(index);
}
},
[steps.length]
);
return {
currentStep,
currentStepIndex: stepIndex,
isFirstStep,
isLastStep,
canGoBack: !isFirstStep,
canGoForward: !isLastStep,
goToNextStep,
goToPreviousStep,
goToStep,
goToStepByIndex,
};
}

View File

@@ -0,0 +1,13 @@
.actionBar {
/* Match the sidebar offset - uses the same CSS variable as the page-wrapper */
left: var(--sidebar-closed-width, 72px);
/* Must match sidebar transition exactly: all 0.4s ease 0s */
transition: all 0.4s ease 0s;
border-top: 1px solid var(--border-widget);
}
/* When sidebar is open, adjust the left position using global class */
:global(#page-wrapper.open) .actionBar {
left: var(--sidebar-width, 300px);
}

View File

@@ -0,0 +1,121 @@
import { Meta, StoryObj } from '@storybook/react';
import { Button } from '@@/buttons';
import { StickyFooter } from './StickyFooter';
export default {
component: StickyFooter,
title: 'Components/StickyActionBar',
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<div style={{ height: '200vh', paddingTop: '50px' }}>
<p className="px-6">
Scroll down to see the sticky action bar at the bottom of the
viewport.
</p>
<Story />
</div>
),
],
} as Meta<typeof StickyFooter>;
type Story = StoryObj<typeof StickyFooter>;
export function SpaceBetween() {
return (
<StickyFooter className="justify-between">
<Button color="default" onClick={() => {}} data-cy="cancel-button">
Cancel
</Button>
<Button color="primary" onClick={() => {}} data-cy="save-button">
Save
</Button>
</StickyFooter>
);
}
export function RightAligned() {
return (
<StickyFooter className="justify-end gap-3">
<Button color="default" onClick={() => {}} data-cy="cancel-button">
Cancel
</Button>
<Button color="primary" onClick={() => {}} data-cy="save-button">
Save
</Button>
</StickyFooter>
);
}
export function LeftAligned() {
return (
<StickyFooter className="justify-start gap-3">
<Button color="default" onClick={() => {}} data-cy="back-button">
Back
</Button>
<Button color="primary" onClick={() => {}} data-cy="next-button">
Next
</Button>
</StickyFooter>
);
}
export function Centered() {
return (
<StickyFooter className="justify-center gap-3">
<Button color="default" onClick={() => {}} data-cy="cancel-button">
Cancel
</Button>
<Button color="primary" onClick={() => {}} data-cy="submit-button">
Submit
</Button>
</StickyFooter>
);
}
export function ComplexLayout() {
return (
<StickyFooter className="justify-between">
<div className="flex items-center gap-3">
<Button color="default" onClick={() => {}} data-cy="cancel-button">
Cancel
</Button>
<Button color="dangerlight" onClick={() => {}} data-cy="delete-button">
Delete
</Button>
</div>
<div className="flex items-center gap-3">
<Button
color="default"
onClick={() => {}}
data-cy="save-as-draft-button"
>
Save as Draft
</Button>
<Button color="primary" onClick={() => {}} data-cy="publish-button">
Publish
</Button>
</div>
</StickyFooter>
);
}
export const Default: Story = {
args: {
className: 'justify-end gap-3',
children: (
<>
<Button color="default" data-cy="cancel-button">
Cancel
</Button>
<Button color="primary" data-cy="save-button">
Save
</Button>
</>
),
},
};

View File

@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react';
import { StickyFooter } from './StickyFooter';
test('should render children', () => {
render(
<StickyFooter>
<span>Content</span>
</StickyFooter>
);
expect(screen.getByText('Content')).toBeInTheDocument();
});
test('should apply custom className', () => {
const { container } = render(
<StickyFooter className="custom-class">
<span>Test</span>
</StickyFooter>
);
expect(container.firstChild).toHaveClass('custom-class');
});
test('should render multiple children', () => {
render(
<StickyFooter>
<button type="button">Cancel</button>
<button type="button">Save</button>
</StickyFooter>
);
expect(screen.getByText('Cancel')).toBeInTheDocument();
expect(screen.getByText('Save')).toBeInTheDocument();
});

View File

@@ -0,0 +1,28 @@
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import styles from './StickyFooter.module.css';
interface Props {
className?: string;
}
export function StickyFooter({
className,
children,
}: PropsWithChildren<Props>) {
return (
<div
className={clsx(
styles.actionBar,
'fixed bottom-0 right-0 z-50 h-16',
'flex items-center px-6',
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
'shadow-[0_-2px_10px_rgba(0,0,0,0.1)]',
className
)}
>
{children}
</div>
);
}

View File

@@ -50,6 +50,7 @@ interface SharedProps<TValue>
rawInput: string
) => boolean;
getOptionValue?: (option: TValue) => string;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
}
interface MultiProps<TValue> extends SharedProps<TValue> {
@@ -120,6 +121,7 @@ export function SingleSelect<TValue = string>({
isMulti,
size,
getOptionValue,
onBlur,
...aria
}: SingleProps<TValue>) {
const selectedValue =
@@ -152,6 +154,7 @@ export function SingleSelect<TValue = string>({
noOptionsMessage={noOptionsMessage}
size={size}
loadingMessage={loadingMessage}
onBlur={onBlur}
// eslint-disable-next-line react/jsx-props-no-spreading
{...aria}
/>
@@ -213,6 +216,7 @@ export function MultiSelect<TValue = string>({
isCreatable,
size,
getOptionValue,
onBlur,
...aria
}: Omit<MultiProps<TValue>, 'isMulti'>) {
const [inputValue, setInputValue] = useState('');
@@ -258,7 +262,8 @@ export function MultiSelect<TValue = string>({
/>
);
function handleBlur() {
function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
onBlur?.(e);
const trimmed = inputValue.trim();
if (!trimmed || value.includes(trimmed as TValue)) {
setInputValue('');

View File

@@ -8,6 +8,7 @@ import { withTestRouter } from '@/react/test-utils/withRouter';
import { server } from '@/setup-tests/server';
import { Role, User } from '@/portainer/users/types';
import { createMockUsers } from '@/react-tools/test-mocks';
import { isoDate } from '@/portainer/filters/filters';
import { ConfigsDatatable } from './ConfigsDatatable';
@@ -81,10 +82,11 @@ it('should render datatable with configs', async () => {
});
it('should display config creation date formatted', async () => {
const createdAt = '2024-06-15T14:30:00.000000000Z';
const mockConfigs = [
createMockConfig({
ID: 'config-1',
CreatedAt: '2024-06-15T14:30:00.000000000Z',
CreatedAt: createdAt,
}),
];
@@ -100,7 +102,7 @@ it('should display config creation date formatted', async () => {
expect(screen.getByRole('region', { name: 'Configs' })).toBeVisible();
});
expect(screen.getByText(/2024-06-15/)).toBeVisible();
expect(screen.getByText(isoDate(createdAt))).toBeInTheDocument();
});
it('should show Add config button for admin user', async () => {

View File

@@ -347,8 +347,6 @@ describe('ImportExportButtons', () => {
});
it('should not export when user cancels confirmation', async () => {
mockConfirmImageExport.mockResolvedValue(false);
const selectedImages = [
createMockImage({ id: 'sha256:abc123', tags: ['nginx:latest'] }),
];
@@ -376,6 +374,7 @@ describe('ImportExportButtons', () => {
const exportButton = await waitFor(() =>
screen.getByRole('button', { name: /export/i })
);
mockConfirmImageExport.mockResolvedValue(false);
await user.click(exportButton);
expect(mockConfirmImageExport).toHaveBeenCalled();

View File

@@ -1,8 +1,7 @@
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { useState } from 'react';
import _ from 'lodash';
import clsx from 'clsx';
import { ArrowLeft, ArrowRight, Wand2 } from 'lucide-react';
import { Wand2 } from 'lucide-react';
import { notifyError } from '@/portainer/services/notifications';
import {
@@ -10,13 +9,13 @@ import {
EnvironmentId,
} from '@/react/portainer/environments/types';
import { Stepper } from '@@/Stepper';
import { Stepper } from '@@/Stepper/Stepper';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { PageHeader } from '@@/PageHeader';
import { Button } from '@@/buttons';
import { FormSection } from '@@/form-components/FormSection';
import { Icon } from '@@/Icon';
import { Alert } from '@@/Alert';
import { StickyFooter } from '@@/StickyFooter/StickyFooter';
import {
EnvironmentOptionValue,
@@ -59,6 +58,7 @@ export function EnvironmentCreationView() {
currentStep,
onNextClick,
onPreviousClick,
onStepClick,
currentStepIndex,
Component,
isFirstStep,
@@ -68,27 +68,29 @@ export function EnvironmentCreationView() {
const isDockerStandalone = currentStep.id === 'dockerStandalone';
return (
<>
<div className="pb-20">
<PageHeader
title="Quick Setup"
breadcrumbs={[{ label: 'Environment Wizard' }]}
reload
/>
<div className="row">
<div className="col-sm-12">
<Stepper
steps={steps}
currentStepIndex={currentStepIndex}
onStepClick={onStepClick}
/>
</div>
</div>
<div className={styles.wizardWrapper}>
<Widget>
<WidgetTitle icon={Wand2} title="Environment Wizard" />
<WidgetBody>
<Stepper steps={steps} currentStep={currentStepIndex + 1} />
<div className="mt-12">
<FormSection title={formTitles[currentStep.id]}>
{currentStep.id === 'kaas' && (
<Alert
color="warn"
title="Deprecated Feature"
className="mb-2"
>
<Alert color="warn" title="Deprecated Feature" className="mb-2">
Provisioning a KaaS environment from Portainer is deprecated
and will be removed in a future release. You will still be
able to use any Kubernetes clusters provisioned using this
@@ -100,37 +102,34 @@ export function EnvironmentCreationView() {
onCreate={handleCreateEnvironment}
isDockerStandalone={isDockerStandalone}
/>
<div
className={clsx(
styles.wizardStepAction,
'flex justify-between'
)}
>
<Button
disabled={isFirstStep}
onClick={onPreviousClick}
data-cy="environment-wizard-previous-button"
>
<Icon icon={ArrowLeft} /> Previous
</Button>
<Button
onClick={onNextClick}
data-cy="environment-wizard-next-button"
>
{isLastStep ? 'Close' : 'Next'}
<Icon icon={ArrowRight} />
</Button>
</div>
</FormSection>
</div>
</WidgetBody>
</Widget>
<div>
<WizardEndpointsList environmentIds={environmentIds} />
</div>
</div>
</>
<StickyFooter className="justify-end gap-4">
<Button
color="default"
onClick={onPreviousClick}
disabled={isFirstStep}
data-cy="environment-wizard-back-button"
size="medium"
>
Back
</Button>
<Button
color="primary"
onClick={onNextClick}
data-cy="environment-wizard-continue-button"
size="medium"
>
{isLastStep ? 'Close' : 'Continue'}
</Button>
</StickyFooter>
</div>
);
function handleCreateEnvironment(
@@ -178,6 +177,7 @@ function useStepper(
currentStep,
onNextClick,
onPreviousClick,
onStepClick,
isFirstStep,
isLastStep,
currentStepIndex,
@@ -197,6 +197,10 @@ function useStepper(
setCurrentStepIndex(currentStepIndex - 1);
}
function onStepClick(stepIndex: number) {
setCurrentStepIndex(stepIndex);
}
function getComponent(id: EnvironmentOptionValue) {
switch (id) {
case 'dockerStandalone':