Skip to content

Commit

Permalink
Use indexedDB to store VEP submissions, and add an initial display of…
Browse files Browse the repository at this point in the history
… VEP submissions list (#1154)

- Added vepStorageService to persist VEP submission data in indexedDB
  - Moved VEP input file from redux into indexedDB
- Added a view for listed VEP submissions
  - It displays a list of VEP submissions (without separating them into viewed and unviewed)
  - It is not yet styled appropriately
  - Upon pressing the VEP form submit ("Run") button, the user is taken to the submissions list view
  • Loading branch information
azangru authored Jul 19, 2024
1 parent 976abf9 commit 80360ad
Show file tree
Hide file tree
Showing 25 changed files with 1,083 additions and 61 deletions.
3 changes: 2 additions & 1 deletion src/content/app/tools/vep/VepPageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import VepAppBar from './components/vep-app-bar/VepAppBar';
import VepTopBar from './components/vep-top-bar/VepTopBar';
import VepForm from './views/vep-form/VepForm';
import VepSpeciesSelector from './views/vep-species-selector/VepSpeciesSelector';
import VepSubmissions from './views/vep-submissions/VepSubmissions';
import VepSubmissionResults from './views/vep-submission-results/VepSubmissionResults';
import { NotFoundErrorScreen } from 'src/shared/components/error-screen';

Expand All @@ -45,7 +46,7 @@ const Main = () => {
element={<div>List of unviewed submissions</div>}
/>
<Route path="submissions">
<Route index={true} element={<div>List of viewed submissions</div>} />
<Route index={true} element={<VepSubmissions />} />
<Route path=":submissionId" element={<VepSubmissionResults />} />
</Route>
<Route path="*" element={<NotFoundErrorScreen />} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,48 +14,65 @@
* limitations under the License.
*/

import { useAppSelector } from 'src/store';
import { useNavigate } from 'react-router-dom';

import { useAppDispatch, useAppSelector } from 'src/store';

import * as urlFor from 'src/shared/helpers/urlHelper';

import { getVepSubmission } from 'src/content/app/tools/vep/services/vepStorageService';

import {
getTemporaryVepSubmissionId,
getSelectedSpecies,
getVepFormParameters,
getVepFormInputText,
getVepFormInputFile,
getVepFormInputFileName,
getVepFormInputCommittedFlag
} from 'src/content/app/tools/vep/state/vep-form/vepFormSelectors';

import { useVepFormSubmissionMutation } from 'src/content/app/tools/vep/state/vep-api/vepApiSlice';
import { onVepFormSubmission } from 'src/content/app/tools/vep/state/vep-form/vepFormSlice';

import { PrimaryButton } from 'src/shared/components/button/Button';

import type {
VEPSubmissionPayload,
VepSubmissionPayload,
VepSelectedSpecies
} from 'src/content/app/tools/vep/types/vepSubmission';

const VepSubmitButton = () => {
const submissionId = useAppSelector(getTemporaryVepSubmissionId);
const selectedSpecies = useAppSelector(getSelectedSpecies);
const inputText = useAppSelector(getVepFormInputText);
const inputFile = useAppSelector(getVepFormInputFile);
const inputFileName = useAppSelector(getVepFormInputFileName);
const formParameters = useAppSelector(getVepFormParameters);
const isInputCommitted = useAppSelector(getVepFormInputCommittedFlag);
const [submitVepForm] = useVepFormSubmissionMutation();
const navigate = useNavigate();
const dispatch = useAppDispatch();

const canSubmit = Boolean(
selectedSpecies &&
(inputText || inputFile) &&
(inputText || inputFileName) &&
Object.keys(formParameters).length &&
isInputCommitted
);

const onSubmit = () => {
const payload = preparePayload({
const onSubmit = async () => {
const payload = await preparePayload({
submissionId: submissionId as string,
species: selectedSpecies as VepSelectedSpecies,
inputText,
inputFile,
parameters: formParameters
});

await dispatch(
onVepFormSubmission({ submissionId: submissionId as string })
);

navigate(urlFor.vepSubmissionsList());

submitVepForm(payload);
};

Expand All @@ -66,21 +83,31 @@ const VepSubmitButton = () => {
);
};

const preparePayload = ({
const preparePayload = async ({
submissionId,
species,
inputText,
inputFile,
parameters
}: {
submissionId: string;
species: VepSelectedSpecies;
inputText: string;
inputFile: File | null;
inputText: string | null;
parameters: Record<string, unknown>;
}): VEPSubmissionPayload => {
}): Promise<VepSubmissionPayload> => {
let inputFile: File;

if (inputText) {
inputFile = new File([inputText], 'input.txt', {
type: 'text/plain'
});
} else {
const storedSubmission = await getVepSubmission(submissionId);
if (!storedSubmission) {
throw new Error(
`Submission with id ${submissionId} does not exist in browser storage`
);
}
inputFile = storedSubmission.inputFile as File;
}

return {
Expand Down
233 changes: 233 additions & 0 deletions src/content/app/tools/vep/services/vepStorageService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/**
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import 'fake-indexeddb/auto';
import { openDB } from 'idb';

import IndexedDB from 'src/services/indexeddb-service';

import {
VEP_SUBMISSIONS_STORE_NAME,
VEP_SUBMISSION_STORAGE_DURATION
} from './vepStorageServiceConstants';

import {
saveVepSubmission,
getVepSubmission,
updateVepSubmission,
getUncompletedVepSubmission,
getVepSubmissions,
deleteVepSubmission,
deleteExpiredVepSubmissions
} from './vepStorageService';

import { createVepSubmission } from 'tests/fixtures/vep/vepSubmission';

const getDatabase = async () => {
return await openDB('test-db', 1, {
upgrade(db) {
db.createObjectStore(VEP_SUBMISSIONS_STORE_NAME);
}
});
};

jest.spyOn(IndexedDB, 'getDB').mockImplementation(() => getDatabase());

/**
* NOTE:
* The tests below do not test file manipulation. There are two reasons for this:
* - Our tests do not run in the real browser, but instead in Node with Node with jsdom and some Jest shenanigans.
* This is bad; but it isn't clear what the way out is. Perhaps in the future we will be able to migrate to Vitest,
* which can run tests in the browser; or perhaps Playwright will mature to the point where it can run component tests.
* - As a result, we do not have access to the real indexedDB in tests, and have to use the `fake-indexeddb` polyfill.
* However, at the moment, it does not support files.
* - The reason `fake-indexeddb` does not support saving and restoring files is because it relies on the `structuredClone`
* function to clone the data; and it seems that there is a bug with Node's implementation of `structuredClone`.
* See Node issue https://github.com/nodejs/node/issues/47612
*
* Thus, the behaviour of some functions that return VEP submission information without the attached file
* has been left untested.
*/

describe('vepStorageService', () => {
afterEach(async () => {
await IndexedDB.clear(VEP_SUBMISSIONS_STORE_NAME);
});

describe('saveVepSubmission', () => {
it('saves a VEP submission', async () => {
const vepSubmission = createVepSubmission();
await saveVepSubmission(vepSubmission);

// check that the submission has been saved
const db = await getDatabase();
const savedSubmission = await db.get(
VEP_SUBMISSIONS_STORE_NAME,
vepSubmission.id
);

expect(savedSubmission).toEqual(vepSubmission);
});
});

describe('getVepSubmission', () => {
it('retrieves a VEP submission', async () => {
const vepSubmission = createVepSubmission();
vepSubmission.inputText = 'hello world';

const db = await getDatabase();
await db.put(VEP_SUBMISSIONS_STORE_NAME, vepSubmission, vepSubmission.id);

const storedVepSubmission = await getVepSubmission(vepSubmission.id);

expect(storedVepSubmission).toEqual(vepSubmission);
});
});

describe('updateVepSubmission', () => {
it('updates stored VEP submission', async () => {
// arrange
const vepSubmission = createVepSubmission();
vepSubmission.status = 'NOT_SUBMITTED';
vepSubmission.parameters.symbol = false;
await saveVepSubmission(vepSubmission);

// act
await updateVepSubmission(vepSubmission.id, {
status: 'SUBMITTED',
parameters: {
...vepSubmission.parameters,
symbol: true
}
});

// check that the submission has been updated
const db = await getDatabase();
const savedSubmission = await db.get(
VEP_SUBMISSIONS_STORE_NAME,
vepSubmission.id
);

const expectedSubmission = {
...structuredClone(vepSubmission),
status: 'SUBMITTED',
parameters: {
...vepSubmission.parameters,
symbol: true
}
};

expect(savedSubmission).toEqual(expectedSubmission);
});
});

describe('getUncompletedVepSubmission', () => {
it('retrieves VEP submission data that have not yet been submitted', async () => {
// arrange
const submission1 = createVepSubmission();
const submission2 = createVepSubmission();
const submission3 = createVepSubmission();
submission1.submittedAt = Date.now();
submission2.submittedAt = null;
submission3.submittedAt = Date.now();
await saveVepSubmission(submission1);
await saveVepSubmission(submission2);
await saveVepSubmission(submission3);

// act
const uncompletedSubmission = await getUncompletedVepSubmission();

// assert
expect(uncompletedSubmission).toEqual(submission2);
});
});

describe('getVepSubmissions', () => {
it('retrieves all VEP submissions other than the uncompleted one', async () => {
// arrange
const submission1 = createVepSubmission({
fragment: { status: 'NOT_SUBMITTED', submittedAt: null }
});
const submission2 = createVepSubmission();
const submission3 = createVepSubmission();
const submission4 = createVepSubmission();
await saveVepSubmission(submission1);
await saveVepSubmission(submission2);
await saveVepSubmission(submission3);
await saveVepSubmission(submission4);

// act
const retrievedSubmissions = await getVepSubmissions();

// assert
expect(retrievedSubmissions.length).toBe(3); // without the unsubmitted one
expect(
retrievedSubmissions.map((submission) => submission.id).toSorted()
).toEqual([submission2.id, submission3.id, submission4.id].toSorted());
});
});

describe('deleteVepSubmission', () => {
it('deletes VEP submission from persistent browser storage', async () => {
// arrange
const submission1 = createVepSubmission();
const submission2 = createVepSubmission();
await saveVepSubmission(submission1);
await saveVepSubmission(submission2);

// act
await deleteVepSubmission(submission1.id);

// assert
const db = await IndexedDB.getDB();
const storedSubmissions = await db.getAll(VEP_SUBMISSIONS_STORE_NAME);

expect(storedSubmissions.length).toBe(1);
expect(storedSubmissions[0].id).toBe(submission2.id);
});
});

describe('deleteExpiredVepSubmissions', () => {
it('removes old VEP submissions from persistent browser storage', async () => {
// arrange
const oldDate = Date.now() - (VEP_SUBMISSION_STORAGE_DURATION + 1);
const submission1 = createVepSubmission();
const submission2 = createVepSubmission({
fragment: { submittedAt: oldDate }
});
const submission3 = createVepSubmission();
const submission4 = createVepSubmission({
fragment: { submittedAt: oldDate }
});
await saveVepSubmission(submission1);
await saveVepSubmission(submission2);
await saveVepSubmission(submission3);
await saveVepSubmission(submission4);

// act
await deleteExpiredVepSubmissions();

// assert
const db = await IndexedDB.getDB();
const storedSubmissions = await db.getAll(VEP_SUBMISSIONS_STORE_NAME);

expect(storedSubmissions.length).toBe(2);
expect(
storedSubmissions.map((submission) => submission.id).toSorted()
).toEqual([submission1.id, submission3.id].toSorted());
});
});
});
Loading

0 comments on commit 80360ad

Please sign in to comment.