feature(UI): Help modal form (#53824)

Co-authored-by: Bruce Blaser <bbsmooth@gmail.com>
Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
This commit is contained in:
Pedro Ramos
2024-03-25 04:27:49 -03:00
committed by GitHub
parent 721ce7da7c
commit 1bf8b4b726
6 changed files with 459 additions and 81 deletions

View File

@@ -383,8 +383,14 @@
"percent-complete": "{{percent}}% complete",
"project-complete": "Completed {{completedChallengesInBlock}} of {{totalChallengesInBlock}} certification projects",
"tried-rsa": "If you've already tried the <0>Read-Search-Ask</0> method, then you can ask for help on the freeCodeCamp forum.",
"read-search-ask-checkbox": "I have tried the <0>Read-Search-Ask</0> method",
"similar-questions-checkbox": "I have searched for <0>similar questions that have already been answered on the forum</0>",
"minimum-characters": "Add a minimum of {{characters}} characters",
"characters-left": "{{characters}} characters left",
"must-confirm-statements": "You must confirm the following statements before you can submit your post to the forum.",
"min-50-max-500": "50 character minimum, 500 character maximum",
"rsa": "Read, search, ask",
"rsa-forum": "<strong>Before making a new post</strong> please see if your question has <0>already been answered on the forum</0>.",
"rsa-forum": "<strong>Before making a new post</strong> please <0>check if your question has already been answered on the forum</0>.",
"reset": "Reset this lesson?",
"reset-warn": "Are you sure you wish to reset this lesson? The editors and tests will be reset.",
"reset-warn-2": "This cannot be undone",

View File

@@ -13,6 +13,22 @@
word-spacing: -0.4ch;
}
.help-form-legend {
color: var(--secondary-color);
border: 0;
font-size: 18px;
}
.checkbox {
display: flex;
flex-direction: row;
text-align: left;
}
.checkbox-text {
margin-inline-start: 10px;
}
@media screen and (max-width: 767px) {
.help-modal .btn-lg {
font-size: 16px;

View File

@@ -1,30 +1,32 @@
import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Modal } from '@freecodecamp/react-bootstrap';
import React from 'react';
import { Trans, withTranslation } from 'react-i18next';
import { Button, FormControl } from '@freecodecamp/ui';
import React, { useMemo, useState, useRef, useEffect } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';
import { Button } from '@freecodecamp/ui';
import { Dispatch, bindActionCreators } from 'redux';
import envData from '../../../../config/env.json';
import { Spacer } from '../../../components/helpers';
import { createQuestion, closeModal } from '../redux/actions';
import { isHelpModalOpenSelector } from '../redux/selectors';
import { Spacer } from '../../../components/helpers';
import './help-modal.css';
import callGA from '../../../analytics/call-ga';
interface HelpModalProps {
closeHelpModal: () => void;
createQuestion: () => void;
createQuestion: (description: string) => void;
isOpen?: boolean;
t: (text: string) => string;
challengeTitle: string;
challengeBlock: string;
}
const { forumLocation } = envData;
const DESCRIPTION_MIN_CHARS = 50;
const DESCRIPTION_MAX_CHARS = 500;
const RSA = forumLocation + '/t/19514';
const mapStateToProps = (state: unknown) => ({
isOpen: isHelpModalOpenSelector(state) as boolean
@@ -35,8 +37,6 @@ const mapDispatchToProps = (dispatch: Dispatch) =>
dispatch
);
const RSA = forumLocation + '/t/19514';
const generateSearchLink = (title: string, block: string) => {
const query = /^step\s*\d*$/i.test(title)
? encodeURIComponent(`${block} - ${title}`)
@@ -45,69 +45,245 @@ const generateSearchLink = (title: string, block: string) => {
return search;
};
interface CheckboxProps {
name: string;
i18nkey: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
value: boolean;
href: string;
}
function Checkbox({ name, i18nkey, onChange, value, href }: CheckboxProps) {
const { t } = useTranslation();
return (
<div className='checkbox'>
<label>
<input
name={name}
type='checkbox'
onChange={onChange}
checked={value}
required
/>
<span className='checkbox-text'>
<Trans i18nKey={i18nkey}>
<a href={href} rel='noopener noreferrer' target='_blank'>
placeholder
<span className='sr-only'>{t('aria.opens-new-window')}</span>
</a>
</Trans>
</span>
</label>
</div>
);
}
function HelpModal({
closeHelpModal,
createQuestion,
isOpen,
t,
challengeBlock,
challengeTitle
}: HelpModalProps): JSX.Element {
const { t } = useTranslation();
const [showHelpForm, setShowHelpForm] = useState(false);
const [description, setDescription] = useState('');
const [readSearchCheckbox, setReadSearchCheckbox] = useState(false);
const [similarQuestionsCheckbox, setSimilarQuestionsCheckbox] =
useState(false);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (showHelpForm) {
formRef.current?.querySelector('input')?.focus();
}
}, [showHelpForm]);
const canSubmitForm = useMemo(() => {
return (
description.length >= DESCRIPTION_MIN_CHARS &&
readSearchCheckbox &&
similarQuestionsCheckbox
);
}, [description, readSearchCheckbox, similarQuestionsCheckbox]);
const resetFormValues = () => {
setDescription('');
setReadSearchCheckbox(false);
setSimilarQuestionsCheckbox(false);
};
const handleClose = () => {
closeHelpModal();
setShowHelpForm(false);
resetFormValues();
};
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!canSubmitForm) {
return;
}
setShowHelpForm(false);
resetFormValues();
createQuestion(description);
closeHelpModal();
};
if (isOpen) {
callGA({ event: 'pageview', pagePath: '/help-modal' });
}
return (
<Modal dialogClassName='help-modal' onHide={closeHelpModal} show={isOpen}>
<Modal.Header className='help-modal-header fcc-modal' closeButton={true}>
<Modal.Title className='text-center'>
<Modal
dialogClassName='help-modal'
onHide={handleClose}
show={isOpen}
aria-labelledby='ask-for-help-modal'
>
<Modal.Header
className='help-modal-header fcc-modal'
closeButton={true}
closeLabel={t('buttons.close')}
>
<Modal.Title id='ask-for-help-modal' className='text-center'>
{t('buttons.ask-for-help')}
</Modal.Title>
</Modal.Header>
<Modal.Body className='help-modal-body text-center'>
<h3 className='help-modal-heading'>
<Trans i18nKey='learn.tried-rsa'>
<a
href={RSA}
rel='noopener noreferrer'
target='_blank'
title={t('learn.rsa')}
>
placeholder
</a>
</Trans>
</h3>
<div className='alert alert-danger'>
<FontAwesomeIcon icon={faExclamationCircle} />
<p>
<Trans i18nKey='learn.rsa-forum'>
<a
<Modal.Body className='text-center'>
{showHelpForm ? (
<form onSubmit={handleSubmit} ref={formRef}>
<fieldset>
<legend className='help-form-legend'>
{t('learn.must-confirm-statements')}
</legend>
<Checkbox
name='read-search-ask-checkbox'
i18nkey='learn.read-search-ask-checkbox'
onChange={event => setReadSearchCheckbox(event.target.checked)}
value={readSearchCheckbox}
href={RSA}
/>
<Spacer size='small' />
<Checkbox
name='similar-questions-checkbox'
i18nkey='learn.similar-questions-checkbox'
onChange={event =>
setSimilarQuestionsCheckbox(event.target.checked)
}
value={similarQuestionsCheckbox}
href={generateSearchLink(challengeTitle, challengeBlock)}
rel='noopener noreferrer'
target='_blank'
>
placeholder
</a>
placeholder
</Trans>
</p>
</div>
<Button
block={true}
size='large'
variant='primary'
onClick={createQuestion}
>
{t('buttons.create-post')}
</Button>
<Spacer size='xxSmall' />
<Button
block={true}
size='large'
variant='primary'
onClick={closeHelpModal}
>
{t('buttons.cancel')}
</Button>
/>
</fieldset>
<Spacer size='xSmall' />
<label htmlFor='help-modal-form-description'>
{t('forum-help.whats-happening')}
<span className='sr-only'>{t('learn.min-50-max-500')}</span>
</label>
<FormControl
id='help-modal-form-description'
name='description'
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setDescription(event.target.value);
}}
componentClass='textarea'
rows={5}
value={description}
minLength={DESCRIPTION_MIN_CHARS}
maxLength={DESCRIPTION_MAX_CHARS}
required
/>
<Spacer size='xSmall' />
{description.length < DESCRIPTION_MIN_CHARS ? (
<p>
{t('learn.minimum-characters', {
characters: DESCRIPTION_MIN_CHARS - description.length
})}
</p>
) : (
<p>
{t('learn.characters-left', {
characters: DESCRIPTION_MAX_CHARS - description.length
})}
</p>
)}
<Spacer size='xxSmall' />
<Button
block={true}
size='large'
variant='primary'
type='submit'
disabled={!canSubmitForm}
>
{t('buttons.submit')}
</Button>
<Spacer size='xxSmall' />
<Button
block={true}
size='large'
variant='primary'
onClick={handleClose}
>
{t('buttons.cancel')}
</Button>
</form>
) : (
<>
<p>
<Trans i18nKey='learn.tried-rsa'>
<a href={RSA} rel='noopener noreferrer' target='_blank'>
placeholder
</a>
</Trans>
</p>
<div className='alert alert-danger'>
<FontAwesomeIcon icon={faExclamationCircle} />
<p>
<Trans i18nKey='learn.rsa-forum'>
<a
href={generateSearchLink(challengeTitle, challengeBlock)}
rel='noopener noreferrer'
target='_blank'
>
placeholder
</a>
placeholder
</Trans>
</p>
</div>
<Button
block={true}
size='large'
variant='primary'
onClick={() => setShowHelpForm(true)}
>
{t('buttons.create-post')}
</Button>
<Spacer size='xxSmall' />
<Button
block={true}
size='large'
variant='primary'
onClick={closeHelpModal}
>
{t('buttons.cancel')}
</Button>
</>
)}
</Modal.Body>
</Modal>
);
@@ -115,7 +291,4 @@ function HelpModal({
HelpModal.displayName = 'HelpModal';
export default connect(
mapStateToProps,
mapDispatchToProps
)(withTranslation()(HelpModal));
export default connect(mapStateToProps, mapDispatchToProps)(HelpModal);

View File

@@ -73,10 +73,46 @@ export function insertEditableRegions(challengeFiles = []) {
return challengeFiles;
}
function editableRegionsToMarkdown(challengeFiles = []) {
const moreThanOneFile = challengeFiles?.length > 1;
return challengeFiles.reduce((fileString, challengeFile) => {
if (!challengeFile) {
return fileString;
}
const fileExtension = challengeFile.ext;
const fileName = challengeFile.name;
const fileType = fileExtension === 'js' ? 'javascript' : fileExtension;
let fileDescription;
if (!moreThanOneFile) {
fileDescription = '';
} else if (fileExtension === 'html') {
fileDescription = `<!-- file: ${fileName}.${fileExtension} -->\n`;
} else {
fileDescription = `/* file: ${fileName}.${fileExtension} */\n`;
}
const [start, end] = challengeFile.editableRegionBoundaries;
const lines = challengeFile.contents.split('\n');
const editableRegion = lines.slice(start + 1, end + 4).join('\n');
return `${fileString}\`\`\`${fileType}\n${fileDescription}${editableRegion}\n\`\`\`\n\n`;
}, '\n');
}
function linksOrMarkdown(projectFormValues, markdown) {
return (
projectFormValues
?.map(([key, val]) => `${key}: ${transformEditorLink(val)}\n\n`)
?.join('') || markdown
);
}
function createQuestionEpic(action$, state$, { window }) {
return action$.pipe(
ofType(actionTypes.createQuestion),
tap(() => {
tap(({ payload: describe }) => {
const state = state$.value;
let challengeFiles = challengeFilesSelector(state);
const {
@@ -122,15 +158,21 @@ function createQuestionEpic(action$, state$, { window }) {
: '### ' + i18next.t('forum-help.camper-code') + '\n\n';
const whatsHappeningHeading = i18next.t('forum-help.whats-happening');
const describe = i18next.t('forum-help.describe');
const projectOrCodeHeading = projectFormValues.length
? `###${i18next.t('forum-help.camper-project')}\n\n`
: camperCodeHeading;
const markdownCodeOrLinks =
projectFormValues
?.map(([key, val]) => `${key}: ${transformEditorLink(val)}\n\n`)
?.join('') || filesToMarkdown(challengeFiles);
const textMessage = `### ${whatsHappeningHeading}\n${describe}\n\n${projectOrCodeHeading}${markdownCodeOrLinks}${endingText}`;
const fullCode = filesToMarkdown(challengeFiles);
const fullCodeOrLinks = linksOrMarkdown(projectFormValues, fullCode);
const onlyEditableRegion = editableRegionsToMarkdown(challengeFiles);
const editableRegionOrLinks = linksOrMarkdown(
projectFormValues,
onlyEditableRegion
);
const textMessage = `### ${whatsHappeningHeading}\n${describe}\n\n${projectOrCodeHeading}${fullCodeOrLinks}${endingText}`;
const textMessageOnlyEditableRegion = `### ${whatsHappeningHeading}\n${describe}\n\n${projectOrCodeHeading}${editableRegionOrLinks}${endingText}`;
const warning = i18next.t('forum-help.warning');
const tooLongOne = i18next.t('forum-help.too-long-one');
@@ -139,7 +181,7 @@ function createQuestionEpic(action$, state$, { window }) {
const addCodeOne = i18next.t('forum-help.add-code-one');
const addCodeTwo = i18next.t('forum-help.add-code-two');
const addCodeThree = i18next.t('forum-help.add-code-three');
const altTextMessage = `### ${whatsHappeningHeading}\n\n${camperCodeHeading}\n\n${warning}\n\n${tooLongOne}\n\n${tooLongTwo}\n\n${tooLongThree}\n\n\`\`\`text\n${addCodeOne}\n${addCodeTwo}\n${addCodeThree}\n\`\`\`\n\n${endingText}`;
const altTextMessage = `### ${whatsHappeningHeading}\n${describe}\n\n${camperCodeHeading}\n\n${warning}\n\n${tooLongOne}\n\n${tooLongTwo}\n\n${tooLongThree}\n\n\`\`\`text\n${addCodeOne}\n${addCodeTwo}\n${addCodeThree}\n\`\`\`\n\n${endingText}`;
const titleText = window.encodeURIComponent(
`${i18next.t(
@@ -152,13 +194,25 @@ function createQuestionEpic(action$, state$, { window }) {
);
const studentCode = window.encodeURIComponent(textMessage);
const editableRegionCode = window.encodeURIComponent(
textMessageOnlyEditableRegion
);
const altStudentCode = window.encodeURIComponent(altTextMessage);
const baseURI = `${forumLocation}/new-topic?category=${category}&title=${titleText}&body=`;
const defaultURI = `${baseURI}${studentCode}`;
const onlyEditableRegionURI = `${baseURI}${editableRegionCode}`;
const altURI = `${baseURI}${altStudentCode}`;
window.open(defaultURI.length < 8000 ? defaultURI : altURI, '_blank');
let URIToOpen = defaultURI;
if (defaultURI.length > 8000) {
URIToOpen = onlyEditableRegionURI;
}
if (onlyEditableRegionURI.length > 8000) {
URIToOpen = altURI;
}
window.open(URIToOpen, '_blank');
}),
mapTo(closeModal('help'))
);

View File

@@ -255,7 +255,11 @@ export const reducer = handleActions(
[payload]: !state.visibleEditors[payload]
}
};
}
},
[actionTypes.createQuestion]: (state, { payload }) => ({
...state,
description: payload
})
},
initialState
);

View File

@@ -22,13 +22,13 @@ test.describe('Help Modal component', () => {
})
).toBeVisible();
await expect(
page.getByRole('heading', {
name: `If you've already tried the Read-Search-Ask method, then you can ask for help on the freeCodeCamp forum.`
})
page.getByText(
`If you've already tried the Read-Search-Ask method, then you can ask for help on the freeCodeCamp forum.`
)
).toBeVisible();
await expect(
page.getByText(
`Before making a new post please see if your question has already been answered on the forum.`
`Before making a new post please check if your question has already been answered on the forum.`
)
).toBeVisible();
@@ -43,16 +43,13 @@ test.describe('Help Modal component', () => {
).toBeVisible();
});
test('Create Post button closes help modal and creates new page with forum url', async ({
context,
test('should disable the submit button if the checboxes are not checked', async ({
page
}) => {
await page
.getByRole('button', { name: translations.buttons['ask-for-help'] })
.click();
const newPagePromise = context.waitForEvent('page');
await page
.getByRole('button', {
name: translations.buttons['create-post']
@@ -64,7 +61,135 @@ test.describe('Help Modal component', () => {
name: translations.buttons['ask-for-help'],
exact: true
})
).not.toBeVisible();
).toBeVisible();
const rsaCheckbox = page.getByRole('checkbox', {
name: 'I have tried the Read-Search-Ask method'
});
const similarQuestionsCheckbox = page.getByRole('checkbox', {
name: 'I have searched for similar questions that have already been answered on the forum'
});
const descriptionInput = page.getByRole('textbox', {
name: translations['forum-help']['whats-happening']
});
const submitButton = page.getByRole('button', {
name: translations.buttons['submit']
});
await descriptionInput.fill(
'Example text with a 100 characters to validate if the rules applied to block users from spamming help forum are working.'
);
await expect(submitButton).toBeDisabled();
await rsaCheckbox.check();
await similarQuestionsCheckbox.uncheck();
await expect(submitButton).toBeDisabled();
await rsaCheckbox.uncheck();
await similarQuestionsCheckbox.check();
await expect(submitButton).toBeDisabled();
});
test('should disable the submit button if the description contains less than 50 characters', async ({
page
}) => {
await page
.getByRole('button', { name: translations.buttons['ask-for-help'] })
.click();
await page
.getByRole('button', {
name: translations.buttons['create-post']
})
.click();
await expect(
page.getByRole('heading', {
name: translations.buttons['ask-for-help'],
exact: true
})
).toBeVisible();
const rsaCheckbox = page.getByRole('checkbox', {
name: 'I have tried the Read-Search-Ask method'
});
const similarQuestionsCheckbox = page.getByRole('checkbox', {
name: 'I have searched for similar questions that have already been answered on the forum'
});
const descriptionInput = page.getByRole('textbox', {
name: translations['forum-help']['whats-happening']
});
const submitButton = page.getByRole('button', {
name: translations.buttons['submit']
});
await rsaCheckbox.click();
await similarQuestionsCheckbox.click();
await descriptionInput.fill('Example text');
await expect(submitButton).toBeDisabled();
});
test('should ask the user to fill in the help form and create a forum page', async ({
context,
page
}) => {
await page
.getByRole('button', { name: translations.buttons['ask-for-help'] })
.click();
await page
.getByRole('button', {
name: translations.buttons['create-post']
})
.click();
await expect(
page.getByRole('heading', {
name: translations.buttons['ask-for-help'],
exact: true
})
).toBeVisible();
const rsaCheckbox = page.getByRole('checkbox', {
name: 'I have tried the Read-Search-Ask method'
});
const similarQuestionsCheckbox = page.getByRole('checkbox', {
name: 'I have searched for similar questions that have already been answered on the forum'
});
const descriptionInput = page.getByRole('textbox', {
name: translations['forum-help']['whats-happening']
});
const submitButton = page.getByRole('button', {
name: translations.buttons['submit']
});
await expect(rsaCheckbox).toBeVisible();
await expect(similarQuestionsCheckbox).toBeVisible();
await expect(descriptionInput).toBeVisible();
await rsaCheckbox.check();
await similarQuestionsCheckbox.check();
await descriptionInput.fill(
'Example text with a 100 characters to validate if the rules applied to block users from spamming help forum are working.'
);
await expect(submitButton).toBeEnabled();
await submitButton.click();
const newPagePromise = context.waitForEvent('page');
const newPage = await newPagePromise;
await newPage.waitForLoadState();