Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: 14277 studio pagination content #14278

Merged
merged 11 commits into from
Jan 19, 2025
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
}

.buttonWrapper {
display: flex;
gap: var(--fds-spacing-4);
margin-block: var(--fds-spacing-4);
}

.statusBarContainer {
display: flex;
gap: 4px;
margin-top: 1rem;
}

.statusBarPiece {
flex: 1;
height: 10px;
background: #e0e0e0;
border-radius: 50%;
width: 10px;
margin: 0;
}

.statusBarPiece.active {
background: var(--fds-semantic-surface-action-first-default);
}

.icon {
font-size: var(--fds-sizing-5);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React, { type ChangeEvent, useState } from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { StudioPaginatedContent } from './StudioPaginatedContent';
import { StudioParagraph } from '../StudioParagraph';
import { StudioTextfield } from '../StudioTextfield';
import { usePagination } from './hooks/usePagination';
import { type StudioPaginatedItem } from './types/StudioPaginatedItem';

type ChildrenProps = {
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
value: string;
};
const Children1 = ({ onChange, value }: ChildrenProps) => {
return (
<div>
<StudioParagraph>Children 1</StudioParagraph>
<StudioTextfield
size='sm'
label='Please enter the value "3" to proceed to the next page.'
onChange={onChange}
value={value}
/>
</div>
);
};
const Children2 = () => <StudioParagraph size='sm'>Children 2</StudioParagraph>;
const Children3 = () => <StudioParagraph size='sm'>Children 3</StudioParagraph>;
const Children4 = () => <StudioParagraph size='sm'>Children 4</StudioParagraph>;

type Story = StoryFn<typeof StudioPaginatedContent>;

const meta: Meta = {
title: 'Components/StudioPaginatedContent',
component: StudioPaginatedContent,
argTypes: {},
};

export const Preview: Story = () => {
const [inputValue, setInputValue] = useState('');

const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setInputValue(value);
};

const items: StudioPaginatedItem[] = [
{
pageContent: <Children1 key={1} value={inputValue} onChange={handleInputChange} />,
validationRuleForNextButton: inputValue === '3',
},
{
pageContent: <Children2 key={2} />,
},
{
pageContent: <Children3 key={3} />,
},
{
pageContent: <Children4 key={4} />,
},
];
const { currentPage, pages, navigation } = usePagination(items);

return (
<StudioPaginatedContent
buttonTexts={{ previous: 'Previous', next: 'Next' }}
componentToRender={pages[currentPage]}
navigation={navigation}
currentPageNumber={currentPage}
totalPages={pages.length}
/>
);
};

export default meta;
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { StudioPaginatedContent, type StudioPaginatedContentProps } from './StudioPaginatedContent';

const navigationMock: StudioPaginatedContentProps['navigation'] = {
canGoNext: true,
canGoPrevious: true,
onNext: jest.fn(),
onPrevious: jest.fn(),
};

const buttonTextsMock: StudioPaginatedContentProps['buttonTexts'] = {
previous: 'Previous',
next: 'Next',
};

const defaultProps: StudioPaginatedContentProps = {
totalPages: 5,
currentPageNumber: 2,
componentToRender: <div>Content</div>,
buttonTexts: buttonTextsMock,
navigation: navigationMock,
};

describe('StudioPaginatedContent', () => {
it('renders the componentToRender', () => {
renderStudioPaginatedContent();
expect(screen.getByText('Content')).toBeInTheDocument();
});

it('renders the correct number of navigation circles', () => {
renderStudioPaginatedContent();

const circles = screen.getAllByRole('status');
expect(circles.length).toBe(defaultProps.totalPages);
});

it('disables the previous button when canGoPrevious is false', () => {
renderStudioPaginatedContent({
navigation: { ...navigationMock, canGoPrevious: false },
});

expect(screen.getByText('Previous')).toBeDisabled();
});

it('enables the next button when canGoNext is undefined', () => {
renderStudioPaginatedContent({
navigation: { ...navigationMock, canGoNext: undefined },
});

expect(screen.getByText('Next')).not.toBeDisabled();
});

it('enables the previous button when canGoPrevious is undefined', () => {
renderStudioPaginatedContent({
navigation: { ...navigationMock, canGoPrevious: undefined },
});

expect(screen.getByText('Next')).not.toBeDisabled();
});

it('disables the next button when canGoNext is false', () => {
renderStudioPaginatedContent({
navigation: { ...navigationMock, canGoNext: false },
});

expect(screen.getByText('Next')).toBeDisabled();
});

it('calls onPrevious when the previous button is clicked', () => {
renderStudioPaginatedContent();

fireEvent.click(screen.getByText('Previous'));
expect(defaultProps.navigation.onPrevious).toHaveBeenCalled();
});

it('calls onNext when the next button is clicked', () => {
renderStudioPaginatedContent();
fireEvent.click(screen.getByText('Next'));
expect(defaultProps.navigation.onNext).toHaveBeenCalled();
});

it('highlights the correct navigation circle based on currentPageNumber', () => {
renderStudioPaginatedContent();
const activeCircles = screen
.getAllByRole('status')
.filter((circle) => circle.classList.contains('active'));
expect(activeCircles.length).toBe(defaultProps.currentPageNumber + 1);
});
});

const renderStudioPaginatedContent = (props: Partial<StudioPaginatedContentProps> = {}) => {
return render(<StudioPaginatedContent {...defaultProps} {...props} />);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React, { type ReactNode, type ReactElement } from 'react';
import classes from './StudioPaginatedContent.module.css';
import { StudioButton } from '../StudioButton';
import { ChevronLeftIcon, ChevronRightIcon } from '@studio/icons';
import { type StudioPaginatedNavigation } from './types/StudioPaginatedNavigation';

type ButtonTexts = {
previous: string;
next: string;
};

export type StudioPaginatedContentProps = {
totalPages: number;
currentPageNumber: number;
componentToRender: ReactNode;
buttonTexts: ButtonTexts;
navigation: StudioPaginatedNavigation;
};

export const StudioPaginatedContent = ({
navigation: { canGoNext = true, canGoPrevious = true, onNext, onPrevious },
totalPages,
componentToRender,
currentPageNumber,
buttonTexts: { previous: previousButtonText, next: nextButtonText },
}: StudioPaginatedContentProps): ReactElement => {
return (
<div className={classes.wrapper}>
<div>{componentToRender}</div>
<div className={classes.buttonWrapper}>
<StudioButton variant='tertiary' size='sm' onClick={onPrevious} disabled={!canGoPrevious}>
<ChevronLeftIcon className={classes.icon} />
{previousButtonText}
</StudioButton>
<NavigationCircles totalPages={totalPages} currentPageNumber={currentPageNumber} />
<StudioButton variant='tertiary' size='sm' onClick={onNext} disabled={!canGoNext}>
{nextButtonText}
<ChevronRightIcon />
</StudioButton>
</div>
</div>
);
};

type NavigationCirclesProps = {
totalPages: number;
currentPageNumber: number;
};

const NavigationCircles = ({ totalPages, currentPageNumber }: NavigationCirclesProps) => {
return (
<div className={classes.statusBarContainer}>
{getArrayFromLength(totalPages).map((_, index) => (
<div
key={index}
role='status'
className={`${classes.statusBarPiece} ${index <= currentPageNumber ? classes.active : ''}`}
/>
))}
</div>
);
};

const getArrayFromLength = (length: number): number[] =>
Array.from({ length }, (_, index) => index);
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React from 'react';
import { renderHook, act } from '@testing-library/react';
import { usePagination } from './usePagination';
import { type StudioPaginatedItem } from '../types/StudioPaginatedItem';

const items: StudioPaginatedItem[] = [
{ pageContent: <div>Page 1</div>, validationRuleForNextButton: true },
{ pageContent: <div>Page 2</div>, validationRuleForNextButton: true },
{ pageContent: <div>Page 3</div>, validationRuleForNextButton: false },
];

describe('usePagination', () => {
it('should initialize with the first page', () => {
const { result } = renderHook(() => usePagination(items));
expect(result.current.currentPage).toBe(0);
expect(result.current.pages).toHaveLength(3);
expect(result.current.navigation.canGoNext).toBe(true);
expect(result.current.navigation.canGoPrevious).toBe(false);
});

it('should set canGoNext to true when validationRuleForNextButton is undefined', () => {
const itemsWithoutValidationRuleForNextButton: StudioPaginatedItem[] = [
{ pageContent: <div>Page 1</div> },
{ pageContent: <div>Page 2</div> },
];
const { result } = renderHook(() => usePagination(itemsWithoutValidationRuleForNextButton));

expect(result.current.currentPage).toBe(0);
expect(result.current.pages).toHaveLength(2);
expect(result.current.navigation.canGoNext).toBe(true);
expect(result.current.navigation.canGoPrevious).toBe(false);
});

it('should go to the next page if validation rule allows', () => {
const { result } = renderHook(() => usePagination(items));
act(() => {
result.current.navigation.onNext();
});
expect(result.current.currentPage).toBe(1);
expect(result.current.navigation.canGoNext).toBe(true);
expect(result.current.navigation.canGoPrevious).toBe(true);
});

it('should not go to the next page if validation rule does not allow', () => {
const { result } = renderHook(() => usePagination(items));
act(() => {
result.current.navigation.onNext();
result.current.navigation.onNext();
});
expect(result.current.currentPage).toBe(2);
expect(result.current.navigation.canGoNext).toBe(false);
expect(result.current.navigation.canGoPrevious).toBe(true);
});

it('should go to the previous page', () => {
const { result } = renderHook(() => usePagination(items));
act(() => {
result.current.navigation.onNext();
});
expect(result.current.currentPage).toBe(1);

act(() => {
result.current.navigation.onPrevious();
});
expect(result.current.currentPage).toBe(0);

expect(result.current.navigation.canGoNext).toBe(true);
expect(result.current.navigation.canGoPrevious).toBe(false);
});

it('should not go to the previous page if already on the first page', () => {
const { result } = renderHook(() => usePagination(items));
act(() => {
result.current.navigation.onPrevious();
});
expect(result.current.currentPage).toBe(0);
expect(result.current.navigation.canGoNext).toBe(true);
expect(result.current.navigation.canGoPrevious).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { type ReactNode, useState } from 'react';
import { type StudioPaginatedNavigation } from '../types/StudioPaginatedNavigation';
import { type StudioPaginatedItem } from '../types/StudioPaginatedItem';

export const usePagination = (items: StudioPaginatedItem[]) => {
const [currentPage, setCurrentPage] = useState<number>(0);

const hasPreviousPage: boolean = currentPage > 0;
const hasNextPage: boolean = currentPage < items.length - 1;

const validationRules: boolean[] = mapItemsToValidationRules(items);
const pages: ReactNode[] = mapItemsToPages(items);

const canGoToNextPage: boolean = validationRules[currentPage] && hasNextPage;

const goNext = () => {
if (canGoToNextPage) {
setCurrentPage((current: number) => current + 1);
}
};

const goPrevious = () => {
if (hasPreviousPage) {
setCurrentPage((current: number) => current - 1);
}
};

const navigation: StudioPaginatedNavigation = {
canGoNext: canGoToNextPage,
canGoPrevious: hasPreviousPage,
onNext: goNext,
onPrevious: goPrevious,
};

return { currentPage, pages, navigation };
};

const mapItemsToValidationRules = (items: StudioPaginatedItem[]): boolean[] => {
return items.map((item: StudioPaginatedItem) => item?.validationRuleForNextButton ?? true);
};

const mapItemsToPages = (items: StudioPaginatedItem[]): ReactNode[] => {
return items.map((item: StudioPaginatedItem) => item.pageContent);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { StudioPaginatedContent } from './StudioPaginatedContent';
Loading
Loading