diff --git a/backend/modules/upload/helper.js b/backend/modules/upload/helper.js index cc0ee1bea..73c65500c 100644 --- a/backend/modules/upload/helper.js +++ b/backend/modules/upload/helper.js @@ -39,6 +39,9 @@ const UploadHelper = { generateEventTag: slug => { return `${cloudinaryRootPath}-event-${slug}` }, + generateChallengeTag: slug => { + return `${cloudinaryRootPath}-challenge-${slug}` + }, generateUserTag: userId => { return `${cloudinaryRootPath}-user-${userId}` }, @@ -129,6 +132,24 @@ const UploadHelper = { }).single('image') }, + uploadChallengeLogo: slug => { + const storage = createStorageWithPath( + `challenge/logos/`, + { + width: 640, + height: 640, + crop: 'fit', + }, + { + tag: UploadHelper.generateChallengeTag(slug), + }, + ) + return multer({ + storage, + limits: { fileSize: 2 * 1024 * 1024 }, + }).single('image') + }, + uploadTravelGrantReceipt: (slug, userId) => { const storage = createDocumentStorageWithPath( `events/travel-grant-receipts/`, diff --git a/backend/modules/upload/routes.js b/backend/modules/upload/routes.js index 6cfa5c8a7..a24d5cd0a 100644 --- a/backend/modules/upload/routes.js +++ b/backend/modules/upload/routes.js @@ -172,6 +172,32 @@ router.post( }, ) +/** + * Upload a logo for a challenge + */ +router.post( + '/challenges/:slug/logo', + hasToken, + hasPermission(Auth.Permissions.MANAGE_EVENT), + isEventOrganiser, + (req, res, next) => { + helper.uploadChallengeLogo(req.params.slug)(req, res, function (err) { + if (err) { + if (err.code === 'LIMIT_FILE_SIZE') { + next(new ForbiddenError(err.message)) + } else { + next(err) + } + } else { + res.status(200).json({ + url: req.file.secure_url || req.file.url, + publicId: req.file.public_id, + }) + } + }) + }, +) + /** * Upload icon for a hackerpack partner */ diff --git a/frontend/src/components/challenges/ChallengeDetail.js b/frontend/src/components/challenges/ChallengeDetail.js new file mode 100644 index 000000000..76a39919f --- /dev/null +++ b/frontend/src/components/challenges/ChallengeDetail.js @@ -0,0 +1,42 @@ +import React from 'react' +import { Box, Divider } from '@material-ui/core' + +import GradientBox from 'components/generic/GradientBox' +import ChallengeSection from './ChallengeSection' + +const makeBoxStyles = focus => ({ + boxShadow: `2px 7px 30px rgb(0 0 0 / ${focus ? '12' : '4'}%)`, + cursor: 'pointer', +}) + +const ChallengeDetail = ({ + partner, + title, + subtitle, + logo, + link, + isFocused, +}) => { + return ( + <> + + + + + + + + + ) +} +export default ChallengeDetail diff --git a/frontend/src/components/challenges/ChallengeSection.js b/frontend/src/components/challenges/ChallengeSection.js new file mode 100644 index 000000000..5f7aa2c13 --- /dev/null +++ b/frontend/src/components/challenges/ChallengeSection.js @@ -0,0 +1,58 @@ +import React from 'react' +import { makeStyles } from '@material-ui/core/styles' +import { Box, Typography } from '@material-ui/core' + +import Markdown from 'components/generic/Markdown' + +import { OutboundLink } from 'react-ga' + +const useStyles = makeStyles(theme => ({ + companyLogo: { + width: '200px', + }, + outboundLink: { + '& a': { + textDecoration: 'none !important', + }, + }, + wrapper: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + [theme.breakpoints.up('md')]: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + }, +})) + +export default ({ partner, title, subtitle, logo, link }) => { + const classes = useStyles() + + return ( + + + {logo && ( + {partner} + )} + + + {title} + + + + + + + + + ) +} diff --git a/frontend/src/components/inputs/BottomBar.js b/frontend/src/components/inputs/BottomBar.js index b0c5ce4d6..0e922615b 100644 --- a/frontend/src/components/inputs/BottomBar.js +++ b/frontend/src/components/inputs/BottomBar.js @@ -15,6 +15,7 @@ import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline' import Button from 'components/generic/Button' import BlockExitIfDirty from 'components/inputs/BlockExitIfDirty/index' +import { isArray } from 'lodash-es' const useStyles = makeStyles(theme => ({ wrapper: ({ dirty, hasErrors }) => ({ @@ -120,6 +121,17 @@ const BottomBar = ({ errors, dirty, onSubmit, loading }) => { ) + // TODO: improve + } else if (isArray(errorMsg)) { + if (showErrors) + console.info(`${field} errors`, errorMsg) + return ( + + + + ) } else { return Object.keys(errorMsg).map(key => ( diff --git a/frontend/src/pages/_dashboard/index.js b/frontend/src/pages/_dashboard/index.js index 3fb356717..85a103988 100644 --- a/frontend/src/pages/_dashboard/index.js +++ b/frontend/src/pages/_dashboard/index.js @@ -9,7 +9,7 @@ export default () => { diff --git a/frontend/src/pages/_dashboard/slug/challenges/ChallengePage.js b/frontend/src/pages/_dashboard/slug/challenges/ChallengePage.js new file mode 100644 index 000000000..8700169c1 --- /dev/null +++ b/frontend/src/pages/_dashboard/slug/challenges/ChallengePage.js @@ -0,0 +1,110 @@ +import { Box, makeStyles } from '@material-ui/core' +import { ArrowBack } from '@material-ui/icons' +import Button from 'components/generic/Button' +import Markdown from 'components/generic/Markdown' +import PageHeader from 'components/generic/PageHeader' +import React from 'react' + +const useStyles = makeStyles(theme => ({ + companyLogo: { + width: '200px', + marginRight: '24px', + }, + outboundLink: { + '& a': { + textDecoration: 'none !important', + }, + }, + wrapper: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + [theme.breakpoints.up('md')]: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + }, + subtitle: { + color: theme.palette.theme_orange.main, + }, +})) + +export default ({ + onClose, + description, + title, + partner, + insights, + resources, + prizes, + criteria, + companyInfo, + logo, +}) => { + const classes = useStyles() + + return ( + <> + + + {logo && ( + {partner} + )} + + + + {description && ( + <> +

The challenge

+ + + )} +
+ {insights && ( + <> +

Insights

+ + + )} +
+ {resources && ( + <> +

What we'll bring

+ + + )} +
+ {prizes && ( + <> +

The Prizes

+ + + )} +
+ {criteria && ( + <> +

Judging criteria

+ + + )} +
+ {companyInfo && ( + <> +

About the company

+ + + )} + + ) +} diff --git a/frontend/src/pages/_dashboard/slug/challenges/index.js b/frontend/src/pages/_dashboard/slug/challenges/index.js new file mode 100644 index 000000000..42710ffd4 --- /dev/null +++ b/frontend/src/pages/_dashboard/slug/challenges/index.js @@ -0,0 +1,60 @@ +import React, { useState, useEffect } from 'react' +import { Box, makeStyles, Typography } from '@material-ui/core' +import { Helmet } from 'react-helmet' +import PageHeader from 'components/generic/PageHeader' +import PageWrapper from 'components/layouts/PageWrapper' +import * as DashboardSelectors from 'redux/dashboard/selectors' + +import ChallengeDetail from 'components/challenges/ChallengeDetail' +import { useSelector } from 'react-redux' +import ChallengePage from './ChallengePage' +export default () => { + const event = useSelector(DashboardSelectors.event) + const [openChallenge, setOpenChallenge] = useState(null) + const [activeChallenge, setActiveChallenge] = useState(null) + + const challenges = event.challenges + console.info('challenges', challenges) + console.info('open challenge', openChallenge) + if (openChallenge !== null) { + console.info('here') + return ( + setOpenChallenge(null)} + {...challenges[openChallenge]} + /> + ) + } + return ( + <> + + Junction App || Dashboard + + + + {challenges.map((c, index) => ( +
setOpenChallenge(index)} + onMouseOver={() => setActiveChallenge(index)} + onMouseLeave={() => setActiveChallenge(null)} + > + +
+ ))} + + + Anything you would like to see here in the future? + Contact us at partnerships@hackjunction.com with your + suggestion. + + +
+ + ) +} diff --git a/frontend/src/pages/_dashboard/slug/index.js b/frontend/src/pages/_dashboard/slug/index.js index 2b07b5a58..cfd103792 100644 --- a/frontend/src/pages/_dashboard/slug/index.js +++ b/frontend/src/pages/_dashboard/slug/index.js @@ -11,6 +11,7 @@ import AmpStoriesIcon from '@material-ui/icons/AmpStories' import AssignmentOutlinedIcon from '@material-ui/icons/AssignmentOutlined' import StarRateIcon from '@material-ui/icons/StarRate' import HowToVoteIcon from '@material-ui/icons/HowToVote' +import FormatListBulletedIcon from '@material-ui/icons/FormatListBulleted' import SidebarLayout from 'components/layouts/SidebarLayout' import Image from 'components/generic/Image' @@ -25,6 +26,7 @@ import ReviewingPage from './reviewing' import TravelGrantPage from './travel-grant' import EventIDPage from './event-id' import HackerpackPage from './hackerpack' +import ChallengesIndex from './challenges' import * as DashboardSelectors from 'redux/dashboard/selectors' import * as DashboardActions from 'redux/dashboard/actions' @@ -184,6 +186,14 @@ export default () => { label: t('Hackerpack_'), component: HackerpackPage, }, + { + key: 'challenges', + path: '/challenges', + exact: true, + icon: , + label: 'Challenges', + component: ChallengesIndex, + }, ]} /> diff --git a/frontend/src/pages/_events/slug/context.js b/frontend/src/pages/_events/slug/context.js index 73fc2f926..863f8f0b1 100644 --- a/frontend/src/pages/_events/slug/context.js +++ b/frontend/src/pages/_events/slug/context.js @@ -28,6 +28,23 @@ const eventQuery = gql` optionalFields requiredFields } + challenges { + name + partner + slug + title + subtitle + description + insights + resources + prizes + criteria + companyInfo + logo { + url + publicId + } + } customQuestions { label name diff --git a/frontend/src/pages/_organise/slug/edit/challenges/ChallengesForm.js b/frontend/src/pages/_organise/slug/edit/challenges/ChallengesForm.js new file mode 100644 index 000000000..6e22c956e --- /dev/null +++ b/frontend/src/pages/_organise/slug/edit/challenges/ChallengesForm.js @@ -0,0 +1,394 @@ +import React, { useState, useCallback, useMemo } from 'react' + +import { + Paper, + Grid, + Box, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + IconButton, + Tooltip, + Typography, +} from '@material-ui/core' +import getSlug from 'speakingurl' +import HighlightOffIcon from '@material-ui/icons/HighlightOff' +import EditIcon from '@material-ui/icons/Edit' +import SaveIcon from '@material-ui/icons/Save' +import CloseIcon from '@material-ui/icons/Close' +import TextInput from 'components/inputs/TextInput' +import Button from 'components/generic/Button' +import MarkdownInput from 'components/inputs/MarkdownInput' +import ImageUpload from 'components/inputs/ImageUpload' +import { SettingsBrightnessSharp } from '@material-ui/icons' + +export default ({ value, onChange }) => { + const [name, setName] = useState(undefined) + const [slug, setSlug] = useState(undefined) + const [partner, setPartner] = useState(undefined) + const [title, setTitle] = useState(undefined) + const [subtitle, setSubtitle] = useState(undefined) + const [description, setDescription] = useState(undefined) + const [insights, setInsights] = useState(undefined) + const [resources, setResources] = useState(undefined) + const [prizes, setPrizes] = useState(undefined) + const [criteria, setCriteria] = useState(undefined) + const [companyInfo, setCompanyInfo] = useState(undefined) + const [logo, setLogo] = useState(undefined) + + const [editIndex, setEditIndex] = useState(-1) + const [editing, setEditing] = useState(false) + + const handleNameChange = useCallback(name => { + setName(name) + setSlug(getSlug(name)) + }, []) + + const handleAdd = useCallback(() => { + handleNameChange(name) + setEditing(true) + }, [handleNameChange, name]) + + const handleRemove = useCallback( + index => { + onChange( + value.filter((item, idx) => { + return idx !== index + }), + ) + }, + [value, onChange], + ) + + const handleEditStart = useCallback( + index => { + setEditIndex(index) + setEditing(true) + setName(value[index].name) + setPartner(value[index].partner) + setSlug(value[index].slug) + setTitle(value[index].title) + setSubtitle(value[index].subtitle) + setDescription(value[index].description) + setInsights(value[index].insights) + setResources(value[index].resources) + setPrizes(value[index].prizes) + setCriteria(value[index].criteria) + setCompanyInfo(value[index].companyInfo) + setLogo(value[index].logo) + }, + [value], + ) + + const handleEditCancel = useCallback(() => { + setEditIndex(-1) + setEditing(false) + setName(undefined) + setPartner(undefined) + setTitle(undefined) + setSubtitle(undefined) + setDescription(undefined) + setInsights(undefined) + setResources(undefined) + setPrizes(undefined) + setCriteria(undefined) + setCompanyInfo(undefined) + setLogo(undefined) + }, []) + + const handleEditDone = useCallback(() => { + if (editIndex > -1) { + onChange( + value.map((item, index) => { + if (index === editIndex) { + return { + ...item, + name, + partner, + slug, + title, + subtitle, + description, + insights, + resources, + prizes, + criteria, + companyInfo: companyInfo, + logo, + } + } + return item + }), + ) + } else { + onChange( + value.concat({ + name, + partner, + slug, + title, + subtitle, + description, + insights, + resources, + prizes, + criteria, + companyInfo: companyInfo, + logo, + }), + ) + } + handleEditCancel() + }, [ + editIndex, + handleEditCancel, + onChange, + value, + name, + partner, + slug, + title, + subtitle, + description, + insights, + resources, + prizes, + criteria, + companyInfo, + logo, + ]) + + const isValid = useMemo(() => { + return ( + partner && + name && + slug && + value.filter((challenge, index) => { + return ( + index !== editIndex && + (challenge.name === name || challenge.slug === slug) + ) + }).length === 0 + ) + }, [editIndex, name, partner, slug, value]) + + const renderListItem = (challenge, index) => { + return ( + + + + + handleEditStart(index)}> + + + + + handleRemove(index)}> + + + + + + ) + } + + const renderForm = () => ( + <> + + + + The unique publicly visible name of the challenge. + + + + + + A unique slug for the challenge. This will be used in e.g. + url paths related to this challenge. + + + + + + Who is the partner responsible for this challenge? + + + + + + Title. Displayed in the event list. + + + + + + Subtitle. Displayed below title. + + + + Description + + + Challenge description. + + + + Insights + + Challenge insights. + + + Resources + + Challenge resources. + + + Prizes + + Challenge Prizes. + + + Criteria + + Challenge criteria. + + + Company Info + + Company Info + + + Logo + + + + + Challenge description. + + + + + + + + + + + + + ) + + return ( + + + + {!editing ? ( + <> + + {value.map(renderListItem)} + + + + + + + + ) : ( + renderForm() + )} + + + + ) +} diff --git a/frontend/src/pages/_organise/slug/edit/challenges/index.js b/frontend/src/pages/_organise/slug/edit/challenges/index.js new file mode 100644 index 000000000..b9552897c --- /dev/null +++ b/frontend/src/pages/_organise/slug/edit/challenges/index.js @@ -0,0 +1,78 @@ +import React from 'react' + +import { Grid, Box } from '@material-ui/core' +import { FastField, Field } from 'formik' + +import FormControl from 'components/inputs/FormControl' +import BooleanInput from 'components/inputs/BooleanInput' + +import ChallengesForm from './ChallengesForm' + +export default () => { + return ( + + + ( + + + form.setFieldValue(field.name, value) + } + /> + + )} + /> + + + { + if (form.values.challengesEnabled) { + return ( + + { + form.setFieldValue( + field.name, + value, + ) + }} + /> + + ) + } + return null + }} + /> + + + + + + + ) +} diff --git a/frontend/src/pages/_organise/slug/edit/configuration/ChallengesForm.js b/frontend/src/pages/_organise/slug/edit/configuration/ChallengesForm.js deleted file mode 100644 index 7cad938ef..000000000 --- a/frontend/src/pages/_organise/slug/edit/configuration/ChallengesForm.js +++ /dev/null @@ -1,218 +0,0 @@ -import React, { useState, useCallback, useMemo } from 'react' - -import { - Paper, - Grid, - Box, - List, - ListItem, - ListItemText, - ListItemSecondaryAction, - IconButton, - Tooltip, - Typography, -} from '@material-ui/core' -import getSlug from 'speakingurl' -import HighlightOffIcon from '@material-ui/icons/HighlightOff' -import EditIcon from '@material-ui/icons/Edit' -import SaveIcon from '@material-ui/icons/Save' -import CloseIcon from '@material-ui/icons/Close' -import TextInput from 'components/inputs/TextInput' -import Button from 'components/generic/Button' - -export default ({ value, onChange }) => { - const [inputValue, setInputValue] = useState() - const [slugValue, setSlugValue] = useState() - const [partnerValue, setPartnerValue] = useState() - const [editIndex, setEditIndex] = useState(-1) - const [editName, setEditName] = useState() - const [editPartner, setEditPartner] = useState() - - const handleNameChange = useCallback(name => { - setInputValue(name) - setSlugValue(getSlug(name)) - }, []) - - const handleAdd = useCallback(() => { - onChange( - value.concat({ - name: inputValue, - slug: slugValue, - partner: partnerValue, - }), - ) - setInputValue() - setSlugValue() - setPartnerValue() - }, [value, inputValue, slugValue, partnerValue, onChange]) - - const handleRemove = useCallback( - index => { - onChange( - value.filter((item, idx) => { - return idx !== index - }), - ) - }, - [value, onChange], - ) - - const handleEditStart = useCallback( - index => { - setEditIndex(index) - setEditName(value[index].name) - setEditPartner(value[index].partner) - }, - [value], - ) - - const handleEditCancel = useCallback(() => { - setEditIndex(-1) - setEditName() - setEditPartner() - }, []) - - const handleEditDone = useCallback(() => { - onChange( - value.map((item, index) => { - if (index === editIndex) { - return { - ...item, - name: editName, - partner: editPartner, - } - } - return item - }), - ) - handleEditCancel() - }, [value, editIndex, editName, editPartner, onChange, handleEditCancel]) - - const isValid = useMemo(() => { - return ( - partnerValue && - inputValue && - slugValue && - value.filter(challenge => { - return ( - challenge.name === inputValue || - challenge.slug === slugValue - ) - }).length === 0 - ) - }, [value, partnerValue, inputValue, slugValue]) - - const renderListItem = (challenge, index) => { - if (index === editIndex) { - return ( - - - - - - - - - - - - - - - - - - - - ) - } - - return ( - - - - - handleEditStart(index)}> - - - - - handleRemove(index)}> - - - - - - ) - } - - return ( - - - - - - - The unique publicly visible name of the challenge. - - - - - - A unique slug for the challenge. This will be used - in e.g. url paths related to this challenge. - - - - - - Who is the partner responsible for this challenge? - - - - - - - - - {value.map(renderListItem)} - - - - - ) -} diff --git a/frontend/src/pages/_organise/slug/edit/configuration/index.js b/frontend/src/pages/_organise/slug/edit/configuration/index.js index 1a5fdfc1b..5239907d6 100644 --- a/frontend/src/pages/_organise/slug/edit/configuration/index.js +++ b/frontend/src/pages/_organise/slug/edit/configuration/index.js @@ -15,7 +15,6 @@ import StreetAddressForm from 'components/inputs/StreetAddressForm' import TravelGrantConfig from './TravelGrantConfig' import TracksForm from './TracksForm' -import ChallengesForm from './ChallengesForm' export default () => { return ( @@ -105,31 +104,8 @@ export default () => { form.values.eventLocation ?? {}, ) } - console.info( - 'change', - form.values.eventLocation, - ) form.setFieldValue(field.name, value) }} - onBlur={() => { - console.info(field.value) - /* if (field.value === 'online') { - form.setFieldValue( - 'eventLocation', - null, - ) - } - if (field.value === 'physical') { - form.setFieldValue( - 'eventLocation', - form.values.eventLocation ?? {}, - ) - } */ - console.info( - 'blur', - form.values.eventLocation, - ) - }} options={Object.keys(EventTypes).map(key => ({ label: EventTypes[key].label, value: key, @@ -238,60 +214,6 @@ export default () => { } /> - - ( - - - form.setFieldValue(field.name, value) - } - /> - - )} - /> - - - { - if (form.values.challengesEnabled) { - return ( - - - form.setFieldValue( - field.name, - value, - ) - } - /> - - ) - } - return null - }} - /> - { label: 'Configuration', component: ConfigurationTab, }, + { + path: '/challenges', + key: 'challenges', + label: 'Challenges', + component: ChallengesTab, + }, { path: '/schedule', key: 'schedule', diff --git a/shared/schemas/Challenge.js b/shared/schemas/Challenge.js index b23646cd6..bd93a2c10 100644 --- a/shared/schemas/Challenge.js +++ b/shared/schemas/Challenge.js @@ -3,7 +3,7 @@ const { GraphQLObjectType, GraphQLString, GraphQLInputObjectType, - GraphQLNonNull + GraphQLNonNull, } = require('graphql') const CloudinaryImageSchema = require('./CloudinaryImage') @@ -22,37 +22,37 @@ const ChallengeSchema = new mongoose.Schema({ }, title: { type: String, - required: true + required: true, }, subtitle: { type: String, - required: true + required: true, }, description: { type: String, - required: true + required: true, }, insights: { type: String, - required: true + required: true, }, resources: { type: String, - required: true + required: true, }, prizes: { type: String, - required: true + required: true, }, criteria: { type: String, - required: true + required: true, }, - company_info: { + companyInfo: { type: String, - required: true + required: true, }, - logo: CloudinaryImageSchema.mongoose + logo: CloudinaryImageSchema.mongoose, }) const ChallengeType = new GraphQLObjectType({ @@ -88,12 +88,12 @@ const ChallengeType = new GraphQLObjectType({ criteria: { type: GraphQLString, }, - company_info: { + companyInfo: { type: GraphQLString, }, logo: { type: CloudinaryImageSchema.graphql, - } + }, }, }) @@ -133,13 +133,13 @@ const ChallengeInput = new GraphQLInputObjectType({ criteria: { type: GraphQLString, }, - company_info: { + companyInfo: { type: GraphQLString, }, logo: { type: CloudinaryImageSchema.graphqlInput, - } - } + }, + }, }) module.exports = { diff --git a/shared/schemas/CloudinaryImage.js b/shared/schemas/CloudinaryImage.js index d834f5c80..3990c7665 100644 --- a/shared/schemas/CloudinaryImage.js +++ b/shared/schemas/CloudinaryImage.js @@ -38,6 +38,9 @@ const CloudinaryImageInput = new GraphQLInputObjectType({ publicId: { type: GraphQLNonNull(GraphQLString), }, + _id: { + type: GraphQLString, + }, }, }) diff --git a/shared/schemas/validation/eventSchema.js b/shared/schemas/validation/eventSchema.js index 69503328a..d5c3823c5 100644 --- a/shared/schemas/validation/eventSchema.js +++ b/shared/schemas/validation/eventSchema.js @@ -1,9 +1,13 @@ import * as yup from 'yup' -const cloudinaryImage = yup.object().shape({ - url: yup.string().required(), - publicId: yup.string().required(), -}) +const cloudinaryImage = yup + .object() + .shape({ + url: yup.string().required(), + publicId: yup.string().required(), + }) + .default(null) + .nullable() const address = yup.object().shape({ country: yup.string().required(), @@ -29,6 +33,15 @@ const challenge = yup.object().shape({ name: yup.string().required(), partner: yup.string(), slug: yup.string().required(), + title: yup.string(), + subtitle: yup.string(), + description: yup.string(), + insights: yup.string(), + resources: yup.string(), + prizes: yup.string(), + criteria: yup.string(), + companyInfo: yup.string(), + logo: cloudinaryImage, }) const travelGrantConfig = yup.object().shape({ @@ -98,11 +111,11 @@ export default yup.object().shape({ reviewingStartTime: yup.date(), reviewingEndTime: yup.date(), finalsActive: yup.boolean(), - eventLocation: address.notRequired().nullable(), + eventLocation: address.notRequired().nullable(true), tracksEnabled: yup.boolean(), tracks: yup.array().of(track), challengesEnabled: yup.boolean(), - challenges: yup.array().of(challenge), + challenges: yup.array().of(challenge).min(0), travelGrantConfig, reviewMethod: yup.string(), overallReviewMethod: yup.string(),