Skip to content

Commit

Permalink
feat(APP-3659): Update ProposalVoting module component to support mul…
Browse files Browse the repository at this point in the history
…ti-body stages (#352)
  • Loading branch information
shan8851 authored Nov 29, 2024
1 parent fa94a9a commit 32b4195
Show file tree
Hide file tree
Showing 22 changed files with 864 additions and 159 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Added

- Add new `ProposalVoting` module components (`ProposalVotingBodyContent`, `ProposalVotingBodySummary`,
`ProposalVotingBodySummaryList` and `ProposalVotingBodySummaryListItem`)
- Add story and export `ProposalVotingProgress` module component

### Changed

- Update `ProposalVotingStage` module component to accept new optional `bodyList` prop and update rendering logic to
handle multi body proposals per stage.

## [1.0.56] - 2024-11-26

### Added
Expand Down
3 changes: 3 additions & 0 deletions src/modules/assets/copy/modulesCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ export const modulesCopy = {
proposalVotingStage: {
stage: (index: number) => `Stage ${index.toString()}`,
},
proposalVotingBodyContent: {
back: 'All bodies',
},
voteDataListItemStructure: {
yourDelegate: 'Your delegate',
you: 'You',
Expand Down
15 changes: 15 additions & 0 deletions src/modules/components/proposal/proposalVoting/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { ProposalVotingBodyContent } from './proposalVotingBodyContent';
import { ProposalVotingBodySummary } from './proposalVotingBodySummary';
import { ProposalVotingBodySummaryList } from './proposalVotingBodySummaryList';
import { ProposalVotingBodySummaryListItem } from './proposalVotingBodySummaryListItem';
import { ProposalVotingBreakdownMultisig } from './proposalVotingBreakdownMultisig';
import { ProposalVotingBreakdownToken } from './proposalVotingBreakdownToken';
import { ProposalVotingContainer } from './proposalVotingContainer';
import { ProposalVotingDetails } from './proposalVotingDetails';
import { ProposalVotingProgress } from './proposalVotingProgress';
import { ProposalVotingStage } from './proposalVotingStage';
import { ProposalVotingVotes } from './proposalVotingVotes';

Expand All @@ -12,12 +17,22 @@ export const ProposalVoting = {
Details: ProposalVotingDetails,
Stage: ProposalVotingStage,
Votes: ProposalVotingVotes,
BodySummary: ProposalVotingBodySummary,
BodySummaryList: ProposalVotingBodySummaryList,
BodySummaryListItem: ProposalVotingBodySummaryListItem,
BodyContent: ProposalVotingBodyContent,
Progress: ProposalVotingProgress,
};

export * from './proposalVotingBodyContent';
export * from './proposalVotingBodySummary';
export * from './proposalVotingBodySummaryList';
export * from './proposalVotingBodySummaryListItem';
export * from './proposalVotingBreakdownMultisig';
export * from './proposalVotingBreakdownToken';
export * from './proposalVotingContainer';
export * from './proposalVotingDefinitions';
export * from './proposalVotingDetails';
export * from './proposalVotingProgress';
export * from './proposalVotingStage';
export * from './proposalVotingVotes';

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ProposalVotingBodyContent, type IProposalVotingBodyContentProps } from './proposalVotingBodyContent';
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { ProposalVotingStatus } from '../../proposalUtils';
import { ProposalVotingStageContextProvider, type IProposalVotingStageContext } from '../proposalVotingStageContext';
import { ProposalVotingBodyContent, type IProposalVotingBodyContentProps } from './proposalVotingBodyContent';

describe('<ProposalVotingBodyContent /> component', () => {
const createTestComponent = (
props?: Partial<IProposalVotingBodyContentProps>,
contextValues?: Partial<IProposalVotingStageContext>,
) => {
const completeProps: IProposalVotingBodyContentProps = {
status: ProposalVotingStatus.PENDING,
name: 'Test Stage',
bodyId: 'body1',
...props,
};
const completeContextValues: IProposalVotingStageContext = {
startDate: 0,
endDate: 0,
...contextValues,
};

return (
<ProposalVotingStageContextProvider value={completeContextValues}>
<ProposalVotingBodyContent {...completeProps} />
</ProposalVotingStageContextProvider>
);
};

it('renders null when bodyId does not match activeBody', () => {
const bodyId = 'body1';

const { container } = render(createTestComponent({ bodyId }));
expect(container).toBeEmptyDOMElement();
});

it('renders content when bodyId matches activeBody', () => {
const bodyId = 'body1';
const activeBody = 'body1';
const contextValues = {
activeBody: activeBody,
};

const children = 'Test Children';

render(createTestComponent({ bodyId, children }, contextValues));

expect(screen.getByText(children)).toBeInTheDocument();
expect(screen.getByRole('tablist')).toBeInTheDocument();
});

it('renders back button and name when bodyList has more than one element', () => {
const bodyId = 'body1';
const activeBody = 'body1';
const name = 'Test Stage';
const contextValues = {
bodyList: ['body1', 'body2'],
activeBody: activeBody,
};

render(createTestComponent({ bodyId, name }, contextValues));

expect(screen.getByRole('button', { name: 'All bodies' })).toBeInTheDocument();
expect(screen.getByText(name)).toBeInTheDocument();
});

it('does not render back button and name when bodyList has one or fewer elements', () => {
const bodyId = 'body1';
const activeBody = 'body1';
const contextValues = {
activeBody: activeBody,
};

render(createTestComponent({ bodyId }, contextValues));

expect(screen.queryByRole('button', { name: 'All bodies' })).not.toBeInTheDocument();
expect(screen.queryByText('Test Stage')).not.toBeInTheDocument();
});

test.each([
{ status: ProposalVotingStatus.PENDING, expectedTab: 'Details' },
{ status: ProposalVotingStatus.UNREACHED, expectedTab: 'Details' },
{ status: ProposalVotingStatus.ACTIVE, expectedTab: 'Breakdown' },
{ status: ProposalVotingStatus.ACCEPTED, expectedTab: 'Breakdown' },
{ status: ProposalVotingStatus.REJECTED, expectedTab: 'Breakdown' },
])('sets initial activeTab based on status', ({ status, expectedTab }) => {
const bodyId = 'body1';
const activeBody = 'body1';
const contextValues = {
activeBody: activeBody,
};

render(createTestComponent({ bodyId, status }, contextValues));

const selectedTab = screen.getByRole('tab', { selected: true });
expect(selectedTab).toHaveTextContent(expectedTab);
});

it('updates activeTab when status changes', () => {
const bodyId = 'body1';
const activeBody = 'body1';
const contextValues = {
activeBody: activeBody,
};

const { rerender } = render(
createTestComponent({ bodyId, status: ProposalVotingStatus.PENDING }, contextValues),
);

let selectedTab = screen.getByRole('tab', { selected: true });
expect(selectedTab).toHaveTextContent('Details');

rerender(createTestComponent({ bodyId, status: ProposalVotingStatus.ACTIVE }, contextValues));

selectedTab = screen.getByRole('tab', { selected: true });
expect(selectedTab).toHaveTextContent('Breakdown');
});

it('clicking back button calls setActiveBody with undefined', async () => {
const user = userEvent.setup();
const bodyId = 'body1';
const activeBody = 'body1';
const setActiveBodyMock = jest.fn();
const contextValues = {
bodyList: ['body1', 'body2'],
activeBody: activeBody,
setActiveBody: setActiveBodyMock,
};

render(createTestComponent({ bodyId }, contextValues));

const backButton = screen.getByRole('button', { name: 'All bodies' });
await user.click(backButton);

expect(setActiveBodyMock).toHaveBeenCalledWith(undefined);
});

it('does not render back button and name when bodyList is undefined', () => {
const bodyId = 'body1';
const activeBody = 'body1';
const contextValues = {
bodyList: undefined,
activeBody: activeBody,
};

render(createTestComponent({ bodyId }, contextValues));

expect(screen.queryByRole('button', { name: 'All bodies' })).not.toBeInTheDocument();
expect(screen.queryByText('Test Stage')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import classNames from 'classnames';
import { useEffect, useState, type ComponentProps } from 'react';
import { Button, IconType } from '../../../../../core';
import { useGukModulesContext } from '../../../gukModulesProvider';
import { ProposalVotingStatus } from '../../proposalUtils';
import { ProposalVotingTab } from '../proposalVotingDefinitions';
import { useProposalVotingStageContext } from '../proposalVotingStageContext';
import { ProposalVotingTabs } from '../proposalVotingTabs';

export interface IProposalVotingBodyContentProps extends ComponentProps<'div'> {
/**
* Status of the stage.
*/
status: ProposalVotingStatus;
/**
* Name of the proposal stage displayed for multi-stage proposals.
*/
name?: string;
/**
* plugin address of the body used to determine if the content should be rendered or not.
*/
bodyId?: string;
}

export const ProposalVotingBodyContent: React.FC<IProposalVotingBodyContentProps> = (props) => {
const { bodyId, children, name, status, className, ...otherProps } = props;

const { copy } = useGukModulesContext();

const { bodyList, setActiveBody, activeBody } = useProposalVotingStageContext();

const futureStatuses = [ProposalVotingStatus.PENDING, ProposalVotingStatus.UNREACHED];

const stateActiveTab = futureStatuses.includes(status) ? ProposalVotingTab.DETAILS : ProposalVotingTab.BREAKDOWN;

const [activeTab, setActiveTab] = useState<string | undefined>(stateActiveTab);

// Update active tab when stage status changes (e.g from PENDING to UNREACHED)
useEffect(() => setActiveTab(stateActiveTab), [stateActiveTab]);

if (bodyId !== activeBody) {
return null;
}

return (
<div className={classNames('flex w-full flex-col gap-3', className)} {...otherProps}>
{bodyList && bodyList.length > 1 && (
<>
<Button
className="w-fit"
iconLeft={IconType.CHEVRON_LEFT}
variant="tertiary"
onClick={() => setActiveBody?.(undefined)}
size="sm"
>
{copy.proposalVotingBodyContent.back}
</Button>
<p className="text-neutral-500">{name}</p>
</>
)}
<ProposalVotingTabs value={activeTab} onValueChange={setActiveTab} status={status}>
{children}
</ProposalVotingTabs>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ProposalVotingBodySummary, type IProposalVotingBodySummaryProps } from './proposalVotingBodySummary';
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react';
import { type IProposalVotingStageContext, ProposalVotingStageContextProvider } from '../proposalVotingStageContext';
import { type IProposalVotingBodySummaryProps, ProposalVotingBodySummary } from './proposalVotingBodySummary';

describe('<ProposalVotingBodySummary /> component', () => {
const createTestComponent = (
props?: Partial<IProposalVotingBodySummaryProps>,
contextValues?: Partial<IProposalVotingStageContext>,
) => {
const completeProps: IProposalVotingBodySummaryProps = {
children: 'Test Content',
...props,
};

const completeContextValues: IProposalVotingStageContext = {
startDate: 0,
endDate: 0,
...contextValues,
};

return (
<ProposalVotingStageContextProvider value={completeContextValues}>
<ProposalVotingBodySummary {...completeProps} />
</ProposalVotingStageContextProvider>
);
};

it('renders null when activeBody is set', () => {
const contextValues = { activeBody: 'body1' };
const { container } = render(createTestComponent(undefined, contextValues));
expect(container).toBeEmptyDOMElement();
});

it('renders children when activeBody is undefined', () => {
render(createTestComponent({ children: 'Some content' }));
expect(screen.getByText('Some content')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import classNames from 'classnames';
import type { ComponentProps, PropsWithChildren } from 'react';
import { useProposalVotingStageContext } from '../proposalVotingStageContext';

export interface IProposalVotingBodySummaryProps extends ComponentProps<'div'> {}

export const ProposalVotingBodySummary: React.FC<PropsWithChildren<IProposalVotingBodySummaryProps>> = (props) => {
const { children, className, ...otherProps } = props;

const { activeBody } = useProposalVotingStageContext();

if (activeBody) {
return null;
}

return (
<div className={classNames('flex w-full flex-col gap-3', className)} {...otherProps}>
{children}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
ProposalVotingBodySummaryList,
type IProposalVotingBodySummaryListProps,
} from './proposalVotingBodySummaryList';
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react';
import {
ProposalVotingBodySummaryList,
type IProposalVotingBodySummaryListProps,
} from './proposalVotingBodySummaryList';

describe('<ProposalVotingBodySummaryList /> component', () => {
const createTestComponent = (props?: Partial<IProposalVotingBodySummaryListProps>) => {
const completeProps: IProposalVotingBodySummaryListProps = {
children: 'Test Body',
...props,
};

return <ProposalVotingBodySummaryList {...completeProps} />;
};

it('renders children', () => {
render(createTestComponent({ children: 'Test Content' }));
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import classNames from 'classnames';
import type { ComponentProps, PropsWithChildren } from 'react';

export interface IProposalVotingBodySummaryListProps extends ComponentProps<'div'> {}

export const ProposalVotingBodySummaryList: React.FC<PropsWithChildren<IProposalVotingBodySummaryListProps>> = (
props,
) => {
const { children, className, ...otherProps } = props;

return (
<div className={classNames('flex w-full flex-col gap-3', className)} {...otherProps}>
{children}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
ProposalVotingBodySummaryListItem,
type IProposalVotingBodySummaryListItemProps,
} from './proposalVotingBodySummaryListItem';
Loading

0 comments on commit 32b4195

Please sign in to comment.