Creating the add-exercise page
Pre-dev
Create a mini design doc that goes through the things that need to be made or thought of before creating the page.
The mini design doc for this page
Data
This page will have features like listing all the modules for a specific lesson and ultimately used to add exercises.
Before coding, you need to find what data this page needs. In our case, we need to get all the lessons and each lesson's modules.
We need the lessons to filter them and find the lesson that has the slug of the URL's param lessonSlug
value.
The modules will be listed in the Dropdown menu, and later used to set the module to the exercise we want to add belongs to.
Before finding the way to get the lessons
, we'll have to update the lessons resolver first to include the modules
:
// graphql/resolvers/lessons.tsexport const lessons = () => {return prisma.lesson.findMany({include: {challenges: { orderBy: { order: 'asc' } },modules: { orderBy: { order: 'asc' } },},orderBy: {order: 'asc',},})}
We'll be using withGetAppQuery
that gets the data we need lessons
(and a bunch of others) and pass it to the component withGetAppQuery()(Component)
. In the component, we will extract the data
from the parameters and set its type as AppQueryProps
:
const Component = ({ data }: AppQueryProps) => {}withGetAppQuery()(Component)
The hook for mutation to add an exercise didn't exist (code-gen requires us to create a type defintion with gql
for each mutation/query in order to create a hook for it), so we had to create a file under graphql/queries
called addExercise.ts
that will have the following type definition.
import { gql } from '@apollo/client'const ADD_EXERCISE = gql`mutation addExercise($moduleId: Int!$description: String!$answer: String!$testStr: String$explanation: String) {addExercise(moduleId: $moduleIddescription: $descriptionanswer: $answertestStr: $testStrexplanation: $explanation) {iddescriptionanswerexplanation}}`export default ADD_EXERCISE
Structure
Dropdown menu
The Dropdown
menu will be used to switch between the modules. The selected module will be stored in the parent state as we'll later need it when executing the createExercise
mutation.
We need to consider the following cases before coding it:
- Should we display the lesson's first module or make the user set the module they want to add an exercise to?
- Make the user set the module because there's a chance the user might create an exercise for the default by accident.
- What happens if the user didn't select a module?
- Show them an error that explains how they need to select a module to add an exercise to it.
We found out we already have a DropdownMenu
component in our components library. Instead of creating a new one, we chose to refactor and restyle it.
The DropdownMenu
should have the following features for it to work for us:
- Set the select item as the active one
- When an item is clicked, run the item's callback function
item.onClick
that will set the parent component'smodule
state with theitem
Before:
// DropdownMenuexport type Item = {title: stringpath?: stringas?: 'a' | 'button'onClick?: Function} | nulltype DropDownMenuProps = {drop?: DropDirectionitems: Item[]title: stringsize?: 'sm' | 'lg' | undefinedvariant?:| 'primary'| 'secondary'| 'success'| 'info'| 'warning'| 'danger'| 'none'//changes the underlying component CSS base class name//https://react-bootstrap.github.io/components/dropdowns/#apibsPrefix?: string}export const DropdownMenu: React.FC<DropDownMenuProps> = ({drop = 'down',variant = 'none',title,size,items,bsPrefix,}) => {const menuItems = items.map((item: Item, itemsIndex: number) =>!item ? (<Dropdown.Divider key={itemsIndex} />) : (<div className="text-center py-2 px-4" key={item.title}><Dropdown.Itemas={item.as || 'a'}key={itemsIndex}href={item.path}onClick={() => item.onClick && item.onClick(item.title)}bsPrefix={bsPrefix}>{item.title}</Dropdown.Item></div>))return (<><div className="d-none d-lg-block"><DropdownButtontitle={title}variant={variant}size={size}drop={drop}bsPrefix={styles.title}>{menuItems}</DropdownButton></div><div className="d-lg-none">{menuItems}</div></>)}
After:
export type Item = {title?: stringname: stringpath?: stringas?: 'a' | 'button'onClick?: Function}type DropDownMenuProps = {drop?: DropDirectionitems?: Item[] | nulltitle?: stringcustomTitle?: stringsize?: 'sm' | 'lg' | undefinedvariant?:| 'primary'| 'secondary'| 'success'| 'info'| 'warning'| 'danger'| 'none'//changes the underlying component CSS base class name//https://react-bootstrap.github.io/components/dropdowns/#apibsPrefix?: string}const ChevronRight = () => <ChevronRightIcon size={17} />export const DropdownMenu: React.FC<DropDownMenuProps> = ({items,title,customTitle,}) => {const [activeItem, setActiveItem] = useState({name: title || customTitle,})return (<Dropdown><Dropdown.Toggle bsPrefix={styles.dropdown} id="dropdown-lesson">{activeItem.name || 'None'}<ChevronRight /></Dropdown.Toggle><Dropdown.Menu className={styles.dropdown__menu}>{items?.map((item, index) => (<Dropdown.Itemkey={`${item?.name}-${index}`}onClick={() => {item?.onClick && item.onClick(item)setActiveItem({name: item?.name,})}}>{item?.name}</Dropdown.Item>))}</Dropdown.Menu></Dropdown>)}
Main differences:
- Remove most of the component props as they're not used in any other reference of the component
- Add a
customTitle
to pass in a custom title (this is the exact same astitle
) - Refactor the component so it sets the selected item as the active one by calling
item.onClick
callback function
Inputs
For the inputs, we chose to use the FormCard
component from the components library. This component takes an array of objects. Each object represents the input's type
, value
, and title
. The input could be a markdown input or a regular input.
It all went fine except the part of not creating an execrise when one of the inputs is invalid.
FormCard
has a submit button that we could pass to it our logic when it's clicked. This function executes what you pass without validating the inputs first.
One solution we used is to run the formChange
helper for each input before adding the exercise. This method will validate each input and set its error message if it's invalid.
const handleChange = async (value: string, propertyIndex: number) => {await formChange(value, // Input valuepropertyIndex, // Input indexformOptions, // All inputs objectsetFormOptions, // SetState action to update the inputsexercisesValidation // Validation schema)}const onClick = () => {try {/*With how handleChange work, it'll run the exercisesValidation with each input and display its error message if it's invalid*/formOptions.forEach((form, index) => {handleChange(form.value, index)})// ....// ...// ...} catch (err) {// ...}}
Creating an exercise (submit)
After we made sure the inputs show an error message when they're invalid. We can now work on the logic of creating the exercise.
The solution to show the error message for each input if it's invalid doesn't prevent the submit button from being executed when one of the inputs is empty.
To solve it, we created a check that goes through each input and validate if it's not empty. If all of them are not empty, submit, else show an error.
const onClick = () => {try {// ...const validateInputs = formOptions.every((form) => form.value)// ...// If all the inputs are not empty, add an exerciseif (validateInputs) {return addExercise()}} catch (err) {// ...}}