Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a0d05c1
Initial AI version
milosh86 Aug 26, 2025
8e7deb4
Initial AI version
milosh86 Aug 26, 2025
45c03e2
Implement MD breakpoint
milosh86 Aug 26, 2025
bd6795d
Fix label
milosh86 Aug 26, 2025
85296c3
Wrap in DataListItem
milosh86 Aug 26, 2025
7783cb1
Add copy
milosh86 Aug 26, 2025
cf40399
Refactor status props
milosh86 Aug 26, 2025
e6aed69
Add error message
milosh86 Aug 26, 2025
68da41f
Add isSimulatable flag
milosh86 Aug 26, 2025
c63951b
Disable button if no tenderly url
milosh86 Aug 26, 2025
a3ebe2a
Cleanup
milosh86 Aug 26, 2025
33eb4c4
Cleanup stories
milosh86 Aug 26, 2025
25aa7d2
Fix tests
milosh86 Aug 26, 2025
54cc47d
Add changeset
milosh86 Aug 26, 2025
54cd5c0
Fix lint
milosh86 Aug 26, 2025
a2cf1f7
Update error story
milosh86 Aug 27, 2025
31308a5
Update story folder position
milosh86 Aug 27, 2025
769de3f
Add variant prop to Dropdown
milosh86 Aug 27, 2025
2caf8f6
Refactor props structure
milosh86 Aug 27, 2025
1cc6e66
Rename props
milosh86 Aug 27, 2025
97648f5
Remove custom date formatting
milosh86 Aug 27, 2025
a977bf4
Update dropdownItem to support submit button
milosh86 Aug 28, 2025
e9fcb6d
Align Tenderly button to the left when not simulatable
milosh86 Aug 28, 2025
86e5c42
Refactor module structure - add action module
milosh86 Aug 28, 2025
de79dae
Update changeset
milosh86 Aug 28, 2025
656198b
Include action module
milosh86 Aug 28, 2025
ed94713
p -> div to avoid hydration error
milosh86 Aug 29, 2025
304690b
Merge branch 'main' into APP-4058-tenderly
cgero-eth Aug 29, 2025
356ad24
Create IActionSimulationRun interface
milosh86 Aug 29, 2025
cc8fca0
Update tests to use copy
milosh86 Aug 29, 2025
a784306
Address PR commets
milosh86 Aug 29, 2025
0b69f28
Update exports
milosh86 Aug 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/young-flies-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@aragon/gov-ui-kit': minor
---

Implement `ActionSimulation` component
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,29 @@ export const Default: Story = {
},
};

/**
* DropdownContainer component with a variant.
*/
export const WithVariant: Story = {
render: (props: IDropdownContainerProps) => (
<Dropdown.Container {...props}>
<Dropdown.Item>First item</Dropdown.Item>
<Dropdown.Item>Second item</Dropdown.Item>
<Dropdown.Item>Third item with a longer label</Dropdown.Item>
</Dropdown.Container>
),
args: {
label: 'Dropdown',
variant: 'secondary',
},
argTypes: {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary', 'tertiary'],
},
},
};

export const OnlyIcon: Story = {
render: (props: IDropdownContainerProps) => (
<Dropdown.Container {...props}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as RadixDropdown from '@radix-ui/react-dropdown-menu';
import classNames from 'classnames';
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
import { Button, type IButtonProps } from '../../button';
import { Button, type ButtonVariant, type IButtonProps } from '../../button';
import { IconType } from '../../icon';

export interface IDropdownContainerProps extends Omit<ComponentProps<'div'>, 'dir'> {
Expand Down Expand Up @@ -62,6 +62,11 @@ export interface IDropdownContainerProps extends Omit<ComponentProps<'div'>, 'di
* Additional classnames for the dropdown container (e.g. for setting a max width for the dropdown items).
*/
contentClassNames?: string;
/**
* Variant of the dropdown.
* @default tertiary
*/
variant?: ButtonVariant;
}

export const DropdownContainer: React.FC<IDropdownContainerProps> = (props) => {
Expand All @@ -80,6 +85,7 @@ export const DropdownContainer: React.FC<IDropdownContainerProps> = (props) => {
constrainContentWidth = true,
constrainContentHeight = true,
contentClassNames,
variant = 'tertiary',
...otherProps
} = props;

Expand All @@ -103,7 +109,7 @@ export const DropdownContainer: React.FC<IDropdownContainerProps> = (props) => {
<RadixDropdown.Trigger className="group" asChild={true} disabled={disabled}>
{customTrigger ?? (
<Button
variant="tertiary"
variant={variant}
size={size}
responsiveSize={responsiveSize}
iconLeft={!hasLabel ? triggerIcon : undefined}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,27 @@ export const Link: Story = {
},
};

/**
* Set the `formId` property to the DropdownItem component to render a button of type "submit".
*/
export const SubmitButton: Story = {
render: (props: IDropdownItemProps) => (
<form
id="form-id"
onSubmit={(e) => {
e.preventDefault();
alert('Form submitted');
}}
>
<Dropdown.Container label="Dropdown with link">
<Dropdown.Item {...props} />
</Dropdown.Container>
</form>
),
args: {
children: 'As submit button',
formId: 'form-id',
},
};

export default meta;
39 changes: 35 additions & 4 deletions src/core/components/dropdown/dropdownItem/dropdownItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export interface IDropdownItemProps extends Omit<ComponentProps<'div'>, 'onSelec
* Rel attribute of the dropdown link.
*/
rel?: string;
/**
* Form id to associate the dropdown item with a form. In this case, the dropdown item will behave as a submit button.
*/
formId?: string;
Comment thread
milosh86 marked this conversation as resolved.
/**
* Disables the dropdown item when set to true.
*/
Expand All @@ -51,22 +55,41 @@ export const DropdownItem: React.FC<IDropdownItemProps> = (props) => {
href,
target,
rel,
formId,
...otherProps
} = props;

const renderSubmitButton = formId != null;
const renderLink = href != null && href.length > 0;
const linkRel = target === '_blank' ? `noopener noreferrer ${rel ?? ''}` : rel;

const ItemWrapper = renderLink ? 'a' : React.Fragment;
const itemWrapperProps = renderLink ? { href, target, rel: linkRel } : {};
let ItemWrapper: React.ElementType = React.Fragment;
let itemWrapperProps:
| React.AnchorHTMLAttributes<HTMLAnchorElement>
| React.ButtonHTMLAttributes<HTMLButtonElement>
| object = {};

if (renderLink) {
ItemWrapper = 'a';
itemWrapperProps = {
href,
target,
rel: target === '_blank' ? `noopener noreferrer ${rel ?? ''}` : rel,
};
} else if (renderSubmitButton) {
ItemWrapper = 'button';
itemWrapperProps = {
type: 'submit' as const,
form: formId,
};
}

const defaultIcon = renderLink ? IconType.LINK_EXTERNAL : selected ? IconType.CHECKMARK : undefined;
const processedIcon = icon ?? defaultIcon;

return (
<RadixDropdown.Item
disabled={disabled}
asChild={renderLink}
asChild={renderLink || renderSubmitButton}
className={classNames(
'flex items-center gap-3 px-4 py-3', // Layout
'cursor-pointer rounded-xl text-base leading-tight focus-visible:outline-hidden', // Style
Expand All @@ -79,6 +102,14 @@ export const DropdownItem: React.FC<IDropdownItemProps> = (props) => {
{ 'flex-row-reverse justify-end': iconPosition === 'left' && icon != null },
className,
)}
onSelect={(event) => {
// For submit buttons, we need to prevent the dropdown from closing immediately to allow the form submission to complete properly
if (renderSubmitButton) {
event.preventDefault();
}

props.onSelect?.(event);
}}
Comment thread
milosh86 marked this conversation as resolved.
Outdated
{...otherProps}
>
<ItemWrapper {...itemWrapperProps}>
Expand Down
16 changes: 16 additions & 0 deletions src/modules/assets/copy/modulesCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ export const modulesCopy = {
delegations: 'Delegations',
votingPower: 'Voting Power',
},
actionSimulation: {
action: 'action',
actions: 'actions',
totalActionsTerm: 'Total actions',
lastSimulationTerm: 'Last simulation',
never: 'Never',
executableTerm: 'Executable',
simulateAgain: 'Simulate again',
simulate: 'Simulate',
simulating: 'Simulating',
viewOnTenderly: 'View on Tenderly',
likelyToSucceed: 'Likely to succeed',
likelyToFail: 'Likely to fail',
unknown: 'Unknown',
now: 'Now',
Comment thread
milosh86 marked this conversation as resolved.
Outdated
},
proposalActionsContainer: {
emptyHeader: 'No actions added',
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { DateTime } from 'luxon';
import { ActionSimulation } from './actionSimulation';

const meta: Meta<typeof ActionSimulation> = {
title: 'Modules/Components/Action/ActionSimulation',
component: ActionSimulation,
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/ISSDryshtEpB7SUSdNqAcw/Governance-UI-Kit?node-id=30069-34747&t=tQiF5klPD9cjUit6-4',
},
},
args: {
className: 'flex-1',
},
};

type Story = StoryObj<typeof ActionSimulation>;

/**
* Completed simulation with positive outcome
*/
export const Success: Story = {
args: {
totalActions: 3,
lastSimulation: {
timestamp: DateTime.now().minus({ seconds: 10 }).toMillis(),
url: 'https://dashboard.tenderly.co/simulation/12345',
status: 'success',
},
},
};

/**
* Completed simulation with negative outcome (can't be executed)
*/
export const Failure: Story = {
args: {
totalActions: 2,
lastSimulation: {
timestamp: DateTime.now().minus({ seconds: 10 }).toMillis(),
url: 'https://dashboard.tenderly.co/simulation/12345',
status: 'failure',
},
},
};

/**
* Loading state while simulation is running
*/
export const Loading: Story = {
args: {
totalActions: 5,
isLoading: true,
},
};

/**
* No simulation has been run yet
*/
export const NoPreviousSimulation: Story = {
args: {
totalActions: 1,
},
};

/**
* With error message displayed
*/
export const WithErrorMessage: Story = {
args: {
totalActions: 2,
lastSimulation: {
timestamp: DateTime.now().minus({ weeks: 2 }).toMillis(),
url: 'https://dashboard.tenderly.co/simulation/12345',
status: 'success',
},
error: 'Simulation failed to run. Please try again.',
},
};

/**
* Not simulatable - only shows Tenderly link
*/
export const NotSimulatable: Story = {
args: {
totalActions: 3,
lastSimulation: {
timestamp: DateTime.now().minus({ hours: 2 }).toMillis(),
url: 'https://dashboard.tenderly.co/simulation/12345',
status: 'success',
},
isEnabled: false,
},
};

export default meta;
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { render, screen } from '@testing-library/react';
import { DateTime } from 'luxon';
import { GukCoreProvider } from '../../../../core';
import type { IActionSimulationProps } from './actionSimulation';
import { ActionSimulation } from './actionSimulation';

describe('<ActionSimulation /> component', () => {
const createTestComponent = (props?: Partial<IActionSimulationProps>) => {
const completeProps: IActionSimulationProps = {
totalActions: 3,
...props,
};

return (
<GukCoreProvider>
<ActionSimulation {...completeProps} />
</GukCoreProvider>
);
};

it('renders all definition list items', () => {
render(createTestComponent());

expect(screen.getByText('Total actions')).toBeInTheDocument();
expect(screen.getByText('Last simulation')).toBeInTheDocument();
expect(screen.getByText('Executable')).toBeInTheDocument();
Comment thread
milosh86 marked this conversation as resolved.
Outdated
});

it('renders the total actions count', () => {
render(createTestComponent({ totalActions: 5 }));
expect(screen.getByText('5 actions')).toBeInTheDocument();
});

it('renders singular action text for one action', () => {
render(createTestComponent({ totalActions: 1 }));
expect(screen.getByText('1 action')).toBeInTheDocument();
});

it('renders "Never" when no last simulation is provided', () => {
render(createTestComponent());
expect(screen.getByText('Never')).toBeInTheDocument();
});

it('renders the execution status label', () => {
render(
createTestComponent({
lastSimulation: {
timestamp: DateTime.now().toMillis(),
url: 'https://dashboard.tenderly.co/simulation/12345',
status: 'success',
},
}),
);
Comment thread
milosh86 marked this conversation as resolved.
expect(screen.getByText('Likely to succeed')).toBeInTheDocument();
});

it('renders failure status label', () => {
render(
createTestComponent({
lastSimulation: {
timestamp: DateTime.now().toMillis(),
url: 'https://dashboard.tenderly.co/simulation/12345',
status: 'failure',
},
}),
);
expect(screen.getByText('Likely to fail')).toBeInTheDocument();
});

it('renders unknown status when no lastSimulation is provided', () => {
render(createTestComponent());
expect(screen.getByText('Unknown')).toBeInTheDocument();
});

it('renders loading state when execution status is loading', () => {
render(
createTestComponent({
isLoading: true,
}),
);
Comment thread
milosh86 marked this conversation as resolved.
Outdated

expect(screen.getAllByText('Simulating')).toHaveLength(3);
});

it('shows simulate button by default when isSimulatable is not specified', () => {
render(createTestComponent());

expect(screen.getByText(/simulate/i)).toBeInTheDocument();
});

it('hides simulate button when isSimulatable is false', () => {
Comment thread
milosh86 marked this conversation as resolved.
Outdated
render(createTestComponent({ isEnabled: false }));

expect(screen.queryByText(/simulate/i)).toBeNull();
Comment thread
milosh86 marked this conversation as resolved.
Outdated
});
});
Loading