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:
@ -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$() {
|
||||||
|
@ -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": []
|
||||||
|
@ -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']);
|
||||||
|
@ -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(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
updateData.$set = {
|
let newSavedChallenges;
|
||||||
completedChallenges: uniqBy(
|
|
||||||
[finalChallenge, ...completedChallenges.map(fixCompletedChallengeItem)],
|
|
||||||
'id'
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
|
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 = {
|
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
|
||||||
);
|
);
|
||||||
|
@ -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 }
|
||||||
})
|
})
|
||||||
|
@ -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
|
||||||
|
@ -38,6 +38,7 @@ export const publicUserProps = [
|
|||||||
'portfolio',
|
'portfolio',
|
||||||
'profileUI',
|
'profileUI',
|
||||||
'projects',
|
'projects',
|
||||||
|
'savedChallenges',
|
||||||
'streak',
|
'streak',
|
||||||
'twitter',
|
'twitter',
|
||||||
'username',
|
'username',
|
||||||
|
@ -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",
|
||||||
|
@ -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',
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
@ -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
|
||||||
? {
|
? {
|
||||||
|
@ -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;
|
||||||
|
68
client/src/redux/save-challenge-saga.js
Normal file
68
client/src/redux/save-challenge-saga.js
Normal 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)];
|
||||||
|
}
|
@ -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 />
|
||||||
<button className='restart-step-tab' onClick={resetChallenge}>
|
{!isMultiFileCertProject && (
|
||||||
{t('learn.editor-tabs.restart-step')}
|
<button className='restart-step-tab' onClick={resetChallenge}>
|
||||||
</button>
|
{t('learn.editor-tabs.restart-step')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className='panel-display-tabs'>
|
<div className='panel-display-tabs'>
|
||||||
<button
|
<button
|
||||||
aria-expanded={showConsole ? 'true' : 'false'}
|
aria-expanded={showConsole ? 'true' : 'false'}
|
||||||
|
@ -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}
|
||||||
|
@ -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.startLineNumber - 1,
|
(coveringRange && [
|
||||||
coveringRange.endLineNumber + 1
|
coveringRange.startLineNumber - 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) {
|
||||||
|
@ -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
|
||||||
|
@ -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,14 +81,26 @@ function ToolPanel({
|
|||||||
>
|
>
|
||||||
{isMobile ? t('buttons.run') : t('buttons.run-test')}
|
{isMobile ? t('buttons.run') : t('buttons.run-test')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{isSignedIn && challengeType === challengeTypes.multiFileCertProject && (
|
||||||
block={true}
|
<Button
|
||||||
bsStyle='primary'
|
block={true}
|
||||||
className='btn-invert'
|
bsStyle='primary'
|
||||||
onClick={openResetModal}
|
className='btn-invert'
|
||||||
>
|
onClick={saveChallenge}
|
||||||
{isMobile ? t('buttons.reset') : t('buttons.reset-code')}
|
>
|
||||||
</Button>
|
{isMobile ? t('buttons.save') : t('buttons.save-code')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{challengeType !== challengeTypes.multiFileCertProject && (
|
||||||
|
<Button
|
||||||
|
block={true}
|
||||||
|
bsStyle='primary'
|
||||||
|
className='btn-invert'
|
||||||
|
onClick={openResetModal}
|
||||||
|
>
|
||||||
|
{isMobile ? t('buttons.reset') : t('buttons.reset-code')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<DropdownButton
|
<DropdownButton
|
||||||
block={true}
|
block={true}
|
||||||
bsStyle='primary'
|
bsStyle='primary'
|
||||||
|
@ -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({
|
||||||
username,
|
submittedChallenge: {
|
||||||
points,
|
username,
|
||||||
...payloadWithClientProperties
|
points,
|
||||||
|
...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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -68,7 +68,7 @@ export const createFiles = createAction(
|
|||||||
challengeFile.editableRegionBoundaries
|
challengeFile.editableRegionBoundaries
|
||||||
),
|
),
|
||||||
seedEditableRegionBoundaries:
|
seedEditableRegionBoundaries:
|
||||||
challengeFile.editableRegionBoundaries.slice()
|
challengeFile.editableRegionBoundaries?.slice()
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -149,16 +149,19 @@ 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 {
|
||||||
build: concatHtml({
|
challengeType:
|
||||||
required: finalRequires,
|
challengeTypes.html || challengeTypes.multiFileCertProject,
|
||||||
template,
|
build: concatHtml({
|
||||||
challengeFiles
|
required: finalRequires,
|
||||||
}),
|
template,
|
||||||
sources: buildSourceMap(challengeFiles),
|
challengeFiles
|
||||||
loadEnzyme
|
}),
|
||||||
}));
|
sources: buildSourceMap(challengeFiles),
|
||||||
|
loadEnzyme
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildJSChallenge({ challengeFiles }, options) {
|
export function buildJSChallenge({ challengeFiles }, options) {
|
||||||
|
@ -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';
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
44
client/src/utils/challenge-request-helpers.ts
Normal file
44
client/src/utils/challenge-request-helpers.ts
Normal 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;
|
||||||
|
}
|
@ -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,
|
||||||
|
@ -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');
|
||||||
|
Reference in New Issue
Block a user