feat: add framework for rwd cert projects (#44505)
* feat: add rwd cert projects feat: save projects with flag revert: not needed things revert: empty line revert: empty line fix: it fix: remove log * fix: snapshot tests * fix: show bread crumbs by default * revert: snapshot fix * Update curriculum/challenges/_meta/responsive-web-design-projects/meta.json Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> * fix: manuallyApproved -> isManuallyApproved * fix: add review suggestions Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@ -208,6 +208,7 @@
|
|||||||
"solution": "string",
|
"solution": "string",
|
||||||
"githubLink": "string",
|
"githubLink": "string",
|
||||||
"challengeType": "number",
|
"challengeType": "number",
|
||||||
|
"isManuallyApproved": "boolean",
|
||||||
"files": {
|
"files": {
|
||||||
"type": [
|
"type": [
|
||||||
{
|
{
|
||||||
|
@ -17,5 +17,6 @@ export const fixCompletedChallengeItem = obj =>
|
|||||||
'solution',
|
'solution',
|
||||||
'githubLink',
|
'githubLink',
|
||||||
'challengeType',
|
'challengeType',
|
||||||
'files'
|
'files',
|
||||||
|
'isManuallyApproved'
|
||||||
]);
|
]);
|
||||||
|
@ -69,7 +69,7 @@ export default async function bootChallenge(app, done) {
|
|||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsProjects = [
|
const jsCertProjectIds = [
|
||||||
'aaa48de84e1ecc7c742e1124',
|
'aaa48de84e1ecc7c742e1124',
|
||||||
'a7f4d8f2483413a6ce226cac',
|
'a7f4d8f2483413a6ce226cac',
|
||||||
'56533eb9ac21ba0edf2244e2',
|
'56533eb9ac21ba0edf2244e2',
|
||||||
@ -77,6 +77,10 @@ const jsProjects = [
|
|||||||
'aa2e6f85cab2ab736c9a9b24'
|
'aa2e6f85cab2ab736c9a9b24'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const multiFileCertProjectIds = getChallenges()
|
||||||
|
.filter(challenge => challenge.challengeType === 14)
|
||||||
|
.map(challenge => challenge.id);
|
||||||
|
|
||||||
export function buildUserUpdate(
|
export function buildUserUpdate(
|
||||||
user,
|
user,
|
||||||
challengeId,
|
challengeId,
|
||||||
@ -85,7 +89,10 @@ export function buildUserUpdate(
|
|||||||
) {
|
) {
|
||||||
const { files, completedDate = Date.now() } = _completedChallenge;
|
const { files, completedDate = Date.now() } = _completedChallenge;
|
||||||
let completedChallenge = {};
|
let completedChallenge = {};
|
||||||
if (jsProjects.includes(challengeId)) {
|
if (
|
||||||
|
jsCertProjectIds.includes(challengeId) ||
|
||||||
|
multiFileCertProjectIds.includes(challengeId)
|
||||||
|
) {
|
||||||
completedChallenge = {
|
completedChallenge = {
|
||||||
..._completedChallenge,
|
..._completedChallenge,
|
||||||
files: files.map(file =>
|
files: files.map(file =>
|
||||||
@ -223,14 +230,19 @@ export function modernChallengeCompleted(req, res, next) {
|
|||||||
.getCompletedChallenges$()
|
.getCompletedChallenges$()
|
||||||
.flatMap(() => {
|
.flatMap(() => {
|
||||||
const completedDate = Date.now();
|
const completedDate = Date.now();
|
||||||
const { id, files } = req.body;
|
const { id, files, challengeType } = req.body;
|
||||||
|
|
||||||
const { alreadyCompleted, updateData } = buildUserUpdate(user, id, {
|
const data = {
|
||||||
id,
|
id,
|
||||||
files,
|
files,
|
||||||
completedDate
|
completedDate
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (challengeType === 14) {
|
||||||
|
data.isManuallyApproved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { alreadyCompleted, updateData } = buildUserUpdate(user, id, data);
|
||||||
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 => {
|
||||||
|
@ -2,6 +2,7 @@ import { first } from 'lodash-es';
|
|||||||
import React, { useState, ReactElement } from 'react';
|
import React, { useState, ReactElement } from 'react';
|
||||||
import { ReflexContainer, ReflexSplitter, ReflexElement } from 'react-reflex';
|
import { ReflexContainer, ReflexSplitter, ReflexElement } from 'react-reflex';
|
||||||
import { sortChallengeFiles } from '../../../../../utils/sort-challengefiles';
|
import { sortChallengeFiles } from '../../../../../utils/sort-challengefiles';
|
||||||
|
import { challengeTypes } from '../../../../utils/challenge-types';
|
||||||
import {
|
import {
|
||||||
ChallengeFile,
|
ChallengeFile,
|
||||||
ChallengeFiles,
|
ChallengeFiles,
|
||||||
@ -14,6 +15,7 @@ type Pane = { flex: number };
|
|||||||
interface DesktopLayoutProps {
|
interface DesktopLayoutProps {
|
||||||
block: string;
|
block: string;
|
||||||
challengeFiles: ChallengeFiles;
|
challengeFiles: ChallengeFiles;
|
||||||
|
challengeType: number;
|
||||||
editor: ReactElement | null;
|
editor: ReactElement | null;
|
||||||
hasEditableBoundaries: boolean;
|
hasEditableBoundaries: boolean;
|
||||||
hasNotes: boolean;
|
hasNotes: boolean;
|
||||||
@ -68,6 +70,7 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
block,
|
block,
|
||||||
|
challengeType,
|
||||||
resizeProps,
|
resizeProps,
|
||||||
instructions,
|
instructions,
|
||||||
editor,
|
editor,
|
||||||
@ -83,11 +86,15 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
|
|||||||
|
|
||||||
const challengeFile = getChallengeFile();
|
const challengeFile = getChallengeFile();
|
||||||
const projectBasedChallenge = hasEditableBoundaries;
|
const projectBasedChallenge = hasEditableBoundaries;
|
||||||
const displayPreview = projectBasedChallenge
|
const isMultiFileCertProject =
|
||||||
|
challengeType === challengeTypes.multiFileCertProject;
|
||||||
|
const displayPreview =
|
||||||
|
projectBasedChallenge || isMultiFileCertProject
|
||||||
? showPreview && hasPreview
|
? showPreview && hasPreview
|
||||||
: hasPreview;
|
: hasPreview;
|
||||||
const displayNotes = projectBasedChallenge ? showNotes && hasNotes : false;
|
const displayNotes = projectBasedChallenge ? showNotes && hasNotes : false;
|
||||||
const displayConsole = projectBasedChallenge ? showConsole : true;
|
const displayConsole =
|
||||||
|
projectBasedChallenge || isMultiFileCertProject ? showConsole : true;
|
||||||
const {
|
const {
|
||||||
codePane,
|
codePane,
|
||||||
editorPane,
|
editorPane,
|
||||||
@ -99,7 +106,7 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='desktop-layout'>
|
<div className='desktop-layout'>
|
||||||
{projectBasedChallenge && (
|
{(projectBasedChallenge || isMultiFileCertProject) && (
|
||||||
<ActionRow
|
<ActionRow
|
||||||
block={block}
|
block={block}
|
||||||
hasNotes={hasNotes}
|
hasNotes={hasNotes}
|
||||||
|
@ -303,15 +303,25 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
|
|||||||
const { challengeType } = this.getChallenge();
|
const { challengeType } = this.getChallenge();
|
||||||
return (
|
return (
|
||||||
challengeType === challengeTypes.html ||
|
challengeType === challengeTypes.html ||
|
||||||
challengeType === challengeTypes.modern
|
challengeType === challengeTypes.modern ||
|
||||||
|
challengeType === challengeTypes.multiFileCertProject
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderInstructionsPanel({ showToolPanel }: { showToolPanel: boolean }) {
|
renderInstructionsPanel({ showToolPanel }: { showToolPanel: boolean }) {
|
||||||
const { block, description, instructions, superBlock, translationPending } =
|
const {
|
||||||
this.getChallenge();
|
block,
|
||||||
|
challengeType,
|
||||||
|
description,
|
||||||
|
forumTopicId,
|
||||||
|
instructions,
|
||||||
|
superBlock,
|
||||||
|
title,
|
||||||
|
translationPending
|
||||||
|
} = this.getChallenge();
|
||||||
|
|
||||||
const { forumTopicId, title } = this.getChallenge();
|
const showBreadCrumbs =
|
||||||
|
challengeType !== challengeTypes.multiFileCertProject;
|
||||||
return (
|
return (
|
||||||
<SidePanel
|
<SidePanel
|
||||||
block={block}
|
block={block}
|
||||||
@ -326,6 +336,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
|
|||||||
<ChallengeTitle
|
<ChallengeTitle
|
||||||
block={block}
|
block={block}
|
||||||
isCompleted={this.props.isChallengeCompleted}
|
isCompleted={this.props.isChallengeCompleted}
|
||||||
|
showBreadCrumbs={showBreadCrumbs}
|
||||||
superBlock={superBlock}
|
superBlock={superBlock}
|
||||||
translationPending={translationPending}
|
translationPending={translationPending}
|
||||||
>
|
>
|
||||||
@ -405,6 +416,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
|
|||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
block,
|
block,
|
||||||
|
challengeType,
|
||||||
fields: { blockName },
|
fields: { blockName },
|
||||||
forumTopicId,
|
forumTopicId,
|
||||||
hasEditableBoundaries,
|
hasEditableBoundaries,
|
||||||
@ -457,6 +469,7 @@ class ShowClassic extends Component<ShowClassicProps, ShowClassicState> {
|
|||||||
<DesktopLayout
|
<DesktopLayout
|
||||||
block={block}
|
block={block}
|
||||||
challengeFiles={challengeFiles}
|
challengeFiles={challengeFiles}
|
||||||
|
challengeType={challengeType}
|
||||||
editor={this.renderEditor()}
|
editor={this.renderEditor()}
|
||||||
hasEditableBoundaries={hasEditableBoundaries}
|
hasEditableBoundaries={hasEditableBoundaries}
|
||||||
hasNotes={!!notes}
|
hasNotes={!!notes}
|
||||||
|
@ -10,6 +10,7 @@ interface ChallengeTitleProps {
|
|||||||
block: string;
|
block: string;
|
||||||
children: string;
|
children: string;
|
||||||
isCompleted: boolean;
|
isCompleted: boolean;
|
||||||
|
showBreadCrumbs?: boolean;
|
||||||
superBlock: string;
|
superBlock: string;
|
||||||
translationPending: boolean;
|
translationPending: boolean;
|
||||||
}
|
}
|
||||||
@ -18,6 +19,7 @@ function ChallengeTitle({
|
|||||||
block,
|
block,
|
||||||
children,
|
children,
|
||||||
isCompleted,
|
isCompleted,
|
||||||
|
showBreadCrumbs = true,
|
||||||
superBlock,
|
superBlock,
|
||||||
translationPending
|
translationPending
|
||||||
}: ChallengeTitleProps): JSX.Element {
|
}: ChallengeTitleProps): JSX.Element {
|
||||||
@ -34,7 +36,7 @@ function ChallengeTitle({
|
|||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<BreadCrumb block={block} superBlock={superBlock} />
|
{showBreadCrumbs && <BreadCrumb block={block} superBlock={superBlock} />}
|
||||||
<div className='challenge-title'>
|
<div className='challenge-title'>
|
||||||
<div className='title-text'>
|
<div className='title-text'>
|
||||||
<b>{children}</b>
|
<b>{children}</b>
|
||||||
|
@ -71,10 +71,15 @@ function submitModern(type, state) {
|
|||||||
const challengeFiles = challengeFilesSelector(state);
|
const challengeFiles = challengeFilesSelector(state);
|
||||||
const { username } = userSelector(state);
|
const { username } = userSelector(state);
|
||||||
const challengeInfo = {
|
const challengeInfo = {
|
||||||
id
|
id,
|
||||||
|
challengeType
|
||||||
};
|
};
|
||||||
// Only send files to server, if it is a JS project
|
|
||||||
if (block === 'javascript-algorithms-and-data-structures-projects') {
|
// Only send files to server, if it is a JS project or multiFile cert project
|
||||||
|
if (
|
||||||
|
block === 'javascript-algorithms-and-data-structures-projects' ||
|
||||||
|
challengeType === challengeTypes.multiFileCertProject
|
||||||
|
) {
|
||||||
challengeInfo.files = challengeFiles.reduce(
|
challengeInfo.files = challengeFiles.reduce(
|
||||||
(acc, { fileKey, ...curr }) => [...acc, { ...curr, key: fileKey }],
|
(acc, { fileKey, ...curr }) => [...acc, { ...curr, key: fileKey }],
|
||||||
[]
|
[]
|
||||||
|
@ -190,7 +190,8 @@ export const challengeDataSelector = state => {
|
|||||||
};
|
};
|
||||||
} else if (
|
} else if (
|
||||||
challengeType === challengeTypes.html ||
|
challengeType === challengeTypes.html ||
|
||||||
challengeType === challengeTypes.modern
|
challengeType === challengeTypes.modern ||
|
||||||
|
challengeType === challengeTypes.multiFileCertProject
|
||||||
) {
|
) {
|
||||||
const { required = [], template = '' } = challengeMetaSelector(state);
|
const { required = [], template = '' } = challengeMetaSelector(state);
|
||||||
challengeData = {
|
challengeData = {
|
||||||
|
@ -72,7 +72,8 @@ const buildFunctions = {
|
|||||||
[challengeTypes.modern]: buildDOMChallenge,
|
[challengeTypes.modern]: buildDOMChallenge,
|
||||||
[challengeTypes.backend]: buildBackendChallenge,
|
[challengeTypes.backend]: buildBackendChallenge,
|
||||||
[challengeTypes.backEndProject]: buildBackendChallenge,
|
[challengeTypes.backEndProject]: buildBackendChallenge,
|
||||||
[challengeTypes.pythonProject]: buildBackendChallenge
|
[challengeTypes.pythonProject]: buildBackendChallenge,
|
||||||
|
[challengeTypes.multiFileCertProject]: buildDOMChallenge
|
||||||
};
|
};
|
||||||
|
|
||||||
export function canBuildChallenge(challengeData) {
|
export function canBuildChallenge(challengeData) {
|
||||||
@ -93,7 +94,8 @@ const testRunners = {
|
|||||||
[challengeTypes.js]: getJSTestRunner,
|
[challengeTypes.js]: getJSTestRunner,
|
||||||
[challengeTypes.html]: getDOMTestRunner,
|
[challengeTypes.html]: getDOMTestRunner,
|
||||||
[challengeTypes.backend]: getDOMTestRunner,
|
[challengeTypes.backend]: getDOMTestRunner,
|
||||||
[challengeTypes.pythonProject]: getDOMTestRunner
|
[challengeTypes.pythonProject]: getDOMTestRunner,
|
||||||
|
[challengeTypes.multiFileCertProject]: getDOMTestRunner
|
||||||
};
|
};
|
||||||
export function getTestRunner(buildData, runnerConfig, document) {
|
export function getTestRunner(buildData, runnerConfig, document) {
|
||||||
const { challengeType } = buildData;
|
const { challengeType } = buildData;
|
||||||
@ -146,7 +148,11 @@ export function buildDOMChallenge({
|
|||||||
.then(checkFilesErrors)
|
.then(checkFilesErrors)
|
||||||
.then(challengeFiles => ({
|
.then(challengeFiles => ({
|
||||||
challengeType: challengeTypes.html,
|
challengeType: challengeTypes.html,
|
||||||
build: concatHtml({ required: finalRequires, template, challengeFiles }),
|
build: concatHtml({
|
||||||
|
required: finalRequires,
|
||||||
|
template,
|
||||||
|
challengeFiles
|
||||||
|
}),
|
||||||
sources: buildSourceMap(challengeFiles),
|
sources: buildSourceMap(challengeFiles),
|
||||||
loadEnzyme
|
loadEnzyme
|
||||||
}));
|
}));
|
||||||
@ -184,7 +190,10 @@ export function buildBackendChallenge({ url }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updatePreview(buildData, document, proxyLogger) {
|
export function updatePreview(buildData, document, proxyLogger) {
|
||||||
if (buildData.challengeType === challengeTypes.html) {
|
if (
|
||||||
|
buildData.challengeType === challengeTypes.html ||
|
||||||
|
buildData.challengeType === challengeTypes.multiFileCertProject
|
||||||
|
) {
|
||||||
createMainPreviewFramer(document, proxyLogger)(buildData);
|
createMainPreviewFramer(document, proxyLogger)(buildData);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -194,7 +203,10 @@ export function updatePreview(buildData, document, proxyLogger) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updateProjectPreview(buildData, document) {
|
export function updateProjectPreview(buildData, document) {
|
||||||
if (buildData.challengeType === challengeTypes.html) {
|
if (
|
||||||
|
buildData.challengeType === challengeTypes.html ||
|
||||||
|
buildData.challengeType === challengeTypes.multiFileCertProject
|
||||||
|
) {
|
||||||
createProjectPreviewFramer(document)(buildData);
|
createProjectPreviewFramer(document)(buildData);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -206,7 +218,8 @@ export function updateProjectPreview(buildData, document) {
|
|||||||
export function challengeHasPreview({ challengeType }) {
|
export function challengeHasPreview({ challengeType }) {
|
||||||
return (
|
return (
|
||||||
challengeType === challengeTypes.html ||
|
challengeType === challengeTypes.html ||
|
||||||
challengeType === challengeTypes.modern
|
challengeType === challengeTypes.modern ||
|
||||||
|
challengeType === challengeTypes.multiFileCertProject
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ const invalid = 9;
|
|||||||
const pythonProject = 10;
|
const pythonProject = 10;
|
||||||
const video = 11;
|
const video = 11;
|
||||||
const codeally = 12;
|
const codeally = 12;
|
||||||
|
const multiFileCertProject = 14;
|
||||||
|
|
||||||
// individual exports
|
// individual exports
|
||||||
exports.backend = backend;
|
exports.backend = backend;
|
||||||
@ -33,7 +34,8 @@ exports.challengeTypes = {
|
|||||||
quiz,
|
quiz,
|
||||||
invalid,
|
invalid,
|
||||||
video,
|
video,
|
||||||
codeally
|
codeally,
|
||||||
|
multiFileCertProject
|
||||||
};
|
};
|
||||||
|
|
||||||
// (Oliver) I don't think we need this for codeally projects, so they're ignored
|
// (Oliver) I don't think we need this for codeally projects, so they're ignored
|
||||||
@ -67,7 +69,8 @@ exports.viewTypes = {
|
|||||||
[quiz]: 'quiz',
|
[quiz]: 'quiz',
|
||||||
[backend]: 'backend',
|
[backend]: 'backend',
|
||||||
[video]: 'video',
|
[video]: 'video',
|
||||||
[codeally]: 'codeally'
|
[codeally]: 'codeally',
|
||||||
|
[multiFileCertProject]: 'classic'
|
||||||
};
|
};
|
||||||
|
|
||||||
// determine the type of submit function to use for the challenge on completion
|
// determine the type of submit function to use for the challenge on completion
|
||||||
@ -87,7 +90,8 @@ exports.submitTypes = {
|
|||||||
[quiz]: 'quiz',
|
[quiz]: 'quiz',
|
||||||
[backend]: 'backend',
|
[backend]: 'backend',
|
||||||
[modern]: 'tests',
|
[modern]: 'tests',
|
||||||
[video]: 'tests'
|
[video]: 'tests',
|
||||||
|
[multiFileCertProject]: 'tests'
|
||||||
};
|
};
|
||||||
|
|
||||||
// determine which help forum questions should be posted to
|
// determine which help forum questions should be posted to
|
||||||
|
@ -2,7 +2,7 @@ const path = require('path');
|
|||||||
const { createPoly } = require('../../../utils/polyvinyl');
|
const { createPoly } = require('../../../utils/polyvinyl');
|
||||||
const { dasherize } = require('../../../utils/slugs');
|
const { dasherize } = require('../../../utils/slugs');
|
||||||
const { sortChallengeFiles } = require('../../../utils/sort-challengefiles');
|
const { sortChallengeFiles } = require('../../../utils/sort-challengefiles');
|
||||||
const { viewTypes } = require('../challenge-types');
|
const { challengeTypes, viewTypes } = require('../challenge-types');
|
||||||
|
|
||||||
const backend = path.resolve(
|
const backend = path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
@ -102,7 +102,8 @@ exports.createChallengePages = function (createPage) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function getProjectPreviewConfig(challenge, allChallengeEdges) {
|
function getProjectPreviewConfig(challenge, allChallengeEdges) {
|
||||||
const { block, challengeOrder, usesMultifileEditor } = challenge;
|
const { block, challengeOrder, challengeType, usesMultifileEditor } =
|
||||||
|
challenge;
|
||||||
|
|
||||||
const challengesInBlock = allChallengeEdges
|
const challengesInBlock = allChallengeEdges
|
||||||
.filter(({ node: { challenge } }) => challenge.block === block)
|
.filter(({ node: { challenge } }) => challenge.block === block)
|
||||||
@ -122,7 +123,10 @@ function getProjectPreviewConfig(challenge, allChallengeEdges) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
showProjectPreview: challengeOrder === 0 && usesMultifileEditor,
|
showProjectPreview:
|
||||||
|
challengeOrder === 0 &&
|
||||||
|
usesMultifileEditor &&
|
||||||
|
challengeType !== challengeTypes.multiFileCertProject,
|
||||||
challengeData: {
|
challengeData: {
|
||||||
challengeType: lastChallenge.challengeType,
|
challengeType: lastChallenge.challengeType,
|
||||||
challengeFiles: projectPreviewChallengeFiles,
|
challengeFiles: projectPreviewChallengeFiles,
|
||||||
|
@ -28,7 +28,7 @@ const schema = Joi.object()
|
|||||||
challengeOrder: Joi.number(),
|
challengeOrder: Joi.number(),
|
||||||
removeComments: Joi.bool(),
|
removeComments: Joi.bool(),
|
||||||
certification: Joi.string().regex(slugRE),
|
certification: Joi.string().regex(slugRE),
|
||||||
challengeType: Joi.number().min(0).max(12).required(),
|
challengeType: Joi.number().min(0).max(14).required(),
|
||||||
checksum: Joi.number(),
|
checksum: Joi.number(),
|
||||||
// __commentCounts is only used to test the comment replacement
|
// __commentCounts is only used to test the comment replacement
|
||||||
__commentCounts: Joi.object(),
|
__commentCounts: Joi.object(),
|
||||||
|
Reference in New Issue
Block a user