← All proposals

Timeline

Net-new, in two parts.

Net-new componentOpen in Storybook

proposals/Timeline/Timeline.stories.tsx217 lines · View on GitHub
import { useEffect, useState } from 'react';

import {
	Button,
	CodeBlock,
	FlexContainer,
	Tag,
} from '@pantheon-systems/pds-toolkit-react';
import { Meta, StoryObj } from '@storybook/react-vite';

import { ThinkingState } from '../ThinkingState/ThinkingState';
import { SlotPlaceholder } from '../_story-helpers/SlotPlaceholder';

import { Timeline } from './Timeline';
import { TimelineItem } from './TimelineItem';

const meta: Meta<typeof Timeline> = {
	tags: ['new-pattern', 'ready-for-dev'],
	title: 'Component Proposals/Timeline/Timeline',
	component: Timeline,
	parameters: {
		docs: {
			description: {
				component:
					'**New pattern:** not in PDS v2 today. A list of events over time. It owns the list semantics, the spacing and an optional rail that joins the icons of neighbouring Timeline Items. Dividers, a Chain of Thought or a Thinking State can sit between items.',
			},
		},
	},
	argTypes: {
		children: { control: false, table: { category: 'Content' } },
		className: { table: { category: 'Base attributes' } },
		hasRail: {
			control: 'boolean',
			table: { category: 'Component configuration' },
		},
		size: {
			control: 'select',
			options: ['xs', 's'],
			table: { category: 'Component configuration' },
		},
	},
	args: {
		hasRail: true,
	},
	render: (args) => (
		<Timeline {...args}>
			<TimelineItem
				label='Verified the finding still applies'
				status='done'
				time='01:00 AM'
			/>
			<TimelineItem
				label='Patch available upstream; no custom code touches this plugin'
				status='done'
				time='01:00 AM'
			/>
			<TimelineItem
				iconName='codeBranch'
				label='Opened a pull request with the update for 5 sites'
				time='01:01 AM'
			/>
			<TimelineItem
				label='Ran automated checks: build, smoke tests and visual regression'
				status='done'
				time='01:05 AM'
			/>
			<TimelineItem
				label='Checks failed on 1 canary site'
				status='error'
				time='01:07 AM'
			/>
			<TimelineItem
				label='Rolling out to the remaining site'
				status='active'
				time='now'
			/>
		</Timeline>
	),
};

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

// A run of five steps. While a step is active it shows its slot; between steps
// a Thinking State shows. Each slot is owned by the screen.
const progressionSteps = [
	{
		label: 'Reading the failed build logs and error trace',
		slot: (
			<FlexContainer flexDirection='column' gap='xs'>
				<CodeBlock
					code={
						'ERROR: Your requirements could not be resolved to an installable set of packages.\n  drupal/pathauto is locked to ^1.11 and an update was not requested.'
					}
					hasCopyButton={false}
					language='bash'
				/>
				<FlexContainer gap='xs'>
					<Tag size='xs' tagColor='slate' tagLabel='composer.json' />
					<Tag size='xs' tagColor='slate' tagLabel='composer.lock' />
				</FlexContainer>
			</FlexContainer>
		),
	},
	{
		label: 'Comparing composer.json against the last known-good commit',
		slot: (
			<CodeBlock
				code={'- "drupal/pathauto": "^1.11"\n+ "drupal/pathauto": "^1.13"'}
				hasCopyButton={false}
				language='diff'
			/>
		),
	},
	{
		label: 'Isolating the root cause',
		slot: <SlotPlaceholder label='children: root cause callout' />,
	},
	{
		label: 'Forking a sandbox from Dev to reproduce the failure safely',
		slot: <SlotPlaceholder label='children: Dev to Sandbox diagram' />,
	},
	{
		label: 'Applying a fix and re-running the full build',
		slot: <SlotPlaceholder label='children: before and after screenshots' />,
	},
];

const thinkingLabels = ['Thinking...', 'Tinkering...', 'Zappying...'];

// Moment k: even k is step k / 2 working (with its slot); odd k is the pause
// after that step, with a Thinking State. The last moment is all steps done.
const lastMoment = progressionSteps.length * 2 - 1;

// How long each moment shows, in milliseconds.
const stepDelay = 1500;

const ProgressionDemo = () => {
	const [moment, setMoment] = useState(0);
	const [isPlaying, setIsPlaying] = useState(false);

	// Advance one moment at a time until the run is done.
	useEffect(() => {
		if (!isPlaying) return;
		if (moment === lastMoment) {
			setIsPlaying(false);
			return;
		}
		const timer = setTimeout(() => setMoment((m) => m + 1), stepDelay);
		return () => clearTimeout(timer);
	}, [isPlaying, moment]);
	const isPaused = moment % 2 === 1;
	const activeIndex = isPaused ? -1 : moment / 2;
	const visibleCount = isPaused ? (moment + 1) / 2 : activeIndex + 1;

	return (
		<FlexContainer flexDirection='column' gap='l'>
			<FlexContainer gap='s'>
				<Button
					disabled={isPlaying || moment === lastMoment}
					label='Play'
					onClick={() => setIsPlaying(true)}
					variant='primary'
				/>
				<Button
					label='Restart'
					onClick={() => {
						setIsPlaying(false);
						setMoment(0);
					}}
					variant='secondary'
				/>
			</FlexContainer>
			<Timeline size='s'>
				{[
					...progressionSteps.slice(0, visibleCount).map((step, index) => (
						<TimelineItem
							key={step.label}
							label={step.label}
							status={index === activeIndex ? 'active' : 'done'}
						>
							{index === activeIndex ? step.slot : undefined}
						</TimelineItem>
					)),
					...(isPaused
						? [
								<ThinkingState
									key='thinking'
									label={
										thinkingLabels[((moment - 1) / 2) % thinkingLabels.length]
									}
									size='s'
								/>,
							]
						: []),
				]}
			</Timeline>
		</FlexContainer>
	);
};

export const Progression: Story = {
	parameters: {
		controls: { disable: true },
		docs: {
			description: {
				story:
					'A run from start to finish. Press Play to run it and Restart to go back to the start. A step that is working shows its slot (a log, a diff, a callout, a diagram, screenshots); finished steps collapse to one line; between steps a Thinking State sits at the end of the list. What goes in each slot is up to the screen.',
			},
		},
	},
	render: () => <ProgressionDemo />,
};