diff --git a/packages/core/src/components/Sequence/Sequence.stories.tsx b/packages/core/src/components/Sequence/Sequence.stories.tsx new file mode 100644 index 0000000000..f1fd817487 --- /dev/null +++ b/packages/core/src/components/Sequence/Sequence.stories.tsx @@ -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:
Slide 1 content
, + }, + { + title: 'Step 2!', + content:
Slide 2 content
, + }, + { + title: 'Step 3!', + content:
Slide 3 content
, + }, +]; + +export const Horizontal = () => ( + +); + +export const 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: ( +
+ setRequired(!!e.target.value)} + /> +
+ ), + }; + return ; +}; + +export const CompletionStep = () => { + const completed = { + title: 'Succcess!', + content:
You've completed the sequence
, + }; + return ( + + ); +}; diff --git a/packages/core/src/components/Sequence/Sequence.test.tsx b/packages/core/src/components/Sequence/Sequence.test.tsx new file mode 100644 index 0000000000..591ea616c2 --- /dev/null +++ b/packages/core/src/components/Sequence/Sequence.test.tsx @@ -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:
step0
, + }, + { + title: 'Step 1', + content:
step1
, + actions: { nextStep: () => 3 }, + }, + { + title: 'Step 2', + content:
step2
, + }, + { + title: 'Step 3', + content:
step3
, + }, + ]; + }); + + it('Handles nextStep property', () => { + const rendered = render( + wrapInTestApp(), + ); + 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(), + ); + 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:
step0
, + }, + { + title: 'Final Step', + actions: { + nextText: 'FinalStepNext', + }, + content:
final step
, + }, + ]; + + const rendered = render( + wrapInTestApp( + , + ), + ); + expect(rendered.getByText('Step0Next')).toBeInTheDocument(); + fireEvent.click(rendered.getByText('Step0Next')); + + expect(rendered.getByText('FinalStepNext')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/Sequence/Sequence.tsx b/packages/core/src/components/Sequence/Sequence.tsx new file mode 100644 index 0000000000..ad298bbcd8 --- /dev/null +++ b/packages/core/src/components/Sequence/Sequence.tsx @@ -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 = ({ + completed, + elevated, + orientation, + onSequenceStepChange, + steps, + footerProps, +}) => { + const classes = useStyles(); + const [stepIndex, setStepIndex] = useState(0); + const [stepArray, setStepArray] = useState([0]); + const isVertical = orientation === 'vertical'; + const isCompleted = !!completed && stepIndex === steps.length; + const horizontalStep = !isVertical && steps[stepIndex]?.content; + + return ( + + + {steps.map((step, i) => ( + + + {step.title} + + {isVertical && ( + + {step.content} + + + )} + + ))} + + {isCompleted && ( +
+ {completed?.title} + {completed?.content} + + {completed?.actions?.completedButton} + +
+ )} + {/* Horizontal steppers have buttons on the bottom of stepper */} + {!isVertical && ( + <> +
+ {horizontalStep} + +
+ + )} +
+ ); +}; + +export default Sequence; diff --git a/packages/core/src/components/Sequence/SequenceFooter.tsx b/packages/core/src/components/Sequence/SequenceFooter.tsx new file mode 100644 index 0000000000..475be30b8e --- /dev/null +++ b/packages/core/src/components/Sequence/SequenceFooter.tsx @@ -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 }) => ( + +); +const NextBtn: FC<{ + text?: string; + handleClick?: () => void; + disabled?: boolean; + last?: boolean; + stepIndex: number; +}> = ({ text, handleClick, disabled, last, stepIndex }) => ( + +); +const BackBtn: FC<{ + text?: string; + handleClick?: () => void; + disabled?: boolean; + stepIndex: number; +}> = ({ text, handleClick, disabled, stepIndex }) => ( + +); + +export type SequenceFooterProps = { + stepIndex: number; + setStepIndex: React.Dispatch>; + stepArray: number[]; + setStepArray: React.Dispatch>; + length: number; + actions: StepActions; + onSequenceStepChange?: (old: number, updated: number) => void; + children?: ReactNode; + className: string; +}; + +const SequenceFooter: FC = ({ + 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 ( +
+ {[undefined, true].includes(actions.showBack) && stepIndex !== 0 && ( + + )} + {[undefined, true].includes(actions.showNext) && ( + = length) || + (!!actions.canNext && !actions.canNext()) + } + stepIndex={stepIndex} + /> + )} + {actions.showRestart && stepIndex !== 0 && ( + + )} + {children} +
+ ); +}; + +export default SequenceFooter; diff --git a/packages/core/src/components/Sequence/index.ts b/packages/core/src/components/Sequence/index.ts new file mode 100644 index 0000000000..4391c987b5 --- /dev/null +++ b/packages/core/src/components/Sequence/index.ts @@ -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'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index af5789ef92..62a89fd337 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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';