Sequence component (#619)
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { TextField } from '@material-ui/core';
|
||||
import Sequence, { StepType } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Sequence',
|
||||
component: Sequence,
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: 'Step 1!',
|
||||
content: <div>Slide 1 content</div>,
|
||||
},
|
||||
{
|
||||
title: 'Step 2!',
|
||||
content: <div>Slide 2 content</div>,
|
||||
},
|
||||
{
|
||||
title: 'Step 3!',
|
||||
content: <div>Slide 3 content</div>,
|
||||
},
|
||||
];
|
||||
|
||||
export const Horizontal = () => (
|
||||
<Sequence steps={steps} orientation="horizontal" />
|
||||
);
|
||||
|
||||
export const Vertical = () => <Sequence steps={steps} orientation="vertical" />;
|
||||
|
||||
export const ConditionalButtons = () => {
|
||||
const [required, setRequired] = useState(false);
|
||||
const customSteps: StepType[] = [...steps];
|
||||
|
||||
customSteps[0] = {
|
||||
title: 'Step 1 with required field',
|
||||
actions: {
|
||||
canNext: () => required,
|
||||
},
|
||||
content: (
|
||||
<div>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
placeholder="Required*"
|
||||
onChange={e => setRequired(!!e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
return <Sequence steps={customSteps} orientation="vertical" />;
|
||||
};
|
||||
|
||||
export const CompletionStep = () => {
|
||||
const completed = {
|
||||
title: 'Succcess!',
|
||||
content: <div>You've completed the sequence</div>,
|
||||
};
|
||||
return (
|
||||
<Sequence
|
||||
steps={steps.slice(0, 1)}
|
||||
completed={completed}
|
||||
orientation="vertical"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import Sequence, { StepType } from './Sequence';
|
||||
|
||||
describe('Sequence', () => {
|
||||
let steps: StepType[];
|
||||
|
||||
beforeEach(() => {
|
||||
steps = [
|
||||
{
|
||||
title: 'Step 0',
|
||||
content: <div>step0</div>,
|
||||
},
|
||||
{
|
||||
title: 'Step 1',
|
||||
content: <div>step1</div>,
|
||||
actions: { nextStep: () => 3 },
|
||||
},
|
||||
{
|
||||
title: 'Step 2',
|
||||
content: <div>step2</div>,
|
||||
},
|
||||
{
|
||||
title: 'Step 3',
|
||||
content: <div>step3</div>,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
it('Handles nextStep property', () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(<Sequence orientation="horizontal" steps={steps} />),
|
||||
);
|
||||
fireEvent.click(rendered.getByText('Next'));
|
||||
expect(rendered.getByText('step1')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(rendered.getByText('Next'));
|
||||
expect(rendered.getByText('step3')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(rendered.getByText('Back'));
|
||||
expect(rendered.getByText('step1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Shows controls and content when going back to first step', () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(<Sequence orientation="horizontal" steps={steps} />),
|
||||
);
|
||||
fireEvent.click(rendered.getByText('Next'));
|
||||
expect(rendered.getByText('step1')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(rendered.getByText('Next'));
|
||||
expect(rendered.getByText('step3')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(rendered.getByText('Back'));
|
||||
expect(rendered.getByText('step1')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Next')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(rendered.getByText('Back'));
|
||||
expect(rendered.getByText('step0')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Next')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses nextText if specified in all steps', () => {
|
||||
const nextTextSteps = [
|
||||
{
|
||||
title: 'Step 0',
|
||||
actions: {
|
||||
nextText: 'Step0Next',
|
||||
},
|
||||
content: <div>step0</div>,
|
||||
},
|
||||
{
|
||||
title: 'Final Step',
|
||||
actions: {
|
||||
nextText: 'FinalStepNext',
|
||||
},
|
||||
content: <div>final step</div>,
|
||||
},
|
||||
];
|
||||
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<Sequence orientation="horizontal" steps={nextTextSteps} />,
|
||||
),
|
||||
);
|
||||
expect(rendered.getByText('Step0Next')).toBeInTheDocument();
|
||||
fireEvent.click(rendered.getByText('Step0Next'));
|
||||
|
||||
expect(rendered.getByText('FinalStepNext')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { Fragment, FC, useState } from 'react';
|
||||
import {
|
||||
Stepper,
|
||||
Step,
|
||||
StepContent,
|
||||
StepLabel,
|
||||
Typography,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import SequenceFooter from './SequenceFooter';
|
||||
|
||||
const useStyles = makeStyles((theme: any) => ({
|
||||
content: {
|
||||
padding: theme.spacing(0, 4, 4, 4),
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
completionBox: {
|
||||
padding: theme.spacing(3),
|
||||
},
|
||||
}));
|
||||
|
||||
type Orientation = 'horizontal' | 'vertical';
|
||||
|
||||
export type StepActions = {
|
||||
showNext?: boolean;
|
||||
canNext?: () => boolean;
|
||||
onNext?: () => void;
|
||||
nextStep?: (current: number, last: number) => number;
|
||||
nextText?: string;
|
||||
|
||||
showBack?: boolean;
|
||||
backText?: string;
|
||||
onBack?: () => void;
|
||||
|
||||
showRestart?: boolean;
|
||||
canRestart?: () => boolean;
|
||||
onRestart?: () => void;
|
||||
restartText?: string;
|
||||
|
||||
completedButton?: JSX.Element;
|
||||
};
|
||||
|
||||
export type StepType = {
|
||||
title: string;
|
||||
actions?: StepActions;
|
||||
content: JSX.Element;
|
||||
};
|
||||
|
||||
export interface SequenceProps {
|
||||
completed?: StepType;
|
||||
elevated?: boolean;
|
||||
orientation: Orientation;
|
||||
onSequenceStepChange?: (prevIndex: number, nextIndex: number) => void;
|
||||
steps: StepType[];
|
||||
footerProps?: any;
|
||||
}
|
||||
|
||||
const Sequence: FC<SequenceProps> = ({
|
||||
completed,
|
||||
elevated,
|
||||
orientation,
|
||||
onSequenceStepChange,
|
||||
steps,
|
||||
footerProps,
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const [stepIndex, setStepIndex] = useState<number>(0);
|
||||
const [stepArray, setStepArray] = useState<number[]>([0]);
|
||||
const isVertical = orientation === 'vertical';
|
||||
const isCompleted = !!completed && stepIndex === steps.length;
|
||||
const horizontalStep = !isVertical && steps[stepIndex]?.content;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Stepper
|
||||
activeStep={stepIndex}
|
||||
orientation={orientation}
|
||||
elevation={elevated ? 2 : 0}
|
||||
>
|
||||
{steps.map((step, i) => (
|
||||
<Step key={i}>
|
||||
<StepLabel>
|
||||
<Typography variant="h6">{step.title}</Typography>
|
||||
</StepLabel>
|
||||
{isVertical && (
|
||||
<StepContent>
|
||||
{step.content}
|
||||
<SequenceFooter
|
||||
stepIndex={i}
|
||||
setStepIndex={setStepIndex}
|
||||
stepArray={stepArray}
|
||||
setStepArray={setStepArray}
|
||||
length={steps.length}
|
||||
onSequenceStepChange={onSequenceStepChange}
|
||||
actions={step.actions || {}}
|
||||
{...footerProps}
|
||||
/>
|
||||
</StepContent>
|
||||
)}
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
{isCompleted && (
|
||||
<div key="completed" className={classes.completionBox}>
|
||||
<Typography variant="h6"> {completed?.title} </Typography>
|
||||
{completed?.content}
|
||||
<SequenceFooter
|
||||
stepIndex={stepIndex}
|
||||
setStepIndex={setStepIndex}
|
||||
stepArray={stepArray}
|
||||
setStepArray={setStepArray}
|
||||
length={steps.length}
|
||||
actions={{ ...(completed?.actions || {}), showNext: false }}
|
||||
{...footerProps}
|
||||
>
|
||||
{completed?.actions?.completedButton}
|
||||
</SequenceFooter>
|
||||
</div>
|
||||
)}
|
||||
{/* Horizontal steppers have buttons on the bottom of stepper */}
|
||||
{!isVertical && (
|
||||
<>
|
||||
<div className={classes.content}>
|
||||
{horizontalStep}
|
||||
<SequenceFooter
|
||||
stepIndex={stepIndex}
|
||||
setStepIndex={setStepIndex}
|
||||
stepArray={stepArray}
|
||||
setStepArray={setStepArray}
|
||||
length={steps.length}
|
||||
onSequenceStepChange={onSequenceStepChange}
|
||||
actions={steps[stepIndex]?.actions || {}}
|
||||
{...footerProps}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sequence;
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { FC, ReactNode } from 'react';
|
||||
import { Button, makeStyles } from '@material-ui/core';
|
||||
import { StepActions } from './Sequence';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
marginTop: theme.spacing(3),
|
||||
'& button': {
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const RestartBtn: FC<{
|
||||
text?: string;
|
||||
handleClick?: () => void;
|
||||
stepIndex: number;
|
||||
}> = ({ text, handleClick }) => (
|
||||
<Button onClick={handleClick}>{text || 'Reset'}</Button>
|
||||
);
|
||||
const NextBtn: FC<{
|
||||
text?: string;
|
||||
handleClick?: () => void;
|
||||
disabled?: boolean;
|
||||
last?: boolean;
|
||||
stepIndex: number;
|
||||
}> = ({ text, handleClick, disabled, last, stepIndex }) => (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
data-testid={`nextButton-${stepIndex}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{text || (last ? 'Finish' : 'Next')}
|
||||
</Button>
|
||||
);
|
||||
const BackBtn: FC<{
|
||||
text?: string;
|
||||
handleClick?: () => void;
|
||||
disabled?: boolean;
|
||||
stepIndex: number;
|
||||
}> = ({ text, handleClick, disabled, stepIndex }) => (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
data-testid={`backButton-${stepIndex}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
{text || 'Back'}
|
||||
</Button>
|
||||
);
|
||||
|
||||
export type SequenceFooterProps = {
|
||||
stepIndex: number;
|
||||
setStepIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
stepArray: number[];
|
||||
setStepArray: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
length: number;
|
||||
actions: StepActions;
|
||||
onSequenceStepChange?: (old: number, updated: number) => void;
|
||||
children?: ReactNode;
|
||||
className: string;
|
||||
};
|
||||
|
||||
const SequenceFooter: FC<SequenceFooterProps> = ({
|
||||
stepIndex,
|
||||
setStepIndex,
|
||||
stepArray,
|
||||
setStepArray,
|
||||
length,
|
||||
onSequenceStepChange,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const onChange = (newIndex: number, callback?: () => void) => {
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
if (onSequenceStepChange) {
|
||||
onSequenceStepChange(stepIndex, newIndex);
|
||||
}
|
||||
|
||||
setStepIndex(newIndex);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const newIndex = actions.nextStep
|
||||
? actions.nextStep(stepIndex, length - 1)
|
||||
: stepIndex + 1;
|
||||
onChange(newIndex, actions.onNext);
|
||||
setStepArray([...stepArray, newIndex]);
|
||||
};
|
||||
const handleBack = () => {
|
||||
stepArray.pop();
|
||||
onChange(stepArray[stepArray.length - 1], actions.onBack);
|
||||
setStepArray([...stepArray]);
|
||||
};
|
||||
const handleRestart = () => {
|
||||
onChange(0, actions.onRestart);
|
||||
setStepArray([0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${classes.root} ${className}`}>
|
||||
{[undefined, true].includes(actions.showBack) && stepIndex !== 0 && (
|
||||
<BackBtn
|
||||
text={actions.backText}
|
||||
handleClick={handleBack}
|
||||
disabled={stepIndex === 0}
|
||||
stepIndex={stepIndex}
|
||||
/>
|
||||
)}
|
||||
{[undefined, true].includes(actions.showNext) && (
|
||||
<NextBtn
|
||||
text={actions.nextText}
|
||||
handleClick={handleNext}
|
||||
disabled={
|
||||
(!!length && stepIndex >= length) ||
|
||||
(!!actions.canNext && !actions.canNext())
|
||||
}
|
||||
stepIndex={stepIndex}
|
||||
/>
|
||||
)}
|
||||
{actions.showRestart && stepIndex !== 0 && (
|
||||
<RestartBtn
|
||||
text={actions.restartText}
|
||||
handleClick={handleRestart}
|
||||
stepIndex={stepIndex}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SequenceFooter;
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { default, StepType } from './Sequence';
|
||||
@@ -30,6 +30,7 @@ export { default as CircleProgress } from './components/ProgressBars/CircleProgr
|
||||
export { default as HorizontalProgress } from './components/ProgressBars/HorizontalProgress';
|
||||
export { default as CopyTextButton } from './components/CopyTextButton';
|
||||
export { default as Progress } from './components/Progress';
|
||||
export { default as Sequence } from './components/Sequence';
|
||||
export { AlphaLabel, BetaLabel } from './components/Lifecycle';
|
||||
export { default as SupportButton } from './components/SupportButton';
|
||||
export { default as SortableTable } from './components/SortableTable';
|
||||
|
||||
Reference in New Issue
Block a user