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

feat: Implement help dropdown for new header #2986

Merged
merged 10 commits into from
Jul 8, 2024
7 changes: 7 additions & 0 deletions scripts/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ const enabledIcons = [
'cog',
'light-bulb',
'no-symbol',
'x-circle',
'database',
'trash',
'book-open',
'check-circle',
'question-mark-circle',
'plus-circle',
]

console.log('Generating Icons import')
Expand Down
44 changes: 41 additions & 3 deletions src/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,49 @@
--color-bitbucket: 0, 82, 204;
/* --color-okta: 0, 41, 122; */
--color-okta: 25, 25, 25;

/* Sentry User Feedback widget styles */
--widget-accent-background: rgb(var(--color-ds-blue-darker));
--widget-background-hover: rgb(var(--color-ds-blue-quinary));
--widget-font-family: 'Poppins', sans-serif;
}
}

#sentry-feedback {
--accent-background: rgb(var(--color-ds-blue-darker));
--accent-background-hover: rgb(var(--color-ds-blue-quinary));
--font-family: 'Poppins', sans-serif;
--accent-background: var(--widget-accent-background);
--accent-background-hover: var(--widget-background-hover);
--font-family: var(--widget-font-family);
}

@layer components {
.widget {
--inset: 32px 44px auto auto;
}

.sm-widget {
--inset: 32px calc(50% - 276px) auto auto;
}

.md-widget {
--inset: 32px calc(50% - 340px) auto auto;
}

.lg-widget {
--inset: 32px calc(50% - 468px) auto auto;
}

.xl-widget {
--inset: 32px calc(50% - 596px) auto auto;
}

.twoxl-widget {
--inset: 32px calc(50% - 724px) auto auto;
}
}

#help-dropdown-widget {
--accent-background: var(--widget-accent-background);
--accent-background-hover: var(--widget-background-hover);
--font-family: var(--widget-font-family);
@apply widget sm:sm-widget md:md-widget lg:lg-widget xl:xl-widget 2xl:twoxl-widget;
}
5 changes: 3 additions & 2 deletions src/layouts/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useUser } from 'services/user'

import HelpDropdown from './components/HelpDropdown'
import UserDropdown from './components/UserDropdown'

function Header() {
Expand All @@ -12,9 +13,9 @@ function Header() {
return (
<div className="container flex h-14 w-full items-center">
<div className="flex-1">Navigation</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-4">
<div>Self hosted stuff</div>
<div>Help dropdown</div>
<HelpDropdown />
<UserDropdown />
</div>
</div>
Expand Down
159 changes: 159 additions & 0 deletions src/layouts/Header/components/HelpDropdown/HelpDropdown.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import Sentry from '@sentry/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Switch } from 'react-router-dom'

import HelpDropdown from './HelpDropdown'

const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
<MemoryRouter initialEntries={['/gh/codecov']}>
<Switch>
<Route path="/:provider/:repo" exact>
{children}
</Route>
</Switch>
</MemoryRouter>
)

describe('HelpDropdown', () => {
function setup() {
return {
user: userEvent.setup(),
}
}

it('renders dropdown button', async () => {
setup()
render(<HelpDropdown />, { wrapper })

const dropdown = await screen.findByTestId('help-dropdown')
expect(dropdown).toBeInTheDocument()
})

describe('when not clicked', () => {
it('does not render dropdown', async () => {
setup()
render(<HelpDropdown />, { wrapper })

const dropdown = await screen.findByRole('combobox')
expect(dropdown).toBeInTheDocument()

const docs = screen.queryByText('Developer docs')
expect(docs).not.toBeInTheDocument()
})
})

describe('when clicked', () => {
it('renders dropdown', async () => {
const { user } = setup()
render(<HelpDropdown />, { wrapper })

const dropdown = await screen.findByRole('combobox')
expect(dropdown).toBeInTheDocument()

await user.click(dropdown)

const docs = await screen.findByText('Developer docs')
expect(docs).toBeInTheDocument()

const support = await screen.findByText('Support center')
expect(support).toBeInTheDocument()

const feedback = await screen.findByText('Share feedback')
expect(feedback).toBeInTheDocument()

const discussions = await screen.findByText('Join GitHub discussions')
expect(discussions).toBeInTheDocument()
})
})

describe('when Share feedback item is selected', () => {
it('opens the sentry user feedback modal', async () => {
console.error = () => {}
const { user } = setup()
const open = jest.fn()
const appendToDom = jest.fn()
const removeFromDom = jest.fn()
const createForm = jest.fn().mockReturnValue({
open,
appendToDom,
removeFromDom,
})

const mockedFeedbackIntegration = jest
.spyOn(Sentry, 'feedbackIntegration')
.mockImplementation(() => ({
createForm,
name: 'asdf',
attachTo: jest.fn(),
createWidget: jest.fn(),
remove: jest.fn(),
}))

render(<HelpDropdown />, { wrapper })

const dropdown = await screen.findByRole('combobox')
expect(dropdown).toBeInTheDocument()

await user.click(dropdown)

const feedback = await screen.findByText('Share feedback')
expect(feedback).toBeInTheDocument()

await user.click(feedback)

expect(mockedFeedbackIntegration).toHaveBeenCalled()
expect(createForm).toHaveBeenCalled()
expect(appendToDom).toHaveBeenCalled()
expect(open).toHaveBeenCalled()
})
})

describe('if Sentry form has been loaded', () => {
describe('and component unmounts', () => {
it('removes the form from the DOM', async () => {
console.error = () => {}
const { user } = setup()
const open = jest.fn()
const appendToDom = jest.fn()
const removeFromDom = jest.fn()
const createForm = jest.fn().mockReturnValue({
open,
appendToDom,
removeFromDom,
})

const mockedFeedbackIntegration = jest
.spyOn(Sentry, 'feedbackIntegration')
.mockImplementation(() => ({
createForm,
name: 'asdf',
attachTo: jest.fn(),
createWidget: jest.fn(),
remove: jest.fn(),
}))

const { unmount } = render(<HelpDropdown />, { wrapper })

const dropdown = await screen.findByRole('combobox')
expect(dropdown).toBeInTheDocument()

await user.click(dropdown)

const feedback = await screen.findByText('Share feedback')
expect(feedback).toBeInTheDocument()

await user.click(feedback)

expect(mockedFeedbackIntegration).toHaveBeenCalled()
expect(createForm).toHaveBeenCalled()
expect(appendToDom).toHaveBeenCalled()
expect(open).toHaveBeenCalled()

unmount()

expect(removeFromDom).toHaveBeenCalled()
})
})
})
})
140 changes: 140 additions & 0 deletions src/layouts/Header/components/HelpDropdown/HelpDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copying UserDropdown implementation for now until we get a proper
// component made up.

import { feedbackIntegration } from '@sentry/react'
import { useSelect } from 'downshift'
import { useEffect, useMemo, useState } from 'react'

import { cn } from 'shared/utils/cn'
import Button from 'ui/Button'
import Icon from 'ui/Icon'

type toProps = {
pageName: string
options?: object
}

type ItemProps = {
to?: toProps
hook?: string
onClick?: () => void
}

type Item = {
props: ItemProps
children: string
}

function HelpDropdown() {
const sentryFeedback = useMemo(
() =>
feedbackIntegration({
colorScheme: 'light',
showBranding: false,
formTitle: 'Give Feedback',
buttonLabel: 'Give Feedback',
submitButtonLabel: 'Send Feedback',
nameLabel: 'Username',
isEmailRequired: true,
autoInject: false,
id: 'help-dropdown-widget',
}),
[]
)

// Remove the Sentry form from the DOM on unmount.
const [removeSentryForm, setRemoveSentryForm] = useState<() => void>(
() => () => {}
)
useEffect(() => removeSentryForm, [removeSentryForm])

const items: Item[] = [
{
props: { to: { pageName: 'docs' } },
children: 'Developer docs',
},
{
props: { to: { pageName: 'support' } },
children: 'Support center',
},
{
props: {
onClick: async () => {
const form = await sentryFeedback.createForm()
form.appendToDom()
form.open()
setRemoveSentryForm(() => form.removeFromDom)
},
hook: 'open-modal',
},
children: 'Share feedback',
},
{
props: { to: { pageName: 'feedback' } },
children: 'Join GitHub discussions',
},
]

const {
isOpen,
getToggleButtonProps,
getItemProps,
getLabelProps,
getMenuProps,
} = useSelect({
items,
})

return (
<div
className="relative"
data-testid="help-dropdown"
data-cy="auth-help-dropdown"
>
<label className="sr-only" {...getLabelProps()}>
Help menu dropdown
</label>
<button
className="flex flex-1 items-center gap-1 whitespace-nowrap text-left focus:outline-1"
data-marketing="help menu"
type="button"
{...getToggleButtonProps()}
>
<Icon variant="outline" name="questionMarkCircle" />
<span
aria-hidden="true"
className={cn('transition-transform', {
'rotate-180': isOpen,
'rotate-0': !isOpen,
})}
>
<Icon variant="solid" name="chevronDown" size="sm" />
</span>
</button>
<ul
className={cn(
'z-50 w-[15.5rem] border border-gray-ds-tertiary overflow-hidden rounded bg-white text-gray-900 border-ds-gray-tertiary absolute right-0 top-8 min-w-fit',
{ hidden: !isOpen }
)}
aria-label="help menu items"
{...getMenuProps()}
>
{isOpen &&
items.map((item, index) => (
<li
key={`main-dropdown-${index}`}
className="grid cursor-pointer text-sm first:pt-2 last:pb-2 hover:bg-ds-gray-secondary"
{...getItemProps({ item, index })}
>
{/* @ts-expect-error props might be overloaded with stuff */}
<Button variant="listbox" {...item.props}>
{item.children}
</Button>
</li>
))}
</ul>
</div>
)
}

export default HelpDropdown
1 change: 1 addition & 0 deletions src/layouts/Header/components/HelpDropdown/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './HelpDropdown'
2 changes: 1 addition & 1 deletion src/ui/Icon/svg/developer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ export { ReactComponent as merge } from './merge.svg'
export { ReactComponent as pullRequestClosed } from './pull-request-closed.svg'
export { ReactComponent as pullRequestOpen } from './pull-request-open.svg'
// export { ReactComponent as pullRequest } from './pull-request.svg'
export { ReactComponent as statusRunning } from './status-running.svg'
// export { ReactComponent as statusRunning } from './status-running.svg'
Loading
Loading