Sequence component (#619)

This commit is contained in:
Wesley Yee
2020-04-24 12:48:35 -04:00
committed by GitHub
parent 55c0385dc4
commit 076f44bb15
6 changed files with 517 additions and 0 deletions
@@ -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';
+1
View File
@@ -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';