Skip to content
This repository has been archived by the owner on May 10, 2023. It is now read-only.

Commit

Permalink
feat: add keyboard shortcuts to Swipe Review Tool
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaelKohler committed Mar 10, 2021
1 parent 31fb562 commit e51dece
Show file tree
Hide file tree
Showing 4 changed files with 208 additions and 130 deletions.
92 changes: 28 additions & 64 deletions web/src/components/review-form.js
@@ -1,9 +1,8 @@
import React, { useState } from 'react';

import Cards from './swipecard/Cards';
import Card from "./swipecard/CardSwitcher";
import Pager from './pager';
import SubmitButton from './submit-button';
import SwipeReviewForm from './swipe-review-form';

import '../../css/review-form.css';

Expand All @@ -17,23 +16,13 @@ export default function ReviewForm({ message, useSwipeReview, sentences, onRevie
const totalPages = Math.ceil(sentences.length / PAGE_SIZE);
const lastPage = totalPages - 1;
const offset = page * PAGE_SIZE;
const currentSentences = sentences.slice(offset, offset + PAGE_SIZE);

const onSubmit = async (event) => {
event.preventDefault();

const { validated, invalidated, unreviewed } = sentences.reduce((acc, sentence, index) => {
if (reviewApproval[index] === true) {
acc.validated.push(sentence);
} else if (reviewApproval[index] === false) {
acc.invalidated.push(sentence);
} else {
acc.unreviewed.push(sentence);
}

return acc;
}, { validated: [], invalidated: [], unreviewed: [] });

onReviewed({ validated, invalidated, unreviewed });
const categorizedSentences = mapSentencesAccordingToState(sentences, reviewApproval);
onReviewed(categorizedSentences);
setReviewApproval({});
};

Expand All @@ -52,57 +41,18 @@ export default function ReviewForm({ message, useSwipeReview, sentences, onRevie
return (<h2>nothing to review</h2>);
}

const currentSentences = sentences.slice(offset, offset + PAGE_SIZE);

if (useSwipeReview) {
const cardsRef = React.createRef();

const skip = (event) => {
event.preventDefault();
const currentIndex = cardsRef.current.state.index;
cardsRef.current.setState({ index: currentIndex + 1 });
};

const onReviewButtonPress = (event, approval) => {
event.preventDefault();
const currentIndex = cardsRef.current.state.index;
reviewSentence(currentIndex, approval);
cardsRef.current.setState({ index: currentIndex + 1 });
};

return (
<form id="review-form" onSubmit={onSubmit}>
<p>Swipe right to approve the sentence. Swipe left to reject it.</p>
<p>You have reviewed {reviewedSentencesCount} sentences. Do not forget to submit your review by clicking on the &quot;Finish Review&quot; button below!</p>

<SubmitButton submitText="Finish&nbsp;Review"/>

{ message && ( <p>{message}</p> ) }

<Cards onEnd={() => {
if (page === lastPage) {
onSubmit({preventDefault: () => {}});
}
}} className="main-root" ref={cardsRef}>
{ sentences.map((sentence, i) => (
<Card
key={offset + i}
onSwipeLeft={() => reviewSentence(i, false)}
onSwipeRight={() => reviewSentence(i, true)}
>
<div className="card-sentence-box">
<p>{sentence.sentence || sentence}</p>
<small className="card-source">{sentence.source ? `Source: ${sentence.source}` : ''}</small>
</div>
</Card>
))}
</Cards>
<section className="card-review-footer">
<button className="standalone secondary big" onClick={(event) => onReviewButtonPress(event, false)}>Reject</button>
<button className="standalone secondary big" onClick={skip}>Skip</button>
<button className="standalone secondary big" onClick={(event) => onReviewButtonPress(event, true)}>Approve</button>
</section>
</form>
<SwipeReviewForm
onReviewSentence={reviewSentence}
onSubmit={onSubmit}
sentences={sentences}
page={page}
lastPage={lastPage}
offset={offset}
message={message}
reviewedSentencesCount={reviewedSentencesCount}
/>
);
}

Expand Down Expand Up @@ -142,3 +92,17 @@ export default function ReviewForm({ message, useSwipeReview, sentences, onRevie
</form>
);
}

function mapSentencesAccordingToState(sentences, reviewApproval) {
return sentences.reduce((acc, sentence, index) => {
if (reviewApproval[index] === true) {
acc.validated.push(sentence);
} else if (reviewApproval[index] === false) {
acc.invalidated.push(sentence);
} else {
acc.unreviewed.push(sentence);
}

return acc;
}, { validated: [], invalidated: [], unreviewed: [] });
}
152 changes: 87 additions & 65 deletions web/src/components/review-form.test.js
@@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, act } from '@testing-library/react';
import { render, screen, act, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import ReviewForm from './review-form';
Expand Down Expand Up @@ -122,6 +122,8 @@ describe('Normal Review Tool', () => {
});
});

// Testing the Swipe Review Form through the ReviewForm here so we can
// test all the logic
describe('Swipe Review Tool', () => {
test('should render swipe review tool', () => {
render(<ReviewForm sentences={sentences} useSwipeReview={true}/>);
Expand All @@ -145,70 +147,8 @@ describe('Swipe Review Tool', () => {
expect(screen.getByText('Finish Review')).toBeTruthy();
});

test('should skip sentence on swipe review tool skip button', async () => {
const onReviewed = jest.fn();
render(<ReviewForm sentences={[sentences[0]]} onReviewed={onReviewed} useSwipeReview={true}/>);

await userEvent.click(screen.getByText('Skip'));

await act(async () => await userEvent.click(screen.getByText('Finish Review')));
expect(onReviewed).toHaveBeenCalledWith({
validated: [],
invalidated: [],
unreviewed: [{
sentence: 'Hi there',
source: 'Me',
}]
});
});

test('should approve sentence on approve button', async () => {
const onReviewed = jest.fn();
render(<ReviewForm sentences={[sentences[0]]} onReviewed={onReviewed} useSwipeReview={true}/>);

await userEvent.click(screen.getByText('Approve'));

await act(async () => await userEvent.click(screen.getByText('Finish Review')));
expect(onReviewed).toHaveBeenCalledWith({
validated: [{
sentence: 'Hi there',
source: 'Me',
}],
invalidated: [],
unreviewed: []
});
});

test('should reject sentence on reject button', async () => {
const onReviewed = jest.fn();
render(<ReviewForm sentences={[sentences[0]]} onReviewed={onReviewed} useSwipeReview={true}/>);

await userEvent.click(screen.getByText('Reject'));

await act(async () => await userEvent.click(screen.getByText('Finish Review')));
expect(onReviewed).toHaveBeenCalledWith({
validated: [],
invalidated: [{
sentence: 'Hi there',
source: 'Me',
}],
unreviewed: []
});
});

test('should set state of sentence on multiple button reviews', async () => {
const onReviewed = jest.fn();
render(<ReviewForm sentences={sentences} onReviewed={onReviewed} useSwipeReview={true}/>);

await userEvent.click(screen.getByText('Reject'));
await userEvent.click(screen.getByText('Approve'));
await userEvent.click(screen.getByText('Skip'));
await userEvent.click(screen.getByText('Skip'));
await userEvent.click(screen.getByText('Approve'));
await userEvent.click(screen.getByText('Skip'));

await act(async () => await userEvent.click(screen.getByText('Finish Review')));
expect(onReviewed).toHaveBeenCalledWith({
describe('Reviews', () => {
const fullTestExpectedCategorization = {
validated: [{
sentence: 'Hi there two',
source: 'Me',
Expand All @@ -233,6 +173,88 @@ describe('Swipe Review Tool', () => {
sentence: 'Hi there seven',
source: 'Me',
}]
};

test('should skip sentence on swipe review tool skip button', async () => {
const onReviewed = jest.fn();
render(<ReviewForm sentences={[sentences[0]]} onReviewed={onReviewed} useSwipeReview={true}/>);

await userEvent.click(screen.getByText('Skip'));

await act(async () => await userEvent.click(screen.getByText('Finish Review')));
expect(onReviewed).toHaveBeenCalledWith({
validated: [],
invalidated: [],
unreviewed: [{
sentence: 'Hi there',
source: 'Me',
}]
});
});

test('should approve sentence on approve button', async () => {
const onReviewed = jest.fn();
render(<ReviewForm sentences={[sentences[0]]} onReviewed={onReviewed} useSwipeReview={true}/>);

await userEvent.click(screen.getByText('Approve'));

await act(async () => await userEvent.click(screen.getByText('Finish Review')));
expect(onReviewed).toHaveBeenCalledWith({
validated: [{
sentence: 'Hi there',
source: 'Me',
}],
invalidated: [],
unreviewed: []
});
});

test('should reject sentence on reject button', async () => {
const onReviewed = jest.fn();
render(<ReviewForm sentences={[sentences[0]]} onReviewed={onReviewed} useSwipeReview={true}/>);

await userEvent.click(screen.getByText('Reject'));

await act(async () => await userEvent.click(screen.getByText('Finish Review')));
expect(onReviewed).toHaveBeenCalledWith({
validated: [],
invalidated: [{
sentence: 'Hi there',
source: 'Me',
}],
unreviewed: []
});
});

test('should set state of sentence on multiple button reviews', async () => {
const onReviewed = jest.fn();
render(<ReviewForm sentences={sentences} onReviewed={onReviewed} useSwipeReview={true}/>);

await userEvent.click(screen.getByText('Reject'));
await userEvent.click(screen.getByText('Approve'));
await userEvent.click(screen.getByText('Skip'));
await userEvent.click(screen.getByText('Skip'));
await userEvent.click(screen.getByText('Approve'));
await userEvent.click(screen.getByText('Skip'));

await act(async () => await userEvent.click(screen.getByText('Finish Review')));
expect(onReviewed).toHaveBeenCalledWith(fullTestExpectedCategorization);
});

test('should set state of sentence on multiple keyboard reviews', async () => {
const onReviewed = jest.fn();
render(<ReviewForm sentences={sentences} onReviewed={onReviewed} useSwipeReview={true}/>);

await fireEvent.keyDown(document, { key: 'n' });
await fireEvent.keyDown(document, { key: 'y' });
await fireEvent.keyDown(document, { key: 's' });
await fireEvent.keyDown(document, { key: 's' });
await fireEvent.keyDown(document, { key: 'y' });
await fireEvent.keyDown(document, { key: 's' });

await act(async () => await userEvent.click(screen.getByText('Finish Review')));
expect(onReviewed).toHaveBeenCalledWith(fullTestExpectedCategorization);
});
});

});
92 changes: 92 additions & 0 deletions web/src/components/swipe-review-form.js
@@ -0,0 +1,92 @@
import React, { useEffect, useRef } from 'react';

import Cards from './swipecard/Cards';
import Card from "./swipecard/CardSwitcher";
import SubmitButton from './submit-button';

export default function SwipeReview(props) {
const {
onReviewSentence,
onSubmit,
sentences,
page,
lastPage,
offset,
message,
reviewedSentencesCount,
} = props;

const cardsRef = useRef(null);

const onReviewButtonPress = (event, approval) => {
event.preventDefault();
processReviewOnCurrentCard(approval);
};

const processReviewOnCurrentCard = (approval) => {
const currentIndex = cardsRef.current.state.index;

if (typeof approval !== 'undefined') {
onReviewSentence(currentIndex, approval);
}

cardsRef.current.setState({ index: currentIndex + 1 });
};

useEffect(() => {
const handler = ({ key }) => {
if (key === 'y') {
return processReviewOnCurrentCard(true);
}

if (key === 'n') {
return processReviewOnCurrentCard(false);
}

if (key === 's') {
return processReviewOnCurrentCard(undefined);
}
};

window.addEventListener('keydown', handler);

return () => {
window.removeEventListener('keydown', handler);
};
}, []);

return (
<form id="review-form" onSubmit={onSubmit}>
<p>Swipe right to approve the sentence. Swipe left to reject it.</p>
<p>You have reviewed {reviewedSentencesCount} sentences. Do not forget to submit your review by clicking on the &quot;Finish Review&quot; button below!</p>

<SubmitButton submitText="Finish&nbsp;Review"/>

{ message && ( <p>{message}</p> ) }

<Cards onEnd={() => {
if (page === lastPage) {
onSubmit({preventDefault: () => {}});
}
}} className="main-root" ref={cardsRef}>
{ sentences.map((sentence, i) => (
<Card
key={offset + i}
onSwipeLeft={() => onReviewSentence(i, false)}
onSwipeRight={() => onReviewSentence(i, true)}
>
<div className="card-sentence-box">
<p>{sentence.sentence || sentence}</p>
<small className="card-source">{sentence.source ? `Source: ${sentence.source}` : ''}</small>
</div>
</Card>
))}
</Cards>
<section className="card-review-footer">
<button className="standalone secondary big" onClick={(event) => onReviewButtonPress(event, false)}>Reject</button>
<button className="standalone secondary big" onClick={(event) => onReviewButtonPress(event, undefined)}>Skip</button>
<button className="standalone secondary big" onClick={(event) => onReviewButtonPress(event, true)}>Approve</button>
</section>
</form>
);
}

0 comments on commit e51dece

Please sign in to comment.