DEV Community

loading...
Cover image for Writing New Cards

Writing New Cards

jacobwicks
Legal Aid Lawyer turned fullstack developer. Passionate about improving the user experience by developing efficient and beautiful apps.
・27 min read

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>

Input Pass

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>

TextArea Pass

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>

Answer Pass

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>

Save Button Pass

Run the app, select Edit Flashcards and you will see Writing on screen.

Writing Component
Now it looks good.

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);
});

Save Fails

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);
    });

Alt Text

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
        }
    }

Reducer Passes

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.
Writing Passing Save
They pass!

Loading the Current Card into Writing

We want the Input and TextAreas 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 TextAreas 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>
)};

Writing Loads Current Card

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>

Has a New Card Button

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 TextAreas 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
            }
          }

Case New Passes

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.
Writing Pass New

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

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})
});

NavBar Fail

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>
)};

NavBar Pass
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 in Writing
  • Make a new test for handling delete in the CardContext 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

Delete Button Pass

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.

CardContext Delete Passes

The delete button in Writing works too!

Writing Delete Button Passes

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.

Discussion (0)