feat(policies): UI stepper in policy create and environment wizard [R8S-718] (#1672)
This commit is contained in:
2
Makefile
2
Makefile
@@ -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
|
||||
|
||||
@@ -211,6 +211,7 @@ export const ngModule = angular
|
||||
'size',
|
||||
'loadingMessage',
|
||||
'getOptionValue',
|
||||
'onBlur',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
163
app/react/components/Stepper/Step.tsx
Normal file
163
app/react/components/Stepper/Step.tsx
Normal 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'
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { Stepper } from './Stepper';
|
||||
149
app/react/components/Stepper/useWizardSteps.test.ts
Normal file
149
app/react/components/Stepper/useWizardSteps.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
81
app/react/components/Stepper/useWizardSteps.ts
Normal file
81
app/react/components/Stepper/useWizardSteps.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
13
app/react/components/StickyFooter/StickyFooter.module.css
Normal file
13
app/react/components/StickyFooter/StickyFooter.module.css
Normal 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);
|
||||
}
|
||||
121
app/react/components/StickyFooter/StickyFooter.stories.tsx
Normal file
121
app/react/components/StickyFooter/StickyFooter.stories.tsx
Normal 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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
35
app/react/components/StickyFooter/StickyFooter.test.tsx
Normal file
35
app/react/components/StickyFooter/StickyFooter.test.tsx
Normal 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();
|
||||
});
|
||||
28
app/react/components/StickyFooter/StickyFooter.tsx
Normal file
28
app/react/components/StickyFooter/StickyFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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('');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user