Framework for submitting survey comments (#50632)
Co-authored-by: Rachael Sewell <rachmari@github.com>
This commit is contained in:
@@ -65,9 +65,11 @@ survey:
|
||||
email_label: If we can contact you with more questions, please enter your email address
|
||||
email_validation: Please enter a valid email address
|
||||
send: Send
|
||||
next: Next
|
||||
feedback: Thank you! We received your feedback.
|
||||
not_support: If you need a reply, please contact support instead.
|
||||
not_support: If you need a reply, please contact <a href="https://support.github.com/">support</a>.
|
||||
privacy_policy: Privacy policy
|
||||
server_error: Unable to process comment at the moment. Please try again.
|
||||
contribution_cta:
|
||||
title: Help us make these docs great!
|
||||
body: All GitHub docs are open source. See something that's wrong or unclear? Submit a pull request.
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useState, useRef, useEffect } from 'react'
|
||||
import cx from 'classnames'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ThumbsdownIcon, ThumbsupIcon } from '@primer/octicons-react'
|
||||
import { Spinner } from '@primer/react'
|
||||
import useSWR from 'swr'
|
||||
|
||||
import { useTranslation } from 'src/languages/components/useTranslation'
|
||||
import { Link } from 'src/frame/components/Link'
|
||||
import { sendEvent, EventType, startVisitTime } from 'src/events/components/events'
|
||||
@@ -10,23 +13,30 @@ import styles from './Survey.module.scss'
|
||||
|
||||
enum ViewState {
|
||||
START = 'START',
|
||||
NEXT = 'NEXT',
|
||||
END = 'END',
|
||||
}
|
||||
|
||||
enum VoteState {
|
||||
YES = 'YES',
|
||||
NO = 'NO',
|
||||
END = 'END',
|
||||
}
|
||||
|
||||
export const Survey = () => {
|
||||
const { asPath, locale } = useRouter()
|
||||
const { t } = useTranslation('survey')
|
||||
const [state, setState] = useState<ViewState>(ViewState.START)
|
||||
const [voteState, setVoteState] = useState<VoteState | null>(null)
|
||||
const [isEmailError, setIsEmailError] = useState(false)
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
// Always reset the form if navigating to a new page because what
|
||||
// you might have said or started to say belongs exclusively to
|
||||
// to the page you started on.
|
||||
setState(ViewState.START)
|
||||
setVoteState(null)
|
||||
}, [asPath])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -42,10 +52,10 @@ export const Survey = () => {
|
||||
}
|
||||
}, [state])
|
||||
|
||||
function vote(state: ViewState) {
|
||||
function vote(vote: VoteState) {
|
||||
return () => {
|
||||
trackEvent(getFormData())
|
||||
setState(state)
|
||||
setVoteState(vote)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +74,43 @@ export const Survey = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error, isLoading } = useSWR(
|
||||
state === ViewState.NEXT && comment.trim() ? '/api/events/survey/preview/v1' : null,
|
||||
async (url: string) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
comment: comment.trim(),
|
||||
locale,
|
||||
url: `/${locale}${asPath}`,
|
||||
vote: voteState,
|
||||
}),
|
||||
})
|
||||
if (response.ok) {
|
||||
return await response.json()
|
||||
} else {
|
||||
throw new Error(`${response.status} on preview`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const hasPreview = !!data && !error
|
||||
|
||||
function submit(evt: React.FormEvent) {
|
||||
evt.preventDefault()
|
||||
if (hasPreview) {
|
||||
trackEvent(getFormData())
|
||||
if (!isEmailError) {
|
||||
setState(ViewState.END)
|
||||
setIsEmailError(false)
|
||||
setComment('')
|
||||
}
|
||||
} else if (comment.trim()) {
|
||||
setState(ViewState.NEXT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,18 +141,21 @@ export const Survey = () => {
|
||||
name="survey-vote"
|
||||
value="Y"
|
||||
aria-label={t`yes`}
|
||||
onChange={vote(ViewState.YES)}
|
||||
checked={state === ViewState.YES}
|
||||
onChange={vote(VoteState.YES)}
|
||||
checked={voteState === VoteState.YES}
|
||||
/>
|
||||
<label
|
||||
className={cx(
|
||||
'btn mr-1 color-border-accent-emphasis',
|
||||
state === ViewState.YES && 'color-bg-accent-emphasis',
|
||||
voteState === VoteState.YES && 'color-bg-accent-emphasis',
|
||||
)}
|
||||
htmlFor="survey-yes"
|
||||
>
|
||||
<span className="visually-hidden">{t`yes`}</span>
|
||||
<ThumbsupIcon size={16} className={state === ViewState.YES ? '' : 'color-fg-muted'} />
|
||||
<ThumbsupIcon
|
||||
size={16}
|
||||
className={voteState === VoteState.YES ? '' : 'color-fg-muted'}
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
className={cx(styles.visuallyHidden, styles.customRadio)}
|
||||
@@ -120,29 +164,34 @@ export const Survey = () => {
|
||||
name="survey-vote"
|
||||
value="N"
|
||||
aria-label={t`no`}
|
||||
onChange={vote(ViewState.NO)}
|
||||
checked={state === ViewState.NO}
|
||||
onChange={vote(VoteState.NO)}
|
||||
checked={voteState === VoteState.NO}
|
||||
/>
|
||||
<label
|
||||
className={cx(
|
||||
'btn color-border-accent-emphasis',
|
||||
state === ViewState.NO && 'color-bg-danger-emphasis',
|
||||
voteState === VoteState.NO && 'color-bg-danger-emphasis',
|
||||
)}
|
||||
htmlFor="survey-no"
|
||||
>
|
||||
<span className="visually-hidden">{t`no`}</span>
|
||||
<ThumbsdownIcon size={16} className={state === ViewState.NO ? '' : 'color-fg-muted'} />
|
||||
<ThumbsdownIcon
|
||||
size={16}
|
||||
className={voteState === VoteState.NO ? '' : 'color-fg-muted'}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{[ViewState.YES, ViewState.NO].includes(state) && (
|
||||
{error && <span className="f6 color-fg-danger">{t`server_error`}</span>}
|
||||
|
||||
{[ViewState.START, ViewState.NEXT].includes(state) && voteState && (
|
||||
<>
|
||||
<p className="mb-3">
|
||||
<label className="d-block mb-1 f6" htmlFor="survey-comment">
|
||||
<span>
|
||||
{state === ViewState.YES && t`comment_yes_label`}
|
||||
{state === ViewState.NO && t`comment_no_label`}
|
||||
{voteState === VoteState.YES && t`comment_yes_label`}
|
||||
{voteState === VoteState.NO && t`comment_no_label`}
|
||||
</span>
|
||||
<span className="text-normal color-fg-muted float-right ml-1">{t`optional`}</span>
|
||||
</label>
|
||||
@@ -150,8 +199,11 @@ export const Survey = () => {
|
||||
className="form-control input-sm width-full"
|
||||
name="survey-comment"
|
||||
id="survey-comment"
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
></textarea>
|
||||
</p>
|
||||
{hasPreview && (
|
||||
<div className={cx('form-group', isEmailError ? 'warn' : '')}>
|
||||
<label className="d-block mb-1 f6" htmlFor="survey-email">
|
||||
{t`email_label`}
|
||||
@@ -173,18 +225,27 @@ export const Survey = () => {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="f6 color-fg-muted">{t`not_support`}</span>
|
||||
)}
|
||||
|
||||
{hasPreview && (
|
||||
<span
|
||||
className="f6 color-fg-muted"
|
||||
dangerouslySetInnerHTML={{ __html: t`not_support` }}
|
||||
></span>
|
||||
)}
|
||||
<div className="d-flex flex-justify-end flex-items-center mt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-invisible mr-3"
|
||||
onClick={() => {
|
||||
setState(ViewState.START)
|
||||
setVoteState(null)
|
||||
setIsEmailError(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{hasPreview ? (
|
||||
<button
|
||||
disabled={isEmailError}
|
||||
type="submit"
|
||||
@@ -192,6 +253,21 @@ export const Survey = () => {
|
||||
>
|
||||
{t`send`}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-sm color-border-accent-emphasis"
|
||||
disabled={isLoading || !comment.trim()}
|
||||
>
|
||||
{t`next`}
|
||||
{isLoading && (
|
||||
<>
|
||||
{' '}
|
||||
<Spinner size="small" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -77,4 +77,32 @@ router.post(
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/survey/preview/v1',
|
||||
catchMiddlewareError(async function previewComment(req, res) {
|
||||
noCacheControl(res)
|
||||
|
||||
const { comment, locale, url, vote } = req.body
|
||||
|
||||
console.log(`The comment was posted in ${locale} on ${url} with vote ${vote}`)
|
||||
|
||||
if (!comment || !comment.trim()) {
|
||||
return res.status(400).json({ message: 'Empty comment' })
|
||||
}
|
||||
|
||||
const signals = []
|
||||
const rating = 1.0
|
||||
|
||||
// if (comment.includes('@') && !comment.includes(' ')) {
|
||||
// // XXX Make it a simple email validator
|
||||
// signals.push({
|
||||
// email: 'Looks like an email address',
|
||||
// })
|
||||
// rating -= 0.1
|
||||
// }
|
||||
|
||||
return res.json({ rating, signals })
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -113,3 +113,57 @@ describe('POST /events', () => {
|
||||
expect(statusCode).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
// These are mostly placeholder tests for now since most of the
|
||||
// implementation of this endpoint is not yet written.
|
||||
describe('POST /events/survey/preview/v1', () => {
|
||||
test('should repond with 400 when no comment is provided', async () => {
|
||||
const body = JSON.stringify({
|
||||
locale: 'en',
|
||||
url: '/quickstart',
|
||||
vote: 'yes',
|
||||
})
|
||||
const res = await post('/api/events/survey/preview/v1', {
|
||||
body,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
test('should repond with 400 when comment is provided but empty', async () => {
|
||||
const body = JSON.stringify({
|
||||
locale: 'en',
|
||||
url: '/quickstart',
|
||||
vote: 'yes',
|
||||
comment: ' ',
|
||||
})
|
||||
const res = await post('/api/events/survey/preview/v1', {
|
||||
body,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
test('should repond with 200 when comment is provided', async () => {
|
||||
const body = JSON.stringify({
|
||||
locale: 'en',
|
||||
url: '/quickstart',
|
||||
vote: 'yes',
|
||||
comment: 'Wonderful',
|
||||
})
|
||||
const res = await post('/api/events/survey/preview/v1', {
|
||||
body,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
const respBody = JSON.parse(res.body)
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(respBody.rating).toEqual(1.0)
|
||||
expect(respBody.signals).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,9 +65,11 @@ survey:
|
||||
email_label: If we can contact you with more questions, please enter your email address
|
||||
email_validation: Please enter a valid email address
|
||||
send: Send
|
||||
next: Next
|
||||
feedback: Thank you! We received your feedback.
|
||||
not_support: If you need a reply, please contact support instead.
|
||||
not_support: If you need a reply, please contact <a href="https://support.github.com/">support</a>.
|
||||
privacy_policy: Privacy policy
|
||||
server_error: Unable to process comment at the moment. Please try again.
|
||||
contribution_cta:
|
||||
title: Help us make these docs great!
|
||||
body: All GitHub docs are open source. See something that's wrong or unclear? Submit a pull request.
|
||||
|
||||
@@ -503,9 +503,19 @@ test.describe('survey', () => {
|
||||
|
||||
// The label is visually an SVG. Finding it by its `for` value feels easier.
|
||||
await page.locator('[for=survey-yes]').click()
|
||||
await expect(page.getByRole('button', { name: 'Next' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Send' })).not.toBeVisible()
|
||||
|
||||
await page.locator('[for=survey-comment]').click()
|
||||
await page.locator('[for=survey-comment]').fill('This is a comment')
|
||||
await page.getByRole('button', { name: 'Next' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Send' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Next' })).not.toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('email@example.com').click()
|
||||
await page.getByPlaceholder('email@example.com').fill('test@example.com')
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click()
|
||||
// One for the page view event, one for the thumbs up click, one for
|
||||
// the submission.
|
||||
@@ -533,9 +543,26 @@ test.describe('survey', () => {
|
||||
// One for the page view event and one for the thumbs up click
|
||||
expect(fulfilled).toBe(1 + 1)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Send' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Next' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Send' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('vote on one page, then go to another and it should reset', async ({ page }) => {
|
||||
// Important to set this up *before* interacting with the page
|
||||
// in case of possible race conditions.
|
||||
await page.route('**/api/events', (route) => {
|
||||
route.fulfill({})
|
||||
})
|
||||
|
||||
await page.goto('/get-started/foo/for-playwright')
|
||||
|
||||
await expect(page.locator('[for=survey-comment]')).not.toBeVisible()
|
||||
await page.locator('[for=survey-yes]').click()
|
||||
await expect(page.getByRole('button', { name: 'Next' })).toBeVisible()
|
||||
await expect(page.locator('[for=survey-comment]')).toBeVisible()
|
||||
|
||||
await page.getByTestId('product-sidebar').getByLabel('Bar', { exact: true }).click()
|
||||
await expect(page.locator('[for=survey-comment]')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user