From 9f753a566264c0e8dd4714b33f65e82169b40673 Mon Sep 17 00:00:00 2001 From: Tom <20648924+moT01@users.noreply.github.com> Date: Thu, 31 Mar 2022 10:34:40 -0500 Subject: [PATCH] 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 * fix: typo * fix: prettier * fix: props types * fix: flash messages * Update client/src/utils/challenge-request-helpers.ts Co-authored-by: Oliver Eyton-Williams * chore: rename function uniformize -> standardize * fix: flash message * fix: add link to forum on flash messages Co-authored-by: Shaun Hamilton Co-authored-by: Oliver Eyton-Williams --- api-server/src/common/models/user.js | 14 ++ api-server/src/common/models/user.json | 33 +++++ api-server/src/common/utils/index.js | 3 + api-server/src/server/boot/challenge.js | 122 ++++++++++++++++-- api-server/src/server/boot/user.js | 14 +- .../src/server/middlewares/error-handlers.js | 5 + .../src/server/utils/publicUserProps.js | 1 + client/i18n/locales/english/translations.json | 7 +- .../components/Flash/redux/flash-messages.ts | 4 + client/src/components/layouts/default.tsx | 2 +- .../src/components/profile/profile.test.tsx | 1 + client/src/redux/action-types.js | 3 +- client/src/redux/index.js | 37 +++++- client/src/redux/prop-types.ts | 28 +++- client/src/redux/save-challenge-saga.js | 68 ++++++++++ .../Challenges/classic/action-row.tsx | 10 +- .../Challenges/classic/desktop-layout.tsx | 1 + .../templates/Challenges/classic/editor.tsx | 12 +- .../src/templates/Challenges/classic/show.tsx | 19 ++- .../Challenges/components/tool-panel.tsx | 55 ++++++-- .../Challenges/redux/completion-epic.js | 32 +++-- .../redux/execute-challenge-saga.js | 29 +++++ .../src/templates/Challenges/redux/index.js | 2 +- .../src/templates/Challenges/utils/build.js | 23 ++-- .../Introduction/components/block.tsx | 3 +- client/src/utils/ajax.ts | 64 ++++++--- client/src/utils/challenge-request-helpers.ts | 44 +++++++ client/src/utils/tone/index.ts | 4 + .../learn/responsive-web-design/intro-page.js | 1 + 29 files changed, 547 insertions(+), 94 deletions(-) create mode 100644 client/src/redux/save-challenge-saga.js create mode 100644 client/src/utils/challenge-request-helpers.ts diff --git a/api-server/src/common/models/user.js b/api-server/src/common/models/user.js index c8fe2571f0..31efb6a04d 100644 --- a/api-server/src/common/models/user.js +++ b/api-server/src/common/models/user.js @@ -994,6 +994,20 @@ export default function initializeUser(User) { 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$ = function getPartiallyCompletedChallenges$() { diff --git a/api-server/src/common/models/user.json b/api-server/src/common/models/user.json index e65bd7fa92..cfa8b432a7 100644 --- a/api-server/src/common/models/user.json +++ b/api-server/src/common/models/user.json @@ -250,6 +250,39 @@ ], "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": { "type": "array", "default": [] diff --git a/api-server/src/common/utils/index.js b/api-server/src/common/utils/index.js index b756789c7e..556c2f839a 100644 --- a/api-server/src/common/utils/index.js +++ b/api-server/src/common/utils/index.js @@ -21,5 +21,8 @@ export const fixCompletedChallengeItem = obj => 'isManuallyApproved' ]); +export const fixSavedChallengeItem = obj => + pick(obj, ['id', 'lastSavedDate', 'files']); + export const fixPartiallyCompletedChallengeItem = obj => pick(obj, ['id', 'completedDate']); diff --git a/api-server/src/server/boot/challenge.js b/api-server/src/server/boot/challenge.js index e5c8c33d68..4b734adbeb 100644 --- a/api-server/src/server/boot/challenge.js +++ b/api-server/src/server/boot/challenge.js @@ -18,7 +18,8 @@ import { jwtSecret } from '../../../../config/secrets'; import { environment, deploymentEnv } from '../../../../config/env.json'; import { fixCompletedChallengeItem, - fixPartiallyCompletedChallengeItem + fixPartiallyCompletedChallengeItem, + fixSavedChallengeItem } from '../../common/utils'; import { getChallenges } from '../utils/get-curriculum'; import { ifNoUserSend } from '../utils/middleware'; @@ -64,6 +65,13 @@ export default async function bootChallenge(app, done) { backendChallengeCompleted ); + api.post( + '/save-challenge', + send200toNonUser, + isValidChallengeCompletion, + saveChallenge + ); + router.get('/challenges/current-challenge', redirectToCurrentChallenge); const coderoadChallengeCompleted = createCoderoadChallengeCompleted(app); @@ -87,6 +95,33 @@ const multiFileCertProjectIds = getChallenges() .filter(challenge => challenge.challengeType === 14) .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( user, challengeId, @@ -101,7 +136,7 @@ export function buildUserUpdate( ) { completedChallenge = { ..._completedChallenge, - files: files.map(file => + files: files?.map(file => pick(file, ['contents', 'key', 'index', 'name', 'path', 'ext']) ) }; @@ -133,13 +168,34 @@ export function buildUserUpdate( }; } - updateData.$set = { - completedChallenges: uniqBy( - [finalChallenge, ...completedChallenges.map(fixCompletedChallengeItem)], - 'id' - ) - }; + 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 = { + completedChallenges: uniqBy( + [finalChallenge, ...completedChallenges.map(fixCompletedChallengeItem)], + 'id' + ) + }; + } + + // remove from partiallyCompleted on submit updateData.$pull = { partiallyCompletedChallenges: { id: challengeId } }; @@ -157,7 +213,8 @@ export function buildUserUpdate( return { alreadyCompleted, updateData, - completedDate: finalChallenge.completedDate + completedDate: finalChallenge.completedDate, + savedChallenges: newSavedChallenges }; } @@ -214,6 +271,7 @@ export function isValidChallengeCompletion(req, res, next) { body: { id, challengeType, solution } } = req; + // ToDO: Validate other things (challengeFiles, etc) const isValidChallengeCompletionErrorMsg = { type: 'error', message: 'That does not appear to be a valid challenge submission.' @@ -248,6 +306,7 @@ export function modernChallengeCompleted(req, res, next) { completedDate }; + // if multifile cert project if (challengeType === 14) { completedChallenge.isManuallyApproved = false; } @@ -261,11 +320,12 @@ export function modernChallengeCompleted(req, res, next) { completedChallenge.challengeType = challengeType; } - const { alreadyCompleted, updateData } = buildUserUpdate( + const { alreadyCompleted, savedChallenges, updateData } = buildUserUpdate( user, id, completedChallenge ); + const points = alreadyCompleted ? user.points : user.points + 1; const updatePromise = new Promise((resolve, reject) => user.updateAttributes(updateData, err => { @@ -279,7 +339,8 @@ export function modernChallengeCompleted(req, res, next) { return res.json({ points, alreadyCompleted, - completedDate + completedDate, + savedChallenges }); }); }) @@ -389,6 +450,45 @@ function backendChallengeCompleted(req, res, 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( ({ challengeType }) => challengeType === 12 || challengeType === 13 ); diff --git a/api-server/src/server/boot/user.js b/api-server/src/server/boot/user.js index 0bf4a4115f..37480003a1 100644 --- a/api-server/src/server/boot/user.js +++ b/api-server/src/server/boot/user.js @@ -6,7 +6,8 @@ import { Observable } from 'rx'; import { fixCompletedChallengeItem, - fixPartiallyCompletedChallengeItem + fixPartiallyCompletedChallengeItem, + fixSavedChallengeItem } from '../../common/utils'; import { removeCookies } from '../utils/getSetAccessToken'; import { ifNoUser401, ifNoUserRedirectHome } from '../utils/middleware'; @@ -114,18 +115,21 @@ function createReadSessionUser(app) { Observable.forkJoin( queryUser.getCompletedChallenges$(), queryUser.getPartiallyCompletedChallenges$(), + queryUser.getSavedChallenges$(), queryUser.getPoints$(), Donation.getCurrentActiveDonationCount$(), ( completedChallenges, partiallyCompletedChallenges, + savedChallenges, progressTimestamps, activeDonations ) => ({ activeDonations, completedChallenges, partiallyCompletedChallenges, - progress: getProgress(progressTimestamps, queryUser.timezone) + progress: getProgress(progressTimestamps, queryUser.timezone), + savedChallenges }) ); Observable.if( @@ -137,7 +141,8 @@ function createReadSessionUser(app) { activeDonations, completedChallenges, partiallyCompletedChallenges, - progress + progress, + savedChallenges }) => ({ user: { ...queryUser.toJSON(), @@ -147,7 +152,8 @@ function createReadSessionUser(app) { ), partiallyCompletedChallenges: partiallyCompletedChallenges.map( fixPartiallyCompletedChallengeItem - ) + ), + savedChallenges: savedChallenges.map(fixSavedChallengeItem) }, sessionMeta: { activeDonations } }) diff --git a/api-server/src/server/middlewares/error-handlers.js b/api-server/src/server/middlewares/error-handlers.js index 573e817077..e78f26a3e2 100644 --- a/api-server/src/server/middlewares/error-handlers.js +++ b/api-server/src/server/middlewares/error-handlers.js @@ -26,6 +26,11 @@ export default function prodErrorHandler() { // error handling in production. // eslint-disable-next-line no-unused-vars 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 handled = unwrapHandledError(err); // respect handled error status diff --git a/api-server/src/server/utils/publicUserProps.js b/api-server/src/server/utils/publicUserProps.js index ca71fd21de..e6b8871e69 100644 --- a/api-server/src/server/utils/publicUserProps.js +++ b/api-server/src/server/utils/publicUserProps.js @@ -38,6 +38,7 @@ export const publicUserProps = [ 'portfolio', 'profileUI', 'projects', + 'savedChallenges', 'streak', 'twitter', 'username', diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index b0fc4200ae..388ad6aaf0 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -58,6 +58,7 @@ "resubscribe": "You can click here to resubscribe", "click-here": "Click here to sign in", "save": "Save", + "save-code": "Save your Code", "no-thanks": "No thanks", "yes-please": "Yes please", "update-email": "Update my Email", @@ -529,7 +530,11 @@ "start-project-err": "Something went wrong trying to start the project. Please try again.", "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-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": { "max-characters": "There is a maximum limit of 288 characters, you have {{charsLeft}} left", diff --git a/client/src/components/Flash/redux/flash-messages.ts b/client/src/components/Flash/redux/flash-messages.ts index efd4d37109..0658f13b93 100644 --- a/client/src/components/Flash/redux/flash-messages.ts +++ b/client/src/components/Flash/redux/flash-messages.ts @@ -5,6 +5,10 @@ export enum FlashMessages { CertClaimSuccess = 'flash.cert-claim-success', CertificateMissing = 'flash.certificate-missing', 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', DeleteTokenErr = 'flash.delete-token-err', EmailValid = 'flash.email-valid', diff --git a/client/src/components/layouts/default.tsx b/client/src/components/layouts/default.tsx index 8d006b8e00..6802b377a0 100644 --- a/client/src/components/layouts/default.tsx +++ b/client/src/components/layouts/default.tsx @@ -209,7 +209,7 @@ class DefaultLayout extends Component { removeFlashMessage={removeFlashMessage} /> ) : null} - {children} + {fetchState.complete && children} {showFooter &&