From e72b77591e0e333ea3b8874b9a04280e5dca6d95 Mon Sep 17 00:00:00 2001 From: "cgero.eth" Date: Fri, 4 Oct 2024 15:00:34 +0100 Subject: [PATCH] feat(APP-3668): Update ProposalVotingTabs component to disable Breakdown / Votes tabs when status is not active (#306) --- CHANGELOG.md | 8 ++ .../accordionContainer.stories.tsx | 73 +++++++++++-------- .../accordionItemContent.tsx | 28 ++++--- .../tabs/tabsRoot/tabsRoot.stories.tsx | 8 +- .../tabs/tabsTrigger/tabsTrigger.stories.tsx | 41 +++++++---- .../tabs/tabsTrigger/tabsTrigger.test.tsx | 28 ++++--- .../tabs/tabsTrigger/tabsTrigger.tsx | 26 +++---- .../proposalActionsActionRawView/index.ts | 2 +- .../proposalVotingStage.tsx | 33 +++++++-- .../proposalVotingTabs.test.tsx | 18 ++++- .../proposalVotingTabs/proposalVotingTabs.tsx | 11 ++- src/modules/components/vote/index.ts | 2 +- 12 files changed, 177 insertions(+), 101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aae59c08..d5cadd8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Update `Tabs` core component to handle disabled tab trigger state +- Support `forceMount` property on `Accordion` core component and `ProposalVotingStage` module component to correctly + render dynamic content on proposal stages. + ### Fixed - Fix truncation issue on `VoteProposalDataListItem` module component @@ -14,6 +20,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed +- Update `` module component to disable `Breakdown` and `Votes` tabs when voting status is not + active - Bump `actions/setup-node` from 4.0.3 to 4.0.4 - Bump `actions/checkout` from 4.1.7 to 4.2.0 - Update minor and patch dependencies diff --git a/src/core/components/accordion/accordionContainer/accordionContainer.stories.tsx b/src/core/components/accordion/accordionContainer/accordionContainer.stories.tsx index 3318c20a..18b8f016 100644 --- a/src/core/components/accordion/accordionContainer/accordionContainer.stories.tsx +++ b/src/core/components/accordion/accordionContainer/accordionContainer.stories.tsx @@ -1,10 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { type RefAttributes } from 'react'; -import { Accordion, type IAccordionContainerProps } from '..'; +import { Accordion } from '..'; -/** - * Accordion.Container can contain multiple Accordion.Items which comprises an Accordion.Header and its collapsible Accordion.Content. - */ const meta: Meta = { title: 'Core/Components/Accordion/Accordion.Container', component: Accordion.Container, @@ -18,44 +14,57 @@ const meta: Meta = { type Story = StoryObj; -const reusableStoryComponent = (props: IAccordionContainerProps & RefAttributes, count: number) => { - return ( - - {Array.from({ length: count }, (_, index) => ( - - Item {index + 1} Header - -
- Item {index + 1} Content -
-
-
- ))} -
- ); -}; +const DefaultChildComponent = (childCount: number, forceMount?: true) => + [...Array(childCount)].map((_, index) => ( + + Item {index + 1} Header + +
+ Item {index + 1} Content +
+
+
+ )); + /** - * Default usage example of a full Accordion component. + * Default usage example of the Accordion component. */ export const Default: Story = { - args: { isMulti: false }, - render: (args) => reusableStoryComponent(args as IAccordionContainerProps & RefAttributes, 1), + args: { + isMulti: false, + children: DefaultChildComponent(2), + }, +}; + +/** + * Example of an Accordion component with multiple items open at the same time. + */ +export const MultiType: Story = { + args: { + isMulti: true, + children: DefaultChildComponent(3), + }, }; /** - * Example of an Accordion component implementation with a type of "single" and no defaultValue is set. + * Example of an Accordion component with two accordion item open by default. */ -export const SingleTypeItems: Story = { - args: { isMulti: false }, - render: (args) => reusableStoryComponent(args as IAccordionContainerProps & RefAttributes, 3), +export const DefaultValue: Story = { + args: { + isMulti: true, + children: DefaultChildComponent(3), + defaultValue: ['item-1', 'item-2'], + }, }; /** - * Example of an Accordion component implementation with a type of "multiple" where the second and third items have been set as the defaultValue. + * Use the `forceMount` property to always render the accordion item content. */ -export const MultipleTypeItems: Story = { - args: { isMulti: true, defaultValue: ['item-2', 'item-3'] }, - render: (args) => reusableStoryComponent(args as IAccordionContainerProps & RefAttributes, 3), +export const ForceMount: Story = { + args: { + isMulti: true, + children: DefaultChildComponent(3, true), + }, }; export default meta; diff --git a/src/core/components/accordion/accordionItemContent/accordionItemContent.tsx b/src/core/components/accordion/accordionItemContent/accordionItemContent.tsx index 87b0e94c..6597cd4d 100644 --- a/src/core/components/accordion/accordionItemContent/accordionItemContent.tsx +++ b/src/core/components/accordion/accordionItemContent/accordionItemContent.tsx @@ -2,22 +2,26 @@ import { AccordionContent as RadixAccordionContent } from '@radix-ui/react-accor import classNames from 'classnames'; import { forwardRef, type ComponentPropsWithRef } from 'react'; -export interface IAccordionItemContentProps extends ComponentPropsWithRef<'div'> {} +export interface IAccordionItemContentProps extends ComponentPropsWithRef<'div'> { + /** + * Forces the content to be mounted when set to true. + */ + forceMount?: true; +} export const AccordionItemContent = forwardRef((props, ref) => { - const { children, className, ...otherProps } = props; + const { children, className, forceMount, ...otherProps } = props; + + const contentClassNames = classNames( + 'overflow-hidden', // Default + { 'data-[state=closed]:hidden': forceMount }, // Force mount variant + 'data-[state=open]:animate-[accordionExpand_0.3s_cubic-bezier(0.87,_0,_0.13,_1)_forwards]', // Expanding animation + 'data-[state=closed]:animate-[accordionCollapse_0.3s_cubic-bezier(0.87,_0,_0.13,_1)_forwards]', // Collapsing animation + className, + ); return ( - +
{children}
); diff --git a/src/core/components/tabs/tabsRoot/tabsRoot.stories.tsx b/src/core/components/tabs/tabsRoot/tabsRoot.stories.tsx index 75091b80..8d0d6eaf 100644 --- a/src/core/components/tabs/tabsRoot/tabsRoot.stories.tsx +++ b/src/core/components/tabs/tabsRoot/tabsRoot.stories.tsx @@ -23,9 +23,9 @@ const reusableStoryComponent = (props: ITabsRootProps) => { return ( - - - + + +
@@ -66,7 +66,7 @@ export const Underlined: Story = { * Usage example of a Tabs component inside a Card component with the defaultValue set. */ export const InsideCard: Story = { - args: { defaultValue: '2' }, + args: { defaultValue: '3' }, render: (args) => {reusableStoryComponent(args)}, }; diff --git a/src/core/components/tabs/tabsTrigger/tabsTrigger.stories.tsx b/src/core/components/tabs/tabsTrigger/tabsTrigger.stories.tsx index aea29ee7..f7274ffb 100644 --- a/src/core/components/tabs/tabsTrigger/tabsTrigger.stories.tsx +++ b/src/core/components/tabs/tabsTrigger/tabsTrigger.stories.tsx @@ -1,12 +1,20 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { Tabs, type ITabsTriggerProps } from '..'; +import type { ComponentType } from 'react'; +import { Tabs } from '..'; +import { IconType } from '../../icon'; + +const ComponentWrapper = (Story: ComponentType) => ( + + + + + +); -/** - * Tabs.Root can contain multiple Tabs.Triggers inside it's requisite Tabs.List. These tabs will coordinate with what Tabs.Content to show by matching their value prop. - */ const meta: Meta = { title: 'Core/Components/Tabs/Tabs.Trigger', component: Tabs.Trigger, + decorators: ComponentWrapper, parameters: { design: { type: 'figma', @@ -17,25 +25,26 @@ const meta: Meta = { type Story = StoryObj; -const reusableStoryComponent = (props: ITabsTriggerProps) => { - return ( - - - - - - ); +/** + * Default usage example of a single Tabs.Trigger component. + */ +export const Default: Story = { + args: { + label: 'Example', + value: 'example', + }, }; /** * Default usage example of a single Tabs.Trigger component. */ -export const Default: Story = { +export const Disabled: Story = { args: { - label: 'Tab 1', - value: '1', + label: 'Disabled tab', + value: 'disabled', + iconRight: IconType.APP_ASSETS, + disabled: true, }, - render: (args) => reusableStoryComponent(args), }; export default meta; diff --git a/src/core/components/tabs/tabsTrigger/tabsTrigger.test.tsx b/src/core/components/tabs/tabsTrigger/tabsTrigger.test.tsx index 680590b4..d18764c4 100644 --- a/src/core/components/tabs/tabsTrigger/tabsTrigger.test.tsx +++ b/src/core/components/tabs/tabsTrigger/tabsTrigger.test.tsx @@ -3,7 +3,7 @@ import { IconType } from '../../icon'; import { Tabs, type ITabsTriggerProps } from '../../tabs'; describe(' component', () => { - const createTestComponent = (props?: Partial, isUnderlined?: boolean) => { + const createTestComponent = (props?: Partial) => { const completeProps: ITabsTriggerProps = { label: 'Tab 1', value: '1', @@ -11,7 +11,7 @@ describe(' component', () => { }; return ( - + @@ -19,24 +19,22 @@ describe(' component', () => { ); }; - it('should render without crashing', () => { + it('renders a tab', () => { render(createTestComponent()); - - expect(screen.getByRole('tab')).toBeInTheDocument(); + const tab = screen.getByRole('tab'); + expect(tab).toBeInTheDocument(); + expect(tab.getAttribute('disabled')).toBeNull(); }); - it('should pass the correct value prop', () => { - const value = 'complex1'; - render(createTestComponent({ value })); - - const triggerElement = screen.getByRole('tab'); - expect(triggerElement).toHaveAttribute('id', `radix-:r2:-trigger-${value}`); - }); - - it('should render the icon when iconRight is provided', () => { + it('renders the icon when iconRight is provided', () => { const iconRight = IconType.BLOCKCHAIN_BLOCK; render(createTestComponent({ iconRight })); + expect(screen.getByTestId(iconRight)).toBeInTheDocument(); + }); - expect(screen.getByTestId('BLOCKCHAIN_BLOCK')).toBeInTheDocument(); + it('disables the tab when the disabled property is set to true', () => { + const disabled = true; + render(createTestComponent({ disabled })); + expect(screen.getByRole('tab').getAttribute('disabled')).toEqual(''); }); }); diff --git a/src/core/components/tabs/tabsTrigger/tabsTrigger.tsx b/src/core/components/tabs/tabsTrigger/tabsTrigger.tsx index 5f652204..a9055a4c 100644 --- a/src/core/components/tabs/tabsTrigger/tabsTrigger.tsx +++ b/src/core/components/tabs/tabsTrigger/tabsTrigger.tsx @@ -20,29 +20,29 @@ export interface ITabsTriggerProps extends ComponentProps<'button'> { } export const TabsTrigger: React.FC = (props) => { - const { label, iconRight, className, value, ...otherProps } = props; + const { label, iconRight, className, value, disabled, ...otherProps } = props; const { isUnderlined } = useContext(TabsContext); const triggerClassNames = classNames( - 'group line-clamp-1 flex cursor-pointer items-center gap-x-4 rounded-t border-primary-400 py-3 text-base font-normal leading-tight text-neutral-500', // base - 'hover:text-neutral-800', // hover - 'active:data-[state=active]:text-neutral-800 active:data-[state=active]:shadow-[inset_0_0_0_0,0_1px_0_0] active:data-[state=active]:shadow-primary-400', // active click - 'focus:outline-none', // focus -- might need style updates pending conversation - { 'hover:shadow-[inset_0_0_0_0,0_1px_0_0] hover:shadow-neutral-800': isUnderlined }, // isUnderlined variant - 'data-[state=active]:text-neutral-800 data-[state=active]:shadow-[inset_0_-1px_0_0,0_1px_0_0] data-[state=active]:shadow-primary-400', // active selection + 'group line-clamp-1 flex items-center gap-x-4 rounded-t border-primary-400 py-3 text-base font-normal leading-tight', // Base + 'active:data-[state=active]:text-neutral-800 active:data-[state=active]:shadow-[inset_0_0_0_0,0_1px_0_0] active:data-[state=active]:shadow-primary-400', // Active state + 'focus:outline-none', // Focus state + { 'hover:shadow-[inset_0_0_0_0,0_1px_0_0] hover:shadow-neutral-800': isUnderlined && !disabled }, // Underlined & enabled variant + 'data-[state=active]:text-neutral-800 data-[state=active]:shadow-[inset_0_-1px_0_0,0_1px_0_0] data-[state=active]:shadow-primary-400', // Active selection + { 'cursor-pointer text-neutral-500 hover:text-neutral-800': !disabled }, // Enabled state + { 'text-neutral-300': disabled }, // Disabled state className, ); const iconClassNames = classNames( - 'group-data-[state=active]:text-neutral-800', - 'text-neutral-500', - 'group-hover:text-neutral-300', - 'group-active:text-neutral-600', - 'group-focus:text-neutral-500', + 'group-data-[state=active]:text-neutral-800', // Base + { 'text-neutral-200': disabled }, // Disabled state + { 'text-neutral-500 group-hover:text-neutral-300': !disabled }, // Enabled state + { 'group-focus:text-neutral-500 group-active:text-neutral-600': !disabled }, // Enabled & Active/Focus states ); return ( - + {label} {iconRight && } diff --git a/src/modules/components/proposal/proposalActions/proposalActionsAction/proposalActionsActionRawView/index.ts b/src/modules/components/proposal/proposalActions/proposalActionsAction/proposalActionsActionRawView/index.ts index da3b59ba..45971f57 100644 --- a/src/modules/components/proposal/proposalActions/proposalActionsAction/proposalActionsActionRawView/index.ts +++ b/src/modules/components/proposal/proposalActions/proposalActionsAction/proposalActionsActionRawView/index.ts @@ -1 +1 @@ -export { IProposalActionsActionRawViewProps, ProposalActionsActionRawView } from './proposalActionsActionRawView'; +export { ProposalActionsActionRawView, type IProposalActionsActionRawViewProps } from './proposalActionsActionRawView'; diff --git a/src/modules/components/proposal/proposalVoting/proposalVotingStage/proposalVotingStage.tsx b/src/modules/components/proposal/proposalVoting/proposalVotingStage/proposalVotingStage.tsx index 1fe4cdaa..de833454 100644 --- a/src/modules/components/proposal/proposalVoting/proposalVotingStage/proposalVotingStage.tsx +++ b/src/modules/components/proposal/proposalVoting/proposalVotingStage/proposalVotingStage.tsx @@ -30,6 +30,10 @@ export interface IProposalVotingStageProps extends ComponentProps<'div'> { * Name of the proposal stage displayed for multi-stage proposals. */ name?: string; + /** + * Forces the multi-stage content to be rendered when set to true. + */ + forceMount?: true; /** * Index of the stage set automatically by the ProposalVotingContainer for multi-stage proposals. */ @@ -41,8 +45,19 @@ export interface IProposalVotingStageProps extends ComponentProps<'div'> { } export const ProposalVotingStage: React.FC = (props) => { - const { name, status, startDate, endDate, defaultTab, index, children, isMultiStage, className, ...otherProps } = - props; + const { + name, + status, + startDate, + endDate, + defaultTab, + forceMount, + index, + children, + isMultiStage, + className, + ...otherProps + } = props; const { copy } = useOdsModulesContext(); @@ -64,7 +79,11 @@ export const ProposalVotingStage: React.FC = (props) return (
- + {children} @@ -86,8 +105,12 @@ export const ProposalVotingStage: React.FC = (props)

- - + + {children} diff --git a/src/modules/components/proposal/proposalVoting/proposalVotingTabs/proposalVotingTabs.test.tsx b/src/modules/components/proposal/proposalVoting/proposalVotingTabs/proposalVotingTabs.test.tsx index b6d8605b..51cdd4b0 100644 --- a/src/modules/components/proposal/proposalVoting/proposalVotingTabs/proposalVotingTabs.test.tsx +++ b/src/modules/components/proposal/proposalVoting/proposalVotingTabs/proposalVotingTabs.test.tsx @@ -1,12 +1,16 @@ import { render, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { type RefObject } from 'react'; +import { ProposalVotingStatus } from '../../proposalUtils'; import { ProposalVotingTab } from '../proposalVotingDefinitions'; import { type IProposalVotingTabsProps, ProposalVotingTabs } from './proposalVotingTabs'; describe(' component', () => { const createTestComponent = (props?: Partial) => { - const completeProps: IProposalVotingTabsProps = { ...props }; + const completeProps: IProposalVotingTabsProps = { + status: ProposalVotingStatus.ACTIVE, + ...props, + }; return ; }; @@ -47,4 +51,16 @@ describe(' component', () => { await userEvent.click(screen.getByRole('tab', { name: 'Details' })); expect(screen.getByRole('tab', { name: 'Breakdown' })).toBeInTheDocument(); }); + + it.each([{ status: ProposalVotingStatus.PENDING }, { status: ProposalVotingStatus.UNREACHED }])( + 'disables the breakdown and votes tabs when voting status is $status', + ({ status }) => { + render(createTestComponent({ status })); + expect(screen.getByRole('tab', { name: 'Breakdown' }).getAttribute('disabled')).toEqual(''); + expect(screen.getByRole('tab', { name: 'Votes' }).getAttribute('disabled')).toEqual(''); + + const detailsTab = screen.getByRole('tab', { name: 'Details' }); + expect(detailsTab.getAttribute('disabled')).toBeNull(); + }, + ); }); diff --git a/src/modules/components/proposal/proposalVoting/proposalVotingTabs/proposalVotingTabs.tsx b/src/modules/components/proposal/proposalVoting/proposalVotingTabs/proposalVotingTabs.tsx index 5e1f3c45..e327bfab 100644 --- a/src/modules/components/proposal/proposalVoting/proposalVotingTabs/proposalVotingTabs.tsx +++ b/src/modules/components/proposal/proposalVoting/proposalVotingTabs/proposalVotingTabs.tsx @@ -1,9 +1,14 @@ import { useRef, type RefObject } from 'react'; import { Tabs, type ITabsRootProps } from '../../../../../core'; import { useOdsModulesContext } from '../../../odsModulesProvider'; +import { ProposalVotingStatus } from '../../proposalUtils'; import { ProposalVotingTab } from '../proposalVotingDefinitions'; export interface IProposalVotingTabsProps extends ITabsRootProps { + /** + * Voting status of the proposal. + */ + status: ProposalVotingStatus; /** * Default proposal voting tab selected. * @default ProposalVotingTab.BREAKDOWN @@ -16,7 +21,7 @@ export interface IProposalVotingTabsProps extends ITabsRootProps { } export const ProposalVotingTabs: React.FC = (props) => { - const { defaultValue = ProposalVotingTab.BREAKDOWN, accordionRef, children, ...otherProps } = props; + const { defaultValue = ProposalVotingTab.BREAKDOWN, accordionRef, children, status, ...otherProps } = props; const { copy } = useOdsModulesContext(); @@ -33,17 +38,21 @@ export const ProposalVotingTabs: React.FC = (props) => style.setProperty('--radix-collapsible-content-height', clientHeight.toString()); }; + const isVotingActive = status !== ProposalVotingStatus.PENDING && status !== ProposalVotingStatus.UNREACHED; + return (