diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index ae6c8efe396..209aaa5bed9 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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-Ask0> method, then you can ask for help on the freeCodeCamp forum.", + "read-search-ask-checkbox": "I have tried the <0>Read-Search-Ask0> method", + "similar-questions-checkbox": "I have searched for <0>similar questions that have already been answered on the forum0>", + "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": "Before making a new post please see if your question has <0>already been answered on the forum0>.", + "rsa-forum": "Before making a new post please <0>check if your question has already been answered on the forum0>.", "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", diff --git a/client/src/templates/Challenges/components/help-modal.css b/client/src/templates/Challenges/components/help-modal.css index b32a2b64d08..0b1cacfee99 100644 --- a/client/src/templates/Challenges/components/help-modal.css +++ b/client/src/templates/Challenges/components/help-modal.css @@ -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; diff --git a/client/src/templates/Challenges/components/help-modal.tsx b/client/src/templates/Challenges/components/help-modal.tsx index 399a8690f40..b968f5895dc 100644 --- a/client/src/templates/Challenges/components/help-modal.tsx +++ b/client/src/templates/Challenges/components/help-modal.tsx @@ -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) => void; + value: boolean; + href: string; +} + +function Checkbox({ name, i18nkey, onChange, value, href }: CheckboxProps) { + const { t } = useTranslation(); + + return ( + + + + + + + placeholder + {t('aria.opens-new-window')} + + + + + + ); +} + 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(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 ( - - - + + + {t('buttons.ask-for-help')} - - - - - placeholder - - - - - - - - + {showHelpForm ? ( + + + + {t('learn.must-confirm-statements')} + + + setReadSearchCheckbox(event.target.checked)} + value={readSearchCheckbox} + href={RSA} + /> + + + + + setSimilarQuestionsCheckbox(event.target.checked) + } + value={similarQuestionsCheckbox} href={generateSearchLink(challengeTitle, challengeBlock)} - rel='noopener noreferrer' - target='_blank' - > - placeholder - - placeholder - - - - - {t('buttons.create-post')} - - - - {t('buttons.cancel')} - + /> + + + + + + {t('forum-help.whats-happening')} + {t('learn.min-50-max-500')} + + + ) => { + setDescription(event.target.value); + }} + componentClass='textarea' + rows={5} + value={description} + minLength={DESCRIPTION_MIN_CHARS} + maxLength={DESCRIPTION_MAX_CHARS} + required + /> + + + + {description.length < DESCRIPTION_MIN_CHARS ? ( + + {t('learn.minimum-characters', { + characters: DESCRIPTION_MIN_CHARS - description.length + })} + + ) : ( + + {t('learn.characters-left', { + characters: DESCRIPTION_MAX_CHARS - description.length + })} + + )} + + + + + {t('buttons.submit')} + + + + {t('buttons.cancel')} + + + ) : ( + <> + + + + placeholder + + + + + + + + + placeholder + + placeholder + + + + + setShowHelpForm(true)} + > + {t('buttons.create-post')} + + + + {t('buttons.cancel')} + + > + )} ); @@ -115,7 +291,4 @@ function HelpModal({ HelpModal.displayName = 'HelpModal'; -export default connect( - mapStateToProps, - mapDispatchToProps -)(withTranslation()(HelpModal)); +export default connect(mapStateToProps, mapDispatchToProps)(HelpModal); diff --git a/client/src/templates/Challenges/redux/create-question-epic.js b/client/src/templates/Challenges/redux/create-question-epic.js index 11ca5ce5703..17b00ccab5e 100644 --- a/client/src/templates/Challenges/redux/create-question-epic.js +++ b/client/src/templates/Challenges/redux/create-question-epic.js @@ -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 = `\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')) ); diff --git a/client/src/templates/Challenges/redux/index.js b/client/src/templates/Challenges/redux/index.js index dc6bff25c2e..baad5231580 100644 --- a/client/src/templates/Challenges/redux/index.js +++ b/client/src/templates/Challenges/redux/index.js @@ -255,7 +255,11 @@ export const reducer = handleActions( [payload]: !state.visibleEditors[payload] } }; - } + }, + [actionTypes.createQuestion]: (state, { payload }) => ({ + ...state, + description: payload + }) }, initialState ); diff --git a/e2e/help-modal.spec.ts b/e2e/help-modal.spec.ts index 15d80674b05..249adc2c110 100644 --- a/e2e/help-modal.spec.ts +++ b/e2e/help-modal.spec.ts @@ -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();
- - + {showHelpForm ? ( + + + + {t('learn.must-confirm-statements')} + + + setReadSearchCheckbox(event.target.checked)} + value={readSearchCheckbox} + href={RSA} + /> + + + + + setSimilarQuestionsCheckbox(event.target.checked) + } + value={similarQuestionsCheckbox} href={generateSearchLink(challengeTitle, challengeBlock)} - rel='noopener noreferrer' - target='_blank' - > - placeholder - - placeholder - -
+ {t('learn.minimum-characters', { + characters: DESCRIPTION_MIN_CHARS - description.length + })} +
+ {t('learn.characters-left', { + characters: DESCRIPTION_MAX_CHARS - description.length + })} +
+ + + placeholder + + +
+ + + placeholder + + placeholder + +