Skip to content

Commit

Permalink
DTRA / Vinu, Kate / DTRA-1670 / Onboarding Guide [DTrader-V2] (binary…
Browse files Browse the repository at this point in the history
…-com#16589)

* feat: add npm package, prepare local store and create a base modal for starting guide

* feat: add guide container and tooltip component

* refactor: tooltip componnet

* refactor: set flag to trye on finish and styling

* fix: close and skip functionality

* feat: add the video

* feat: add step 3 and inprove tooltip width

* feat: add steps 3 5 6 and remove skip button

* refactor: make guide modal reusable for positions page

* refactor: add tests

* feat: added step 4 for onboarding in dtrader v2 (#82)

* feat: added step 4 for onboarding in dtrader v2

* fix: resolved code suggestion

* fix: replaced pixel for scroll icon with quill token

* refactor: update quill ui refactor onboarding guide functions and fix tests

* fix: link console error

* refactor: apply suggestions

* chore: update quill

---------

Co-authored-by: vinu-deriv <100689171+vinu-deriv@users.noreply.github.com>
  • Loading branch information
kate-deriv and vinu-deriv committed Sep 4, 2024
1 parent 71a5dd7 commit 122e4d5
Show file tree
Hide file tree
Showing 24 changed files with 760 additions and 26 deletions.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/account/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@deriv-com/utils": "^0.0.25",
"@deriv-com/ui": "1.29.10",
"@deriv/api": "^1.0.0",
"@deriv-com/quill-ui": "1.16.4",
"@deriv-com/quill-ui": "1.16.5",
"@deriv/components": "^1.0.0",
"@deriv/hooks": "^1.0.0",
"@deriv/integration": "1.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
"@datadog/browser-rum": "^5.11.0",
"@deriv-com/analytics": "1.12.1",
"@deriv-com/quill-tokens": "^2.0.4",
"@deriv-com/quill-ui": "1.16.4",
"@deriv-com/quill-ui": "1.16.5",
"@deriv-com/translations": "1.3.5",
"@deriv-com/ui": "1.29.10",
"@deriv-com/utils": "^0.0.25",
Expand Down
Binary file not shown.
3 changes: 2 additions & 1 deletion packages/trader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
"dependencies": {
"@deriv-com/analytics": "1.12.1",
"@deriv-com/quill-tokens": "^2.0.4",
"@deriv-com/quill-ui": "1.16.4",
"@deriv-com/quill-ui": "1.16.5",
"@deriv-com/utils": "^0.0.25",
"@deriv-com/ui": "1.29.10",
"@deriv/api-types": "1.0.172",
Expand Down Expand Up @@ -121,6 +121,7 @@
"react-beautiful-dnd": "^13.1.1",
"react-content-loader": "^6.2.0",
"react-dom": "^17.0.2",
"react-joyride": "^2.5.3",
"react-loadable": "^5.5.0",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
Expand Down
12 changes: 12 additions & 0 deletions packages/trader/src/AppV2/Components/BottomNav/bottom-nav.scss
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,17 @@ $BOTTOM_NAV_HEIGHT: var(--core-spacing-2800);
fill: var(--core-color-opacity-coral-600);
}
}
&--positions {
position: relative;

.user-guide__anchor {
position: absolute;
height: var(--core-size-2500);
width: var(--core-size-4600);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
}
}
}
13 changes: 11 additions & 2 deletions packages/trader/src/AppV2/Components/BottomNav/bottom-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@ const BottomNav = observer(({ children, className, onScroll }: BottomNavProps) =
fill='var(--semantic-color-monochrome-textIcon-normal-high)'
/>
),
label: <Localize i18n_default_text='Positions' />,
label: (
<React.Fragment>
<span className='user-guide__anchor' />
<Localize i18n_default_text='Positions' />
</React.Fragment>
),
path: routes.trader_positions,
},
];
Expand All @@ -80,7 +85,11 @@ const BottomNav = observer(({ children, className, onScroll }: BottomNavProps) =
label={item.label}
selected={index === selectedIndex}
showLabel
className={clsx('bottom-nav-item', index === selectedIndex && 'bottom-nav-item--active')}
className={clsx(
'bottom-nav-item',
index === selectedIndex && 'bottom-nav-item--active',
item.path === routes.trader_positions && 'bottom-nav-item--positions'
)}
/>
))}
</Navigation.Bottom>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CallBackProps } from 'react-joyride';
import GuideContainer from '../guide-container';

jest.mock('react-joyride', () => ({
__esModule: true,
default: jest.fn(({ callback }: { callback: (data: CallBackProps) => void }) => (
<div>
<p>Joyride</p>
<button onClick={() => callback({ status: 'finished' } as CallBackProps)} />
</div>
)),
STATUS: { SKIPPED: 'skipped', FINISHED: 'finished' },
}));

const mock_props = {
should_run: true,
onFinishGuide: jest.fn(),
};

describe('GuideContainer', () => {
it('should render component', () => {
render(<GuideContainer {...mock_props} />);

expect(screen.getByText('Joyride')).toBeInTheDocument();
});

it('should call onFinishGuide inside of callbackHandle if passed status is equal to "skipped" or "finished"', () => {
render(<GuideContainer {...mock_props} />);
userEvent.click(screen.getByRole('button'));

expect(mock_props.onFinishGuide).toBeCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import GuideTooltip, { GuideTooltipProps } from '../guide-tooltip';

jest.mock('react-joyride', () => jest.fn(() => <div>Joyride</div>));

const mock_props = {
isLastStep: false,
primaryProps: {
title: 'Title',
},
skipProps: {
title: 'Title',
},
step: {
title: 'Title',
content: 'Step content',
},
setStepIndex: jest.fn(),
tooltipProps: {},
} as unknown as GuideTooltipProps;

describe('GuideTooltip', () => {
it('should render correct content for tooltip if isLastStep === false', () => {
render(<GuideTooltip {...mock_props} />);

expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Step content')).toBeInTheDocument();
expect(screen.getByText('Next')).toBeInTheDocument();
expect(screen.queryByText('Done')).not.toBeInTheDocument();
});

it('should render correct content for tooltip if isLastStep === true', () => {
render(<GuideTooltip {...mock_props} isLastStep={true} />);

expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Step content')).toBeInTheDocument();
expect(screen.getByText('Done')).toBeInTheDocument();
expect(screen.queryByText('Next')).not.toBeInTheDocument();
});

it('should render scroll icon if title of the step is scroll-icon', () => {
mock_props.step.title = 'scroll-icon';
render(<GuideTooltip {...mock_props} isLastStep={true} />);
expect(screen.getByText('Swipe up to see the chart')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import OnboardingGuide from '../onboarding-guide';

const trading_modal_text = 'Welcome to the new Deriv Trader';
const positions_modal_text = 'View your positions';
const guide_container = 'GuideContainer';

jest.mock('../guide-container', () =>
jest.fn(({ should_run }: { should_run?: boolean }) => <div>{should_run && guide_container}</div>)
);
jest.mock('../onboarding-video', () => jest.fn(() => <div>OnboardingVideo</div>));

describe('OnboardingGuide', () => {
beforeEach(() => {
localStorage.clear();
});

it('should render Modal with correct content for trading page after 800ms after mounting', async () => {
jest.useFakeTimers();
render(<OnboardingGuide />);

await waitFor(() => jest.advanceTimersByTime(800));

expect(screen.getByText('OnboardingVideo')).toBeInTheDocument();
expect(screen.getByText(trading_modal_text)).toBeInTheDocument();
expect(screen.getByText("Let's begin")).toBeInTheDocument();

jest.useRealTimers();
});

it('should render Modal with correct content for positions page after 800ms after mounting', async () => {
jest.useFakeTimers();
render(<OnboardingGuide type='positions_page' />);

await waitFor(() => jest.advanceTimersByTime(800));

expect(screen.queryByText('OnboardingVideo')).not.toBeInTheDocument();
expect(screen.getByText(positions_modal_text)).toBeInTheDocument();
expect(screen.getByText('Got it')).toBeInTheDocument();

jest.useRealTimers();
});

it('should close the Modal for trading page and start the guide after user clicks on "Let\'s begin" button', async () => {
jest.useFakeTimers();
render(<OnboardingGuide />);

await waitFor(() => jest.advanceTimersByTime(800));

expect(screen.getByText(trading_modal_text)).toBeInTheDocument();
expect(screen.queryByText(guide_container)).not.toBeInTheDocument();

userEvent.click(screen.getByRole('button'));
await waitFor(() => jest.advanceTimersByTime(300));

expect(screen.queryByText(trading_modal_text)).not.toBeInTheDocument();
expect(screen.getByText(guide_container)).toBeInTheDocument();

jest.useRealTimers();
});

it('should close the Modal for positions page, set flag to localStorage equal to true and do NOT start the guide after user clicks on "Got it" button', async () => {
const key = 'guide_dtrader_v2_positions_page';
jest.useFakeTimers();
render(<OnboardingGuide type='positions_page' />);

await waitFor(() => jest.advanceTimersByTime(800));

expect(screen.getByText(positions_modal_text)).toBeInTheDocument();
expect(screen.queryByText(guide_container)).not.toBeInTheDocument();
expect(localStorage.getItem(key)).toBe('false');

userEvent.click(screen.getByRole('button'));
await waitFor(() => jest.advanceTimersByTime(300));

expect(screen.queryByText(positions_modal_text)).not.toBeInTheDocument();
expect(screen.queryByText(guide_container)).not.toBeInTheDocument();
expect(localStorage.getItem(key)).toBe('true');

jest.useRealTimers();
});

it('should close the Modal for trading page and set flag to localStorage equal to true if user clicks on overlay and do NOT start the guide', async () => {
const key = 'guide_dtrader_v2_trade_page';
jest.useFakeTimers();
render(<OnboardingGuide />);

await waitFor(() => jest.advanceTimersByTime(800));

expect(screen.getByText(trading_modal_text)).toBeInTheDocument();
expect(screen.queryByText(guide_container)).not.toBeInTheDocument();
expect(localStorage.getItem(key)).toBe('false');

userEvent.click(screen.getByTestId('dt-actionsheet-overlay'));
await waitFor(() => jest.advanceTimersByTime(300));

expect(screen.queryByText(trading_modal_text)).not.toBeInTheDocument();
expect(screen.queryByText(guide_container)).not.toBeInTheDocument();
expect(localStorage.getItem(key)).toBe('true');

jest.useRealTimers();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import OnboardingVideo from '../onboarding-video';

const dt_video = 'dt_onboarding_guide_video';
const dt_loader = 'square-skeleton';

jest.mock('@deriv/shared', () => ({
...jest.requireActual('@deriv/shared'),
getUrlBase: jest.fn(() => 'video_src.mp4'),
}));

describe('OnboardingVideo', () => {
beforeAll(() => {
Object.defineProperty(HTMLMediaElement.prototype, 'muted', {
set: jest.fn(),
});
});

it('should render loader and video if the data is not fully loaded', () => {
render(<OnboardingVideo />);

expect(screen.getByTestId(dt_loader)).toBeInTheDocument();
expect(screen.getByTestId(dt_video)).toBeInTheDocument();
});

it('should render only video tag if the data has already loaded', () => {
render(<OnboardingVideo />);

const video = screen.getByTestId(dt_video);
fireEvent.loadedData(video);

expect(screen.queryByTestId(dt_loader)).not.toBeInTheDocument();
expect(video).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import Joyride, { CallBackProps, STATUS } from 'react-joyride';
import GuideTooltip from './guide-tooltip';
import STEPS from './steps-config';

type TGuideContainerProps = {
should_run: boolean;
onFinishGuide: () => void;
};

type TFinishedStatuses = CallBackProps['status'][];

const GuideContainer = ({ should_run, onFinishGuide }: TGuideContainerProps) => {
const [step_index, setStepIndex] = React.useState(0);

const callbackHandle = (data: CallBackProps) => {
const { status } = data;
const finished_statuses: TFinishedStatuses = [STATUS.FINISHED, STATUS.SKIPPED];

if (finished_statuses.includes(status)) onFinishGuide();
};

return (
<Joyride
continuous
callback={callbackHandle}
disableCloseOnEsc
disableOverlayClose
disableScrolling
floaterProps={{
styles: {
arrow: {
length: 4,
spread: 8,
display: step_index === 3 ? 'none' : 'inline-flex',
},
},
}}
run={should_run}
showSkipButton
steps={STEPS}
spotlightPadding={0}
scrollToFirstStep
styles={{
options: {
arrowColor: 'var(--component-textIcon-normal-prominent)',
overlayColor: 'var(--core-color-opacity-black-600)',
},
spotlight: {
borderRadius: 'unset',
},
}}
stepIndex={step_index}
tooltipComponent={props => <GuideTooltip {...props} setStepIndex={setStepIndex} />}
/>
);
};

export default React.memo(GuideContainer);
Loading

0 comments on commit 122e4d5

Please sign in to comment.