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

Commit

Permalink
feat: request confirmation when leaving a form with unsubmitted data (#…
Browse files Browse the repository at this point in the history
…465)

* feat: request confirmation when leaving a form with unsubmitted data

I personally forgot to press the "Confirm" button on the final view a couple of
times when adding sentences. Such leave-page notifications are a bit annoying,
but more annoying to find out data has been lost IMO.

* fixup: rename and move testUtils.tsx

* fixup: rename renderRoute
  • Loading branch information
olejorgenb authored Aug 7, 2021
1 parent 9743fbe commit e117831
Show file tree
Hide file tree
Showing 10 changed files with 128 additions and 64 deletions.
9 changes: 5 additions & 4 deletions web/src/components/add.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React from 'react';
import * as redux from 'react-redux';
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import { screen, fireEvent, act, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { renderWithBrowserRouter } from '../../tests/test-utils';
import Add from './add';

const languages = [{
Expand Down Expand Up @@ -39,7 +40,7 @@ test('should submit sentences including review', async () => {
}));
(redux.useDispatch as jest.Mock).mockImplementation(() => dispatchMock);

render(<Add />);
renderWithBrowserRouter(<Add />);

fireEvent.change(screen.getByRole('combobox'), { target: { value: 'en' } });
fireEvent.input(screen.getByRole('textbox', { name: /Add public domain sentences/i }), { target: { value: sentences.join('\n') } });
Expand Down Expand Up @@ -91,7 +92,7 @@ test('should submit sentences including review - with errors', async () => {
}));
(redux.useDispatch as jest.Mock).mockImplementation(() => dispatchMock);

render(<Add />);
renderWithBrowserRouter(<Add />);

fireEvent.change(screen.getByRole('combobox'), { target: { value: 'en' } });
fireEvent.input(screen.getByRole('textbox', { name: /Add public domain sentences/i }), { target: { value: sentences.join('\n') } });
Expand Down Expand Up @@ -136,7 +137,7 @@ test('should submit sentences including review - with unexpected server response
}));
(redux.useDispatch as jest.Mock).mockImplementation(() => dispatchMock);

render(<Add />);
renderWithBrowserRouter(<Add />);

fireEvent.change(screen.getByRole('combobox'), { target: { value: 'en' } });
fireEvent.input(screen.getByRole('textbox', { name: /Add public domain sentences/i }), { target: { value: sentences.join('\n') } });
Expand Down
19 changes: 10 additions & 9 deletions web/src/components/confirm-form.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { renderWithBrowserRouter } from '../../tests/test-utils';
import ConfirmForm from './confirm-form';

const submitted = ['This is a test444.', 'This too!', 'Hi.'];
Expand All @@ -12,7 +13,7 @@ const onSubmit = jest.fn();
const onReview = jest.fn();

test('should render full form', () => {
render(
renderWithBrowserRouter(
<ConfirmForm
submitted={submitted}
invalidated={invalidated}
Expand All @@ -34,7 +35,7 @@ test('should render full form', () => {
});

test('should not render review if none to review', () => {
render(
renderWithBrowserRouter(
<ConfirmForm
submitted={submitted}
invalidated={invalidated}
Expand All @@ -50,7 +51,7 @@ test('should not render review if none to review', () => {
});

test('should not render invalidated if there are none', () => {
render(
renderWithBrowserRouter(
<ConfirmForm
submitted={submitted}
invalidated={[]}
Expand All @@ -66,7 +67,7 @@ test('should not render invalidated if there are none', () => {
});

test('should not render already validated if there are none', () => {
render(
renderWithBrowserRouter(
<ConfirmForm
submitted={submitted}
invalidated={[]}
Expand All @@ -86,7 +87,7 @@ test('should render working submit button', async () => {
event.preventDefault();
});

render(
renderWithBrowserRouter(
<ConfirmForm
submitted={submitted}
invalidated={invalidated}
Expand All @@ -104,7 +105,7 @@ test('should render working submit button', async () => {
});

test('should disable submit button if no sentences', async () => {
render(
renderWithBrowserRouter(
<ConfirmForm
submitted={[]}
invalidated={[]}
Expand All @@ -120,7 +121,7 @@ test('should disable submit button if no sentences', async () => {
});

test('should not show submit button while uploading sentences', async () => {
render(
renderWithBrowserRouter(
<ConfirmForm
submitted={[]}
invalidated={[]}
Expand All @@ -136,7 +137,7 @@ test('should not show submit button while uploading sentences', async () => {
});

test('should show submission notice while uploading', async () => {
render(
renderWithBrowserRouter(
<ConfirmForm
submitted={[]}
invalidated={[]}
Expand Down
15 changes: 9 additions & 6 deletions web/src/components/confirm-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';

import ReviewLink from './review-link';
import SpinnerButton from './spinner-button';
import { Prompt } from './prompt';

type Props = {
submitted: string[]
Expand All @@ -28,26 +29,28 @@ export default function ConfirmForm(props: Props) {

return (
<form onSubmit={onSubmit}>
<Prompt message="Sentences not submitted, are you sure you want to leave?" when={true} />

<h2>Confirm New Sentences</h2>
<p>
{`${submitted.length} sentences found.`}
</p>

{ invalidated.length > 0 && (
<p style={{color: 'red'}}>
{invalidated.length > 0 && (
<p style={{ color: 'red' }}>
{`${invalidated.length} rejected by you`}
</p>
)}

{ validated.length + invalidated.length > 0 && (
{validated.length + invalidated.length > 0 && (
<p>
{`-- ${validated.length + invalidated.length} sentences are already reviewed. Great job!`}
</p>
)}

<p><strong>{`${readyCount} sentences ready for submission!`}</strong></p>

{ unreviewed.length > 0 && (
{unreviewed.length > 0 && (
<p>
{`-- ${unreviewed.length} of these sentences are unreviewed. If you want, you can also review your sentences now before submitting them.`}&nbsp;
<ReviewLink onReview={onReview}
Expand All @@ -56,12 +59,12 @@ export default function ConfirmForm(props: Props) {
)}

<section>
{ isUploadingSentences ?
{isUploadingSentences ?
<SpinnerButton></SpinnerButton> :
<button type="submit" className="standalone" disabled={readyCount === 0}>Confirm</button>
}

{ isUploadingSentences && (
{isUploadingSentences && (
<div>
<p className="loading-text">
Sentences are being uploaded. This can take several minutes depending on the number of sentences added.
Expand Down
33 changes: 33 additions & 0 deletions web/src/components/prompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React, { useEffect } from 'react';
import { Prompt as ReactRouterPrompt } from 'react-router-dom';

interface PromptProps {
when: boolean;
message: string;
}

/**
* Wrapper around react-router Prompt adding confirmation on tab close and external navigation.
*/
export const Prompt: React.FC<PromptProps> = (props) => {
const { when } = props;
useEffect(() => {
if (!when) {
return () => null;
}

function listener(event: BeforeUnloadEvent) {
// Cancel the event as stated by the standard.
event.preventDefault();
// Chrome requires returnValue to be set.
event.returnValue = '';
}

window.addEventListener('beforeunload', listener);
return () => window.removeEventListener('beforeunload', listener);
}, [when]);

return (
<ReactRouterPrompt {...props}/>
);
};
19 changes: 10 additions & 9 deletions web/src/components/review-form.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import { screen, fireEvent, act, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { renderWithBrowserRouter } from '../../tests/test-utils';
import ReviewForm from './review-form';

const allSentences = [{
Expand All @@ -22,7 +23,7 @@ beforeEach(() => {
});

test('should approve and reject sentences using buttons', async () => {
render(
renderWithBrowserRouter(
<ReviewForm
onReviewed={onReviewedMock}
onSkip={onSkipMock}
Expand Down Expand Up @@ -56,7 +57,7 @@ test('should approve and reject sentences using buttons', async () => {
});

test('should approve and reject sentences using shortcuts', async () => {
render(
renderWithBrowserRouter(
<ReviewForm
onReviewed={onReviewedMock}
onSkip={onSkipMock}
Expand Down Expand Up @@ -90,7 +91,7 @@ test('should approve and reject sentences using shortcuts', async () => {
});

test('should skip sentences using button', async () => {
render(
renderWithBrowserRouter(
<ReviewForm
onReviewed={onReviewedMock}
onSkip={onSkipMock}
Expand All @@ -113,7 +114,7 @@ test('should skip sentences using button', async () => {
});

test('should skip sentence using shortcut', async () => {
render(
renderWithBrowserRouter(
<ReviewForm
onReviewed={onReviewedMock}
onSkip={onSkipMock}
Expand All @@ -129,7 +130,7 @@ test('should skip sentence using shortcut', async () => {
});

test('should not mark anything as validated or invalidated if no review done', async () => {
render(
renderWithBrowserRouter(
<ReviewForm
onReviewed={onReviewedMock}
onSkip={onSkipMock}
Expand All @@ -156,7 +157,7 @@ test('should not mark anything as validated or invalidated if no review done', a
});

test('should submit review at end of review queue', async () => {
render(
renderWithBrowserRouter(
<ReviewForm
onReviewed={onReviewedMock}
onSkip={onSkipMock}
Expand All @@ -182,7 +183,7 @@ test('should submit review at end of review queue', async () => {
});

test('should return empty component if no sentences', async () => {
const { container } = render(
const { container } = renderWithBrowserRouter(
<ReviewForm
onReviewed={onReviewedMock}
onSkip={onSkipMock}
Expand All @@ -194,7 +195,7 @@ test('should return empty component if no sentences', async () => {
});

test('should show message', async () => {
render(
renderWithBrowserRouter(
<ReviewForm
onReviewed={onReviewedMock}
onSkip={onSkipMock}
Expand Down
38 changes: 22 additions & 16 deletions web/src/components/review-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ReviewedState, SentenceRecord } from '../types';
import TinderCard from 'react-tinder-card';
import Sentence from './sentence';
import SubmitButton from './submit-button';
import { Prompt } from './prompt';

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

Expand Down Expand Up @@ -131,37 +132,42 @@ export default function SwipeReview(props: Props) {
}, [reviewedSentencesCount, skippedSentencesCount]);

return (
<form id="review-form" onSubmit={onSubmit}>
<form id="review-form" onSubmit={ onSubmit }>
<Prompt
when={ reviewedSentencesCount > 0 }
message="Reviewed sentences not submitted, are sure?"
/>

<p>Swipe right to approve the sentence. Swipe left to reject it. Swipe up to skip 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>
<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" pendingAction={false} />
<SubmitButton submitText="Finish&nbsp;Review" pendingAction={ false }/>

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

<section className="cards-container">
{sentences.map((sentence, i) => (
{ sentences.map((sentence, i) => (
<TinderCard
key={i}
key={ i }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ref={cardsRefs[i]}
onSwipe={(direction) => handleSwipe(i, direction)}
preventSwipe={['down']}
ref={ cardsRefs[i] }
onSwipe={ (direction) => handleSwipe(i, direction) }
preventSwipe={ ['down'] }
>
<div className="swipe card-sentence-box">
<Sentence language={language}>{sentence.sentence || sentence}</Sentence>
<small className="card-source">{sentence.source ? `Source: ${sentence.source}` : ''}</small>
<Sentence language={ language }>{ sentence.sentence || sentence }</Sentence>
<small className="card-source">{ sentence.source ? `Source: ${ sentence.source }` : '' }</small>
</div>
</TinderCard>
))}
)) }
</section>

<section className="card-review-footer">
<div className="buttons">
<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>
<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>
</div>
<p className="small">You can also use Keyboard Shortcuts: Y to Approve, N to Reject, S to Skip</p>
</section>
Expand All @@ -181,4 +187,4 @@ function mapSentencesAccordingToState(sentences: SentenceRecord[], reviewApprova

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

0 comments on commit e117831

Please sign in to comment.