diff --git a/server/lib/sentences.js b/server/lib/sentences.js index 37f38f39..9aae7062 100644 --- a/server/lib/sentences.js +++ b/server/lib/sentences.js @@ -17,6 +17,8 @@ module.exports = { getRejectedSentencesForLocale, getSentencesForReview, getRejectedSentences, + getMySentences, + deleteMySentences, getStats, getUserAddedSentencesPerLocale, getUnreviewedByYouCountForLocales, @@ -88,6 +90,48 @@ async function getRejectedSentences({ userId }) { return sentencesPerLocale; } +async function getMySentences({ userId }) { + debug('GETTING_MY_SENTENCES'); + + const options = { + where: { + userId, + }, + }; + + const sentences = await Sentence.findAll(options); + + const sentencesPerLocale = sentences.reduce((perLocale, sentenceInfo) => { + perLocale[sentenceInfo.localeId] = perLocale[sentenceInfo.localeId] || {}; + + const batch = sentenceInfo.batch || 0; + perLocale[sentenceInfo.localeId][batch] = perLocale[sentenceInfo.localeId][batch] || { + source: sentenceInfo.source, + sentences: [] + }; + perLocale[sentenceInfo.localeId][batch].sentences.push(sentenceInfo); + + return perLocale; + }, {}); + + return sentencesPerLocale; +} + +async function deleteMySentences({ userId, sentenceIds }) { + debug('DELETING_MY_SENTENCES'); + + const options = { + where: { + id: sentenceIds, + // Passing the userId here as well makes sure that sentences that + // do not belong to this user would silently be ignored. + userId, + }, + }; + + await Sentence.destroy(options); +} + async function getStats(locales) { debug('GETTING_STATS'); diff --git a/server/routes/sentences.js b/server/routes/sentences.js index 673d50a5..eebd3cd2 100644 --- a/server/routes/sentences.js +++ b/server/routes/sentences.js @@ -37,6 +37,31 @@ router.get('/rejected', async (req, res) => { }); }); +router.get('/my', async (req, res) => { + const userId = req.user && req.user.email; + debug('GET_MY_SENTENCES', userId); + sentences.getMySentences({ userId }) + .then((foundSentences) => res.json(foundSentences)) + .catch((error) => { + debug('GET_MY_SENTENCES_ERROR', error); + res.status(STATUS_ERROR); + res.json({ message: error.message }); + }); +}); + +router.post('/delete', async (req, res) => { + const userId = req.user && req.user.email; + const sentenceIds = req.body.sentences; + debug('DELETE_MY_SENTENCES', userId); + sentences.deleteMySentences({ userId, sentenceIds }) + .then(() => res.json({})) + .catch((error) => { + debug('DELETE_SENTENCES_ERROR', error); + res.status(STATUS_ERROR); + res.json({ message: error.message }); + }); +}); + router.put('/', async (req, res) => { const userId = req.user && req.user.email; debug('CREATE_SENTENCES', req.body, userId); diff --git a/server/tests/lib/sentences.test.js b/server/tests/lib/sentences.test.js index 1ecccb28..5c5d15d4 100644 --- a/server/tests/lib/sentences.test.js +++ b/server/tests/lib/sentences.test.js @@ -16,6 +16,7 @@ test.beforeEach((t) => { t.context.sandbox.stub(Sentence, 'count').resolves(0); t.context.sandbox.stub(Sentence, 'create').resolves(exampleSentenceRecord); t.context.sandbox.stub(Sentence, 'findAll').resolves([exampleSentenceRecord]); + t.context.sandbox.stub(Sentence, 'destroy').resolves(); t.context.sandbox.stub(sequelize, 'query').resolves([exampleSentenceRecord]); t.context.transactionMock = { commit: t.context.sandbox.stub(), @@ -249,3 +250,73 @@ test.serial('getUserAddedSentencesPerLocale: should fetch user stats correctly', }, }); }); + +test.serial('getMySentences: should fetch correctly', async (t) => { + const userId = ['foo']; + Sentence.findAll.resolves([{ + id: 1, + sentence: 'Hi', + source: 'bla', + localeId: 'en', + batch: 1, + }, { + id: 2, + sentence: 'Hi there', + source: 'bla', + localeId: 'en', + batch: 1, + }, { + id: 3, + sentence: 'Hallo', + source: 'meh', + localeId: 'de', + batch: 2, + }]); + + const stats = await sentences.getMySentences({ userId }); + t.deepEqual(stats, { + en: { + '1': { + source: 'bla', + sentences: [{ + id: 1, + sentence: 'Hi', + source: 'bla', + localeId: 'en', + batch: 1, + }, { + id: 2, + sentence: 'Hi there', + source: 'bla', + localeId: 'en', + batch: 1, + }], + }, + }, + de: { + '2': { + source: 'meh', + sentences: [{ + id: 3, + sentence: 'Hallo', + source: 'meh', + localeId: 'de', + batch: 2, + }], + }, + }, + }); +}); + +test.serial('deleteMySentences: should delete correctly', async (t) => { + const userId = ['foo']; + Sentence.destroy.resolves(); + + await sentences.deleteMySentences({ userId, sentenceIds: [1, 2] }); + t.true(Sentence.destroy.calledWith({ + where: { + id: [1, 2], + userId, + }, + })); +}); diff --git a/server/tests/routes/sentences.test.js b/server/tests/routes/sentences.test.js index 6d785820..01fc6ab9 100644 --- a/server/tests/routes/sentences.test.js +++ b/server/tests/routes/sentences.test.js @@ -26,6 +26,8 @@ test.beforeEach((t) => { t.context.sandbox.stub(sentences, 'getRejectedSentencesForLocale').resolves(sentencesMock); t.context.sandbox.stub(sentences, 'getSentencesForReview').resolves(sentencesMock); t.context.sandbox.stub(sentences, 'getRejectedSentences').resolves(sentencesMock); + t.context.sandbox.stub(sentences, 'getMySentences').resolves(sentencesMock); + t.context.sandbox.stub(sentences, 'deleteMySentences').resolves({}); t.context.sandbox.stub(sentences, 'addSentences').resolves(sentencesMock); }); @@ -258,6 +260,50 @@ test.serial('getting rejected sentences should pass on error message', async (t) }); }); +test.serial('should get my sentences', async (t) => { + const response = await request(app) + .get('/sentence-collector/sentences/my'); + + t.is(response.status, 200); + t.deepEqual(response.body, sentencesMock); + t.true(sentences.getMySentences.calledWith({ userId: undefined })); +}); + +test.serial('getting my sentences should pass on error message', async (t) => { + sentences.getMySentences.rejects(new Error('nope')); + + const response = await request(app) + .get('/sentence-collector/sentences/my'); + + t.is(response.status, 500); + t.deepEqual(response.body, { + message: 'nope', + }); +}); + +test.serial('should delete my sentences', async (t) => { + const response = await request(app) + .post('/sentence-collector/sentences/delete') + .send({ sentences: [1] }); + + t.is(response.status, 200); + t.log(sentences.deleteMySentences.getCall(0).args); + t.true(sentences.deleteMySentences.calledWith({ userId: undefined, sentenceIds: [1] })); +}); + +test.serial('deleting sentences should pass on error message', async (t) => { + sentences.deleteMySentences.rejects(new Error('nope')); + + const response = await request(app) + .post('/sentence-collector/sentences/delete') + .send({ sentences: [1] }); + + t.is(response.status, 500); + t.deepEqual(response.body, { + message: 'nope', + }); +}); + test.serial('should add sentences', async (t) => { const sentenceParams = { sentence: 'Hi', diff --git a/web/css/sentences-list.css b/web/css/sentences-list.css new file mode 100644 index 00000000..1620c5db --- /dev/null +++ b/web/css/sentences-list.css @@ -0,0 +1,19 @@ +.language-section { + padding-bottom: 4rem; +} + +.submission-section { + padding-bottom: 2rem; +} + +.submission-section li input { + vertical-align: middle; +} + +.submission-section li p { + margin-top: 0; + margin-bottom: 0; + display: inline-block; + margin-left: 1rem; + vertical-align: middle; +} diff --git a/web/package.json b/web/package.json index 538a2776..66b67b08 100644 --- a/web/package.json +++ b/web/package.json @@ -83,8 +83,8 @@ ], "coverageThreshold": { "global": { - "branches": 89, - "functions": 81, + "branches": 87, + "functions": 80, "lines": 93, "statements": 91 } diff --git a/web/src/actions/sentences.test.ts b/web/src/actions/sentences.test.ts index b08dfeb7..8b02a75c 100644 --- a/web/src/actions/sentences.test.ts +++ b/web/src/actions/sentences.test.ts @@ -42,6 +42,55 @@ describe('loadRejectedSentences', () => { }); }); +describe('loadMySentences', () => { + test('should load my sentences', async () => { + (backend.sendRequest as jest.Mock).mockImplementation(() => exampleSentences); + await sentences.loadMySentences()(dispatch, getState, null); + expect((backend.sendRequest as jest.Mock).mock.calls[0][0]).toEqual('sentences/my'); + expect(dispatch.mock.calls[0][0]).toEqual({ + type: sentences.ACTION_LOAD_MY_SENTENCES, + }); + expect(dispatch.mock.calls[1][0]).toEqual({ + type: sentences.ACTION_GOT_MY_SENTENCES, + sentences: exampleSentences, + }); + }); + + test('should not throw on error', async () => { + const error = new Error('NOPE'); + (backend.sendRequest as jest.Mock).mockImplementation(() => { throw error; }); + expect(sentences.loadMySentences()(dispatch, getState, null)).resolves.not.toThrow(error); + expect(dispatch.mock.calls[1][0]).toEqual({ + type: sentences.ACTION_MY_SENTENCES_FAILURE, + errorMessage: 'NOPE', + }); + }); +}); + +describe('deleteSentences', () => { + test('should delete sentences', async () => { + (backend.sendRequest as jest.Mock).mockImplementation(() => ({})); + await sentences.deleteSentences([1])(dispatch, getState, null); + expect((backend.sendRequest as jest.Mock).mock.calls[0][0]).toEqual('sentences/delete'); + expect(dispatch.mock.calls[0][0]).toEqual({ + type: sentences.ACTION_DELETE_SENTENCES, + }); + expect(dispatch.mock.calls[1][0]).toEqual({ + type: sentences.ACTION_DELETE_SENTENCES_DONE, + }); + }); + + test('should not throw on error', async () => { + const error = new Error('NOPE'); + (backend.sendRequest as jest.Mock).mockImplementation(() => { throw error; }); + expect(sentences.deleteSentences([1])(dispatch, getState, null)).resolves.not.toThrow(error); + expect(dispatch.mock.calls[1][0]).toEqual({ + type: sentences.ACTION_DELETE_SENTENCES_FAILURE, + errorMessage: 'NOPE', + }); + }); +}); + describe('resetReviewMessage', () => { test('should reset message', async () => { await sentences.resetReviewMessage()(dispatch, getState, null); diff --git a/web/src/actions/sentences.ts b/web/src/actions/sentences.ts index 7284c6ef..41f06579 100644 --- a/web/src/actions/sentences.ts +++ b/web/src/actions/sentences.ts @@ -5,6 +5,7 @@ import { sendRequest } from '../backend'; import type { BackendSentenceFailure, RejectedSentences, + MySentences, RootState, SentenceRecord, } from '../types'; @@ -16,6 +17,12 @@ export const ACTION_SUBMIT_SENTENCES_ERRORS = 'SUBMIT_SENTENCES_ERRORS'; export const ACTION_LOAD_REJECTED_SENTENCES = 'LOAD_REJECTED_SENTENCES'; export const ACTION_GOT_REJECTED_SENTENCES = 'GOT_REJECTED_SENTENCES'; export const ACTION_REJECTED_SENTENCES_FAILURE = 'REJECTED_SENTENCES_FAILURE'; +export const ACTION_LOAD_MY_SENTENCES = 'LOAD_MY_SENTENCES'; +export const ACTION_GOT_MY_SENTENCES = 'GOT_MY_SENTENCES'; +export const ACTION_MY_SENTENCES_FAILURE = 'MY_SENTENCES_FAILURE'; +export const ACTION_DELETE_SENTENCES = 'DELETE_SENTENCES'; +export const ACTION_DELETE_SENTENCES_DONE = 'DELETE_SENTENCES_DONE'; +export const ACTION_DELETE_SENTENCES_FAILURE = 'DELETE_SENTENCES_FAILURE'; export const ACTION_LOAD_SENTENCES = 'LOAD_SENTENCES'; export const ACTION_GOT_SENTENCES = 'GOT_SENTENCES'; export const ACTION_REVIEWED_SENTENCES = 'REVIEWED_SENTENCES'; @@ -60,6 +67,31 @@ export function loadRejectedSentences(): ThunkAction { + return async function(dispatch){ + dispatch(loadMySentencesStart()); + try { + const results = await sendRequest('sentences/my'); + dispatch(loadMySentencesDone(results)); + } catch (error) { + dispatch(loadMySentencesFailure(error.message)); + } + }; +} + +export function deleteSentences(sentences: number[]): ThunkAction { + return async function(dispatch){ + dispatch(deleteSentencesStart()); + try { + const results = await sendRequest>('sentences/delete', 'POST', { sentences }); + dispatch(deleteSentencesDone(results)); + dispatch(loadMySentences()); + } catch (error) { + dispatch(deleteSentencesFailure(error.message)); + } + }; +} + export function resetReviewMessage(): ThunkAction { return async function(dispatch) { dispatch(resetMessage()); @@ -170,6 +202,45 @@ function loadRejectedSentencesFailure(errorMessage: string) { }; } +function loadMySentencesStart() { + return { + type: ACTION_LOAD_MY_SENTENCES, + }; +} + +function loadMySentencesDone(sentences: MySentences) { + return { + type: ACTION_GOT_MY_SENTENCES, + sentences, + }; +} + +function loadMySentencesFailure(errorMessage: string) { + return { + type: ACTION_MY_SENTENCES_FAILURE, + errorMessage, + }; +} + +function deleteSentencesStart() { + return { + type: ACTION_DELETE_SENTENCES, + }; +} + +function deleteSentencesDone(sentences: MySentences) { + return { + type: ACTION_DELETE_SENTENCES_DONE, + }; +} + +function deleteSentencesFailure(errorMessage: string) { + return { + type: ACTION_DELETE_SENTENCES_FAILURE, + errorMessage, + }; +} + function loadSentencesStart() { return { type: ACTION_LOAD_SENTENCES, diff --git a/web/src/components/header.tsx b/web/src/components/header.tsx index 0a6b003f..efed8037 100644 --- a/web/src/components/header.tsx +++ b/web/src/components/header.tsx @@ -21,6 +21,7 @@ function NavItems({ authed, closeNavigation }: Props) { Add Review Rejected Sentences + My Sentences Statistics { authed && ( Profile diff --git a/web/src/components/my-sentences-list.test.tsx b/web/src/components/my-sentences-list.test.tsx new file mode 100644 index 00000000..8db30a8c --- /dev/null +++ b/web/src/components/my-sentences-list.test.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import MySentencesList from './my-sentences-list'; + +const deleteMock = jest.fn(); +const selectMock = jest.fn(); + +beforeEach(() => { + jest.resetAllMocks(); + deleteMock.mockReset(); + selectMock.mockReset(); +}); + +test('should render loading notice', () => { + render( + + ); + expect(screen.getByText('Loading your sentences..')).toBeTruthy(); +}); + +test('should render error', () => { + const errorMessage = 'Oh no!'; + render( + + ); + expect(screen.getByText(`Error while fetching your sentences: ${errorMessage}`)).toBeTruthy(); +}); + +test('should render no sentences found notice', () => { + render( + + ); + expect(screen.getByText('No sentences found!')).toBeTruthy(); +}); + +test('should render sentences', () => { + const sentences = { + de: { + '1': { + source: 'foo', + sentences: [{ + id: 1, + sentence: 'I failed.', + }, { + id: 2, + sentence: 'I failed too.', + }], + }, + }, + }; + + render( + + ); + + expect(screen.getByText('de')).toBeTruthy(); + expect(screen.getByText('Submission: 1')).toBeTruthy(); + expect(screen.getByText('Source: foo')).toBeTruthy(); + expect(screen.getByText('I failed.')).toBeTruthy(); + expect(screen.getByText('I failed too.')).toBeTruthy(); +}); + +test('should render delete button', () => { + render( + + ); + expect(screen.getByText('Delete selected sentences')).toBeTruthy(); +}); + +test('should render delete loading notice', () => { + render( + + ); + expect(screen.getByText('Deleting selected sentences...')).toBeTruthy(); +}); + +test('should render delete error', () => { + render( + + ); + expect(screen.getByText('Failed to delete selected sentences.. Please try again!')).toBeTruthy(); +}); diff --git a/web/src/components/my-sentences-list.tsx b/web/src/components/my-sentences-list.tsx new file mode 100644 index 00000000..e81b46e6 --- /dev/null +++ b/web/src/components/my-sentences-list.tsx @@ -0,0 +1,97 @@ +import React from 'react'; + +import '../../css/sentences-list.css'; + +import type { MySentences } from '../types'; +import Sentence from './sentence'; +import SpinnerButton from './spinner-button'; + +type Props = { + onSelectSentence: (sentenceId: string, checked: boolean) => void + onDelete: () => void + loading: boolean + sentences: MySentences + error: string + deleteSentencesLoading: boolean + deleteSentencesError: string +} + +export default function MySentencesList({ + loading, + sentences = {}, + error, + deleteSentencesLoading, + deleteSentencesError, + onSelectSentence, + onDelete, +}: Props) { + const hasNoSentences = Object.keys(sentences).length === 0; + + const onSelect = (event: React.ChangeEvent) => { + onSelectSentence(event.target.name, event.target.checked); + }; + + const deleteSelected = (event: React.MouseEvent) => { + event.preventDefault(); + onDelete(); + }; + + return ( + +

+ This page gives you an overview of all your submitted sentencens. You may also delete already + submitted sentences if needed by marking the checkbox next to it and clicking on "Remove sentences" + at the bottom. Please only remove sentences if absolutely necessary, for example if you noticed + after the fact that a sentence is copyright protected. +

+ + { loading && ( +

Loading your sentences..

+ )} + + { error && ( +

Error while fetching your sentences: {error}

+ )} + + { hasNoSentences && !loading && ( +

No sentences found!

+ )} + + { Object.keys(sentences).map((language) => ( +
+

{language}

+ + { Object.keys(sentences[language]).map((batchId) => ( +
+

Submission: {batchId}

+ Source: {sentences[language][batchId].source} + +
    + { sentences[language][batchId].sentences.map((sentence) => ( +
  • + + +
  • + ))} +
+
+ ))} +
+ ))} + + { !deleteSentencesLoading && ( + + )} + + { deleteSentencesLoading && ( + + )} + + { deleteSentencesError && ( +

Failed to delete selected sentences.. Please try again!

+ )} +
+ ); +} diff --git a/web/src/components/spinner-button.test.tsx b/web/src/components/spinner-button.test.tsx index d4599d20..d0351423 100644 --- a/web/src/components/spinner-button.test.tsx +++ b/web/src/components/spinner-button.test.tsx @@ -8,3 +8,9 @@ test('should render spinner button', () => { expect(screen.queryByRole('button')).toBeTruthy(); expect(screen.queryByText('Submitting...')).toBeTruthy(); }); + +test('should render spinner button with custom text', () => { + render(); + expect(screen.queryByRole('button')).toBeTruthy(); + expect(screen.queryByText('Hi...')).toBeTruthy(); +}); diff --git a/web/src/components/spinner-button.tsx b/web/src/components/spinner-button.tsx index 27fd2f0c..6d780e6f 100644 --- a/web/src/components/spinner-button.tsx +++ b/web/src/components/spinner-button.tsx @@ -2,8 +2,8 @@ import React from 'react'; import '../../css/spinner-button.css'; -export default function SpinnerButton() { +export default function SpinnerButton({ text = 'Submitting...'}) { return ( - + ); } diff --git a/web/src/containers/app.tsx b/web/src/containers/app.tsx index 1ce963f1..54f7ffd6 100644 --- a/web/src/containers/app.tsx +++ b/web/src/containers/app.tsx @@ -20,6 +20,7 @@ import HowTo from './how-to'; import { LoginSuccess, LoginFailure, LogoutSuccess } from './login'; import Profile from './profile'; import Rejected from './rejected'; +import MySentences from './sentences'; import Add from './add'; import Review from './review'; import Stats from './stats'; @@ -49,6 +50,7 @@ export default function App({ history }: { history: History }) { + ( diff --git a/web/src/containers/sentences.test.tsx b/web/src/containers/sentences.test.tsx new file mode 100644 index 00000000..08ee368a --- /dev/null +++ b/web/src/containers/sentences.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import * as redux from 'react-redux'; +import { render, screen } from '@testing-library/react'; + +import MySentencesList from '../components/my-sentences-list'; +import MySentences from './sentences'; + +jest.mock('../components/my-sentences-list'); + +const dispatchMock = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(redux, 'useDispatch'); + jest.spyOn(redux, 'useSelector'); + + (redux.useDispatch as jest.Mock).mockImplementation(() => dispatchMock); + (redux.useSelector as jest.Mock).mockImplementation(() => ({ + mySentences: [], + mySentencesLoading: false, + mySentencesError: '', + })); + (MySentencesList as jest.Mock).mockReturnValue(
...MySentencesList...
); +}); + +test('should render Rejected', () => { + render(); + expect(screen.getByText('Your sentences')).toBeTruthy(); + expect(screen.getByText('...MySentencesList...')).toBeTruthy(); +}); + +test('should dispatch load', () => { + render(); + expect(dispatchMock).toHaveBeenCalled(); +}); diff --git a/web/src/containers/sentences.tsx b/web/src/containers/sentences.tsx new file mode 100644 index 00000000..8fa07d7c --- /dev/null +++ b/web/src/containers/sentences.tsx @@ -0,0 +1,60 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { loadMySentences, deleteSentences } from '../actions/sentences'; +import MySentencesList from '../components/my-sentences-list'; +import truthyFilter from '../truthyFilter'; +import type { RootState } from '../types'; + +export default function Sentences() { + const { + mySentences = {}, + mySentencesLoading, + mySentencesError, + deleteSentencesLoading, + deleteSentencesError, + } = useSelector((state: RootState) => state.sentences); + const dispatch = useDispatch(); + const [sentencesToDelete, setSentencesToDelete] = useState>({}); + + useEffect(() => { + dispatch(loadMySentences()); + }, []); + + const onSelectSentence = (sentenceId: string, checked: boolean) => { + setSentencesToDelete((previousValue) => { + const newSentencesToDelete = Object.assign({}, previousValue, { + [sentenceId]: checked, + }); + + return newSentencesToDelete; + }); + }; + + const onDelete = () => { + const sentenceIds = Object.entries(sentencesToDelete).map(([sentenceId, toDelete]) => { + if (!toDelete) { + return; + } + + return parseInt(sentenceId, 10); + }).filter(truthyFilter); + dispatch(deleteSentences(sentenceIds)); + setSentencesToDelete({}); + }; + + return ( + +

Your sentences

+ +
+ ); +} diff --git a/web/src/reducers/sentences.test.ts b/web/src/reducers/sentences.test.ts index 797e4a7e..bbfa9b0a 100644 --- a/web/src/reducers/sentences.test.ts +++ b/web/src/reducers/sentences.test.ts @@ -23,6 +23,11 @@ test('should use initial state', async () => { rejectedSentencesLoading: false, rejectedSentences: {}, rejectedSentencesError: '', + mySentencesLoading: false, + mySentences: {}, + mySentencesError: '', + deleteSentencesError: '', + deleteSentencesLoading: false, sentences: [], sentencesLoading: false, reviewMessage: '', @@ -104,6 +109,38 @@ test('should reduce rejected sentences failure', async () => { expect(newState.rejectedSentencesError).toEqual(errorMessage); }); +test('should reduce my sentences request', async () => { + const newState = sentencesReducer(combineState({ mySentencesError: 'oh no' }), { + type: sentences.ACTION_LOAD_MY_SENTENCES, + }); + + expect(newState.mySentencesLoading).toEqual(true); + expect(newState.mySentencesError).toEqual(null); +}); + +test('should reduce my sentences', async () => { + const testSentences = ['Hi', 'All good?']; + const newState = sentencesReducer(combineState({}), { + type: sentences.ACTION_GOT_MY_SENTENCES, + sentences: testSentences, + }); + + expect(newState.mySentencesLoading).toEqual(false); + expect(newState.mySentences).toEqual(testSentences); +}); + +test('should reduce my sentences failure', async () => { + const errorMessage = 'oh no'; + const newState = sentencesReducer(combineState({ mySentencesLoading: true }), { + type: sentences.ACTION_MY_SENTENCES_FAILURE, + errorMessage, + }); + + expect(newState.mySentencesLoading).toEqual(false); + expect(newState.mySentences).toEqual({}); + expect(newState.mySentencesError).toEqual(errorMessage); +}); + test('should reduce loading sentences', async () => { const newState = sentencesReducer(combineState({}), { type: sentences.ACTION_LOAD_SENTENCES, diff --git a/web/src/reducers/sentences.ts b/web/src/reducers/sentences.ts index ef70ce54..44ecf797 100644 --- a/web/src/reducers/sentences.ts +++ b/web/src/reducers/sentences.ts @@ -7,6 +7,12 @@ import { ACTION_LOAD_REJECTED_SENTENCES, ACTION_GOT_REJECTED_SENTENCES, ACTION_REJECTED_SENTENCES_FAILURE, + ACTION_LOAD_MY_SENTENCES, + ACTION_GOT_MY_SENTENCES, + ACTION_MY_SENTENCES_FAILURE, + ACTION_DELETE_SENTENCES, + ACTION_DELETE_SENTENCES_DONE, + ACTION_DELETE_SENTENCES_FAILURE, ACTION_LOAD_SENTENCES, ACTION_GOT_SENTENCES, ACTION_REVIEWED_SENTENCES, @@ -19,6 +25,7 @@ import { import type { BackendSentenceFailure, RejectedSentences, + MySentences, SentenceRecord, SubmissionFailures, } from '../types'; @@ -29,6 +36,11 @@ export type SentencesState = { rejectedSentencesLoading: boolean rejectedSentences: RejectedSentences rejectedSentencesError: string + mySentencesLoading: boolean + mySentences: MySentences + mySentencesError: string + deleteSentencesLoading: boolean + deleteSentencesError: string sentences: SentenceRecord[] sentencesLoading: boolean reviewMessage: string @@ -41,6 +53,11 @@ export const INITIAL_STATE: SentencesState = { rejectedSentencesLoading: false, rejectedSentences: {}, rejectedSentencesError: '', + mySentencesLoading: false, + mySentences: {}, + mySentencesError: '', + deleteSentencesLoading: false, + deleteSentencesError: '', sentences: [], sentencesLoading: false, reviewMessage: '', @@ -93,6 +110,42 @@ export default function(state = INITIAL_STATE, action: AnyAction): SentencesStat rejectedSentencesError: action.errorMessage, }); + case ACTION_LOAD_MY_SENTENCES: + return Object.assign({}, state, { + mySentencesLoading: true, + mySentencesError: null, + }); + + case ACTION_GOT_MY_SENTENCES: + return Object.assign({}, state, { + mySentencesLoading: false, + mySentences: action.sentences, + }); + + case ACTION_MY_SENTENCES_FAILURE: + return Object.assign({}, state, { + mySentencesLoading: false, + mySentences: {}, + mySentencesError: action.errorMessage, + }); + + case ACTION_DELETE_SENTENCES: + return Object.assign({}, state, { + deleteSentencesLoading: true, + deleteSentencesError: null, + }); + + case ACTION_DELETE_SENTENCES_DONE: + return Object.assign({}, state, { + deleteSentencesLoading: false, + }); + + case ACTION_DELETE_SENTENCES_FAILURE: + return Object.assign({}, state, { + deleteSentencesLoading: false, + deleteSentencesError: action.errorMessage, + }); + case ACTION_LOAD_SENTENCES: return Object.assign({}, state, { sentencesLoading: true, diff --git a/web/src/types.ts b/web/src/types.ts index a8a0eb2c..57eb316c 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -56,6 +56,13 @@ export type BackendSentenceFailure = { export type RejectedSentences = Record +type MySentenceBatch = { + source: string + sentences: SentenceRecord[] +} + +export type MySentences = Record> + export type ReviewedState = { validated: SentenceRecord[] invalidated: SentenceRecord[]