fix(client): give useful error in solutionform (#40225)
This commit is contained in:
@ -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 });
|
||||||
});
|
});
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
14
client/src/components/formHelpers/FormValidators.js
Normal file
14
client/src/components/formHelpers/FormValidators.js
Normal 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);
|
@ -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') {
|
||||||
value = normalizeUrl(value, normalizeOptions);
|
try {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
|
@ -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) {
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user