Writing New Cards
jacobwicks
Posted on January 17, 2020
In this post we will make it possible for the user to write new cards. We will make a new scene called Writing
where the user can write new cards. In the next post we will make it possible for the user to save the cards that they write to the browsers localStorage, so the cards can persist between sessions.
User Stories
The user thinks of a new card. The user opens the card editor. The user clicks the button to create a new card. The user writes in the card subject, question prompt, and an answer to the question. The user saves their new card.
The user deletes a card.
The user changes an existing card and saves their changes.
Features
The features from the user stories:
- a component that lets the user write new cards
- inputs for question, subject, and answer
- the component can load existing cards
- a button to create a new card that clears the writing component
- a button to save a card into the deck of cards
- a button to delete the current card
In addition to these features, for Writing
to change existing cards we'll need a way to select cards. The Selector
component will let the user select cards. We'll write the Selector
in a later post.
Writing
In this post we will make Writing
work. We will change the CardContext
so that it can handle actions dispatched from Writing
. Handling actions is how the CardContext
will add the cards that the user writes to the array of cards
that the app uses. After we write the test for Writing
being able to save cards, we will go change the CardContext
so that saving works. Then we will go back to Writing
and make the Save
button work. Same for the new card action.
Handling actions is also how the CardContext
will delete cards. After we write the test for Writing
being able to delete cards, we will go change the CardContext
so that deleting works. Then we will go back to Writing
and make the Delete
button work.
Tests for Writing
File: src/scenes/Writing/index.test.tsx
Will Match: src/scenes/Writing/complete/test-1.tsx
In the last post we didn't write tests for Writing
because we only made a placeholder component. We made the placeholder because we wanted to make NavBar
so the user could choose what scene to show. We made the placeholder so that we could see NavBar
working. Now it is time to make the real Writing
component. So now it is time to write the tests for Writing
.
How to Decide What to Test For
We don't have to test for everything. We want to test for the parts that matter. Think about what we just described the Writing component doing. Creating a new card. Changing a card. Saving changes. Deleting a card. You want to write tests that tell you that these important features work.
Now think about what you know about card objects. Remember the structure of each card:
//File: src/types.ts
//defines the flashcard objects that the app stores and displays
export interface Card {
//the answer to the question
answer: string,
//the question prompt
question: string,
//the subject of the question and answer
subject: string
}
Choose the Components
The user will need a place to enter the answer, the question, and the subject of the card. It is really a Form for the user to fill. So we will use the Semantic UI React Form component.
The subject is probably short, so use an Input for that. The question and the answer can be longer, so use TextAreas for those.
The Input and both TextAreas will have headers so the user knows what they are, but we aren't going to write tests for the headers because they aren't important to how the page functions. Remember from earlier in the App, Semantic UI React TextAreas need to be inside of a Form to look right.
You'll need to give the user a Button to save their card once they've written it. You'll also need to give them a button to create a new card. Let's add a delete button too, so the user can get rid of cards they don't want.
Write a comment for each test you plan to make:
//there's an input where the user can enter the subject of the card
//There's a textarea where the user can enter the question prompt of the card
//there's a textarea where the user can enter the answer to the question
//there's a button to save the card
//when you enter a subject, question, and answer and click the save button a new card is created
//there's a button to create a new card
//when you click the new button the writing component clears its inputs
//there's a button to delete the current card
//when you click the delete button the card matching the question in the question textarea is deleted from the array cards
Ok, let's get started writing some code. Write your imports at the top of the test file.
import React, { useContext } from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { CardProvider, CardContext, initialState } from '../../services/CardContext';
import { CardState } from '../../types';
import Writing from './index';
Invoke afterEach
afterEach(cleanup);
Helper Component: Displays Last Card
Sometimes we'll want to know if the contents of the cards array has changed. If we add a card or delete a card we want cards to change. But Writing
only displays the current card. Let's make a helper component that just displays the last card in the cards array. When we want to know if the cards array has changed, we'll render this component and look at what's in it.
//displays last card in the cards array
const LastCard = () => {
const { cards } = useContext(CardContext);
//gets the question from the last card in the array
const lastCard = cards[cards.length - 1].question;
return <div data-testid='lastCard'>{lastCard}</div>
};
Helper Function: Render Writing inside CardContext
Write a helper function to render Writing inside of the CardContext. It takes two optional parameters.
The first paramater is testState
. testState
is a CardState
object, so we can pass in specific values instead of the default initialState
.
The second parameter is child
. child
accepts JSX elements, so we can pass our LastCard display component in and render it when we want to.
const renderWriting = (
testState?: CardState,
child?: JSX.Element
) => render(
<CardProvider testState={testState}>
<Writing />
{child}
</CardProvider>);
Writing Test 1: Has Subject Input
File: src/scenes/Writing/index.test.tsx
Will Match: src/scenes/Writing/complete/test-1.tsx
it('has an input to write the subject in', () => {
const { getByTestId } = renderWriting();
const subject = getByTestId('subject');
expect(subject).toBeInTheDocument();
});
Pass Writing Test 1: Has Subject Input
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/index-2.tsx
First, add the imports.
We are going to use many of the React Hooks to make the form work. useCallback is a hook that we haven't seen before. Sometimes the way useEffect
and the setState function from useState
interact can cause infinite loops. The useCallBack
hook prevents that. We'll use useCallBack
to make useEffect
and useState
work together to clear out the form when the user switches cards.
import React, {
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import {
Button,
Container,
Form,
Header,
Input,
TextArea
} from 'semantic-ui-react';
import { CardContext } from '../../services/CardContext';
import { CardActionTypes } from '../../types';
We'll put the Input
in a Form
. Give Inputs
inside a Form
a name so that you can collect the contents when the user submits the form. The name of this input is 'subject', which is the same as the testId. But the name doesn't have to be the same as the testId, they are completely separate.
const Writing = () =>
<Form>
<Header as='h3'>Subject</Header>
<Input data-testid='subject' name='subject'/>
</Form>
Writing Test 2: Has Question TextArea
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/test-2.tsx
//There's a textarea where the user can enter the question prompt of the card
it('has a textarea to write the question in', () => {
const { getByTestId } = renderWriting();
const question = getByTestId('question');
expect(question).toBeInTheDocument();
});
Pass Writing Test 2: Has Question TextArea
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/index-3.tsx
const Writing = () =>
<Form>
<Header as='h3'>Subject</Header>
<Input data-testid='subject' name='subject'/>
<Header as='h3' content='Question'/>
<TextArea data-testid='question' name='question'/>
</Form>
Writing Test 3: Has Answer TextArea
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/test-3.tsx
//there's a textarea where the user can enter the answer to the question
it('has a textarea to write the answer in', () => {
const { getByTestId } = renderWriting();
const answer = getByTestId('answer');
expect(answer).toBeInTheDocument();
});
Pass Writing Test 3: Has Question TextArea
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/index-4.tsx
const Writing = () =>
<Form>
<Header as='h3'>Subject</Header>
<Input data-testid='subject' name='subject'/>
<Header as='h3' content='Question'/>
<TextArea data-testid='question' name='question'/>
<Header as='h3' content='Answer'/>
<TextArea data-testid='answer' name='answer'/>
</Form>
Writing Test 4: Has Save Button
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/test-4.tsx
//there's a button to save the card
it('has a save button', () => {
const { getByText } = renderWriting();
const save = getByText(/save/i);
expect(save).toBeInTheDocument();
});
Pass Writing Test 4: Has Save Button
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/index-5.tsx
<Form>
<Header as='h3'>Subject</Header>
<Input data-testid='subject' name='subject'/>
<Header as='h3' content='Question'/>
<TextArea data-testid='question' name='question'/>
<Header as='h3' content='Answer'/>
<TextArea data-testid='answer' name='answer'/>
<Button content='Save'/>
</Form>
Run the app, select Edit Flashcards
and you will see Writing
on screen.
Saving Cards
Now it is time to make saving cards work. When a card is saved, it will be added to the array cards
in the CardContext
. To make saving work, we will
- Make the new test for Writing
- Add save to CardActionTypes in types.ts
- Write the onSubmit function for the Form in Writing
- Make a new test for handling save in the CardContext reducer
- Add a new case 'save' to the CardContext reducer
Writing Test 5: Saving
File: src/scenes/Writing/index.test.tsx
Will Match: src/scenes/Writing/complete/test-5.tsx
To test if saving works, we need to find the Input
and TextAreas
and put example text in them. Then we'll find the save button and click it. After that, we check the textContent
of the LastCard
helper component and expect it to match the example text.
//when you enter a subject, question, and answer and click the save button a new card is created
it('adds a card when you save', () => {
//the LastCard component just displays the question from the last card in cardContext
//if we add a card and it shows up in last card, we'll know saving works
const { getByTestId, getByText } = renderWriting(undefined, <LastCard/>);
//the strings that we will set the input values to
const newSubject = 'Test Subject';
const newQuestion = 'Test Question';
const newAnswer = 'Test Answer';
//We are using a Semantic UI React Input component
//this renders as an input inside a div => <div><input></div>
//so targeting 'subject' will target the outside div, while we want the actual input
//subject has a children property, which is an array of the child nodes
//children[0] is the input
const subject = getByTestId('subject');
const subjectInput = subject.children[0];
fireEvent.change(subjectInput, { target: { value: newSubject } });
expect(subjectInput).toHaveValue(newSubject);
//The TextArea component doesn't have the same quirk
//question and answer use TextAreas instead of Input
const question = getByTestId('question');
fireEvent.change(question, { target: { value: newQuestion } });
expect(question).toHaveValue(newQuestion);
const answer = getByTestId('answer');
fireEvent.change(answer, { target: { value: newAnswer } });
expect(answer).toHaveValue(newAnswer);
const save = getByText(/save/i);
fireEvent.click(save);
const lastCard = getByTestId('lastCard');
expect(lastCard).toHaveTextContent(newQuestion);
});
Saving doesn't work yet. We need to add the function that collects the data from the Form
. We need to dispatch a save
action to CardContext
. And we also need to write the case in the CardContext
reducer
that will handle the save
action.
Types: Add Save to CardActionType
File: src/types.ts
Will Match: src/complete/types-6.ts
Add save
to CardActionTypes
. Add a save
action to CardAction
. The save
action takes three strings: answer, question, and subject.
//the types of action that the reducer in CardContext will handle
export enum CardActionTypes {
next = 'next',
save = 'save'
};
export type CardAction =
//moves to the next card
| { type: CardActionTypes.next }
//saves a card
| { type: CardActionTypes.save, answer: string, question: string, subject: string }
Pass Writing Test 5: Saving
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/index-6.tsx
Add the function to collect data from the Form
. When a form is submitted, the form emits and event that you can get the value of the inputs from. The data type of the form submission event is React.FormEvent<HTMLFormElement>
.
First we prevent the default Form handling by calling the preventDefault
method of the form event. Then we make a new FormData object from the event.
After we turn the event into a FormData object, we can get the values of the inputs from it using the get
method and the name of the input. We named our inputs 'answer,' 'subject,' and 'question' so those are the names we'll get
out of the form event and assign to variables.
Once we have assigned the input values to variables, we can do whatever we need to with them. We'll dispatch them as a save
action to the CardContext
. Later we will write the code for CardContext
to handle a save
action, and then dispatching a save
action will result in a new card being added to the array cards
in the CardContext
.
const Writing = () => {
const { dispatch } = useContext(CardContext);
return (
<Form onClick={(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const card = new FormData(e.target as HTMLFormElement);
const answer = card.get('answer') as string;
const question = card.get('question') as string;
const subject = card.get('subject') as string;
dispatch({
type: CardActionTypes.save,
answer,
question,
subject
});
}}>
This still won't pass the test named 'adds a card when you save.' We need to add a save
case to the CardContext
reducer so it can handle the save
action.
CardContext Tests 1-2: Handling Save in the CardContext Reducer
File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-8.tsx
We'll write our tests inside the 'CardContext reducer' describe block.
Write a quote for each test we are going to write. save
will add a new card to the context. save
can also save changes to a card. If a card with the question from the save
action already exists, save
will overwrite that card.
//save new card
//save changes to existing card
To test the reducer
, we need to create an action. Then we pass the state and the action to the reducer
and look at the results.
In this test we use two new array methods. Array.findIndex
and Array.filter
.
Array.findIndex accepts a function and returns a number. It will iterate over each element in the array and pass the element to the function. If it finds an element that returns true from the function, findIndex
will return the index of that element. If it does not find an element that returns true from the function, then it will return -1.
We use findIndex
to make sure that the cards
array from initialState
does not already contain the example text.
Array.filter accepts a function and returns a new array. It will iterate over each element in the array and pass the element to the function. If the element returns true from the function, then it will be added to the new array. If the element does not return true from the function, it will be 'filtered out' and will not be added to the new array.
We use filter
to check that the cards
array has a card with the example text after the reducer
handles the save
action. We filter out all cards that don't have the example text. We check the length
property of the resulting array, and expect that it's equal to 1. The length
should be equal to 1 because the array should only contain the card that was just added.
//save new card
it('save action with new question saves new card', () => {
const answer = 'Example Answer';
const question = 'Example Question';
const subject = 'Example Subject';
//declare CardAction with type of 'save'
const saveAction: CardAction = {
type: CardActionTypes.save,
question,
answer,
subject
};
//before the action is processed initialState should not have a card with that question
expect(initialState.cards.findIndex(card => card.question === question)).toEqual(-1);
//pass initialState and saveAction to the reducer
const { cards } = reducer(initialState, saveAction);
//after the save action is processed, should have one card with that question
expect(cards.filter(card => card.question === question).length).toEqual(1);
//array destructuring to get the card out of the filtered array
const [ card ] = cards.filter(card => card.question === question);
//the saved card should have the answer from the save action
expect(card.answer).toEqual(answer);
//the saved card should have the subject from the save action
expect(card.subject).toEqual(subject);
});
To test saving changes to an existing card, we create existingState
, a cardState
with a cards
array that includes our example card. Then we create a save
action and send the state and the action to the reducer
. We use filter
to check that the cards
array still just has one copy of the card. We expect the contents of the card to have changed.
//save changes to existing card
it('save action with existing question saves changes to existing card', () => {
const answer = 'Example Answer';
const question = 'Example Question';
const subject = 'Example Subject';
const existingCard = {
answer,
question,
subject
};
const existingState = {
...initialState,
cards: [
...initialState.cards,
existingCard
]};
const newAnswer = 'New Answer';
const newSubject = 'New Subject';
//declare CardAction with type of 'save'
const saveAction: CardAction = {
type: CardActionTypes.save,
question,
answer: newAnswer,
subject: newSubject
};
//the state should have one card with that question
expect(existingState.cards.filter(card => card.question === question).length).toEqual(1);
//pass initialState and saveAction to the reducer
const { cards } = reducer(initialState, saveAction);
//Ater processing the action, we should still only have one card with that question
expect(cards.filter(card => card.question === question).length).toEqual(1);
//array destructuring to get the card out of the filtered array
const [ card ] = cards.filter(card => card.question === question);
//answer should have changed
expect(card.answer).toEqual(newAnswer);
//subject should have changed
expect(card.subject).toEqual(newSubject);
});
Pass CardContext Tests 1-2: Handling Save in the CardContext Reducer
File: src/services/CardContext/index.tsx
Will Match: src/services/CardContext/complete/index-5.tsx
Add a new case 'save' to the CardContext
reducer
. Add save
to the switch statement. I like to keep the cases in alphabetical order. Except for default, which has to go at the bottom of the switch statement.
To make saving work, we use findIndex
to get the index of the card in the cards
array. We create a card object using the values received from the action, and put it into the cards
array.
Create a New Cards Array
When you write a reducer, you don't want to change the existing state object. You want to create a new state object and return it. If you just grab a reference to the cards array from state and start adding or deleting cards from it, you could cause some difficult to track down bugs. So instead of doing that, you want to make a copy of the array, then change the copy.
In the save
case, we create a new array using Array.filter
. Then we work with that array. In the delete
case that we'll write later, we'll use the spread operator to create a new array.
const newCards = cards.filter(v => !!v.question);
This line of code is doing a couple of things. cards.filter
creates a new array. !!
is the cast to boolean operator. So it casts any value to true or false.
The function v => !!v.question
means that any card with a question that is 'falsy' will be filtered out of the array. I wrote this in here to clear out some example cards that I had written that didn't have questions, which caused some problems with the app. I have left it in here as an example of how you can prevent poorly formed objects from reaching your components and causing a crash.
case 'save' :{
const { cards } = state;
const { answer, question, subject, } = action;
//get the index of the card with this question
//if there is no existing card with that question
//index will be -1
const index = cards
.findIndex(card => card.question === question);
//A card object with the values received in the action
const card = {
answer,
question,
subject
} as Card;
//create a new array of cards
//filter out 'invalid' cards that don't have a question
const newCards = cards.filter(v => !!v.question);
//if the question already exists in the array
if (index > -1) {
//assign the card object to the index
newCards[index] = card;
} else {
//if the question does not already exist in the array
//add the card object to the array
newCards.push(card);
}
//return new context
return {
...state,
cards: newCards
}
}
Look at the code above. Do you understand how it works? Does it prevent adding a card with no question? How would you rewrite it to make adding a card with no question impossible?
Do you think it is actually possible for the user to use the Writing
component to add a card with no question? Or would the question always at least be an empty string?
Run the Tests For Writing
Use Jest commands to run the tests for Writing
.
They pass!
Loading the Current Card into Writing
We want the Input
and TextArea
s in the Form
to automatically load the values of the current card. To do that, we will make them into controlled components. Remember that controlled components are components that take their values as a prop that is held in state. When the value of a controlled component is changed, it invokes a function to handle the change. The useState
hook will let us make the Input
and TextArea
s into controlled components.
Writing Test 6: Loads Current Card
File: src/scenes/Writing/index.test.tsx
Will Match: src/scenes/Writing/complete/test-6.tsx
Write a test for loading the current card. We'll write the same withoutLineBreaks
function that we've written before. Pull a reference to the current card from initialState
.
There is always a danger of introducing errors into your tests when you use references to objects instead of using hardcoded values. Especially when you reference objects that are imported from other code.
What assertion would you add to this test to make sure that you know if the variable card
is undefined? How about assertions that would warn you if it was missing the question, subject, or answer?
//when you load writing, it loads the current card
it('loads the current card', () => {
//the question and answer may have linebreaks
//but the linebreaks don't render inside the components
//this function strips the linebreaks out of a string
//so we can compare the string to text content that was rendered
const withoutLineBreaks = (string: string) => string.replace(/\s{2,}/g, " ")
//we'll test with the first card
const card = initialState.cards[initialState.current];
const { getByTestId } = renderWriting();
//a textarea
const answer = getByTestId('answer');
expect(answer).toHaveTextContent(withoutLineBreaks(card.answer));
//a textarea
const question = getByTestId('question');
expect(question).toHaveTextContent(withoutLineBreaks(card.question));
// semantic-ui-react Input. It renders an input inside of a div
//so we need the first child of the div
//and because it's an input, we test value not textcontent
const subject = getByTestId('subject').children[0];
expect(subject).toHaveValue(card.subject);
});
Pass Writing Test 6: Loads Current Card
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/index-7.tsx
The useState
hook lets us store the value of the cards. Notice the starting value of the useState
hooks is an expression using the ternary operator. If card
evaluates to true, then the starting value will be a property of the card
object. If card
evaluates to false, the starting value will be an empty string.
const Writing = () => {
const { cards, current, dispatch } = useContext(CardContext);
//a reference to the current card object
const card = cards[current];
//useState hooks to store the value of the three input fields
const [question, setQuestion ] = useState(card ? card.question : '')
const [answer, setAnswer ] = useState(card ? card.answer : '')
const [subject, setSubject ] = useState(card ? card.subject : '');
return (
Make the Input
and the TextAreas
into controlled components. Notice the onChange function is different for Inputs
and TextAreas
.
In the onChange function for question
, you can see that we use Object Destructuring on the second argument and get the property 'value' out of it. Then we call the setQuestion function with value. There's an exclamation point after value but before the call to the toString
method.
onChange={(e, { value }) => setQuestion(value!.toString())}
The exclamation point is the TypeScript non null assertion operator. The non null assertion operator tells TypeScript that even though the value could technically be null, we are sure that the value will not be null. This prevents TypeScript from giving you an error message telling you that you are trying to use a value that could possibly be null in a place where null will cause an error.
<Header as='h3'>Subject</Header>
<Input data-testid='subject' name='subject'
onChange={(e, { value }) => setSubject(value)}
value={subject}/>
<Header as='h3' content='Question'/>
<TextArea data-testid='question' name='question'
onChange={(e, { value }) => setQuestion(value!.toString())}
value={question}/>
<Header as='h3' content='Answer'/>
<TextArea data-testid='answer' name='answer'
onChange={(e, { value }) => setAnswer(value!.toString())}
value={answer}/>
<Button content='Save'/>
</Form>
)};
New Card
We need a button that lets the user write a new card. The way the new card button will work is it will dispatch a new
action to the CardContext
. The CardContext
reducer
will handle the new
action and set current
to -1. When current is -1, Writing
will will try to find the current card. The current card will evaluate to false, and all the controlled components in the Writing
Form
will be cleared out.
Writing Test 7: Has a New Card Button
File: src/scenes/Writing/index.test.tsx
Will Match: src/scenes/Writing/complete/test-7.tsx
Make a describe block named 'the new card button.' Test for an element with the text 'new.' Use the getByText
method.
describe('the new card button', () => {
//there's a button to create a new card
it('has a new button', () => {
const { getByText } = renderWriting();
const newButton = getByText(/new/i);
expect(newButton).toBeInTheDocument();
});
//when you click the new button the writing component clears its inputs
});
Pass Writing Test 7: Has a New Card Button
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/index-8.tsx
Wrap the form in a container. Notice that the container has a style prop. The style prop lets us apply css styles to React components. This Container
is 200 pixels away from the left edge of the screen. This gives us space for the Selector
component that we'll write later.
Put the New Card
button inside the Container
.
<Container style={{position: 'absolute', left: 200}}>
<Button content='New Card'/>
<Form
onSubmit={(e: React.FormEvent<HTMLFormElement>) => {
//Rest of The Form goes here
</Form>
</Container>
Writing Test 8: New Card Button Clears Inputs
File: src/scenes/Writing/index.test.tsx
Will Match: src/scenes/Writing/complete/test-8.tsx
When the user clicks 'New Card' we want to give them an empty Writing
component to work in. Write this test inside the new card describe block. We expect the textContent of the TextArea
s to be falsy. We expect the Input
not to have value. This is due to the difference in the way the components work.
//when you click the new button the writing component clears its inputs
it('when you click the new card button the writing component clears its inputs', () => {
const { getByText, getByTestId } = renderWriting();
const answer = getByTestId('answer');
expect(answer.textContent).toBeTruthy();
const question = getByTestId('question');
expect(question.textContent).toBeTruthy();
const subject = getByTestId('subject').children[0];
expect(subject).toHaveValue();
const newButton = getByText(/new/i);
fireEvent.click(newButton);
expect(answer.textContent).toBeFalsy();
expect(question.textContent).toBeFalsy();
expect(subject).not.toHaveValue();
})
Types: Add New to CardActionType
File: src/types.ts
Will Match: src/complete/types-7.ts
Add 'new' to CardActionTypes. Add a 'new' action to CardAction.
//the types of action that the reducer in CardContext will handle
export enum CardActionTypes {
new = 'new',
next = 'next',
save = 'save'
};
export type CardAction =
//clears the writing component
| { type: CardActionTypes.new }
//moves to the next card
| { type: CardActionTypes.next }
//saves a card
| { type: CardActionTypes.save, answer: string, question: string, subject: string }
Work on Passing Writing Test 8: New Card Button Clears Inputs
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/index-9.tsx
Add the Function to Dispatch the New Action to the New Card Button
<Button content='New Card' onClick={() => dispatch({type: CardActionTypes.new})}/>
CardContext Test 3: Handling 'New' Action in the CardContext Reducer
File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-9.tsx
We'll write our test inside the 'CardContext reducer' describe block.
Write a comment for the test we are going to write. New will just set current to -1, which won't return a valid card from cards.
//new action returns current === -1
Write the test.
//new action returns current === -1
it('new sets current to -1', () => {
//declare CardAction with type of 'new'
const newAction: CardAction = { type: CardActionTypes.new };
//create a new CardState with current === 0
const zeroState = {
...initialState,
current: 0
};
//pass initialState and newAction to the reducer
expect(reducer(zeroState, newAction).current).toEqual(-1);
});
Pass CardContext Test 3: Handling 'New' Action in the CardContext Reducer
File: src/services/CardContext/index.tsx
Will Match: src/services/CardContext/complete/index-6.tsx
This is the simplest case we'll write. Add it to the switch statement inside the reducer
.
case 'new': {
return {
...state,
current: -1
}
}
Ok, now we are ready to make Writing
clear out its inputs when the New Card
button is clicked.
Pass Writing Test 8: New Card Button Clears Inputs
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/index-10.tsx
//a function that sets all the states to empty strings
const clearAll = useCallback(
() => {
setQuestion('');
setAnswer('');
setSubject('');
}, [
setQuestion,
setAnswer,
setSubject
]);
//a useEffect hook to set the state to the current card
useEffect(() => {
if (!!card) {
const { question, answer, subject } = card;
setQuestion(question);
setAnswer(answer);
setSubject(subject);
} else {
clearAll();
};
}, [
card,
clearAll
]);
return (
Now writing will clear its inputs when the New Card button is clicked.
Run the app. Try it out. Open the Writing scene. Click 'New Card.' The inputs will clear. But what happens if you click back to Answering from a new card?
It crashes! Let's fix that.
Fix the Crash When Switching From New Card to Answering
Answering uses Object Destructuring to get the question out of the card at the current index in cards. But the new
action sets current to -1, and -1 isn't a valid index. cards[-1]
is undefined, and you can't use Object Destructuring on an undefined value.
How would you fix this problem?
We could rewrite Answering
to do something else if the current index does not return a valid card. We could display an error message, or a loading screen. But what we are going to do is change the NavBar
. We'll make the NavBar
dispatch a next
action to CardContext
if the user tries to navigate to Answering
when current is -1. CardContext
will process the next
action and return a valid index for a card.
NavBar Test 1: Clicking Answer When Current Index is -1 Dispatches Next
File: src/components/NavBar/index.test.tsx
Will Match: src/components/NavBar/test-6.tsx
For this test, we'll use jest.fn() to make a mock dispatch function. Remember that using jest.fn() allows us to see whether dispatch has been called, and what the arguments were.
negativeState
is a CardState
with current set to negative 1. Add in the mock dispatch function.
find the Answering
button and click it. Then expect the mock dispatch function to have been called with a next
action.
it('clicking answer when current index is -1 dispatches next action', () => {
const dispatch = jest.fn();
const negativeState = {
...initialState,
current: -1,
dispatch
};
const { getByText } = render(
<CardContext.Provider value={negativeState}>
<NavBar
showScene={SceneTypes.answering}
setShowScene={(scene: SceneTypes) => undefined}/>
</CardContext.Provider>)
const answering = getByText(/answer/i);
fireEvent.click(answering);
expect(dispatch).toHaveBeenCalledWith({type: CardActionTypes.next})
});
Pass NavBar Test 1: Clicking Answer When Current Index is -1 Dispatches Next
File: src/components/NavBar/index.tsx
Will Match: src/components/NavBar/index-6.tsx
Import useContext
.
import React, { useContext } from 'react';
Import CardContext
and CardActionTypes
.
import { CardContext } from '../../services/CardContext';
import { CardActionTypes } from '../../types';
Get current and dispatch from the CardContext
.
Change the onClick function for the 'Answer Flashcards' Menu.Item
. Make it dispatch a next
action if current
is -1.
const NavBar = ({
setShowScene,
showScene
}:{
setShowScene: (scene: SceneTypes) => void,
showScene: SceneTypes
}) => {
const { current, dispatch } = useContext(CardContext);
return(
<Menu data-testid='menu'>
<Menu.Item header content='Flashcard App'/>
<Menu.Item content='Answer Flashcards'
active={showScene === SceneTypes.answering}
onClick={() => {
current === -1 && dispatch({type: CardActionTypes.next});
setShowScene(SceneTypes.answering)
}}
/>
<Menu.Item content='Edit Flashcards'
active={showScene === SceneTypes.writing}
onClick={() => setShowScene(SceneTypes.writing)}
/>
</Menu>
)};
Now the app won't crash anymore when you switch from Writing a new card back to Answering.
Deleting Cards
Now it is time to make deleting cards work. To make deleting work, we will
- Make the new test for the deleting cards button in
Writing
- Add delete to
CardActionTypes
in types.ts - Write the onSubmit function for the
Form
inWriting
- Make a new test for handling
delete
in theCardContext
reducer
- Add a new case 'delete' to the
CardContext
reducer
Writing Test 9: Has a Delete Card Button
File: src/scenes/Writing/index.test.tsx
Will Match: src/scenes/Writing/complete/test-9.tsx
Make a describe block 'the delete card button.'
describe('the delete card button', () => {
//there's a button to delete the current card
it('has a delete button', () => {
const { getByText } = renderWriting();
const deleteButton = getByText(/delete/i);
expect(deleteButton).toBeInTheDocument();
});
//when you click the delete button the card matching the question in the question textarea is deleted from the array cards
});
Pass Writing Test 9: Has a Delete Card Button
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/index-11.tsx
<Button content='New Card' onClick={() => dispatch({type: CardActionTypes.new})}/>
<Button content='Delete this Card'/>
<Form
Writing Test 10: Clicking Delete Card Button Deletes Current Card
File: src/scenes/Writing/index.test.tsx
Will Match: src/scenes/Writing/complete/test-10.tsx
We use the helper component LastCard
to test if the card gets removed from the cards
array.
//when you click the delete button the card matching the question in the question textarea is deleted from the array cards
it('clicking delete removes the selected question', () => {
const lastIndex = initialState.cards.length - 1;
const lastState = {
...initialState,
current: lastIndex
};
const lastQuestion = initialState.cards[lastIndex].question;
const { getByTestId, getByText } = renderWriting(lastState, <LastCard />);
const lastCard = getByTestId('lastCard');
expect(lastCard).toHaveTextContent(lastQuestion);
//call this deleteButton, delete is a reserved word
const deleteButton = getByText(/delete/i);
fireEvent.click(deleteButton);
expect(lastCard).not.toHaveTextContent(lastQuestion);
});
Types.ts: Add Delete to CardActionType
File: src/types.ts
Will Match: src/complete/types-8.ts
Add 'delete' to CardActionTypes
. Add a delete
action to CardAction
. The delete
action takes a question string. When we handle the action in the CardContext
reducer
we'll use the question to find the card in the cards array.
//the types of action that the reducer in CardContext will handle
export enum CardActionTypes {
delete = 'delete',
new = 'new',
next = 'next',
save = 'save'
};
export type CardAction =
//deletes the card with matching question
| { type: CardActionTypes.delete, question: string }
//clears the writing component
| { type: CardActionTypes.new }
//moves to the next card
| { type: CardActionTypes.next }
//saves a card
| { type: CardActionTypes.save, answer: string, question: string, subject: string }
Add the Function to Dispatch the 'Delete' Action to the Delete Card Button
File: src/scenes/Writing/index.tsx
Will Match: src/scenes/Writing/complete/index-12.tsx
<Button content='Delete this Card' onClick={() => dispatch({type: CardActionTypes.delete, question})}/>
CardContext Test 4: CardContext Reducer Handles Delete Action
File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-9.tsx
We'll write the test inside the 'CardContext reducer' describe block.
Write a quote for each test we are going to write. Delete will remove the card with the matching question from the array cards.
Write the test. Use findIndex
to check the cards
array for a card with the deleted question. When findIndex
doesn't find anything, it returns -1.
//delete removes card with matching question
it('delete removes the card with matching question', () => {
const { question } = initialState.cards[initialState.current];
const deleteAction: CardAction = {
type: CardActionTypes.delete,
question
};
const { cards } = reducer(initialState, deleteAction);
//it's gone
expect(cards.findIndex(card => card.question === question)).toEqual(-1);
});
Pass CardContext Test 4: CardContext Reducer Handles Delete Action
File: src/services/CardContext/index.tsx
Will Match: src/services/CardContext/complete/index-7.tsx
Add a new case 'delete' to the CardContext
reducer
. Add delete
to the switch statement. I like to keep the cases in alphabetical order. Except for default, which has to go at the bottom.
case 'delete': {
let { cards, current } = state;
//the question is the unique identifier of a card
const { question } = action;
///creating a new array of cards by spreading the current array of cards
const newCards = [...cards];
//finds the index of the target card
const index = newCards.findIndex(card => card.question === question);
//splice removes the target card from the array
newCards.splice(index, 1);
//current tells the components what card to display
//decrement current
current = current -1;
//don't pass -1 as current
if(current < 0) current = 0;
//spread the old state
//add the new value of current
//and return the newCards array as the value of cards
return {
...state,
current,
cards: newCards
}
}
CardContext passes the test.
The delete button in Writing works too!
Great! Now what happens when you delete all the cards and click back to the Answering screen? How would you fix it?
Next Post: Saving and Loading
In the next post we will write the code to save and load cards to the browser's localStorage. In the post after that we will write the Selector that lets the user choose which card to look at.
Posted on January 17, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.