fix(client): give useful error in solutionform (#40225)

This commit is contained in:
Shaun Hamilton
2021-02-01 13:34:04 +00:00
committed by GitHub
parent 5539dbf086
commit ab83d698f9
11 changed files with 114 additions and 40 deletions

View File

@ -79,12 +79,12 @@ test('should submit', () => {
fireEvent.click(button); fireEvent.click(button);
expect(submit).toHaveBeenCalledTimes(1); expect(submit).toHaveBeenCalledTimes(1);
expect(submit.mock.calls[0][0]).toEqual({ website: websiteValue }); expect(submit.mock.calls[0][0].values).toEqual({ website: websiteValue });
fireEvent.change(websiteInput, { target: { value: `${websiteValue}///` } }); fireEvent.change(websiteInput, { target: { value: `${websiteValue}///` } });
expect(websiteInput).toHaveValue(`${websiteValue}///`); expect(websiteInput).toHaveValue(`${websiteValue}///`);
fireEvent.click(button); fireEvent.click(button);
expect(submit).toHaveBeenCalledTimes(2); expect(submit).toHaveBeenCalledTimes(2);
expect(submit.mock.calls[1][0]).toEqual({ website: websiteValue }); expect(submit.mock.calls[1][0].values).toEqual({ website: websiteValue });
}); });

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { kebabCase } from 'lodash'; import { kebabCase } from 'lodash';
import normalizeUrl from 'normalize-url';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { import {
Alert, Alert,
@ -10,6 +11,11 @@ import {
HelpBlock HelpBlock
} from '@freecodecamp/react-bootstrap'; } from '@freecodecamp/react-bootstrap';
import { Field } from 'react-final-form'; import { Field } from 'react-final-form';
import {
editorValidator,
localhostValidator,
composeValidators
} from './FormValidators';
const propTypes = { const propTypes = {
formFields: PropTypes.arrayOf( formFields: PropTypes.arrayOf(
@ -33,6 +39,28 @@ function FormFields(props) {
types = {} types = {}
} = options; } = options;
const nullOrWarning = (value, error, isURL) => {
let validationError;
if (value && isURL) {
try {
normalizeUrl(value, { stripWWW: false });
} catch (err) {
validationError = err.message;
}
}
const validationWarning = composeValidators(
editorValidator,
localhostValidator
)(value);
const message = error || validationError || validationWarning;
return message ? (
<HelpBlock>
<Alert bsStyle={error || validationError ? 'danger' : 'info'}>
{message}
</Alert>
</HelpBlock>
) : null;
};
return ( return (
<div> <div>
{formFields {formFields
@ -44,6 +72,7 @@ function FormFields(props) {
const type = name in types ? types[name] : 'text'; const type = name in types ? types[name] : 'text';
const placeholder = const placeholder =
name in placeholders ? placeholders[name] : ''; name in placeholders ? placeholders[name] : '';
const isURL = types[name] === 'url';
return ( return (
<Col key={key} xs={12}> <Col key={key} xs={12}>
<FormGroup> <FormGroup>
@ -61,11 +90,7 @@ function FormFields(props) {
type={type} type={type}
value={value} value={value}
/> />
{error && !pristine ? ( {nullOrWarning(value, !pristine && error, isURL)}
<HelpBlock>
<Alert bsStyle='danger'>{error}</Alert>
</HelpBlock>
) : null}
</FormGroup> </FormGroup>
</Col> </Col>
); );

View File

@ -0,0 +1,14 @@
// Matches editor links for: Repl.it, Glitch, CodeSandbox
const editorRegex = /repl\.it\/@|glitch\.com\/edit\/#!|codesandbox\.io\/s\//;
const localhostRegex = /localhost:/;
export const editorValidator = value =>
editorRegex.test(value) ? 'Remember to submit the Live App URL.' : null;
export const localhostValidator = value =>
localhostRegex.test(value)
? 'Remember to submit a publicly visible app URL.'
: null;
export const composeValidators = (...validators) => value =>
validators.reduce((error, validator) => error ?? validator(value), null);

View File

@ -1,4 +1,9 @@
import normalizeUrl from 'normalize-url'; import normalizeUrl from 'normalize-url';
import {
localhostValidator,
editorValidator,
composeValidators
} from './FormValidators';
export { default as BlockSaveButton } from './BlockSaveButton.js'; export { default as BlockSaveButton } from './BlockSaveButton.js';
export { default as BlockSaveWrapper } from './BlockSaveWrapper.js'; export { default as BlockSaveWrapper } from './BlockSaveWrapper.js';
@ -10,11 +15,26 @@ const normalizeOptions = {
}; };
export function formatUrlValues(values, options) { export function formatUrlValues(values, options) {
return Object.keys(values).reduce((result, key) => { const validatedValues = { values: {}, errors: [], invalidValues: [] };
const urlValues = Object.keys(values).reduce((result, key) => {
let value = values[key]; let value = values[key];
const nullOrWarning = composeValidators(
localhostValidator,
editorValidator
)(value);
if (nullOrWarning) {
validatedValues.invalidValues.push(nullOrWarning);
}
if (value && options.types[key] === 'url') { if (value && options.types[key] === 'url') {
try {
value = normalizeUrl(value, normalizeOptions); value = normalizeUrl(value, normalizeOptions);
} catch (err) {
// Not a valid URL for testing or submission
validatedValues.errors.push({ error: err, value });
}
} }
return { ...result, [key]: value }; return { ...result, [key]: value };
}, {}); }, {});
validatedValues.values = urlValues;
return validatedValues;
} }

View File

@ -328,7 +328,7 @@ export class CertificationSettings extends Component {
}; };
// legacy projects rendering // legacy projects rendering
handleSubmitLegacy(formChalObj) { handleSubmitLegacy({ values: formChalObj }) {
const { const {
isHonest, isHonest,
createFlashMessage, createFlashMessage,

View File

@ -27,10 +27,20 @@ export class SolutionForm extends Component {
componentDidMount() { componentDidMount() {
this.props.updateSolutionForm({}); this.props.updateSolutionForm({});
} }
handleSubmit(values) {
this.props.updateSolutionForm(values); handleSubmit(validatedValues) {
this.props.onSubmit(); // Do not execute challenge, if errors
if (validatedValues.errors.length === 0) {
if (validatedValues.invalidValues.length === 0) {
// updates values on server
this.props.updateSolutionForm(validatedValues.values);
this.props.onSubmit({ isShouldCompletionModalOpen: true });
} else {
this.props.onSubmit({ isShouldCompletionModalOpen: false });
} }
}
}
render() { render() {
const { isSubmitting, challengeType, description, t } = this.props; const { isSubmitting, challengeType, description, t } = this.props;

View File

@ -87,6 +87,7 @@ export class BackEnd extends Component {
super(props); super(props);
this.state = {}; this.state = {};
this.updateDimensions = this.updateDimensions.bind(this); this.updateDimensions = this.updateDimensions.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
} }
componentDidMount() { componentDidMount() {
@ -152,6 +153,10 @@ export class BackEnd extends Component {
challengeMounted(challengeMeta.id); challengeMounted(challengeMeta.id);
} }
handleSubmit({ isShouldCompletionModalOpen }) {
this.props.executeChallenge(isShouldCompletionModalOpen);
}
render() { render() {
const { const {
data: { data: {
@ -172,7 +177,6 @@ export class BackEnd extends Component {
}, },
t, t,
tests, tests,
executeChallenge,
updateSolutionFormValues updateSolutionFormValues
} = this.props; } = this.props;
@ -205,7 +209,7 @@ export class BackEnd extends Component {
/> />
<SolutionForm <SolutionForm
challengeType={challengeType} challengeType={challengeType}
onSubmit={executeChallenge} onSubmit={this.handleSubmit}
updateSolutionForm={updateSolutionFormValues} updateSolutionForm={updateSolutionFormValues}
/> />
<ProjectToolPanel <ProjectToolPanel

View File

@ -16,6 +16,7 @@ import {
openModal, openModal,
updateSolutionFormValues updateSolutionFormValues
} from '../../redux'; } from '../../redux';
import { getGuideUrl } from '../../utils'; import { getGuideUrl } from '../../utils';
import LearnLayout from '../../../../components/layouts/Learn'; import LearnLayout from '../../../../components/layouts/Learn';
@ -62,6 +63,10 @@ const propTypes = {
}; };
export class Project extends Component { export class Project extends Component {
constructor() {
super();
this.handleSubmit = this.handleSubmit.bind(this);
}
componentDidMount() { componentDidMount() {
const { const {
challengeMounted, challengeMounted,
@ -106,6 +111,12 @@ export class Project extends Component {
} }
} }
handleSubmit({ isShouldCompletionModalOpen }) {
if (isShouldCompletionModalOpen) {
this.props.openCompletionModal();
}
}
render() { render() {
const { const {
data: { data: {
@ -119,7 +130,6 @@ export class Project extends Component {
} }
}, },
isChallengeCompleted, isChallengeCompleted,
openCompletionModal,
pageContext: { pageContext: {
challengeMeta: { nextChallengePath, prevChallengePath } challengeMeta: { nextChallengePath, prevChallengePath }
}, },
@ -154,7 +164,7 @@ export class Project extends Component {
<SolutionForm <SolutionForm
challengeType={challengeType} challengeType={challengeType}
description={description} description={description}
onSubmit={openCompletionModal} onSubmit={this.handleSubmit}
updateSolutionForm={updateSolutionFormValues} updateSolutionForm={updateSolutionFormValues}
/> />
<ProjectToolPanel <ProjectToolPanel

View File

@ -1,17 +0,0 @@
import { ofType } from 'redux-observable';
import { switchMap } from 'rxjs/operators';
import { of, empty } from 'rxjs';
import { types, openModal } from './';
function challengeModalEpic(action$) {
return action$.pipe(
ofType(types.updateTests),
switchMap(({ payload: tests }) => {
const challengeComplete = tests.every(test => test.pass && !test.err);
return challengeComplete ? of(openModal('completion')) : empty();
})
);
}
export default challengeModalEpic;

View File

@ -24,6 +24,7 @@ import {
updateLogs, updateLogs,
logsToConsole, logsToConsole,
updateTests, updateTests,
openModal,
isBuildEnabledSelector, isBuildEnabledSelector,
disableBuildOnError, disableBuildOnError,
types types
@ -43,11 +44,12 @@ import {
const previewTimeout = 2500; const previewTimeout = 2500;
let previewTask; let previewTask;
export function* executeCancellableChallengeSaga() { export function* executeCancellableChallengeSaga(payload) {
if (previewTask) { if (previewTask) {
yield cancel(previewTask); yield cancel(previewTask);
} }
const task = yield fork(executeChallengeSaga); // executeChallenge with payload containing isShouldCompletionModalOpen
const task = yield fork(executeChallengeSaga, payload);
previewTask = yield fork(previewChallengeSaga, { flushLogs: false }); previewTask = yield fork(previewChallengeSaga, { flushLogs: false });
yield take(types.cancelTests); yield take(types.cancelTests);
@ -58,7 +60,9 @@ export function* executeCancellablePreviewSaga() {
previewTask = yield fork(previewChallengeSaga); previewTask = yield fork(previewChallengeSaga);
} }
export function* executeChallengeSaga() { export function* executeChallengeSaga({
payload: isShouldCompletionModalOpen
}) {
const isBuildEnabled = yield select(isBuildEnabledSelector); const isBuildEnabled = yield select(isBuildEnabledSelector);
if (!isBuildEnabled) { if (!isBuildEnabled) {
return; return;
@ -93,8 +97,13 @@ export function* executeChallengeSaga() {
document document
); );
const testResults = yield executeTests(testRunner, tests); const testResults = yield executeTests(testRunner, tests);
yield put(updateTests(testResults)); yield put(updateTests(testResults));
const challengeComplete = testResults.every(test => test.pass && !test.err);
if (challengeComplete && isShouldCompletionModalOpen) {
yield put(openModal('completion'));
}
yield put(updateConsole(i18next.t('learn.tests-completed'))); yield put(updateConsole(i18next.t('learn.tests-completed')));
yield put(logsToConsole(i18next.t('learn.console-output'))); yield put(logsToConsole(i18next.t('learn.console-output')));
} catch (e) { } catch (e) {

View File

@ -4,7 +4,6 @@ import { createTypes } from '../../../../utils/stateManagement';
import { createPoly } from '../../../../../utils/polyvinyl'; import { createPoly } from '../../../../../utils/polyvinyl';
import { getLines } from '../../../../../utils/get-lines'; import { getLines } from '../../../../../utils/get-lines';
import challengeModalEpic from './challenge-modal-epic';
import completionEpic from './completion-epic'; import completionEpic from './completion-epic';
import codeLockEpic from './code-lock-epic'; import codeLockEpic from './code-lock-epic';
import createQuestionEpic from './create-question-epic'; import createQuestionEpic from './create-question-epic';
@ -97,7 +96,6 @@ export const types = createTypes(
); );
export const epics = [ export const epics = [
challengeModalEpic,
codeLockEpic, codeLockEpic,
completionEpic, completionEpic,
createQuestionEpic, createQuestionEpic,
@ -195,6 +193,7 @@ export const isCompletionModalOpenSelector = state =>
export const isHelpModalOpenSelector = state => state[ns].modal.help; export const isHelpModalOpenSelector = state => state[ns].modal.help;
export const isVideoModalOpenSelector = state => state[ns].modal.video; export const isVideoModalOpenSelector = state => state[ns].modal.video;
export const isResetModalOpenSelector = state => state[ns].modal.reset; export const isResetModalOpenSelector = state => state[ns].modal.reset;
export const isBuildEnabledSelector = state => state[ns].isBuildEnabled; export const isBuildEnabledSelector = state => state[ns].isBuildEnabled;
export const successMessageSelector = state => state[ns].successMessage; export const successMessageSelector = state => state[ns].successMessage;