feat: let users save cert project code to db (#44700)

* feat: let users save cert project code to db

fix: move getChallenges call out of request function so it only runs once

fix: use FlashMessages enum

fix: transform challengeFiles earlier

test: make tribute page use multifile editor

stuff I was playing with - revert this to get it to a working state

refactor: allow undefined editableRegionBoundaries

fix: save history

history is not necessarily ["name.ext"] and using the incorrect history
could cause weird bugs

fix: replace files -> challengeFiles on the client

refactor: DRY out ajax

fix: use file -> challengefile map

refactor: rename ajax types

fix: alphatize flash-messages.ts

revert: tribute page project

fix: remove logs

fix: prettier

fix: cypress

fix: prettier

fix: remove submitComplete action

fix: block UI for new projects

fix: handle code size

* fix: catch undefined files

* fix: don't default to undefined when it's already the default

* fix: only update savedChallenges if applicable

* fix: dehumidify backend + fine tune nearby stuff

* fix: prop-types

* fix: dehumidify sagas

* fix: variable name

* fix: types

* Apply suggestions from code review

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>

* fix: typo

* fix: prettier

* fix: props types

* fix: flash messages

* Update client/src/utils/challenge-request-helpers.ts

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* chore: rename function uniformize -> standardize

* fix: flash message

* fix: add link to forum on flash messages

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Tom
2022-03-31 10:34:40 -05:00
committed by GitHub
parent ed66e5d01c
commit 9f753a5662
29 changed files with 547 additions and 94 deletions

View File

@ -994,6 +994,20 @@ export default function initializeUser(User) {
return user.completedChallenges; return user.completedChallenges;
}); });
}; };
User.prototype.getSavedChallenges$ = function getSavedChallenges$() {
if (Array.isArray(this.savedChallenges) && this.savedChallenges.length) {
return Observable.of(this.savedChallenges);
}
const id = this.getId();
const filter = {
where: { id },
fields: { savedChallenges: true }
};
return this.constructor.findOne$(filter).map(user => {
this.savedChallenges = user.savedChallenges;
return user.savedChallenges;
});
};
User.prototype.getPartiallyCompletedChallenges$ = User.prototype.getPartiallyCompletedChallenges$ =
function getPartiallyCompletedChallenges$() { function getPartiallyCompletedChallenges$() {

View File

@ -250,6 +250,39 @@
], ],
"default": [] "default": []
}, },
"savedChallenges": {
"type": [
{
"lastSavedDate": "number",
"id": "string",
"challengeType": "number",
"files": {
"type": [
{
"contents": {
"type": "string",
"default": ""
},
"ext": {
"type": "string"
},
"path": {
"type": "string"
},
"name": {
"type": "string"
},
"key": {
"type": "string"
}
}
],
"default": []
}
}
],
"default": []
},
"portfolio": { "portfolio": {
"type": "array", "type": "array",
"default": [] "default": []

View File

@ -21,5 +21,8 @@ export const fixCompletedChallengeItem = obj =>
'isManuallyApproved' 'isManuallyApproved'
]); ]);
export const fixSavedChallengeItem = obj =>
pick(obj, ['id', 'lastSavedDate', 'files']);
export const fixPartiallyCompletedChallengeItem = obj => export const fixPartiallyCompletedChallengeItem = obj =>
pick(obj, ['id', 'completedDate']); pick(obj, ['id', 'completedDate']);

View File

@ -18,7 +18,8 @@ import { jwtSecret } from '../../../../config/secrets';
import { environment, deploymentEnv } from '../../../../config/env.json'; import { environment, deploymentEnv } from '../../../../config/env.json';
import { import {
fixCompletedChallengeItem, fixCompletedChallengeItem,
fixPartiallyCompletedChallengeItem fixPartiallyCompletedChallengeItem,
fixSavedChallengeItem
} from '../../common/utils'; } from '../../common/utils';
import { getChallenges } from '../utils/get-curriculum'; import { getChallenges } from '../utils/get-curriculum';
import { ifNoUserSend } from '../utils/middleware'; import { ifNoUserSend } from '../utils/middleware';
@ -64,6 +65,13 @@ export default async function bootChallenge(app, done) {
backendChallengeCompleted backendChallengeCompleted
); );
api.post(
'/save-challenge',
send200toNonUser,
isValidChallengeCompletion,
saveChallenge
);
router.get('/challenges/current-challenge', redirectToCurrentChallenge); router.get('/challenges/current-challenge', redirectToCurrentChallenge);
const coderoadChallengeCompleted = createCoderoadChallengeCompleted(app); const coderoadChallengeCompleted = createCoderoadChallengeCompleted(app);
@ -87,6 +95,33 @@ const multiFileCertProjectIds = getChallenges()
.filter(challenge => challenge.challengeType === 14) .filter(challenge => challenge.challengeType === 14)
.map(challenge => challenge.id); .map(challenge => challenge.id);
const savableChallenges = getChallenges()
.filter(challenge => challenge.challengeType === 14)
.map(challenge => challenge.id);
function buildNewSavedChallenges({
user,
challengeId,
completedDate = Date.now(),
files
}) {
const { savedChallenges } = user;
const challengeToSave = {
id: challengeId,
lastSavedDate: completedDate,
files: files?.map(file =>
pick(file, ['contents', 'key', 'name', 'ext', 'history'])
)
};
const newSavedChallenges = uniqBy(
[challengeToSave, ...savedChallenges.map(fixSavedChallengeItem)],
'id'
);
return newSavedChallenges;
}
export function buildUserUpdate( export function buildUserUpdate(
user, user,
challengeId, challengeId,
@ -101,7 +136,7 @@ export function buildUserUpdate(
) { ) {
completedChallenge = { completedChallenge = {
..._completedChallenge, ..._completedChallenge,
files: files.map(file => files: files?.map(file =>
pick(file, ['contents', 'key', 'index', 'name', 'path', 'ext']) pick(file, ['contents', 'key', 'index', 'name', 'path', 'ext'])
) )
}; };
@ -133,13 +168,34 @@ export function buildUserUpdate(
}; };
} }
let newSavedChallenges;
if (savableChallenges.includes(challengeId)) {
newSavedChallenges = buildNewSavedChallenges({
user,
challengeId,
completedDate,
files
});
// if savableChallenge, update saved array when submitting
updateData.$set = {
completedChallenges: uniqBy(
[finalChallenge, ...completedChallenges.map(fixCompletedChallengeItem)],
'id'
),
savedChallenges: newSavedChallenges
};
} else {
updateData.$set = { updateData.$set = {
completedChallenges: uniqBy( completedChallenges: uniqBy(
[finalChallenge, ...completedChallenges.map(fixCompletedChallengeItem)], [finalChallenge, ...completedChallenges.map(fixCompletedChallengeItem)],
'id' 'id'
) )
}; };
}
// remove from partiallyCompleted on submit
updateData.$pull = { updateData.$pull = {
partiallyCompletedChallenges: { id: challengeId } partiallyCompletedChallenges: { id: challengeId }
}; };
@ -157,7 +213,8 @@ export function buildUserUpdate(
return { return {
alreadyCompleted, alreadyCompleted,
updateData, updateData,
completedDate: finalChallenge.completedDate completedDate: finalChallenge.completedDate,
savedChallenges: newSavedChallenges
}; };
} }
@ -214,6 +271,7 @@ export function isValidChallengeCompletion(req, res, next) {
body: { id, challengeType, solution } body: { id, challengeType, solution }
} = req; } = req;
// ToDO: Validate other things (challengeFiles, etc)
const isValidChallengeCompletionErrorMsg = { const isValidChallengeCompletionErrorMsg = {
type: 'error', type: 'error',
message: 'That does not appear to be a valid challenge submission.' message: 'That does not appear to be a valid challenge submission.'
@ -248,6 +306,7 @@ export function modernChallengeCompleted(req, res, next) {
completedDate completedDate
}; };
// if multifile cert project
if (challengeType === 14) { if (challengeType === 14) {
completedChallenge.isManuallyApproved = false; completedChallenge.isManuallyApproved = false;
} }
@ -261,11 +320,12 @@ export function modernChallengeCompleted(req, res, next) {
completedChallenge.challengeType = challengeType; completedChallenge.challengeType = challengeType;
} }
const { alreadyCompleted, updateData } = buildUserUpdate( const { alreadyCompleted, savedChallenges, updateData } = buildUserUpdate(
user, user,
id, id,
completedChallenge completedChallenge
); );
const points = alreadyCompleted ? user.points : user.points + 1; const points = alreadyCompleted ? user.points : user.points + 1;
const updatePromise = new Promise((resolve, reject) => const updatePromise = new Promise((resolve, reject) =>
user.updateAttributes(updateData, err => { user.updateAttributes(updateData, err => {
@ -279,7 +339,8 @@ export function modernChallengeCompleted(req, res, next) {
return res.json({ return res.json({
points, points,
alreadyCompleted, alreadyCompleted,
completedDate completedDate,
savedChallenges
}); });
}); });
}) })
@ -389,6 +450,45 @@ function backendChallengeCompleted(req, res, next) {
.subscribe(() => {}, next); .subscribe(() => {}, next);
} }
function saveChallenge(req, res, next) {
const user = req.user;
const { id: challengeId, files = [] } = req.body;
if (!savableChallenges.includes(challengeId)) {
return res.status(403).send('That challenge type is not savable');
}
const newSavedChallenges = buildNewSavedChallenges({
user,
challengeId,
completedDate: Date.now(),
files
});
return user
.getSavedChallenges$()
.flatMap(() => {
const updateData = {};
updateData.$set = {
savedChallenges: newSavedChallenges
};
const updatePromise = new Promise((resolve, reject) =>
user.updateAttributes(updateData, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.fromPromise(updatePromise).doOnNext(() => {
return res.json({
savedChallenges: newSavedChallenges
});
});
})
.subscribe(() => {}, next);
}
const codeRoadChallenges = getChallenges().filter( const codeRoadChallenges = getChallenges().filter(
({ challengeType }) => challengeType === 12 || challengeType === 13 ({ challengeType }) => challengeType === 12 || challengeType === 13
); );

View File

@ -6,7 +6,8 @@ import { Observable } from 'rx';
import { import {
fixCompletedChallengeItem, fixCompletedChallengeItem,
fixPartiallyCompletedChallengeItem fixPartiallyCompletedChallengeItem,
fixSavedChallengeItem
} from '../../common/utils'; } from '../../common/utils';
import { removeCookies } from '../utils/getSetAccessToken'; import { removeCookies } from '../utils/getSetAccessToken';
import { ifNoUser401, ifNoUserRedirectHome } from '../utils/middleware'; import { ifNoUser401, ifNoUserRedirectHome } from '../utils/middleware';
@ -114,18 +115,21 @@ function createReadSessionUser(app) {
Observable.forkJoin( Observable.forkJoin(
queryUser.getCompletedChallenges$(), queryUser.getCompletedChallenges$(),
queryUser.getPartiallyCompletedChallenges$(), queryUser.getPartiallyCompletedChallenges$(),
queryUser.getSavedChallenges$(),
queryUser.getPoints$(), queryUser.getPoints$(),
Donation.getCurrentActiveDonationCount$(), Donation.getCurrentActiveDonationCount$(),
( (
completedChallenges, completedChallenges,
partiallyCompletedChallenges, partiallyCompletedChallenges,
savedChallenges,
progressTimestamps, progressTimestamps,
activeDonations activeDonations
) => ({ ) => ({
activeDonations, activeDonations,
completedChallenges, completedChallenges,
partiallyCompletedChallenges, partiallyCompletedChallenges,
progress: getProgress(progressTimestamps, queryUser.timezone) progress: getProgress(progressTimestamps, queryUser.timezone),
savedChallenges
}) })
); );
Observable.if( Observable.if(
@ -137,7 +141,8 @@ function createReadSessionUser(app) {
activeDonations, activeDonations,
completedChallenges, completedChallenges,
partiallyCompletedChallenges, partiallyCompletedChallenges,
progress progress,
savedChallenges
}) => ({ }) => ({
user: { user: {
...queryUser.toJSON(), ...queryUser.toJSON(),
@ -147,7 +152,8 @@ function createReadSessionUser(app) {
), ),
partiallyCompletedChallenges: partiallyCompletedChallenges.map( partiallyCompletedChallenges: partiallyCompletedChallenges.map(
fixPartiallyCompletedChallengeItem fixPartiallyCompletedChallengeItem
) ),
savedChallenges: savedChallenges.map(fixSavedChallengeItem)
}, },
sessionMeta: { activeDonations } sessionMeta: { activeDonations }
}) })

View File

@ -26,6 +26,11 @@ export default function prodErrorHandler() {
// error handling in production. // error handling in production.
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
return function (err, req, res, next) { return function (err, req, res, next) {
// response for when req.body is bigger than body-parser's size limit
if (err?.type === 'entity.too.large') {
return res.status('413').send('Request payload is too large');
}
const { origin } = getRedirectParams(req); const { origin } = getRedirectParams(req);
const handled = unwrapHandledError(err); const handled = unwrapHandledError(err);
// respect handled error status // respect handled error status

View File

@ -38,6 +38,7 @@ export const publicUserProps = [
'portfolio', 'portfolio',
'profileUI', 'profileUI',
'projects', 'projects',
'savedChallenges',
'streak', 'streak',
'twitter', 'twitter',
'username', 'username',

View File

@ -58,6 +58,7 @@
"resubscribe": "You can click here to resubscribe", "resubscribe": "You can click here to resubscribe",
"click-here": "Click here to sign in", "click-here": "Click here to sign in",
"save": "Save", "save": "Save",
"save-code": "Save your Code",
"no-thanks": "No thanks", "no-thanks": "No thanks",
"yes-please": "Yes please", "yes-please": "Yes please",
"update-email": "Update my Email", "update-email": "Update my Email",
@ -529,7 +530,11 @@
"start-project-err": "Something went wrong trying to start the project. Please try again.", "start-project-err": "Something went wrong trying to start the project. Please try again.",
"complete-project-first": "You must complete the project first.", "complete-project-first": "You must complete the project first.",
"local-code-save-error": "Oops, your code did not save, your browser's local storage may be full.", "local-code-save-error": "Oops, your code did not save, your browser's local storage may be full.",
"local-code-saved": "Saved! Your code was saved to your browser's local storage." "local-code-saved": "Saved! Your code was saved to your browser's local storage.",
"code-saved": "Your code was saved to the database. It will be here when you return.",
"code-save-error": "An error occurred trying to save your code.",
"challenge-save-too-big": "Sorry, you cannot save your code. Your code is {{user-size}} bytes. We allow a maximum of {{max-size}} bytes. Please make your code smaller and try again or request assistance on https://forum.freecodecamp.org",
"challenge-submit-too-big": "Sorry, you cannot submit your code. Your code is {{user-size}} bytes. We allow a maximum of {{max-size}} bytes. Please make your code smaller and try again or request assistance on https://forum.freecodecamp.org"
}, },
"validation": { "validation": {
"max-characters": "There is a maximum limit of 288 characters, you have {{charsLeft}} left", "max-characters": "There is a maximum limit of 288 characters, you have {{charsLeft}} left",

View File

@ -5,6 +5,10 @@ export enum FlashMessages {
CertClaimSuccess = 'flash.cert-claim-success', CertClaimSuccess = 'flash.cert-claim-success',
CertificateMissing = 'flash.certificate-missing', CertificateMissing = 'flash.certificate-missing',
CertsPrivate = 'flash.certs-private', CertsPrivate = 'flash.certs-private',
ChallengeSaveTooBig = 'flash.challenge-save-too-big',
ChallengeSubmitTooBig = 'flash.challenge-submit-too-big',
CodeSaved = 'flash.code-saved',
CodeSaveError = 'flash.code-save-error',
CompleteProjectFirst = 'flash.complete-project-first', CompleteProjectFirst = 'flash.complete-project-first',
DeleteTokenErr = 'flash.delete-token-err', DeleteTokenErr = 'flash.delete-token-err',
EmailValid = 'flash.email-valid', EmailValid = 'flash.email-valid',

View File

@ -209,7 +209,7 @@ class DefaultLayout extends Component<DefaultLayoutProps> {
removeFlashMessage={removeFlashMessage} removeFlashMessage={removeFlashMessage}
/> />
) : null} ) : null}
{children} {fetchState.complete && children}
</div> </div>
{showFooter && <Footer />} {showFooter && <Footer />}
</div> </div>

View File

@ -47,6 +47,7 @@ const userProps = {
name: 'string', name: 'string',
picture: 'string', picture: 'string',
points: 1, points: 1,
savedChallenges: [],
sendQuincyEmail: true, sendQuincyEmail: true,
sound: true, sound: true,
theme: Themes.Default, theme: Themes.Default,

View File

@ -34,7 +34,8 @@ export const actionTypes = createTypes(
...createAsyncTypes('showCert'), ...createAsyncTypes('showCert'),
...createAsyncTypes('reportUser'), ...createAsyncTypes('reportUser'),
...createAsyncTypes('postChargeStripeCard'), ...createAsyncTypes('postChargeStripeCard'),
...createAsyncTypes('deleteUserToken') ...createAsyncTypes('deleteUserToken'),
...createAsyncTypes('saveChallenge')
], ],
ns ns
); );

View File

@ -22,6 +22,7 @@ import { createShowCertSaga } from './show-cert-saga';
import { createSoundModeSaga } from './sound-mode-saga'; import { createSoundModeSaga } from './sound-mode-saga';
import updateCompleteEpic from './update-complete-epic'; import updateCompleteEpic from './update-complete-epic';
import { createUserTokenSaga } from './user-token-saga'; import { createUserTokenSaga } from './user-token-saga';
import { createSaveChallengeSaga } from './save-challenge-saga';
export const MainApp = 'app'; export const MainApp = 'app';
@ -82,7 +83,8 @@ export const sagas = [
...createShowCertSaga(actionTypes), ...createShowCertSaga(actionTypes),
...createReportUserSaga(actionTypes), ...createReportUserSaga(actionTypes),
...createSoundModeSaga({ ...actionTypes, ...settingsTypes }), ...createSoundModeSaga({ ...actionTypes, ...settingsTypes }),
...createUserTokenSaga(actionTypes) ...createUserTokenSaga(actionTypes),
...createSaveChallengeSaga(actionTypes)
]; ];
export const appMount = createAction(actionTypes.appMount); export const appMount = createAction(actionTypes.appMount);
@ -121,6 +123,11 @@ export const submitComplete = createAction(actionTypes.submitComplete);
export const updateComplete = createAction(actionTypes.updateComplete); export const updateComplete = createAction(actionTypes.updateComplete);
export const updateFailed = createAction(actionTypes.updateFailed); export const updateFailed = createAction(actionTypes.updateFailed);
export const saveChallenge = createAction(actionTypes.saveChallenge);
export const saveChallengeComplete = createAction(
actionTypes.saveChallengeComplete
);
export const acceptTerms = createAction(actionTypes.acceptTerms); export const acceptTerms = createAction(actionTypes.acceptTerms);
export const acceptTermsComplete = createAction( export const acceptTermsComplete = createAction(
actionTypes.acceptTermsComplete actionTypes.acceptTermsComplete
@ -188,6 +195,8 @@ export const updateCurrentChallengeId = createAction(
actionTypes.updateCurrentChallengeId actionTypes.updateCurrentChallengeId
); );
export const savedChallengesSelector = state =>
userSelector(state).savedChallenges || [];
export const completedChallengesSelector = state => export const completedChallengesSelector = state =>
userSelector(state).completedChallenges || []; userSelector(state).completedChallenges || [];
export const partiallyCompletedChallengesSelector = state => export const partiallyCompletedChallengesSelector = state =>
@ -637,9 +646,12 @@ export const reducer = handleActions(
} }
}), }),
[actionTypes.submitComplete]: (state, { payload }) => { [actionTypes.submitComplete]: (state, { payload }) => {
let submittedchallenges = [{ ...payload, completedDate: Date.now() }]; const { submittedChallenge, savedChallenges } = payload;
if (payload.challArray) { let submittedchallenges = [
submittedchallenges = payload.challArray; { ...submittedChallenge, completedDate: Date.now() }
];
if (submittedChallenge.challArray) {
submittedchallenges = submittedChallenge.challArray;
} }
const { appUsername } = state; const { appUsername } = state;
return { return {
@ -655,7 +667,9 @@ export const reducer = handleActions(
...state.user[appUsername].completedChallenges ...state.user[appUsername].completedChallenges
], ],
'id' 'id'
) ),
savedChallenges:
savedChallenges ?? savedChallengesSelector(state[MainApp])
} }
} }
}; };
@ -702,6 +716,19 @@ export const reducer = handleActions(
...state, ...state,
currentChallengeId: payload currentChallengeId: payload
}), }),
[actionTypes.saveChallengeComplete]: (state, { payload }) => {
const { appUsername } = state;
return {
...state,
user: {
...state.user,
[appUsername]: {
...state.user[appUsername],
savedChallenges: payload
}
}
};
},
[settingsTypes.submitNewUsernameComplete]: (state, { payload }) => [settingsTypes.submitNewUsernameComplete]: (state, { payload }) =>
payload payload
? { ? {

View File

@ -50,6 +50,12 @@ export const UserPropType = PropTypes.shape({
description: PropTypes.string description: PropTypes.string
}) })
), ),
savedChallenges: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string,
challengeFiles: PropTypes.array
})
),
sendQuincyEmail: PropTypes.bool, sendQuincyEmail: PropTypes.bool,
sound: PropTypes.bool, sound: PropTypes.bool,
theme: PropTypes.string, theme: PropTypes.string,
@ -267,6 +273,7 @@ export type User = {
portfolio: Portfolio[]; portfolio: Portfolio[];
profileUI: ProfileUI; profileUI: ProfileUI;
progressTimestamps: Array<unknown>; progressTimestamps: Array<unknown>;
savedChallenges: SavedChallenges;
sendQuincyEmail: boolean; sendQuincyEmail: boolean;
sound: boolean; sound: boolean;
theme: Themes; theme: Themes;
@ -310,6 +317,23 @@ export type ClaimedCertifications = {
isMachineLearningPyCertV7: boolean; isMachineLearningPyCertV7: boolean;
}; };
export type SavedChallenges = SavedChallenge[];
export type SavedChallenge = {
id: string;
challengeFiles: SavedChallengeFiles;
};
export type SavedChallengeFile = {
fileKey: string;
ext: Ext;
name: string;
history?: string[];
contents: string;
};
export type SavedChallengeFiles = SavedChallengeFile[];
export type CompletedChallenge = { export type CompletedChallenge = {
id: string; id: string;
solution?: string | null; solution?: string | null;
@ -359,8 +383,8 @@ export type ChallengeFile = {
fileKey: string; fileKey: string;
ext: Ext; ext: Ext;
name: string; name: string;
editableRegionBoundaries: number[]; editableRegionBoundaries?: number[];
usesMultifileEditor: boolean; usesMultifileEditor?: boolean;
error: null | string; error: null | string;
head: string; head: string;
tail: string; tail: string;

View File

@ -0,0 +1,68 @@
import { call, takeEvery, put, select } from 'redux-saga/effects';
import { postSaveChallenge, mapFilesToChallengeFiles } from '../utils/ajax';
import {
challengeDataSelector,
challengeMetaSelector
} from '../templates/Challenges/redux';
import { createFlashMessage } from '../components/Flash/redux';
import { challengeTypes } from '../../utils/challenge-types';
import { FlashMessages } from '../components/Flash/redux/flash-messages';
import {
standardizeRequestBody,
getStringSizeInBytes,
bodySizeFits,
MAX_BODY_SIZE
} from '../utils/challenge-request-helpers';
import { saveChallengeComplete } from './';
export function* saveChallengeSaga() {
const { id, challengeType } = yield select(challengeMetaSelector);
const { challengeFiles } = yield select(challengeDataSelector);
// only allow saving of multiFileCertProject's
if (challengeType === challengeTypes.multiFileCertProject) {
const body = standardizeRequestBody({ id, challengeFiles, challengeType });
const bodySizeInBytes = getStringSizeInBytes(body);
if (!bodySizeFits(bodySizeInBytes)) {
return yield put(
createFlashMessage({
type: 'danger',
message: FlashMessages.ChallengeSaveTooBig,
variables: { 'max-size': MAX_BODY_SIZE, 'user-size': bodySizeInBytes }
})
);
} else {
try {
const response = yield call(postSaveChallenge, body);
if (response?.message) {
yield put(createFlashMessage(response));
} else if (response?.savedChallenges) {
yield put(
saveChallengeComplete(
mapFilesToChallengeFiles(response.savedChallenges)
)
);
yield put(
createFlashMessage({
type: 'success',
message: FlashMessages.CodeSaved
})
);
}
} catch (e) {
yield put(
createFlashMessage({
type: 'danger',
message: FlashMessages.CodeSaveError
})
);
}
}
}
}
export function createSaveChallengeSaga(types) {
return [takeEvery(types.saveChallenge, saveChallengeSaga)];
}

View File

@ -8,6 +8,7 @@ import EditorTabs from './editor-tabs';
interface ActionRowProps { interface ActionRowProps {
block: string; block: string;
hasNotes: boolean; hasNotes: boolean;
isMultiFileCertProject: boolean;
showConsole: boolean; showConsole: boolean;
showNotes: boolean; showNotes: boolean;
showPreview: boolean; showPreview: boolean;
@ -22,6 +23,7 @@ const mapDispatchToProps = {
const ActionRow = ({ const ActionRow = ({
hasNotes, hasNotes,
isMultiFileCertProject,
togglePane, togglePane,
showNotes, showNotes,
showPreview, showPreview,
@ -38,9 +40,11 @@ const ActionRow = ({
</div> </div>
<div className='tabs-row'> <div className='tabs-row'>
<EditorTabs /> <EditorTabs />
{!isMultiFileCertProject && (
<button className='restart-step-tab' onClick={resetChallenge}> <button className='restart-step-tab' onClick={resetChallenge}>
{t('learn.editor-tabs.restart-step')} {t('learn.editor-tabs.restart-step')}
</button> </button>
)}
<div className='panel-display-tabs'> <div className='panel-display-tabs'>
<button <button
aria-expanded={showConsole ? 'true' : 'false'} aria-expanded={showConsole ? 'true' : 'false'}

View File

@ -110,6 +110,7 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
<ActionRow <ActionRow
block={block} block={block}
hasNotes={hasNotes} hasNotes={hasNotes}
isMultiFileCertProject={isMultiFileCertProject}
showConsole={showConsole} showConsole={showConsole}
showNotes={showNotes} showNotes={showNotes}
showPreview={showPreview} showPreview={showPreview}

View File

@ -82,7 +82,7 @@ interface EditorProps {
updateFile: (object: { updateFile: (object: {
fileKey: FileKey; fileKey: FileKey;
editorValue: string; editorValue: string;
editableRegionBoundaries: number[] | null; editableRegionBoundaries?: number[];
}) => void; }) => void;
usesMultifileEditor: boolean; usesMultifileEditor: boolean;
} }
@ -632,10 +632,12 @@ const Editor = (props: EditorProps): JSX.Element => {
// has changed or if content is dragged between regions) // has changed or if content is dragged between regions)
const coveringRange = getLinesCoveringEditableRegion(); const coveringRange = getLinesCoveringEditableRegion();
const editableRegionBoundaries = coveringRange && [ const editableRegionBoundaries =
(coveringRange && [
coveringRange.startLineNumber - 1, coveringRange.startLineNumber - 1,
coveringRange.endLineNumber + 1 coveringRange.endLineNumber + 1
]; ]) ??
undefined;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (player.current.sampler?.loaded && player.current.shouldPlay) { if (player.current.sampler?.loaded && player.current.shouldPlay) {

View File

@ -12,12 +12,12 @@ import { challengeTypes } from '../../../../utils/challenge-types';
import LearnLayout from '../../../components/layouts/learn'; import LearnLayout from '../../../components/layouts/learn';
import { import {
ChallengeFile,
ChallengeFiles, ChallengeFiles,
ChallengeMeta, ChallengeMeta,
ChallengeNode, ChallengeNode,
CompletedChallenge, CompletedChallenge,
ResizeProps, ResizeProps,
SavedChallengeFiles,
Test Test
} from '../../../redux/prop-types'; } from '../../../redux/prop-types';
import { isContained } from '../../../utils/is-contained'; import { isContained } from '../../../utils/is-contained';
@ -49,6 +49,7 @@ import {
openModal, openModal,
setEditorFocusability setEditorFocusability
} from '../redux'; } from '../redux';
import { savedChallengesSelector } from '../../../redux';
import { getGuideUrl } from '../utils'; import { getGuideUrl } from '../utils';
import MultifileEditor from './MultifileEditor'; import MultifileEditor from './MultifileEditor';
import DesktopLayout from './desktop-layout'; import DesktopLayout from './desktop-layout';
@ -62,7 +63,8 @@ const mapStateToProps = createStructuredSelector({
challengeFiles: challengeFilesSelector, challengeFiles: challengeFilesSelector,
tests: challengeTestsSelector, tests: challengeTestsSelector,
output: consoleOutputSelector, output: consoleOutputSelector,
isChallengeCompleted: isChallengeCompletedSelector isChallengeCompleted: isChallengeCompletedSelector,
savedChallenges: savedChallengesSelector
}); });
const mapDispatchToProps = (dispatch: Dispatch) => const mapDispatchToProps = (dispatch: Dispatch) =>
@ -86,7 +88,7 @@ const mapDispatchToProps = (dispatch: Dispatch) =>
interface ShowClassicProps { interface ShowClassicProps {
cancelTests: () => void; cancelTests: () => void;
challengeMounted: (arg0: string) => void; challengeMounted: (arg0: string) => void;
createFiles: (arg0: ChallengeFile[]) => void; createFiles: (arg0: ChallengeFiles | SavedChallengeFiles) => void;
data: { challengeNode: ChallengeNode }; data: { challengeNode: ChallengeNode };
executeChallenge: (options?: { showCompletionModal: boolean }) => void; executeChallenge: (options?: { showCompletionModal: boolean }) => void;
challengeFiles: ChallengeFiles; challengeFiles: ChallengeFiles;
@ -107,6 +109,7 @@ interface ShowClassicProps {
openModal: (modal: string) => void; openModal: (modal: string) => void;
setEditorFocusability: (canFocus: boolean) => void; setEditorFocusability: (canFocus: boolean) => void;
previewMounted: () => void; previewMounted: () => void;
savedChallenges: CompletedChallenge[];
} }
interface ShowClassicState { interface ShowClassicState {
@ -256,6 +259,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
initTests, initTests,
updateChallengeMeta, updateChallengeMeta,
openModal, openModal,
savedChallenges,
data: { data: {
challengeNode: { challengeNode: {
challenge: { challenge: {
@ -273,7 +277,13 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
} }
} = this.props; } = this.props;
initConsole(''); initConsole('');
createFiles(challengeFiles ?? []);
const savedChallenge = savedChallenges?.find(challenge => {
return challenge.id === challengeMeta.id;
});
createFiles(savedChallenge?.challengeFiles || challengeFiles || []);
initTests(tests); initTests(tests);
if (showProjectPreview) openModal('projectPreview'); if (showProjectPreview) openModal('projectPreview');
updateChallengeMeta({ updateChallengeMeta({
@ -521,6 +531,7 @@ export const query = graphql`
block block
title title
description description
id
hasEditableBoundaries hasEditableBoundaries
instructions instructions
notes notes

View File

@ -7,25 +7,43 @@ import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux'; import { bindActionCreators, Dispatch } from 'redux';
import { createSelector } from 'reselect';
import { challengeTypes } from '../../../../utils/challenge-types';
import './tool-panel.css'; import './tool-panel.css';
import { openModal, executeChallenge } from '../redux'; import { openModal, executeChallenge, challengeMetaSelector } from '../redux';
const mapStateToProps = () => ({}); import { saveChallenge, isSignedInSelector } from '../../../redux';
const mapStateToProps = createSelector(
challengeMetaSelector,
isSignedInSelector,
(
{ challengeType }: { challengeId: string; challengeType: number },
isSignedIn
) => ({
challengeType,
isSignedIn
})
);
const mapDispatchToProps = (dispatch: Dispatch) => const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators( bindActionCreators(
{ {
executeChallenge, executeChallenge,
openHelpModal: () => openModal('help'), openHelpModal: () => openModal('help'),
openVideoModal: () => openModal('video'), openVideoModal: () => openModal('video'),
openResetModal: () => openModal('reset') openResetModal: () => openModal('reset'),
saveChallenge
}, },
dispatch dispatch
); );
interface ToolPanelProps { interface ToolPanelProps {
challengeType: number;
executeChallenge: (options?: { showCompletionModal: boolean }) => void; executeChallenge: (options?: { showCompletionModal: boolean }) => void;
saveChallenge: () => void;
isMobile?: boolean; isMobile?: boolean;
isSignedIn: boolean;
openHelpModal: () => void; openHelpModal: () => void;
openVideoModal: () => void; openVideoModal: () => void;
openResetModal: () => void; openResetModal: () => void;
@ -34,8 +52,11 @@ interface ToolPanelProps {
} }
function ToolPanel({ function ToolPanel({
challengeType,
executeChallenge, executeChallenge,
saveChallenge,
isMobile, isMobile,
isSignedIn,
openHelpModal, openHelpModal,
openVideoModal, openVideoModal,
openResetModal, openResetModal,
@ -60,6 +81,17 @@ function ToolPanel({
> >
{isMobile ? t('buttons.run') : t('buttons.run-test')} {isMobile ? t('buttons.run') : t('buttons.run-test')}
</Button> </Button>
{isSignedIn && challengeType === challengeTypes.multiFileCertProject && (
<Button
block={true}
bsStyle='primary'
className='btn-invert'
onClick={saveChallenge}
>
{isMobile ? t('buttons.save') : t('buttons.save-code')}
</Button>
)}
{challengeType !== challengeTypes.multiFileCertProject && (
<Button <Button
block={true} block={true}
bsStyle='primary' bsStyle='primary'
@ -68,6 +100,7 @@ function ToolPanel({
> >
{isMobile ? t('buttons.reset') : t('buttons.reset-code')} {isMobile ? t('buttons.reset') : t('buttons.reset-code')}
</Button> </Button>
)}
<DropdownButton <DropdownButton
block={true} block={true}
bsStyle='primary' bsStyle='primary'

View File

@ -21,6 +21,8 @@ import {
} from '../../../redux'; } from '../../../redux';
import postUpdate$ from '../utils/postUpdate$'; import postUpdate$ from '../utils/postUpdate$';
import { mapFilesToChallengeFiles } from '../../../utils/ajax';
import { standardizeRequestBody } from '../../../utils/challenge-request-helpers';
import { actionTypes } from './action-types'; import { actionTypes } from './action-types';
import { import {
projectFormValuesSelector, projectFormValuesSelector,
@ -34,7 +36,7 @@ import {
function postChallenge(update, username) { function postChallenge(update, username) {
const saveChallenge = postUpdate$(update).pipe( const saveChallenge = postUpdate$(update).pipe(
retry(3), retry(3),
switchMap(({ points }) => { switchMap(({ points, savedChallenges }) => {
// TODO: do this all in ajax.ts // TODO: do this all in ajax.ts
const payloadWithClientProperties = { const payloadWithClientProperties = {
...omit(update.payload, ['files']) ...omit(update.payload, ['files'])
@ -49,9 +51,12 @@ function postChallenge(update, username) {
} }
return of( return of(
submitComplete({ submitComplete({
submittedChallenge: {
username, username,
points, points,
...payloadWithClientProperties ...payloadWithClientProperties
},
savedChallenges: mapFilesToChallengeFiles(savedChallenges)
}), }),
updateComplete() updateComplete()
); );
@ -76,24 +81,23 @@ function submitModern(type, state) {
const { id, block } = challengeMetaSelector(state); const { id, block } = challengeMetaSelector(state);
const challengeFiles = challengeFilesSelector(state); const challengeFiles = challengeFilesSelector(state);
const { username } = userSelector(state); const { username } = userSelector(state);
const challengeInfo = {
id,
challengeType
};
// Only send files to server, if it is a JS project or multiFile cert project let body;
if ( if (
block === 'javascript-algorithms-and-data-structures-projects' || block === 'javascript-algorithms-and-data-structures-projects' ||
challengeType === challengeTypes.multiFileCertProject challengeType === challengeTypes.multiFileCertProject
) { ) {
challengeInfo.files = challengeFiles.reduce( body = standardizeRequestBody({ id, challengeType, challengeFiles });
(acc, { fileKey, ...curr }) => [...acc, { ...curr, key: fileKey }], } else {
[] body = {
); id,
challengeType
};
} }
const update = { const update = {
endpoint: '/modern-challenge-completed', endpoint: '/modern-challenge-completed',
payload: challengeInfo payload: body
}; };
return postChallenge(update, username); return postChallenge(update, username);
} }

View File

@ -25,6 +25,15 @@ import {
isJavaScriptChallenge, isJavaScriptChallenge,
isLoopProtected isLoopProtected
} from '../utils/build'; } from '../utils/build';
import { challengeTypes } from '../../../../utils/challenge-types';
import { createFlashMessage } from '../../../components/Flash/redux';
import { FlashMessages } from '../../../components/Flash/redux/flash-messages';
import {
standardizeRequestBody,
getStringSizeInBytes,
bodySizeFits,
MAX_BODY_SIZE
} from '../../../utils/challenge-request-helpers';
import { actionTypes } from './action-types'; import { actionTypes } from './action-types';
import { import {
challengeDataSelector, challengeDataSelector,
@ -45,7 +54,27 @@ import {
const previewTimeout = 2500; const previewTimeout = 2500;
let previewTask; let previewTask;
// when 'run tests' is clicked, do this first
export function* executeCancellableChallengeSaga(payload) { export function* executeCancellableChallengeSaga(payload) {
const { challengeType, id } = yield select(challengeMetaSelector);
const { challengeFiles } = yield select(challengeDataSelector);
// if multiFileCertProject, see if body/code size is submittable
if (challengeType === challengeTypes.multiFileCertProject) {
const body = standardizeRequestBody({ id, challengeFiles, challengeType });
const bodySizeInBytes = getStringSizeInBytes(body);
if (!bodySizeFits(bodySizeInBytes)) {
return yield put(
createFlashMessage({
type: 'danger',
message: FlashMessages.ChallengeSubmitTooBig,
variables: { 'max-size': MAX_BODY_SIZE, 'user-size': bodySizeInBytes }
})
);
}
}
if (previewTask) { if (previewTask) {
yield cancel(previewTask); yield cancel(previewTask);
} }

View File

@ -68,7 +68,7 @@ export const createFiles = createAction(
challengeFile.editableRegionBoundaries challengeFile.editableRegionBoundaries
), ),
seedEditableRegionBoundaries: seedEditableRegionBoundaries:
challengeFile.editableRegionBoundaries.slice() challengeFile.editableRegionBoundaries?.slice()
})) }))
); );

View File

@ -149,8 +149,10 @@ export function buildDOMChallenge(
const finalFiles = challengeFiles.map(pipeLine); const finalFiles = challengeFiles.map(pipeLine);
return Promise.all(finalFiles) return Promise.all(finalFiles)
.then(checkFilesErrors) .then(checkFilesErrors)
.then(challengeFiles => ({ .then(challengeFiles => {
challengeType: challengeTypes.html, return {
challengeType:
challengeTypes.html || challengeTypes.multiFileCertProject,
build: concatHtml({ build: concatHtml({
required: finalRequires, required: finalRequires,
template, template,
@ -158,7 +160,8 @@ export function buildDOMChallenge(
}), }),
sources: buildSourceMap(challengeFiles), sources: buildSourceMap(challengeFiles),
loadEnzyme loadEnzyme
})); };
});
} }
export function buildJSChallenge({ challengeFiles }, options) { export function buildJSChallenge({ challengeFiles }, options) {

View File

@ -127,7 +127,8 @@ export class Block extends Component<BlockProps> {
challenge.challengeType === 4 || challenge.challengeType === 4 ||
challenge.challengeType === 10 || challenge.challengeType === 10 ||
challenge.challengeType === 12 || challenge.challengeType === 12 ||
challenge.challengeType === 13; challenge.challengeType === 13 ||
challenge.challengeType === 14;
const isTakeHomeProject = blockDashedName === 'take-home-projects'; const isTakeHomeProject = blockDashedName === 'take-home-projects';

View File

@ -3,7 +3,10 @@ import envData from '../../../config/env.json';
import type { import type {
ChallengeFile, ChallengeFile,
ChallengeFiles,
CompletedChallenge, CompletedChallenge,
SavedChallenge,
SavedChallengeFile,
User User
} from '../redux/prop-types'; } from '../redux/prop-types';
@ -65,15 +68,23 @@ interface SessionUser {
sessionMeta: { activeDonations: number }; sessionMeta: { activeDonations: number };
} }
type ChallengeFilesForFiles = { type CompleteChallengeFromApi = {
files: Array<Omit<ChallengeFile, 'fileKey'> & { key: string }>; files: Array<Omit<ChallengeFile, 'fileKey'> & { key: string }>;
} & Omit<CompletedChallenge, 'challengeFiles'>; } & Omit<CompletedChallenge, 'challengeFiles'>;
type SavedChallengeFromApi = {
files: Array<Omit<SavedChallengeFile, 'fileKey'> & { key: string }>;
} & Omit<SavedChallenge, 'challengeFiles'>;
type ApiSessionResponse = Omit<SessionUser, 'user'>; type ApiSessionResponse = Omit<SessionUser, 'user'>;
type ApiUser = { type ApiUser = {
user: { user: {
[username: string]: Omit<User, 'completedChallenges'> & { [username: string]: Omit<
completedChallenges?: ChallengeFilesForFiles[]; User,
'completedChallenges' & 'savedChallenges'
> & {
completedChallenges?: CompleteChallengeFromApi[];
savedChallenges?: SavedChallengeFromApi[];
}; };
}; };
result?: string; result?: string;
@ -87,30 +98,36 @@ type UserResponse = {
function parseApiResponseToClientUser(data: ApiUser): UserResponse { function parseApiResponseToClientUser(data: ApiUser): UserResponse {
const userData = data.user?.[data?.result ?? '']; const userData = data.user?.[data?.result ?? ''];
let completedChallenges: CompletedChallenge[] = []; let completedChallenges: CompletedChallenge[] = [];
let savedChallenges: SavedChallenge[] = [];
if (userData) { if (userData) {
completedChallenges = completedChallenges = mapFilesToChallengeFiles(
userData.completedChallenges?.reduce( userData.completedChallenges
(acc: CompletedChallenge[], curr: ChallengeFilesForFiles) => { );
return [ savedChallenges = mapFilesToChallengeFiles(userData.savedChallenges);
...acc,
{
...curr,
challengeFiles: curr.files.map(({ key: fileKey, ...file }) => ({
...file,
fileKey
}))
}
];
},
[]
) ?? [];
} }
return { return {
user: { [data.result ?? '']: { ...userData, completedChallenges } }, user: {
[data.result ?? '']: { ...userData, completedChallenges, savedChallenges }
},
result: data.result result: data.result
}; };
} }
export function mapFilesToChallengeFiles<File, Rest>(
fileContainer: ({ files: (File & { key: string })[] } & Rest)[] = []
) {
return fileContainer.map(({ files, ...rest }) => ({
...rest,
challengeFiles: mapKeyToFileKey(files)
}));
}
function mapKeyToFileKey<K>(
files: (K & { key: string })[]
): (Omit<K, 'key'> & { fileKey: string })[] {
return files.map(({ key, ...rest }) => ({ ...rest, fileKey: key }));
}
export function getSessionUser(): Promise<SessionUser> { export function getSessionUser(): Promise<SessionUser> {
const response: Promise<ApiUser & ApiSessionResponse> = get( const response: Promise<ApiUser & ApiSessionResponse> = get(
'/user/get-session-user' '/user/get-session-user'
@ -207,6 +224,13 @@ export function postUserToken(): Promise<void> {
return post('/user/user-token', {}); return post('/user/user-token', {});
} }
export function postSaveChallenge(body: {
id: string;
files: ChallengeFiles;
}): Promise<void> {
return post('/save-challenge', body);
}
/** PUT **/ /** PUT **/
interface MyAbout { interface MyAbout {

View File

@ -0,0 +1,44 @@
import { ChallengeFiles } from '../redux/prop-types';
/*
* Express's body-parser has a default size limit of 102400 bytes for a request body.
* These helper functions make sure the request body isn't too big when saving or submitting multiFile cert projects
*/
export const MAX_BODY_SIZE = 102400;
interface StandardizeRequestBodyArgs {
id: string;
challengeFiles: ChallengeFiles;
challengeType: number;
}
export function standardizeRequestBody({
id,
challengeFiles = [],
challengeType
}: StandardizeRequestBodyArgs) {
return {
id,
files: challengeFiles?.map(({ fileKey, contents, ext, name, history }) => {
return {
contents,
ext,
history,
key: fileKey,
name
};
}),
challengeType
};
}
export function getStringSizeInBytes(str = '') {
const stringSizeInBytes = new Blob([JSON.stringify(str)]).size;
return stringSizeInBytes;
}
export function bodySizeFits(bodySizeInBytes: number) {
return bodySizeInBytes <= MAX_BODY_SIZE;
}

View File

@ -20,6 +20,10 @@ const toneUrls = {
'https://campfire-mode.freecodecamp.org/cert.mp3', 'https://campfire-mode.freecodecamp.org/cert.mp3',
[FlashMessages.CertificateMissing]: TRY_AGAIN, [FlashMessages.CertificateMissing]: TRY_AGAIN,
[FlashMessages.CertsPrivate]: TRY_AGAIN, [FlashMessages.CertsPrivate]: TRY_AGAIN,
[FlashMessages.ChallengeSaveTooBig]: TRY_AGAIN,
[FlashMessages.ChallengeSubmitTooBig]: TRY_AGAIN,
[FlashMessages.CodeSaved]: CHAL_COMP,
[FlashMessages.CodeSaveError]: TRY_AGAIN,
[FlashMessages.CompleteProjectFirst]: TRY_AGAIN, [FlashMessages.CompleteProjectFirst]: TRY_AGAIN,
[FlashMessages.DeleteTokenErr]: TRY_AGAIN, [FlashMessages.DeleteTokenErr]: TRY_AGAIN,
[FlashMessages.EmailValid]: CHAL_COMP, [FlashMessages.EmailValid]: CHAL_COMP,

View File

@ -4,6 +4,7 @@ const selectors = {
describe('Certification intro page', () => { describe('Certification intro page', () => {
before(() => { before(() => {
cy.exec('npm run seed');
cy.clearCookies(); cy.clearCookies();
cy.login(); cy.login();
cy.visit('/learn/responsive-web-design'); cy.visit('/learn/responsive-web-design');