DEV Community

Cover image for Card Selector
jacobwicks
jacobwicks

Posted on • Edited on

Card Selector

In this post we are going to build the Selector component. The Selector will let the user select cards and subjects. We will add the new CardAction types that the Selector will need. We will also write the code for CardContext to handle those new actions.

Selector in Action

User Stories

  • The user sees a card and wants to change the answer. The user opens the card editor. The user selects the the card that they want to change. The user changes an that card and saves their changes.

  • The user deletes a card.

  • The user loads the app. The user sees all the cards they have written. The user selects the subject that they want to study. The program displays the cards in that subject in random order.

Features

  • a way the user can select cards
  • To delete a card, you need to indicate which card you want to delete
  • A button that displays subjects, and allows the user to select the subject

The Selector Component

The Selector will let the user choose what card to look at. Selector will work in both scenes. We will put Selector on the left side of the screen. After we make Selector we are done building components for the app!

Selector

Where to Store the Data for Selector?

The features listed above require us to track what subject or subjects the user wants to display. We don't have a place to track subjects. So we need to add it somewhere.

How would you solve the problem of storing subjects? The subject of each question is a string. What data structure would you use to store 0, 1, or many strings? Where would you keep it?

We are going to store the subjects in an array of strings. We are going to call this array show. We'll call the array show because it tells us what subjects to show the user. We are going to store show in the CardState that we keep in CardContext. We need to be able to refer to this array to write our tests, so we need to add it to the definition of CardState before we write the tests for CardContext.

We'll dispatch actions to the CardContext to add a subject to show, remove a subject from show, and to clear all subjects out of show.

Add Show to Types.ts

File: src/types.ts
Will Match: src/complete/types-9.ts

Add show : string[] to CardState.

//the shape of the state that CardContext returns
export interface CardState {

    //the array of Card objects
    cards: Card[],

    //the index of the currently displayed card object
    current: number,

    //the dispatch function that accepts actions
    //actions are handled by the reducer in CardContext
    dispatch: (action: CardAction) => void

    //the array of subjects currently displayed
    show: string[]
};
Enter fullscreen mode Exit fullscreen mode

Before we write the actions, change getInitialState in CardContext/services so that it returns a show array.

Change getInitialState in CardContext services

File: src/services/CardContext/services/index.ts
Will Match: src/services/CardContext/services/complete/index-2.ts

Add show : [] to the object returned by getInitialState.

//a function that loads the cards from localStorage
//and returns a CardState object
export const getInitialState = () => ({
    //the cards that are displayed to the user
    //if loadedCards is undefined, use cards
    cards: loadedCards ? loadedCards : cards,

    //index of the currently displayed card
    current: 0,

    //placeholder for the dispatch function
    dispatch: (action:CardAction) => undefined,

    //the array of subjects to show the user
    show: []
} as CardState);
Enter fullscreen mode Exit fullscreen mode

The New Actions

We need some new CardActionTypes. We need CardContext to do new things that it hasn't done before. We'll add

  • select - to select a card
  • showAdd - add a subject to the show array
  • showAll - clear the show array so that we show all subjects
  • showRemove - remove a subject from the show array

Add Actions to CardActionTypes

File: src/types.ts
Will Match: src/complete/types-10.ts

Add select, showAdd, showAll, and showRemove to the enum CardActionTypes.

export enum CardActionTypes {
    delete = 'delete',
    next = 'next',
    new = 'new',
    save = 'save',
    select = 'select',
    showAdd = 'showAdd',
    showAll = 'showAll',
    showRemove = 'showRemove'
}
Enter fullscreen mode Exit fullscreen mode

Now add the actions to the union type CardAction:

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 }

    //selects card
    | { type: CardActionTypes.select, question: string }

    //saves a card
    | { type: CardActionTypes.save, answer: string, question: string, subject: string }

    //adds a subject to the array of subjects to show
    | { type: CardActionTypes.showAdd, subject: string }

    //shows all subjects
    | { type: CardActionTypes.showAll }

    //removes a subject from the array of subjects to show
    | { type: CardActionTypes.showRemove, subject: string } 
Enter fullscreen mode Exit fullscreen mode

All right. Now the actions have been defined. Next we will write the tests and the code for the CardContext reducer to handle the actions.

CardContext reducer Tests 1-2: Select Actions

File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-11.tsx

We'll test if the reducer handles select, showAdd, showAll, and showRemove actions.

Write a comment for each test you plan to make:

//select should set the current index to the index of the selected card
//if the question is not found, returns state
//showAdd should add a single subject to the show array
//if the subject is already in show, the subject will not be added
//showAll should clear the show array
//showRemove should remove a single subject from the show array
Enter fullscreen mode Exit fullscreen mode

Make some describe blocks inside the 'CardContext reducer' block.

Name the first block 'select actions change current to the index of the card with the selected question.'

Name the second block 'Actions for showing subjects.'

   describe('select actions change current to the index of the card with the selected question', () => {

    //select should set the current index to the index of the selected card
    //if the question is not found, returns state
 });

    //actions that affect the show array
    describe('Actions for showing subjects', () => {
        //show add adds subjects to the array
        describe('showAdd', () => {
            //showAdd should add a single subject to the show array
            //if the subject is already in show, the subject will not be added

        });

        //showAll should clear the show array

        //showRemove should remove a single subject from the show array
    });
Enter fullscreen mode Exit fullscreen mode

Write the test for the select case. Make a card thirdCard. Make a CardState with three cards in it threeCardState. Put thirdCard in cards at the last index.

it('select changes current to the index of the card with the selected question', () => {
        const answer = 'Example Answer';
        const question = 'Example Question';
        const subject = 'Example Subject';

        const thirdCard = {
            answer,
            question,
            subject
        };

        const threeCardState = {
            ...initialState,
            cards: [
                ...initialState.cards, 
                thirdCard
            ],
            current: 0
        };

        expect(threeCardState.cards.length).toBe(3);

        const selectAction = {
            type: CardActionTypes.select,
            question
        };

        const { current } = reducer(threeCardState, selectAction);

        expect(current).toEqual(2);
    });
Enter fullscreen mode Exit fullscreen mode

Also write the test for a question that is not found in cards.

//if the question is not found, returns state
        it('if no card matches the question, returns state', () => {
            const question = 'Example Question';

            expect(initialState.cards.findIndex(card => card.question === question)).toBe(-1);

            const selectAction = {
                type: CardActionTypes.select,
                question
            };

            const state = reducer(initialState, selectAction);

            expect(state).toEqual(initialState);
        });
Enter fullscreen mode Exit fullscreen mode

Select Fail

Note that the test for returning state when no question is found passes. This test passes because there is no case to handle the select action yet. So the action is handled by the default case. The default case returns state.

Pass CardContext reducer Tests 1-2: Select Actions

File: src/services/CardContext/index.tsx
Will Match: src/services/CardContext/complete/index-10.tsx

Add the select case to the reducer.

    case 'select' : {
        const { cards } = state;
        const { question } = action;

        if (!question) return state;            

        const current = cards.findIndex(card => card.question === question);

        if (current < 0 ) return state;

        return {
            ...state,
            current
        }
    }
Enter fullscreen mode Exit fullscreen mode

Select Pass

CardContext reducer Tests 3-4: showAdd Actions

File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-12.tsx

The first test looks at the resulting show array and expects the item at index 0 to equal the added subject.

The second test uses the toContain assertion to check if the array contains the subject.

//show add adds subjects to the array
        describe('showAdd', () => {
            //showAdd should add a single subject to the show array
            it('adds the selected subject to the show array', () => {
                expect(initialState.show).toHaveLength(0);

                const subject = 'Example Subject';

                const showAddAction = {
                    type: CardActionTypes.showAdd,
                    subject
                };

                const { show } = reducer(initialState, showAddAction);

                expect(show).toHaveLength(1);
                expect(show[0]).toEqual(subject);
            });

            //if the subject is already in show, the subject will not be added
            it('if the selected subject is already in the array, the subject will not be added', () => {
                const subject = 'Example Subject';

                const showWithSubjects = [
                    subject,
                    'Another Subject'
                ];

                const showState = {
                    ...initialState,
                    show: showWithSubjects
                };

                const showAddAction = {
                    type: CardActionTypes.showAdd,
                    subject
                };

                const { show } = reducer(showState, showAddAction);

                expect(show).toHaveLength(2);
                expect(show).toContain(subject);
            })
        });
Enter fullscreen mode Exit fullscreen mode

ShowAdd Fail

Pass CardContext reducer Tests 3-4: showAdd Actions

File: src/services/CardContext/index.tsx
Will Match: src/services/CardContext/complete/index-11.tsx

Use the Array.includes method to figure out if the subject is already in show. Array.includes returns a boolean value.

       case 'showAdd': {
            const { subject } = action;
            const show = [...state.show];

            !show.includes(subject) && show.push(subject);

            return {
                ...state,
                show
            }
        }
Enter fullscreen mode Exit fullscreen mode

ShowAdd Pass

CardContext reducer Test 5: showAll Actions

File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-13.tsx

//showAll should clear the show array
        it('showAll returns empty show array', () => {
            const showWithSubjects = [
                'Example Subject',
                'Another Subject'
            ];

            const showState = {
                ...initialState,
                show: showWithSubjects
            };

            const showAllAction = { type: CardActionTypes.showAll };

            const { show } = reducer(showState, showAllAction);

            expect(show).toHaveLength(0);
        });
Enter fullscreen mode Exit fullscreen mode

ShowAll Fail

Pass CardContext reducer Test 5: showAll Actions

File: src/services/CardContext/index.tsx
Will Match: src/services/CardContext/complete/index-12.tsx

To show all subjects, clear show array.

        case 'showAll': {
            return {
                ...state,
                show: []
            }
        }
Enter fullscreen mode Exit fullscreen mode

ShowAll Pass

CardContext reducer Test 6: showRemove Actions

File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-14.tsx

        //showRemove should remove a single subject from the show array
        it('showRemove removes the subject from show', () => {
            const subject = 'Example Subject';

                const showWithSubjects = [
                    subject,
                    'Another Subject'
                ];

                const showState = {
                    ...initialState,
                    show: showWithSubjects
                };

                const showRemoveAction = {
                    type: CardActionTypes.showRemove,
                    subject
                };

                const { show } = reducer(showState, showRemoveAction);

                expect(show).toHaveLength(1);
                expect(show).not.toContain(subject);
        });
Enter fullscreen mode Exit fullscreen mode

ShowRemove Fail

Pass CardContext reducer Test 6: showRemove Actions

File: src/services/CardContext/index.tsx
Will Match: src/services/CardContext/complete/index-13.tsx

Use Array.filter to remove the subject from show.

        case 'showRemove': {
            const { subject } = action;
            const show = state.show.filter(subj => subj !== subject);

            return {
                ...state,
                show
            }
        }
Enter fullscreen mode Exit fullscreen mode

ShowRemove Pass

Now the reducer in CardContext handles all the actions that we need to make the Selector work.

Making the Selector

The Selector is the last component we'll make for the Flashcard App. The Selector will let the user select cards that they want to see. The Selector will also let the user select subjects that they want to see.

As always, we'll use TDD to write the tests and the code.

Choose components

To let the user choose the questions we need to show the questions to the user. We want the user to be able to choose a single question and see it. We also want to let the user choose one or many subjects. And the user needs to be able to clear the list of subjects when they want to see cards from all the subjects at once.

We are going to use the Sidebar and the Menu components from Semantic UI React. We will use these two components together to make a vertical menu that appears on the left side of the screen.

The Sidebar can hold Menu Items. We want to display a Menu Item for each subject, and when the user clicks on a subject, we will show the user a Menu Item for each card that has that subject. The Menu Item will show the question from the card. When the user clicks on a question we'll dispatch a select action to CardContext so that we can display that question to the user.

Decide what to test

File: src/components/Selector/index.test.tsx
Will Match: src/components/Selector/complete/test-1.tsx

We'll test if the Sidebar shows up. We expect to see Menu Items for each card subject inside the sidebar. Clicking a subject should expand that subject and show all the cards that have that subject. Clicking a card should select that card and set current index in CardContext.

Write a comment for each test you plan to make:

//there is a sidebar
//the sidebar has a menu item that says 'subjects'
//clicking the 'subjects' menu item clears the selected subjects so the app will shows cards from all subjects
//the sidebar has menu items in it
//a menu item appears for each subject in the array cards in CardContext
//clicking on a menu item for a subject selects that subject
//clicking on a menu item for a subject expands that subject and shows a menu item with the question for each card in that subject
//clicking on a menu item for a card question selects that card
Enter fullscreen mode Exit fullscreen mode

Imports and afterEach.

import React, { useContext } from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { CardContext, CardProvider, initialState } from '../../services/CardContext';
import Selector from './index';
import { Card, CardState } from '../../types';

afterEach(cleanup);
Enter fullscreen mode Exit fullscreen mode

A helper component DisplaysCurrent to display the value of current and show. We'll use Array.map to turn the array show into an array of divs that each contain a single subject. React requires child components in an array to have a key. So each subject div gets a key prop.

const DisplaysCurrent = () => {
    const { current, show } = useContext(CardContext);
    return(
        <div>
            <div data-testid='current'>{current}</div>
            <div data-testid='show'>
                {show.map(subject => <div key={subject}>{subject}</div>)}
            </div>
        </div>
    ) 
};
Enter fullscreen mode Exit fullscreen mode

A helper function renderSelector to render the Selector inside of CardProvider. Accepts an optional testState. Accepts an optional child component.

const renderSelector = (
    testState?: CardState, 
    child?: JSX.Element 
    ) => render(
    <CardProvider testState={testState}>
        <Selector/>
        {child}
    </CardProvider>
);
Enter fullscreen mode Exit fullscreen mode

Selector Test 1: Has a Sidebar

File: src/components/Selector/index.test.tsx
Will Match: src/components/Selector/complete/test-1.tsx

//there is a sidebar
it('has a sidebar', () => {
    const { getByTestId } = renderSelector();
    const sidebar = getByTestId('sidebar');
    expect(sidebar).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

This test fails because we haven't made the Selector yet.

Pass Selector Test 1: Has a Sidebar

File: src/components/Selector/index.tsx
Will Match: src/components/Selector/complete/index-1.tsx

Imports. We'll use all of these eventually.

import React, { useContext } from 'react';
import {
    Menu,
    Sidebar
} from 'semantic-ui-react';
import { CardContext } from '../../services/CardContext';
import { CardActionTypes } from '../../types';
Enter fullscreen mode Exit fullscreen mode

Make the Selector component.

const Selector = () => {
    return (
        <Sidebar
        as={Menu}
        data-testid='sidebar'
        style={{top: 50}}
        vertical
        visible
        width='thin'
      >
      </Sidebar>
    )    
};

export default Selector;
Enter fullscreen mode Exit fullscreen mode

Selector Has a Sidebar

Selector Test 2: Has Subjects Menu Item

File: src/components/Selector/index.test.tsx
Will Match: src/components/Selector/complete/test-2.tsx

Make a describe block named 'the subjects menu item.' We'll test for a menu item that says subjects.

describe('the subjects menu item', () => {
    //there is a menu item that says 'subjects'
        it('has a subjects menu item', () => {
            const { getByText } = renderSelector();
            //the first menu item in the selector says 'Subjects' on it
            //if we can find that text, we know the sidebar is showing up
            const selector = getByText(/subjects/i);
            expect(selector).toBeInTheDocument();
        });

        //clicking the 'subjects' menu item clears the selected subjects so the app will shows cards from all subjects    
});
Enter fullscreen mode Exit fullscreen mode

Menu item fail

Pass Selector Test 2: Has Subjects Menu Item

File: src/components/Selector/index.tsx
Will Match: src/components/Selector/complete/index-2.tsx

Make the Selector return a Menu Item that says 'Subjects.'

        <Sidebar
        as={Menu}
        data-testid='sidebar'
        style={{top: 50}}
        vertical
        visible
        width='thin'
      >
        <Menu.Item as='a'>Subjects</Menu.Item>
      </Sidebar>
Enter fullscreen mode Exit fullscreen mode

Alt Text

Selector Test 3: Clicking Subjects Menu Item Clears Show

File: src/components/Selector/index.test.tsx
Will Match: src/components/Selector/complete/test-3.tsx

In this test we render the helper component DisplaysCurrent. We can determine how many items are in the show array by looking at the div with testId 'show' in DisplaysCurrent 'children' property and counting its children.

 //clicking the 'subjects' menu item clears the selected subjects so the app will shows cards from all subjects
        it('clicking the subjects menu clears show', () => {
            const showSubjects = ['First Subject', 'Second Subject'];
            const showState = {
                ...initialState,
                show: showSubjects
            };

            const { getByText, getByTestId } = renderSelector(showState, <DisplaysCurrent />);

            const show = getByTestId('show');
            expect(show.children).toHaveLength(2);

            const subjects = getByText(/subjects/i);
            fireEvent.click(subjects);

            expect(show.children).toHaveLength(0);
        });   
Enter fullscreen mode Exit fullscreen mode

Clear Show Fail

Pass Selector Test 3: Clicking Subjects Menu Item Clears Show

File: src/components/Selector/index.tsx
Will Match: src/components/Selector/complete/index-3.tsx

Get dispatch from CardContext. Add an onClick function to the 'Subjects' Menu.Item that dispatches a showAll action to CardContext.

const Selector = () => {
    const { dispatch } = useContext(CardContext);

    return (
        <Sidebar
        as={Menu}
        data-testid='sidebar'
        style={{top: 50}}
        vertical
        visible
        width='thin'
      >
        <Menu.Item as='a' onClick={() => dispatch({type: CardActionTypes.showAll})}>Subjects</Menu.Item>
      </Sidebar>
    )    
};
Enter fullscreen mode Exit fullscreen mode

Clicking Subjects Clears Show

Selector Tests 4-7: Renders a Menu Item for Each Subject

File: src/components/Selector/index.test.tsx
Will Match: src/components/Selector/complete/test-4.tsx

There should be a menu item for each subject. We are going to test 0 cards, then use test.each to test for 1-3 cards.

Make a describe block named 'when there are cards, the sidebar has a menu item for each subject.'

//the sidebar has menu items in it
describe('when there are cards, the sidebar has a menu item for each subject', () => {
     //test 0 cards
    //test 1-3 cards with different subjects
    //1-3 cards show correct number of subject menu items
    //1-3 cards show subject menu items with correct names
});
Enter fullscreen mode Exit fullscreen mode

Test for 0 cards. Look at the children property of sidebar to figure out how many menu items are being rendered.

//the sidebar has menu items in it
describe('when there are cards, the sidebar has a menu item for each subject', () => {
     //test 0 cards
    it('when there are no cards, there is only the "subjects" menu item', () => {
        const noCards = {
            ...initialState,
            cards: []
        };

        const { getByTestId } = renderSelector(noCards);
        const sidebar = getByTestId('sidebar');

        expect(sidebar.children).toHaveLength(1);
    });
Enter fullscreen mode Exit fullscreen mode

Make a getCard function that takes a number and returns a card object. We'll use getCard to create a CardState with cards with different subjects. The expressions inside of the backticks are template literals.

//getCard returns a card object
    //the subject is the number argument as a string 
    const getCard = (number: number) => ({
        question: `${number}?`,
        answer: `${number}!`,
        subject: number.toString()
    });
Enter fullscreen mode Exit fullscreen mode

Make an array numberOfSubjects. We'll pass this array to test.each. You've already seen test.each accept an array of arrays. If you pass test.each an array of 'primitives,' like numbers or strings, test.each will treat it as an array of arrays.

    //array 1, 2, 3 will get treated as [[1],[2],[3]] by test.each
    const numberOfSubjects = [1, 2, 3];
Enter fullscreen mode Exit fullscreen mode

Test if there's a Menu Item for each subject. Make an empty array cards. Use a for loop to fill cards with Card objects by calling getCard repeatedly.

Make a CardState object named subjectState using the cards array. Then call renderSelector and test how many children sidebar is rendering.

    //test 1-3 cards with different subjects
    //1-3 cards show correct number of subject menu items
    test.each(numberOfSubjects)
    //printing the title uses 'printf syntax'. numbers are %d, not %n
    ('%d different subjects display correct number of subject menu items', 
    //name the arguments, same order as in the array we generated
    (number) => {
        //generate array of cards
        const cards : Card[] = [];

        for (let i = 1; i <= number; i++) {
            cards.push(getCard(i));
        };

        //create state with cards with subjects
        const subjectState = {
            ...initialState,
            cards
        };

        //render selector with the state with the subjects
        const { getByTestId } = renderSelector(subjectState);
        const sidebar = getByTestId('sidebar');

        expect(sidebar.children).toHaveLength(number + 1);
    });
Enter fullscreen mode Exit fullscreen mode

Test if the names are right. We can make Jest assertions inside of a for loop.

    //1-3 cards show subject menu items with correct names
    test.each(numberOfSubjects)
    ('%d different subjects display menu items with correct names', 
    (number) => {
        //generate array of cards
        const cards : Card[] = [];

        for (let i = 1; i <= number; i++) {
            cards.push(getCard(i));
        };

        //create state with cards with subjects
        const subjectState = {
            ...initialState,
            cards
        };

        //render selector with the state with the subjects
        const { getByTestId, getByText } = renderSelector(subjectState);
        const sidebar = getByTestId('sidebar');

        expect(sidebar.children).toHaveLength(number + 1);

        for (let i = 1; i <= number; i++) {
            const numberItem = getByText(i.toString());
            expect(numberItem).toBeInTheDocument();
        };

    });
Enter fullscreen mode Exit fullscreen mode

Subject Menu Item Fail

Pass Selector Tests 4-7: Renders a Menu Item for Each Subject

File: src/components/Selector/index.tsx
Will Match: src/components/Selector/complete/index-4.tsx

Get cards from CardContext.

Use Array.map to get an array subjectArray of just the subject from each card.

Create a new Set subjectSet from subjectArray. A set is an object that only holds unique values. So subjectSet will only contain one copy of each unique subject, regardless of how many times that subject appeared in subjectArray.

Use Array.from to make an array subjects out of the set object subjectSet. Mildly interesting fact that you don't need to know or understand: We could also use the spread operator to make this array, but we would have to change some TypeScript settings.

Use Array.sort to sort subjects into alphabetical order. Array.sort takes a function, uses the function to compares the objects in an array, and manipulates the array order.

Inside our sort function we cast the strings toLowerCase and use the string.localeCompare method to get the correct sort result. If you don't use toLowerCase then capitalization will result in incorrect sorting. If you don't use localeCompare then numbers won't sort correctly.

Once we have subjects, our correctly sorted array of all the unique subjects from all the cards, we use Array.map to turn subjects into Menu.Items.

const Selector = () => {
    const { cards, dispatch } = useContext(CardContext);

    const subjectArray = cards.map(card => card.subject);

    const subjectSet = new Set(subjectArray);

    const subjects = Array.from(subjectSet)
                    .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
    return (
        <Sidebar
        as={Menu}
        data-testid='sidebar'
        style={{top: 50}}
        vertical
        visible
        width='thin'
      >
          <Menu.Item as='a' onClick={() => dispatch({type: CardActionTypes.showAll})}>Subjects</Menu.Item>
        {subjects.map(subject => <Menu.Item key={subject} content={subject}/>)}
      </Sidebar>
    )    
};
Enter fullscreen mode Exit fullscreen mode

Correct Names and Numbers pass

Selector Test 8: Clicking Subject Menu Item Selects That Subject

File: src/components/Selector/index.test.tsx
Will Match: src/components/Selector/complete/test-5.tsx

We call renderSelector with the helper component DisplaysCurrent. By looking at the children of the show div, we can check what subjects are rendered before and after subject Menu.Items are clicked.

//clicking on a menu item for a subject selects that subject
it('clicking a subject item selects that subject', () => {
    const { cards } = initialState;
    expect(cards).toHaveLength(2);

    const first = cards[0];
    const second = cards[1];
    expect(first.subject).toBeTruthy();
    expect(second.subject).toBeTruthy();
    expect(first.subject).not.toEqual(second.subject);

    const { getByText, getByTestId } = renderSelector(initialState, <DisplaysCurrent />);

    const show = getByTestId('show');
    expect(show.children).toHaveLength(0);

    const firstSubject = getByText(first.subject);
    fireEvent.click(firstSubject);

    expect(show.children).toHaveLength(1);
    expect(show.children[0]).toHaveTextContent(first.subject.toString());

    const secondSubject = getByText(second.subject);
    fireEvent.click(secondSubject);

    expect(show.children).toHaveLength(2);
    expect(show.children[1]).toHaveTextContent(second.subject.toString());
});
Enter fullscreen mode Exit fullscreen mode

Click to Select Fail

Pass Selector Test 8: Clicking Subject Menu Item Selects That Subject

File: src/components/Selector/index.tsx
Will Match: src/components/Selector/complete/index-5.tsx

Let's also make the 'Subjects' menu item display how many subjects are selected. Get show from the cardContext.

    const { cards, dispatch, show } = useContext(CardContext);
Enter fullscreen mode Exit fullscreen mode

Add the expression

{!!show.length && \`: ${show.length}\`}
Enter fullscreen mode Exit fullscreen mode

to the 'Subjects' Menu.Item. !!show.length casts the length property of the show array to boolean, so if there's anything in show it will return true. && means that if the first expression returns true, the second expression will be evaluated. : ${show.length} is a template literal that will display a colon followed by the number of subjects in the show array.

Add an onClick function to the Menu.Item returned from subjects.map. The onClick function should dispatch a showAdd action.

<Sidebar
        as={Menu}
        data-testid='sidebar'
        style={{top: 50}}
        vertical
        visible
        width='thin'
      >
        <Menu.Item as='a' onClick={() => dispatch({type: CardActionTypes.showAll})}>
            Subjects{!!show.length && `: ${show.length}`}
        </Menu.Item>
        {subjects.map(subject => 
            <Menu.Item 
                content={subject}
                key={subject} 
                onClick={() => dispatch({type: CardActionTypes.showAdd, subject})}
            />)}
      </Sidebar>
Enter fullscreen mode Exit fullscreen mode

Clicking Select Pass

Subject Component

The next test for the Selector is:
//clicking on a menu item for a subject expands that subject and shows a menu item with the question for each card in that subject

We are making a Subject component that will do all of that.

Features of Subject

  • Displays a subject to user
  • clicking on subject expands subject to show each card in subject
  • clicking on a card selects that card
  • clicking on an expanded subject deselects that subject and collapses the subject, hiding the cards in that subject

What to test:

Write a comment for each test.

//displays the subject as a menu item
//when a menu item is clicked clicked it should expand to show a menu item for each card/question in the subject
//if the subject is already expanded when it is clicked then it should collapse
//clicking a card menuItem selects the card
Enter fullscreen mode Exit fullscreen mode

Subject Test 1: Displays Subject as Menu Item

File: src/components/Selector/components/Subject/index.test.tsx
Will Match: src/components/Selector/components/Subject/complete/test-1.tsx

import React, { useContext } from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { CardContext, CardProvider, initialState } from '../../../services/CardContext';
import Subject from './index';
import { CardState } from '../../../types';

afterEach(cleanup);
Enter fullscreen mode Exit fullscreen mode
const renderSubject = (
    subject: string,
    testState?: CardState, 
    child?: JSX.Element 
    ) => render(
    <CardProvider testState={testState}>
        <Subject subject={subject}/>
        {child}
    </CardProvider>
);
Enter fullscreen mode Exit fullscreen mode

The test

//displays the subject as a menu item
it('shows the subject on screen', () => {
    const subject = initialState.cards[0].subject;
    const { getByText } = renderSubject(subject);
    const subjectDisplay = getByText(subject);
    expect(subjectDisplay).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Pass Subject Test 1: Displays Subject as Menu Item

File: src/components/Selector/components/Subject/index.tsx
Will Match: src/components/Selector/components/Subject/complete/index-1.tsx

Make the Subject component include a Menu.Item.

import React, { Fragment, useContext } from 'react';
import { Icon, Menu } from 'semantic-ui-react';
import { CardContext } from '../../../../services/CardContext';
import { CardActionTypes } from '../../../../types';

const Subject = ({
    subject
  }: {
    subject: string
  }) => <Menu.Item as='a'>
      <Icon name='list'/>
      {subject}
  </Menu.Item>

export default Subject;
Enter fullscreen mode Exit fullscreen mode

Subject Pass

Subject Tests 2-4: Clicking Subject Expands, Shows Cards

File: src/components/Selector/components/Subject/index.test.tsx
Will Match: src/components/Selector/components/Subject/complete/test-2.tsx

Make a getCard function that returns a Card object.

Make a numberOfCards array to pass to test.each. Inside test.each use a for loop to call getCards and generate a subjectState with an array of cards.

Click the subject, test how many children are rendered after the click.

Use a for loop to assert that each child card appears in the document.

describe('expanded', () => {
    //getCard returns a card object
    //the subject is always the same 
    const getCard = (number: number) => ({
        question: `${number}?`,
        answer: `${number}!`,
        subject: 'subject'
    });

    //array 1, 2, 3 will get treated as [[1],[2],[3]] by test.each
    const numberOfCards = [1, 2, 3];

    //when clicked it should expand to show a menu item for each question in the subject
    //1-3 cards show correct number of card menu items
    test.each(numberOfCards)
    //printing the title uses 'printf syntax'. numbers are %d, not %n
    ('%d different cards display correct number of card menu items', 
    //name the arguments, same order as in the array we generated
    (number) => {
        //generate array of cards
        const cards : Card[] = [];

        for (let i = 1; i <= number; i++) {
            cards.push(getCard(i));
        };

        //create state with cards with subjects
        const subjectState = {
            ...initialState,
            cards
        };

        //render selector with the state with the subjects
        const { getAllByText, getByText } = renderSubject('subject', subjectState);
        const subject = getByText('subject');
        fireEvent.click(subject);

        const questions = getAllByText(/\?/);
        expect(questions).toHaveLength(number);

        for (let i = 1; i <= number; i++) {
            const numberItem = getByText(`${i.toString()}?`);
            expect(numberItem).toBeInTheDocument();
        };

    });
});
Enter fullscreen mode Exit fullscreen mode

When Collapsed Click Expands Fail

Pass Subject Tests 2-4: Clicking Subject Expands, Shows Cards

File: src/components/Selector/components/Subject/index.tsx
Will Match: src/components/Selector/components/Subject/complete/index-2.tsx

Get cards, dispatch, and show from CardContext.

Use Array.includes to figure out if the subject is in the array show and should be expanded.

Use Array.filter to get an array of just the cards with this subject.

Declare cardsChild, an array of Menu.Items generated by using Array.map on the array subjectCards.

Put a React Fragment around the component. The Fragment gives us somewhere to render cardsChild when we want to.

When expanded is true, render cardsChild.

const Subject = ({
    subject
  }: {
    subject: string
  }) =>  {
    const { cards, dispatch, show } = useContext(CardContext);

    //true if the subject is in the array show
    const expanded = show.includes(subject);

    //use filter to pull only the cards that have this subject
    const subjectCards = cards
    .filter(card => card.subject === subject)

    //cardsChild will return an array of <Menu.Item/> components
    const cardsChild = subjectCards
    .map(card => {
      const { question } = card;
      return <Menu.Item 
              content={question}
              as='a' 
              key={question}
            />
        });

    return (
        <Fragment>
            <Menu.Item as='a' onClick={() => dispatch({type: CardActionTypes.showAdd, subject})}> 
                <Icon name='list'/>
                {subject}
            </Menu.Item>
            {expanded && cardsChild}
        </Fragment>
    )};
Enter fullscreen mode Exit fullscreen mode

When Collapsed Click Expands Pass

Subject Test 5: Clicking on a Menu Item with a Question Selects the Card With That Question

File: src/components/Selector/components/Subject/index.test.tsx
Will Match: src/components/Selector/components/Subject/complete/test-3.tsx

Make a helper component DisplaysCurrent to display the current index from CardContext. Call renderSubject with the helper component.

Find and click a card Menu.Item. Assert that current should match the index of that card in cards.

describe('Expanded', () => {
    //clicking a card menuItem selects the card
    it('clicking on a question selects the card for that question', () => {        
        const { question, subject } = initialState.cards[1];
        const showState = {
            ...initialState,
            current: 0,
            show: [subject]
        };

        const DisplaysCurrent = () => {
            const { current } = useContext(CardContext);
            return <div data-testid='current'>{current}</div>
        };

        const { getByTestId, getByText } = renderSubject(subject, showState, <DisplaysCurrent />)

        const current = getByTestId('current');
        expect(current).toHaveTextContent('0');

        const menuItem = getByText(question);
        fireEvent.click(menuItem);

        expect(current).toHaveTextContent('1'); 
    });

    //if the subject is already expanded when it is clicked then it should collapse
})
Enter fullscreen mode Exit fullscreen mode

Clicking Selects Fail

Pass Subject Test 5: Clicking on a Menu Item with a Question Selects the Card With That Question

File: src/components/Selector/components/Subject/index.tsx
Will Match: src/components/Selector/components/Subject/complete/index-3.tsx

Add an onClick function to the Menu.Item in cardChild. The onClick function should dispatch a select action to CardContext.

 <Menu.Item 
              content={question}
              as='a' 
              key={question}
              onClick={() => dispatch({type: CardActionTypes.select, question})}
            />
Enter fullscreen mode Exit fullscreen mode

Clicking Selects Pass

Subject Test 6: Clicking on an Expanded Subject Collapses that Subject

File: src/components/Selector/components/Subject/index.test.tsx
Will Match: src/components/Selector/components/Subject/complete/test-4.tsx

This test just looks for one card. How would you use test.each to test for many cards?

//if the subject is already expanded when it is clicked then it should collapse
    it('if already expanded, it collapses when clicked ', () => {
        const { subject, question } = initialState.cards[0];
        expect(subject).toBeTruthy();

        const showState = {
            ...initialState, 
            //subject is in the show array
            show: [subject]
        };

        const { getByText } = renderSubject(subject, showState);

        //because subject is in the show array, <Subject> should be expanded
        //meaning, it should show a menu item for each card in the subject
        const questionItem = getByText(question);
        expect(questionItem).toBeInTheDocument();

        const subjectItem = getByText(subject);
        fireEvent.click(subjectItem);

        expect(questionItem).not.toBeInTheDocument();
      });
Enter fullscreen mode Exit fullscreen mode

Expanded Click Collapse Fail

Pass Subject Test 6: Clicking on an Expanded Subject Collapses that Subject

File: src/components/Selector/components/Subject/index.tsx
Will Match: src/components/Selector/components/Subject/complete/index-4.tsx

Use the ternary operator to dispatch a showRemove action if the subject is expanded, and a showAdd action if the sucbject is not expanded.

    return (
        <Fragment>
            <Menu.Item as='a'
                onClick={() => expanded 
                    ? dispatch({type: CardActionTypes.showRemove, subject})
                    : dispatch({type: CardActionTypes.showAdd, subject})}> 
                <Icon name='list'/>
                {subject}
            </Menu.Item>
            {expanded && cardsChild}
        </Fragment>
Enter fullscreen mode Exit fullscreen mode

Expanded Click Collapse Pass

Refactor Subject- Change Some Implementation Details

File: src/components/Selector/components/Subject/index.tsx
Will Match: src/components/Selector/components/Subject/complete/index-5.tsx

Get current from CardContext so we can know what the current card is. Declare a const currentCard.

    const { cards, current, dispatch, show } = useContext(CardContext);

    const currentCard = cards[current];
Enter fullscreen mode Exit fullscreen mode

Use Array.sort to sort the array of cards alphabetically by question.

//use filter to pull only the cards that have this subject
    const subjectCards = cards
    .filter(card => card.subject === subject)
    //.sort will put the cards in alphabetical order by question
    .sort((a, b) => 
      a.question.toLowerCase().localeCompare(b.question.toLowerCase()))
Enter fullscreen mode Exit fullscreen mode

How would you write a test to make sure that the cards are in alphabetical order by question?

Mark the card as active if it's the current card. This will highlight the card on the screen.

    <Menu.Item 
      active={!!currentCard && question === currentCard.question}
      as='a'
      content={question}
      key={question}
      onClick={() => dispatch({type: CardActionTypes.select, question})}
     />
Enter fullscreen mode Exit fullscreen mode

Mark the subject as active if it has the subject of the current card. This will highlight the subject on the screen.

        <Fragment>
            <Menu.Item as='a'
                active={!!currentCard && currentCard.subject === subject}
                onClick={() => expanded 
                    ? dispatch({type: CardActionTypes.showRemove, subject})
                    : dispatch({type: CardActionTypes.showAdd, subject})}> 
                <Icon name='list'/>
                {subject}
            </Menu.Item>
            {expanded && cardsChild}
        </Fragment>
Enter fullscreen mode Exit fullscreen mode

Ok, Subject is done!

Selector Tests 9-12: Add Subject to Selector

File: src/components/Selector/index.test.tsx
Will Match: src/components/Selector/complete/test-6.tsx

The test for the Selector expanding to show the cards in a subject is almost the same when we use the Subject component, but now we call renderSelector.

//clicking on a menu item for a subject expands that subject and shows a menu item with the question for each card in that subject
describe('When a subject is clicked it expands, shows menu item for each card', () => {
    //getCard returns a card object
    //the subject is always the same 
    const getCard = (number: number) => ({
        question: `${number}?`,
        answer: `${number}!`,
        subject: 'subject'
    });

    //array 1, 2, 3 will get treated as [[1],[2],[3]] by test.each
    const numberOfCards = [1, 2, 3];

    //when clicked it should expand to show a menu item for each question in the subject
    //1-3 cards show correct number of card menu items
    test.each(numberOfCards)
    //printing the title uses 'printf syntax'. numbers are %d, not %n
    ('%d different cards display correct number of card menu items', 
    //name the arguments, same order as in the array we generated
    (number) => {
        //generate array of cards
        const cards : Card[] = [];

        for (let i = 1; i <= number; i++) {
            cards.push(getCard(i));
        };

        //create state with cards with subjects
        const subjectState = {
            ...initialState,
            cards
        };

        //render selector with the state with the subjects
        const { getAllByText, getByText } = renderSelector(subjectState);
        const subject = getByText('subject');
        fireEvent.click(subject);

        const questions = getAllByText(/\?/);
        expect(questions).toHaveLength(number);

        for (let i = 1; i <= number; i++) {
            const numberItem = getByText(`${i.toString()}?`);
            expect(numberItem).toBeInTheDocument();
        };
    });
});
Enter fullscreen mode Exit fullscreen mode

Selector Click Expand Fail

As is the test for clicking on a question selecting the card.

//clicking on a menu item for a card question selects that card
it('clicking on a question selects the card for that question', () => {        
    const { question, subject } = initialState.cards[1];
    const showState = {
        ...initialState,
        current: 0,
        show: [subject]
    };

    const DisplaysCurrent = () => {
        const { current } = useContext(CardContext);
        return <div data-testid='current'>{current}</div>
    };

    const { getByTestId, getByText } = renderSelector(showState, <DisplaysCurrent />)

    const current = getByTestId('current');
    expect(current).toHaveTextContent('0');

    const menuItem = getByText(question);
    fireEvent.click(menuItem);

    expect(current).toHaveTextContent('1'); 
});
Enter fullscreen mode Exit fullscreen mode

Selector Click Select Fail

Pass Selector Tests 9-11: Add Subject to Selector

File: src/components/Selector/index.tsx
Will Match: src/components/Selector/complete/index-6.tsx

Import Subject.

import Subject from './components/Subject';
Enter fullscreen mode Exit fullscreen mode

Instead of mapping to a Menu.Item, map to a Subject.

{subjects.map(subject => <Subject key={subject} subject={subject}/>)}
Enter fullscreen mode Exit fullscreen mode

Selector All Pass

Add Selector To App

Now let's add the Selector to the App so the user can use it to select subjects and cards.

App Test 1: Has Selector

File: src/App.test.tsx
Will Match: src/complete/test-6.tsx

Find the Selector's sidebar by testId.

//shows the Selector
it('shows the Selector', () => {
  const { getByTestId } = render(<App/>);
  const selector = getByTestId('sidebar');
  expect(selector).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

App Fail

Pass App Test 1: Has Selector

File: src/App.tsx
Will Match: src/complete/app-7.tsx

Import Selector.

import Selector from './components/Selector';
Enter fullscreen mode Exit fullscreen mode

Add Selector to the App.

    return (
      <CardProvider>
        <StatsProvider>
          <NavBar showScene={showScene} setShowScene={setShowScene} />
          <Selector/>
          {showScene === SceneTypes.answering && <Answering />}
          {showScene === SceneTypes.writing && <Writing/>}
        </StatsProvider>
      </CardProvider>
    )};
Enter fullscreen mode Exit fullscreen mode

Tests all pass, but the snapshot fails.
App Snapshot Fail

Update your snapshot.

Snapshot Updated

Hit a to run all the tests:

All Pass

Wow! You wrote 13 test suites and 126 tests! But I bet it only felt like 100, right? Good job!

App In Action

Next Post: Finishing Touches

In the final post, we'll write some code to shuffle the cards and display only cards from selected subjects.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.