Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions app/actions/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
type ClearMusdConversionAssetDetailCtasSeenAction,
type SetMoneyOnboardingSeenAction,
type SetTokenOverviewChartTypeAction,
type SetOnboardingStepperStepAction,
UserActionType,
} from './types';

Expand Down Expand Up @@ -250,3 +251,18 @@ export function setTokenOverviewChartType(
payload: { chartType },
};
}

/**
* Action to set the current step for a named onboarding stepper.
* Keyed by stepperId to support multiple independent steppers without
* adding new Redux fields per product.
*/
export function setOnboardingStepperStep(
stepperId: string,
step: number,
): SetOnboardingStepperStepAction {
return {
type: UserActionType.SET_ONBOARDING_STEPPER_STEP,
payload: { stepperId, step },
};
}
9 changes: 8 additions & 1 deletion app/actions/user/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export enum UserActionType {
CLEAR_MUSD_CONVERSION_ASSET_DETAIL_CTAS_SEEN = 'CLEAR_MUSD_CONVERSION_ASSET_DETAIL_CTAS_SEEN',
SET_MONEY_ONBOARDING_SEEN = 'SET_MONEY_ONBOARDING_SEEN',
SET_TOKEN_OVERVIEW_CHART_TYPE = 'SET_TOKEN_OVERVIEW_CHART_TYPE',
SET_ONBOARDING_STEPPER_STEP = 'SET_ONBOARDING_STEPPER_STEP',
}

// User actions
Expand Down Expand Up @@ -124,6 +125,11 @@ export type SetTokenOverviewChartTypeAction =
payload: { chartType: ChartType };
};

export type SetOnboardingStepperStepAction =
Action<UserActionType.SET_ONBOARDING_STEPPER_STEP> & {
payload: { stepperId: string; step: number };
};

/**
* User actions union type
*/
Expand Down Expand Up @@ -154,4 +160,5 @@ export type UserAction =
| SetMusdConversionAssetDetailCtaSeenAction
| ClearMusdConversionAssetDetailCtasSeenAction
| SetMoneyOnboardingSeenAction
| SetTokenOverviewChartTypeAction;
| SetTokenOverviewChartTypeAction
| SetOnboardingStepperStepAction;
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Third party dependencies.
import React from 'react';
import { render } from '@testing-library/react-native';

// Internal dependencies.
import SegmentedProgressBar from './SegmentedProgressBar';

describe('SegmentedProgressBar', () => {
it('renders the outer track with the provided testID', () => {
const { getByTestId } = render(
<SegmentedProgressBar current={1} total={3} testID="progress" />,
);
expect(getByTestId('progress')).toBeOnTheScreen();
});

it('renders the correct number of segment children for a given total', () => {
const total = 4;
const { getByTestId } = render(
<SegmentedProgressBar current={1} total={total} testID="progress" />,
);
expect(getByTestId('progress').children).toHaveLength(total);
});

it('renders no segments when total is 0', () => {
const { getByTestId } = render(
<SegmentedProgressBar current={0} total={0} testID="progress" />,
);
expect(getByTestId('progress').children).toHaveLength(0);
});

it('renders all segments when current is 0', () => {
const total = 3;
const { getByTestId } = render(
<SegmentedProgressBar current={0} total={total} testID="progress" />,
);
expect(getByTestId('progress').children).toHaveLength(total);
});

it('renders all segments when current equals total', () => {
const total = 3;
const { getByTestId } = render(
<SegmentedProgressBar current={total} total={total} testID="progress" />,
);
expect(getByTestId('progress').children).toHaveLength(total);
});

it('renders without a testID when testID prop is omitted', () => {
const { toJSON } = render(<SegmentedProgressBar current={1} total={2} />);
expect(toJSON()).not.toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react';
import { Box } from '@metamask/design-system-react-native';

export interface SegmentedProgressBarProps {
/**
* 1-based count of completed steps used to compute the filled segments.
*/
current: number;
/**
* Total number of steps. Guarded against non-positive values to avoid
* divide-by-zero when the caller hasn't wired this yet.
*/
total: number;
/**
* Optional testID forwarded to the outer track.
*/
testID?: string;
}

enum SegmentState {
Completed = 'completed',
Upcoming = 'upcoming',
}

const Segment = ({ state }: { state: SegmentState }) => (
<Box
twClassName={`flex-1 h-1 rounded-lg ${state === SegmentState.Completed ? 'bg-success-default' : 'bg-muted-hover'}`}
/>
);

const SegmentedProgressBar = ({
current,
total,
testID,
}: SegmentedProgressBarProps) => (
<Box twClassName="flex-row gap-1" testID={testID}>
{Array.from({ length: total }, (_, index) => (
<Segment
key={index}
state={index < current ? SegmentState.Completed : SegmentState.Upcoming}
/>
))}
</Box>
);

export default SegmentedProgressBar;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './SegmentedProgressBar';
export type { SegmentedProgressBarProps } from './SegmentedProgressBar';
227 changes: 227 additions & 0 deletions app/component-library/components/StepperCard/StepperCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// Third party dependencies.
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';

// Internal dependencies.
import StepperCard from './StepperCard';
import type { StepperCardStep } from './StepperCard.types';

jest.mock('@metamask/design-system-twrnc-preset', () => {
const tw = (..._args: unknown[]) => ({});
tw.style = jest.fn(() => ({}));
return { useTailwind: () => tw };
});

const mockImage = { uri: 'test-image' };

const makeStep = (overrides?: Partial<StepperCardStep>): StepperCardStep => ({
title: 'Test Title',
description: 'Test description',
image: mockImage,
primaryCta: {
text: 'Primary',
onPress: jest.fn(),
},
...overrides,
});

describe('StepperCard', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('content rendering', () => {
it('renders the current step title', () => {
const { getByTestId } = render(
<StepperCard
steps={[makeStep({ title: 'Onboarding Step' })]}
currentStep={0}
testID="card"
/>,
);
expect(getByTestId('card-title')).toHaveTextContent('Onboarding Step');
});

it('renders the current step description', () => {
const { getByTestId } = render(
<StepperCard
steps={[makeStep({ description: 'Fund your account' })]}
currentStep={0}
testID="card"
/>,
);
expect(getByTestId('card-description')).toHaveTextContent(
'Fund your account',
);
});

it('renders the container with the derived testID', () => {
const { getByTestId } = render(
<StepperCard steps={[makeStep()]} currentStep={0} testID="my-card" />,
);
expect(getByTestId('my-card-container')).toBeOnTheScreen();
});

it('renders the step at the given currentStep index', () => {
const { getByTestId } = render(
<StepperCard
steps={[makeStep({ title: 'Step 1' }), makeStep({ title: 'Step 2' })]}
currentStep={1}
testID="card"
/>,
);
expect(getByTestId('card-title')).toHaveTextContent('Step 2');
});
});

describe('completion bounds guard', () => {
it('returns null when currentStep equals steps.length', () => {
const { toJSON } = render(
<StepperCard steps={[makeStep()]} currentStep={1} />,
);
expect(toJSON()).toBeNull();
});

it('returns null when currentStep exceeds steps.length', () => {
const { toJSON } = render(
<StepperCard steps={[makeStep()]} currentStep={99} />,
);
expect(toJSON()).toBeNull();
});

it('calls onComplete when currentStep reaches steps.length', () => {
const onComplete = jest.fn();
render(
<StepperCard
steps={[makeStep()]}
currentStep={1}
onComplete={onComplete}
/>,
);
expect(onComplete).toHaveBeenCalledTimes(1);
});
});

describe('primary CTA', () => {
it('renders the primary CTA text', () => {
const { getByText } = render(
<StepperCard
steps={[
makeStep({
primaryCta: { text: 'Get started', onPress: jest.fn() },
}),
]}
currentStep={0}
/>,
);
expect(getByText('Get started')).toBeOnTheScreen();
});

it('fires primaryCta.onPress when pressed', () => {
const onPress = jest.fn();
const { getByText } = render(
<StepperCard
steps={[makeStep({ primaryCta: { text: 'Go', onPress } })]}
currentStep={0}
/>,
);
fireEvent.press(getByText('Go'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});

describe('secondary CTA', () => {
it('does not render secondary CTA when secondaryCta is absent', () => {
const { queryByText } = render(
<StepperCard steps={[makeStep()]} currentStep={0} />,
);
expect(queryByText('Skip')).toBeNull();
});

it('renders secondary CTA when secondaryCta is provided', () => {
const { getByText } = render(
<StepperCard
steps={[
makeStep({
secondaryCta: { text: 'Skip', onPress: jest.fn() },
}),
]}
currentStep={0}
/>,
);
expect(getByText('Skip')).toBeOnTheScreen();
});

it('fires secondaryCta.onPress when pressed', () => {
const onSecondaryPress = jest.fn();
const { getByText } = render(
<StepperCard
steps={[
makeStep({
secondaryCta: { text: 'Skip', onPress: onSecondaryPress },
}),
]}
currentStep={0}
/>,
);
fireEvent.press(getByText('Skip'));
expect(onSecondaryPress).toHaveBeenCalledTimes(1);
});
});

describe('description tooltip', () => {
it('does not render tooltip icon when onDescriptionTooltipPress is absent', () => {
const { queryByLabelText } = render(
<StepperCard steps={[makeStep()]} currentStep={0} />,
);
expect(queryByLabelText('More information')).toBeNull();
});

it('renders tooltip icon when onDescriptionTooltipPress is provided', () => {
const { getByLabelText } = render(
<StepperCard
steps={[makeStep({ onDescriptionTooltipPress: jest.fn() })]}
currentStep={0}
/>,
);
expect(getByLabelText('More information')).toBeOnTheScreen();
});

it('uses the default accessibilityLabel when none is provided', () => {
const { getByLabelText } = render(
<StepperCard
steps={[makeStep({ onDescriptionTooltipPress: jest.fn() })]}
currentStep={0}
/>,
);
expect(getByLabelText('More information')).toBeOnTheScreen();
});

it('uses custom descriptionTooltipAccessibilityLabel when provided', () => {
const { getByLabelText } = render(
<StepperCard
steps={[
makeStep({
onDescriptionTooltipPress: jest.fn(),
descriptionTooltipAccessibilityLabel: 'APY information',
}),
]}
currentStep={0}
/>,
);
expect(getByLabelText('APY information')).toBeOnTheScreen();
});

it('fires onDescriptionTooltipPress when tooltip icon is pressed', () => {
const onTooltipPress = jest.fn();
const { getByLabelText } = render(
<StepperCard
steps={[makeStep({ onDescriptionTooltipPress: onTooltipPress })]}
currentStep={0}
/>,
);
fireEvent.press(getByLabelText('More information'));
expect(onTooltipPress).toHaveBeenCalledTimes(1);
});
});
});
Loading
Loading