Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Only show tour button on some pages #5816

Merged
merged 6 commits into from
Oct 17, 2024
Merged
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
2 changes: 1 addition & 1 deletion frontend/src/landing/LandingPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function LandingPage({ loginRequired, isLoggedIn }) {

return (
<Stack w="100%">
<TourComponent page="landingPage" />
<TourComponent />
<Box mb="16" textAlign="left" px="4">
<Heading as="h1">
<Trans>Track Digital Security</Trans>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/userOnboarding/__tests__/TourButton.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('TourTextButton', () => {
const mockStartTour = jest.fn()
useTourModule.useTour.mockReturnValue({ startTour: mockStartTour })
const mockUseLocation = require('react-router-dom').useLocation
mockUseLocation.mockReturnValue({ pathname: '/organizations' })
mockUseLocation.mockReturnValue({ pathname: '/' })

const { getByRole } = render(
<MemoryRouter>
Expand All @@ -45,6 +45,6 @@ describe('TourTextButton', () => {
//Simulate button click
fireEvent.click(getByRole('button', { name: /Start Tour/i }))

expect(mockStartTour).toHaveBeenCalledWith('organizationsPage')
expect(mockStartTour).toHaveBeenCalledWith('landingPage')
})
})
180 changes: 92 additions & 88 deletions frontend/src/userOnboarding/__tests__/TourComponent.test.js
Original file line number Diff line number Diff line change
@@ -1,120 +1,124 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { render, waitFor } from '@testing-library/react'
import { TourComponent } from '../components/TourComponent'
import { useTour } from '../hooks/useTour'

jest.mock('../hooks/useTour')
import { MockedProvider } from '@apollo/client/testing'
import { UserVarProvider } from '../../utilities/userState'
import { makeVar } from '@apollo/client'
import { I18nProvider } from '@lingui/react'
import { ChakraProvider, theme } from '@chakra-ui/react'
import { MemoryRouter } from 'react-router-dom'
import { TourProvider } from '../contexts/TourContext'
import { setupI18n } from '@lingui/core'
import { en } from 'make-plural'
import { fireEvent } from '@testing-library/dom'

const i18n = setupI18n({
locale: 'en',
messages: {
en: {},
},
localeData: {
en: { plurals: en },
},
})

jest.mock('../config/tourSteps', () => ({
mainTourSteps: {
home: {
landingPage: {
requiresAuth: false,
steps: [
{
target: '.step-1',
content: 'Home Step 1',
content: 'Landing Step 1',
disableBeacon: true,
},
{
target: '.step-2',
content: 'Home Step 2',
content: 'Landing Step 2',
disableBeacon: true,
},
],
},
},
}))

// Mock the Joyride component
jest.mock('react-joyride', () => {
const tourSteps = require('../config/tourSteps').mainTourSteps['home']['steps']
const MockJoyride = () => <div data-testid="mockJoyride">Mocked Joyride with steps: {JSON.stringify(tourSteps)}</div>
MockJoyride.displayName = 'MockJoyride'
return MockJoyride
})

const mockLocalStorage = (() => {
let store = {}
return {
getItem(key) {
return store[key] || null
},
setItem(key, value) {
store[key] = value.toString()
},
removeItem(key) {
delete store[key]
},
clear() {
store = {}
},
}
})()

describe('TourComponent', () => {
beforeEach(() => {
useTour.mockReturnValue({
isTourOpen: false,
startTour: jest.fn(),
endTour: jest.fn(),
})
mockLocalStorage.clear()
// Replace the real localStorage with our mock
Object.defineProperty(window, 'localStorage', { value: mockLocalStorage })
})

afterEach(() => {
jest.clearAllMocks()
localStorage.clear()
})

test('TourComponent renders with mock Joyride', () => {
render(<TourComponent page="home" />)
expect(screen.getByTestId('mockJoyride')).toBeInTheDocument()
})
it('renders the mocked landing page tour for users who have not seen it', async () => {
expect(localStorage.getItem('hasSeenTour_landingPage')).toBe(null)

const { getByText, getByRole, queryByText } = render(
<MockedProvider mocks={[]} addTypename={false}>
<UserVarProvider userVar={makeVar({ jwt: null, tfaSendMethod: null, userName: null })}>
<I18nProvider i18n={i18n}>
<ChakraProvider theme={theme}>
<MemoryRouter initialEntries={['']} initialIndex={0}>
<TourProvider>
<TourComponent />
<p className="step-1">Test 1</p>
<p className="step-2">Test 2</p>
</TourProvider>
</MemoryRouter>
</ChakraProvider>
</I18nProvider>
</UserVarProvider>
</MockedProvider>,
)

// Element will exist but not be visible
expect(queryByText(/Landing Step 1/)).not.toBeNull()

await waitFor(() => {
expect(getByText(/Landing Step 1/)).toBeVisible()
})

it('does not start the tour for users who have seen it', () => {
localStorage.setItem('hasSeenTour_home', 'true')
const mockStartTour = jest.fn()
useTour.mockReturnValue({ isTourOpen: false, startTour: mockStartTour, endTour: jest.fn() })
let nextBtn = getByRole('button', { name: /Next/ })
expect(nextBtn).toBeInTheDocument()
fireEvent.click(nextBtn)

render(<TourComponent page="home" />)
await waitFor(() => {
expect(getByText(/Landing Step 2/)).toBeVisible()
})

expect(mockStartTour).not.toHaveBeenCalled()
})
})
nextBtn = getByRole('button', { name: /Finish/ })
expect(nextBtn).toBeInTheDocument()
fireEvent.click(nextBtn)

describe('handleJoyrideCallback', () => {
const endTourMock = jest.fn()
const originalSetItem = localStorage.setItem

function createHandleJoyrideCallbackFunction() {
return function handleJoyrideCallback({ status }) {
if (status === 'finished' || status === 'skipped') {
localStorage.setItem(`hasSeenTour_home`, 'true')
endTourMock()
}
}
}

beforeEach(() => {
localStorage.setItem = jest.fn()
endTourMock.mockClear()
useTour.mockReturnValue({
isTourOpen: false,
startTour: jest.fn(),
endTour: jest.fn(),
await waitFor(() => {
expect(queryByText(/Landing Step 2/)).toBeNull()
})
})

afterEach(() => {
localStorage.setItem = originalSetItem
// Ensure that the tour has been marked as seen
expect(localStorage.getItem('hasSeenTour_landingPage')).toBe('true')
})

it.each(['finished', 'skipped'])('sets localStorage and ends tour when status is %s', (status) => {
const page = 'home'
const handleJoyrideCallback = createHandleJoyrideCallbackFunction({ endTour: jest.fn(), page })

handleJoyrideCallback({ status, type: 'any', action: 'any' })

expect(localStorage.setItem).toHaveBeenCalledWith(`hasSeenTour_${page}`, 'true')
expect(endTourMock).toHaveBeenCalled()
it('does not render the tour for users who have seen it', async () => {
localStorage.setItem('hasSeenTour_landingPage', 'true')

expect(localStorage.getItem('hasSeenTour_landingPage')).toBe('true')

const { queryByText } = render(
<MockedProvider mocks={[]} addTypename={false}>
<UserVarProvider userVar={makeVar({ jwt: null, tfaSendMethod: null, userName: null })}>
<I18nProvider i18n={i18n}>
<ChakraProvider theme={theme}>
<MemoryRouter initialEntries={['']} initialIndex={0}>
<TourProvider>
<TourComponent />
<p className="step-1">Test 1</p>
<p className="step-2">Test 2</p>
</TourProvider>
</MemoryRouter>
</ChakraProvider>
</I18nProvider>
</UserVarProvider>
</MockedProvider>,
)

// Element should not exist
expect(queryByText(/Landing Step 1/)).toBeNull()
})
})
22 changes: 14 additions & 8 deletions frontend/src/userOnboarding/components/TourButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import { useTour } from '../hooks/useTour'
import { Button } from '@chakra-ui/react'
import { Trans } from '@lingui/macro'

const toursConfig = {
//list of pages with their paths
export const toursConfig = {
// list of pages with their paths
// Start tour button will only appear on these pages
'/': 'landingPage',
'/organizations': 'organizationsPage',
'/domains': 'domainPage',
'/my-tracker/summary': ' myTrackerPage',
'/dmarc-summaries': 'dmarcSummariesPage',
'/admin/organizations': 'adminProfilePage',
// '/organizations': 'organizationsPage',
// '/domains': 'domainPage',
// '/my-tracker/summary': ' myTrackerPage',
// '/dmarc-summaries': 'dmarcSummariesPage',
// '/admin/organizations': 'adminProfilePage',
}
//Tour button as an icon, made for the individual pages (not needed for top banner)

Expand Down Expand Up @@ -43,11 +44,16 @@ export const TourButton = () => {
const { pathname } = useLocation()
const { startTour } = useTour()

const tourName = toursConfig[pathname]

const handleStartTour = () => {
const tourName = toursConfig[pathname]
if (tourName) startTour(tourName)
}

if (!tourName) {
return null
}

return (
<Button onClick={handleStartTour} variant="primaryWhite" mx="2" display={{ base: 'none', md: 'inline' }}>
<Trans>Start Tour</Trans>
Expand Down
33 changes: 20 additions & 13 deletions frontend/src/userOnboarding/components/TourComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,48 @@ import { mainTourSteps } from '../config/tourSteps'
import { Trans } from '@lingui/macro'
import { useUserVar } from '../../utilities/userState'
import theme from '../../theme/canada'
import PropTypes from 'prop-types'
import { useLocation } from 'react-router-dom'
import { toursConfig } from './TourButton'

export const TourComponent = ({ page }) => {
export const TourComponent = () => {
const { isEmailValidated } = useUserVar()
const { isTourOpen, endTour, startTour } = useTour()
const [tourKey, setTourKey] = useState(0)
const { darkOrange } = theme.colors.tracker.logo

//handles starting the tour based on the page and user state
const { pathname } = useLocation()

const tourName = toursConfig[pathname]

// handles starting the tour based on the page and user state
useEffect(() => {
const hasSeenTour = localStorage.getItem(`hasSeenTour_${page}`)
if (!tourName) return

const hasSeenTour = localStorage.getItem(`hasSeenTour_${tourName}`)
if (
!hasSeenTour &&
(!mainTourSteps[page]['requiresAuth'] || (mainTourSteps[page]['requiresAuth'] && isEmailValidated()))
(!mainTourSteps[tourName]['requiresAuth'] || (mainTourSteps[tourName]['requiresAuth'] && isEmailValidated()))
)
startTour()
}, [page, startTour])
}, [tourName, startTour])

useEffect(() => {
if (isTourOpen) setTourKey((prev) => prev + 1)
}, [isTourOpen])

if (!tourName) {
return null
}

// handles the finishing and skipping/closing of tour
const handleJoyrideCallback = ({ status, type, action }) => {
if (['finished', 'skipped'].includes(status)) {
localStorage.setItem(`hasSeenTour_${page}`, true)
localStorage.setItem(`hasSeenTour_${tourName}`, true)
endTour()
}

if (type === 'step:after' && action === 'close') {
localStorage.setItem(`hasSeenTour_${page}`, true)
localStorage.setItem(`hasSeenTour_${tourName}`, true)
endTour()
}
}
Expand All @@ -44,7 +55,7 @@ export const TourComponent = ({ page }) => {
return (
<Joyride
key={tourKey}
steps={mainTourSteps[page]['steps']}
steps={mainTourSteps[tourName]['steps']}
run={isTourOpen}
continuous={true}
showProgress={false}
Expand All @@ -69,7 +80,3 @@ export const TourComponent = ({ page }) => {
/>
)
}

TourComponent.propTypes = {
page: PropTypes.string.isRequired,
}