From 98687de8a23c683432cd7ec6840dac43af815d4b Mon Sep 17 00:00:00 2001 From: Anugerah Erlaut Date: Thu, 1 Apr 2021 22:36:19 +0700 Subject: [PATCH] disable clasifier filter (#189) * disable clasifier filter if sample has preFiltered flag * add actions to load sample --- .../data-processing/index.test.jsx | 43 ++++++++++- .../actions/projects/deleteProject.test.js | 2 - .../actions/samples/loadSamples.test..js | 57 ++++++++++++++ .../__snapshots__/samplesReducer.test.js.snap | 20 +++++ .../redux/reducers/samplesReducer.test.js | 38 +++++++++- .../[experimentId]/data-processing/index.jsx | 75 ++++++++++++++----- src/redux/actionTypes/samples.js | 12 +++ src/redux/actions/samples/deleteSamples.js | 4 - src/redux/actions/samples/index.js | 2 + src/redux/actions/samples/loadSamples.js | 29 +++++++ src/redux/reducers/samples/index.js | 12 +++ src/redux/reducers/samples/initialState.js | 4 + src/redux/reducers/samples/samplesError.js | 13 ++++ src/redux/reducers/samples/samplesLoaded.js | 14 ++++ .../generateClassifierEmptyDropsPlot.js | 40 +++++----- .../generateMitochondrialContentSpec.js | 3 - 16 files changed, 317 insertions(+), 51 deletions(-) create mode 100644 src/__test__/redux/actions/samples/loadSamples.test..js create mode 100644 src/redux/actions/samples/loadSamples.js create mode 100644 src/redux/reducers/samples/samplesError.js create mode 100644 src/redux/reducers/samples/samplesLoaded.js diff --git a/src/__test__/pages/experiments/[experimentId]/data-processing/index.test.jsx b/src/__test__/pages/experiments/[experimentId]/data-processing/index.test.jsx index 9fa3319395..30b5c2a8b0 100644 --- a/src/__test__/pages/experiments/[experimentId]/data-processing/index.test.jsx +++ b/src/__test__/pages/experiments/[experimentId]/data-processing/index.test.jsx @@ -6,6 +6,7 @@ import waitForActions from 'redux-mock-store-await-actions'; import Adapter from 'enzyme-adapter-react-16'; import { act } from 'react-dom/test-utils'; import thunk from 'redux-thunk'; +import _ from 'lodash'; import DataProcessingPage from '../../../../../pages/experiments/[experimentId]/data-processing/index'; @@ -21,8 +22,8 @@ jest.mock('localforage'); const mockStore = configureMockStore([thunk]); -const getStore = () => { - const store = mockStore({ +const getStore = (settings = {}) => { + const initialState = { notifications: {}, experimentSettings: { ...initialExperimentSettingsState, @@ -102,7 +103,22 @@ const getStore = () => { componentConfig: { ...initialPlotConfigStates, }, - }); + samples: { + ids: ['sample-1', 'sample-2'], + meta: { + loading: false, + error: false, + }, + 'sample-1': { + name: 'sample-1', + }, + 'sample-2': { + name: 'sample-2', + }, + }, + }; + + const store = mockStore(_.merge(initialState, settings)); return store; }; @@ -186,4 +202,25 @@ describe('DataProcessingPage', () => { // Run filter is disabled after triggering the pipeline expect(page.find('#runFilterButton').filter('Button').at(0).props().disabled).toEqual(true); }); + + it('preFiltered on a sample disables filter', async () => { + const store = getStore({ + samples: { + 'sample-1': { + preFiltered: true, + }, + }, + }); + + const page = mount( + + + <> + + , + ); + + // Run filter button is disabled on the first + expect(page.find('#runFilterButton').filter('Button').at(0).props().disabled).toEqual(true); + }); }); diff --git a/src/__test__/redux/actions/projects/deleteProject.test.js b/src/__test__/redux/actions/projects/deleteProject.test.js index 2f3e3362f0..775fcb924d 100644 --- a/src/__test__/redux/actions/projects/deleteProject.test.js +++ b/src/__test__/redux/actions/projects/deleteProject.test.js @@ -108,8 +108,6 @@ describe('deleteProject action', () => { const store = mockStore(initialStateMultipleProjects); await store.dispatch(deleteProject(mockProjectUuid1)); - console.log(store.getActions()); - // Delete sample const action1 = store.getActions()[0]; expect(action1.type).toEqual(SAMPLES_DELETE); diff --git a/src/__test__/redux/actions/samples/loadSamples.test..js b/src/__test__/redux/actions/samples/loadSamples.test..js new file mode 100644 index 0000000000..4f3bec5fa7 --- /dev/null +++ b/src/__test__/redux/actions/samples/loadSamples.test..js @@ -0,0 +1,57 @@ +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; + +import initialSampleState from '../../../../redux/reducers/samples/initialState'; +import { SAMPLES_ERROR, SAMPLES_LOADED } from '../../../../redux/actionTypes/samples'; +import { loadSamples } from '../../../../redux/actions/samples'; + +enableFetchMocks(); + +const mockStore = configureStore([thunk]); + +describe('loadSample action', () => { + const experimentId = '1234'; + + const initialState = { + samples: { + ...initialSampleState, + }, + }; + + const response = new Response( + JSON.stringify( + { + samples: { + ids: ['sample-1', 'sample-2'], + 'sample-1': { name: 'sample-1' }, + 'sample-2': { name: 'sample-2' }, + }, + }, + ), + ); + + fetchMock.resetMocks(); + fetchMock.doMock(); + fetchMock.mockResolvedValue(response); + + it('Dispatches event correctly', async () => { + const store = mockStore(initialState); + await store.dispatch(loadSamples(experimentId)); + + // LOAD SAMPLE + const action1 = store.getActions()[0]; + expect(action1.type).toEqual(SAMPLES_LOADED); + }); + + it('Dispatches error correctly', async () => { + fetchMock.mockReject(new Error('Failed fetching samples')); + + const store = mockStore(initialState); + await store.dispatch(loadSamples(experimentId)); + + // LOAD SAMPLE + const action1 = store.getActions()[0]; + expect(action1.type).toEqual(SAMPLES_ERROR); + }); +}); diff --git a/src/__test__/redux/reducers/__snapshots__/samplesReducer.test.js.snap b/src/__test__/redux/reducers/__snapshots__/samplesReducer.test.js.snap index 0b9f327287..e2060403e1 100644 --- a/src/__test__/redux/reducers/__snapshots__/samplesReducer.test.js.snap +++ b/src/__test__/redux/reducers/__snapshots__/samplesReducer.test.js.snap @@ -19,6 +19,10 @@ Object { "asd123", "qwe234", ], + "meta": Object { + "error": false, + "loading": true, + }, "qwe234": Object { "complete": false, "createdDate": "2021-01-02T14:48:00.000Z", @@ -53,6 +57,10 @@ Object { "ids": Array [ "asd123", ], + "meta": Object { + "error": false, + "loading": true, + }, } `; @@ -74,6 +82,10 @@ Object { "ids": Array [ "asd123", ], + "meta": Object { + "error": false, + "loading": true, + }, } `; @@ -95,6 +107,10 @@ Object { "ids": Array [ "asd123", ], + "meta": Object { + "error": false, + "loading": true, + }, } `; @@ -128,5 +144,9 @@ Object { "ids": Array [ "asd123", ], + "meta": Object { + "error": false, + "loading": true, + }, } `; diff --git a/src/__test__/redux/reducers/samplesReducer.test.js b/src/__test__/redux/reducers/samplesReducer.test.js index 9b69760c1a..b5e0035c68 100644 --- a/src/__test__/redux/reducers/samplesReducer.test.js +++ b/src/__test__/redux/reducers/samplesReducer.test.js @@ -2,7 +2,12 @@ import samplesReducer from '../../../redux/reducers/samples'; import initialState, { sampleTemplate, sampleFileTemplate } from '../../../redux/reducers/samples/initialState'; import { - SAMPLES_CREATE, SAMPLES_UPDATE, SAMPLES_FILE_UPDATE, SAMPLES_DELETE, + SAMPLES_CREATE, + SAMPLES_UPDATE, + SAMPLES_FILE_UPDATE, + SAMPLES_LOADED, + SAMPLES_ERROR, + SAMPLES_DELETE, } from '../../../redux/actionTypes/samples'; describe('samplesReducer', () => { @@ -135,4 +140,35 @@ describe('samplesReducer', () => { expect(newState[sample1.uuid].fileNames).toEqual([fileName]); expect(newState[sample1.uuid].files[fileName]).toEqual(mockFile); }); + + it('Loads samples correctly', () => { + const newState = samplesReducer(oneSampleState, { + type: SAMPLES_LOADED, + payload: { + samples: { + ids: [sample1.uuid, sample2.uuid], + [sample1.uuid]: sample1, + [sample2.uuid]: sample2, + }, + }, + }); + + expect(newState.ids).toEqual([mockUuid1, mockUuid2]); + expect(newState.meta.loading).toEqual(false); + expect(newState.meta.error).toEqual(false); + }); + + it('Handles errors correctly', () => { + const error = 'Failed uploading samples'; + + const newState = samplesReducer(oneSampleState, { + type: SAMPLES_ERROR, + payload: { + error, + }, + }); + + expect(newState.meta.loading).toEqual(false); + expect(newState.meta.error).toEqual(error); + }); }); diff --git a/src/pages/experiments/[experimentId]/data-processing/index.jsx b/src/pages/experiments/[experimentId]/data-processing/index.jsx index 9260ac572c..a05a0d2457 100644 --- a/src/pages/experiments/[experimentId]/data-processing/index.jsx +++ b/src/pages/experiments/[experimentId]/data-processing/index.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import Link from 'next/link'; @@ -30,7 +30,6 @@ import DataIntegration from '../../../../components/data-processing/DataIntegrat import ConfigureEmbedding from '../../../../components/data-processing/ConfigureEmbedding/ConfigureEmbedding'; import PlatformError from '../../../../components/PlatformError'; -import Loader from '../../../../components/Loader'; import StepsIndicator from '../../../../components/data-processing/StepsIndicator'; import StatusIndicator from '../../../../components/data-processing/StatusIndicator'; @@ -38,13 +37,12 @@ import StatusIndicator from '../../../../components/data-processing/StatusIndica import SingleComponentMultipleDataContainer from '../../../../components/SingleComponentMultipleDataContainer'; import { loadProcessingSettings, updateProcessingSettings, saveProcessingSettings } from '../../../../redux/actions/experimentSettings'; import loadCellSets from '../../../../redux/actions/cellSets/loadCellSets'; +import { loadSamples } from '../../../../redux/actions/samples' import { runPipeline } from '../../../../redux/actions/pipeline'; -import PreloadContent from '../../../../components/PreloadContent'; import PipelineRedirectToDataProcessing from '../../../../components/PipelineRedirectToDataProcessing'; - -const { Text, Paragraph } = Typography; +const { Text } = Typography; const { Option } = Select; const DataProcessingPage = ({ experimentId, experimentData, route }) => { @@ -60,9 +58,9 @@ const DataProcessingPage = ({ experimentId, experimentData, route }) => { } = useSelector((state) => state.experimentSettings.pipelineStatus); const processingConfig = useSelector((state) => state.experimentSettings.processing); + const samples = useSelector((state) => state.samples) const pipelineStatusKey = pipelineStatus.pipeline?.status; - const pipelineRunning = pipelineStatusKey === 'RUNNING'; // Pipeline is not loaded (either running or in an errored state) @@ -75,8 +73,12 @@ const DataProcessingPage = ({ experimentId, experimentData, route }) => { const [changesOutstanding, setChangesOutstanding] = useState(false); const [showChangesWillBeLost, setShowChangesWillBeLost] = useState(false); + const [stepIdx, setStepIdx] = useState(0); + const [preFilteredSamples, setPreFilteredSamples] = useState([]) + const [disabledByPrefilter, setDisabledByPrefilter] = useState(false) + const carouselRef = useRef(null); - const upcomingStepIdxRef = useRef(null); + const stepsDisabledByPrefilter = ['classifier'] useEffect(() => { if (cellSets.loading && !cellSets.error) { @@ -88,6 +90,41 @@ const DataProcessingPage = ({ experimentId, experimentData, route }) => { } }, [experimentId]); + useEffect(() => { + if (samples.meta.loading) dispatch(loadSamples(experimentId)) + }, [samples.meta.loading]) + + useEffect(() => { + if (samples.ids.length) { + setPreFilteredSamples( + samples.ids.reduce( + (acc, sampleUuid) => samples[sampleUuid].preFiltered ? [...acc, sampleUuid] : acc, [] + ) + ) + } + }, [samples]) + + useEffect(() => { + + if (preFilteredSamples.length && processingConfig[steps[stepIdx].key].enabled) { + stepsDisabledByPrefilter.forEach((step) => { + dispatch(updateProcessingSettings(experimentId, step, { enabled: false })) + dispatch(saveProcessingSettings(experimentId, step)) + }) + } + + }, [preFilteredSamples]) + + useEffect(() => { + if (preFilteredSamples.length + && !processingConfig.meta.loading + && !processingConfig.meta.loadingSettingsError) { + setDisabledByPrefilter(stepsDisabledByPrefilter.includes(steps[stepIdx].key)) + } + }, [stepIdx, processingConfig]) + + const upcomingStepIdxRef = useRef(null); + const sampleKeys = cellSets.hierarchy?.find( (rootNode) => (rootNode.key === 'sample'), )?.children.map( @@ -240,10 +277,6 @@ const DataProcessingPage = ({ experimentId, experimentData, route }) => { }, ]; - const [stepIdx, setStepIdx] = useState(0); - - const carouselRef = useRef(null); - useEffect(() => { if (carouselRef.current) { carouselRef.current.goTo(stepIdx, true); @@ -324,7 +357,7 @@ const DataProcessingPage = ({ experimentId, experimentData, route }) => { disabledByPipeline } > - {processingConfig[key]?.enabled === false ? ( + {!processingConfig[key]?.enabled ? ( <> { {name} @@ -391,16 +423,19 @@ const DataProcessingPage = ({ experimentId, experimentData, route }) => { {steps[stepIdx].multiSample && ( )} @@ -528,9 +563,13 @@ const DataProcessingPage = ({ experimentId, experimentData, route }) => { return ( - {processingConfig[steps[stepIdx].key].enabled === false && - 1 ? 'are' : 'is'} pre-filtered.` + : 'This filter is disabled. You can still modify and save changes, but the filter will not be applied to your data.' + } type="info" showIcon /> diff --git a/src/redux/actionTypes/samples.js b/src/redux/actionTypes/samples.js index 8a8f341237..7b0774797c 100644 --- a/src/redux/actionTypes/samples.js +++ b/src/redux/actionTypes/samples.js @@ -20,9 +20,21 @@ const SAMPLES_DELETE = `${SAMPLES}/delete`; */ const SAMPLES_FILE_UPDATE = `${SAMPLES}/file_update`; +/** + * Load sample. + */ +const SAMPLES_LOADED = `${SAMPLES}/loaded`; + +/** + * Error loading/saving sample. + */ +const SAMPLES_ERROR = `${SAMPLES}/error`; + export { SAMPLES_CREATE, SAMPLES_UPDATE, SAMPLES_DELETE, SAMPLES_FILE_UPDATE, + SAMPLES_LOADED, + SAMPLES_ERROR, }; diff --git a/src/redux/actions/samples/deleteSamples.js b/src/redux/actions/samples/deleteSamples.js index 6e8dc3536b..0131c89d12 100644 --- a/src/redux/actions/samples/deleteSamples.js +++ b/src/redux/actions/samples/deleteSamples.js @@ -22,8 +22,6 @@ const deleteSamples = ( acc[samples[sampleUuid].projectUuid] = []; } - console.log(acc); - return { ...acc, [samples[sampleUuid].projectUuid]: [ @@ -33,8 +31,6 @@ const deleteSamples = ( }; }, {}); - console.log(projectSamples); - Object.entries(projectSamples).forEach(([projectUuid, samplesToDelete]) => { dispatch({ type: SAMPLES_DELETE, diff --git a/src/redux/actions/samples/index.js b/src/redux/actions/samples/index.js index 16bd93e760..377ec7c02f 100644 --- a/src/redux/actions/samples/index.js +++ b/src/redux/actions/samples/index.js @@ -1,11 +1,13 @@ import createSample from './createSample'; import updateSample from './updateSample'; import updateSampleFile from './updateSampleFile'; +import loadSamples from './loadSamples'; import deleteSamples from './deleteSamples'; export { createSample, updateSample, updateSampleFile, + loadSamples, deleteSamples, }; diff --git a/src/redux/actions/samples/loadSamples.js b/src/redux/actions/samples/loadSamples.js new file mode 100644 index 0000000000..bfde136e8e --- /dev/null +++ b/src/redux/actions/samples/loadSamples.js @@ -0,0 +1,29 @@ +import getApiEndpoint from '../../../utils/apiEndpoint'; +import { + SAMPLES_LOADED, + SAMPLES_ERROR, +} from '../../actionTypes/samples'; + +const loadSamples = ( + experimentId, +) => async (dispatch) => { + try { + const response = await fetch(`${getApiEndpoint()}/v1/experiments/${experimentId}/samples`); + const json = await response.json(); + dispatch({ + type: SAMPLES_LOADED, + payload: { + samples: json.samples, + }, + }); + } catch (e) { + dispatch({ + type: SAMPLES_ERROR, + payload: { + error: e, + }, + }); + } +}; + +export default loadSamples; diff --git a/src/redux/reducers/samples/index.js b/src/redux/reducers/samples/index.js index 966862d56b..b11d98942c 100644 --- a/src/redux/reducers/samples/index.js +++ b/src/redux/reducers/samples/index.js @@ -3,12 +3,16 @@ import { SAMPLES_UPDATE, SAMPLES_DELETE, SAMPLES_FILE_UPDATE, + SAMPLES_LOADED, + SAMPLES_ERROR, } from '../../actionTypes/samples'; import initialState from './initialState'; import samplesCreate from './samplesCreate'; import samplesUpdate from './samplesUpdate'; import samplesDelete from './samplesDelete'; import samplesFileUpdate from './samplesFileUpdate'; +import samplesLoaded from './samplesLoaded'; +import samplesError from './samplesError'; const samplesReducer = (state = initialState, action) => { switch (action.type) { @@ -28,6 +32,14 @@ const samplesReducer = (state = initialState, action) => { return samplesFileUpdate(state, action); } + case SAMPLES_LOADED: { + return samplesLoaded(state, action); + } + + case SAMPLES_ERROR: { + return samplesError(state, action); + } + default: { return state; } diff --git a/src/redux/reducers/samples/initialState.js b/src/redux/reducers/samples/initialState.js index 8f15f9ed82..4216a24839 100644 --- a/src/redux/reducers/samples/initialState.js +++ b/src/redux/reducers/samples/initialState.js @@ -24,6 +24,10 @@ const sampleFileTemplate = { const initialState = { ids: [], + meta: { + loading: true, + error: false, + }, }; export default initialState; diff --git a/src/redux/reducers/samples/samplesError.js b/src/redux/reducers/samples/samplesError.js new file mode 100644 index 0000000000..d521caa889 --- /dev/null +++ b/src/redux/reducers/samples/samplesError.js @@ -0,0 +1,13 @@ +const samplesError = (state, action) => { + const { error } = action.payload; + return { + ...state, + meta: { + ...state.meta, + loading: false, + error, + }, + }; +}; + +export default samplesError; diff --git a/src/redux/reducers/samples/samplesLoaded.js b/src/redux/reducers/samples/samplesLoaded.js new file mode 100644 index 0000000000..8253a34ec3 --- /dev/null +++ b/src/redux/reducers/samples/samplesLoaded.js @@ -0,0 +1,14 @@ +const samplesLoad = (state, action) => { + const { samples } = action.payload; + return { + ...state, + meta: { + ...state.meta, + loading: false, + error: false, + }, + ...samples, + }; +}; + +export default samplesLoad; diff --git a/src/utils/plotSpecs/generateClassifierEmptyDropsPlot.js b/src/utils/plotSpecs/generateClassifierEmptyDropsPlot.js index 4f765e8b56..f5405fdbf6 100644 --- a/src/utils/plotSpecs/generateClassifierEmptyDropsPlot.js +++ b/src/utils/plotSpecs/generateClassifierEmptyDropsPlot.js @@ -12,7 +12,7 @@ const generateSpec = (config, plotData) => ({ transform: [ { type: 'filter', - expr: 'datum.size != null && datum.classifierP != null', + expr: 'datum.log_u != null && datum.FDR != null', }, ], }, @@ -23,8 +23,8 @@ const generateSpec = (config, plotData) => ({ { type: 'kde2d', size: [{ signal: 'width' }, { signal: 'height' }], - x: { expr: "scale('x', datum.size)" }, - y: { expr: "scale('y', datum.classifierP)" }, + x: { expr: "scale('x', datum.log_u)" }, + y: { expr: "scale('y', datum.FDR)" }, bandwidth: [config.bandwidth, config.bandwidth], cellSize: 25, }, @@ -43,7 +43,7 @@ const generateSpec = (config, plotData) => ({ round: true, nice: true, zero: true, - domain: { data: 'plotData', field: 'size' }, + domain: { data: 'plotData', field: 'log_u' }, domainMin: 1.5, range: 'width', }, @@ -53,7 +53,7 @@ const generateSpec = (config, plotData) => ({ round: true, nice: true, zero: true, - domain: { data: 'plotData', field: 'classifierP' }, + domain: { data: 'plotData', field: 'FDR' }, range: 'height', }, ], @@ -96,8 +96,8 @@ const generateSpec = (config, plotData) => ({ from: { data: 'plotData' }, encode: { update: { - x: { scale: 'x', field: 'size' }, - y: { scale: 'y', field: 'classifierP' }, + x: { scale: 'x', field: 'log_u' }, + y: { scale: 'y', field: 'FDR' }, size: { value: 4 }, fill: { value: '#ccc' }, }, @@ -124,19 +124,19 @@ const generateSpec = (config, plotData) => ({ }, ], }, - { - type: 'rule', - encode: { - update: { - x: { value: 0 }, - x2: { field: { group: 'width' } }, - y: { scale: 'y', value: config.minProbability, round: false }, - strokeWidth: { value: 2 }, - strokeDash: { value: [8, 4] }, - stroke: { value: 'red' }, - }, - }, - }, + // { + // type: 'rule', + // encode: { + // update: { + // x: { value: 0 }, + // x2: { field: { group: 'width' } }, + // y: { scale: 'y', value: config.minProbability, round: false }, + // strokeWidth: { value: 2 }, + // strokeDash: { value: [8, 4] }, + // stroke: { value: 'red' }, + // }, + // }, + // }, ], title: { diff --git a/src/utils/plotSpecs/generateMitochondrialContentSpec.js b/src/utils/plotSpecs/generateMitochondrialContentSpec.js index e3ed0d93e2..ff355892d7 100644 --- a/src/utils/plotSpecs/generateMitochondrialContentSpec.js +++ b/src/utils/plotSpecs/generateMitochondrialContentSpec.js @@ -2,9 +2,6 @@ const generateSpec = (config, plotData) => { let legend = []; - console.log('plotDataDebug'); - console.log(plotData); - if (config.legend.enabled) { legend = [ {