Card Selector
jacobwicks
Posted on January 17, 2020
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.
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!
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[]
};
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);
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'
}
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 }
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
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
});
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);
});
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);
});
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
}
}
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);
})
});
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
}
}
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);
});
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: []
}
}
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);
});
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
}
}
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
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);
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>
)
};
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>
);
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();
});
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';
Make the Selector
component.
const Selector = () => {
return (
<Sidebar
as={Menu}
data-testid='sidebar'
style={{top: 50}}
vertical
visible
width='thin'
>
</Sidebar>
)
};
export default Selector;
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
});
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>
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);
});
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>
)
};
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
});
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);
});
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()
});
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];
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);
});
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();
};
});
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.Item
s.
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>
)
};
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.Item
s 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());
});
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);
Add the expression
{!!show.length && \`: ${show.length}\`}
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>
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
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);
const renderSubject = (
subject: string,
testState?: CardState,
child?: JSX.Element
) => render(
<CardProvider testState={testState}>
<Subject subject={subject}/>
{child}
</CardProvider>
);
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();
});
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;
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();
};
});
});
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>
)};
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
})
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})}
/>
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();
});
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>
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];
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()))
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})}
/>
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>
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();
};
});
});
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');
});
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';
Instead of mapping to a Menu.Item
, map to a Subject
.
{subjects.map(subject => <Subject key={subject} subject={subject}/>)}
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();
});
Pass App Test 1: Has Selector
File: src/App.tsx
Will Match: src/complete/app-7.tsx
Import Selector
.
import Selector from './components/Selector';
Add Selector
to the App.
return (
<CardProvider>
<StatsProvider>
<NavBar showScene={showScene} setShowScene={setShowScene} />
<Selector/>
{showScene === SceneTypes.answering && <Answering />}
{showScene === SceneTypes.writing && <Writing/>}
</StatsProvider>
</CardProvider>
)};
Tests all pass, but the snapshot fails.
Update your snapshot.
Hit a to run all the tests:
Wow! You wrote 13 test suites and 126 tests! But I bet it only felt like 100, right? Good job!
Next Post: Finishing Touches
In the final post, we'll write some code to shuffle the cards and display only cards from selected subjects.
Posted on January 17, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.