Skip to content

Commit

Permalink
Merge pull request #458 from biomage-ltd/rework-project-details
Browse files Browse the repository at this point in the history
[BIOMAGE-1273] Refactor ProjectDetails component
  • Loading branch information
StefanBabukov authored Aug 30, 2021
2 parents b5c5098 + f8c855e commit 796e4a9
Show file tree
Hide file tree
Showing 27 changed files with 915 additions and 35,828 deletions.
35,249 changes: 64 additions & 35,185 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@
"@zeit/next-css": "^1.0.1",
"@zeit/next-less": "^1.0.1",
"antd": "^4.16.5",
"array-move": "^3.0.1",
"aws-amplify": "^3.3.26",
"aws-sdk": "^2.879.0",
"babel-plugin-dynamic-import-node": "^2.3.3",
Expand Down
48 changes: 43 additions & 5 deletions src/__test__/components/data-management/ProjectDetails.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@ import '@testing-library/jest-dom';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { render, screen } from '@testing-library/react';

import * as rtl from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createStore, applyMiddleware } from 'redux';
import _ from 'lodash';
import { fireEvent } from '@testing-library/dom';
import rootReducer from '../../../redux/reducers/index';
import * as createMetadataTrack from '../../../redux/actions/projects/createMetadataTrack';
import ProjectDetails from '../../../components/data-management/ProjectDetails';

import initialProjectState, { projectTemplate } from '../../../redux/reducers/projects/initialState';
import initialSamplesState, { sampleTemplate } from '../../../redux/reducers/samples/initialState';
import initialExperimentsState from '../../../redux/reducers/experiments/initialState';
import initialExperimentSettingsState from '../../../redux/reducers/experimentSettings/initialState';
import UploadStatus from '../../../utils/upload/UploadStatus';

const mockStore = configureStore([thunk]);

const width = 600;
const height = 400;

const { screen, render } = rtl;
const projectName = 'Project 1';
const projectUuid = 'project-1-uuid';
const projectDescription = 'Some description';
Expand Down Expand Up @@ -99,6 +102,11 @@ const withDataState = {
};

describe('ProjectDetails', () => {
let metadataCreated;
beforeEach(() => {
jest.clearAllMocks();
metadataCreated = jest.spyOn(createMetadataTrack, 'default');
});
it('Has a title, project ID and description', () => {
render(
<Provider store={mockStore(noDataState)}>
Expand Down Expand Up @@ -246,4 +254,34 @@ describe('ProjectDetails', () => {
expect(screen.getByText(sample1Name)).toBeDefined();
expect(screen.getByText(sample2Name)).toBeDefined();
});

it('Creates a metadata column', async () => {
const store = createStore(rootReducer, _.cloneDeep(withDataState), applyMiddleware(thunk));
render(
<Provider store={store}>
<ProjectDetails width={width} height={height} />
</Provider>,
);
const addMetadata = screen.getByText('Add metadata');
userEvent.click(addMetadata);
const field = screen.getByRole('textbox');
userEvent.type(field, 'myBrandNewMetadata');
fireEvent.keyDown(field, { key: 'Enter', code: 'Enter' });
await rtl.waitFor(() => expect(metadataCreated).toBeCalledTimes(1));
});

it('Cancels metadata creation', () => {
const store = createStore(rootReducer, _.cloneDeep(withDataState), applyMiddleware(thunk));
render(
<Provider store={store}>
<ProjectDetails width={width} height={height} />
</Provider>,
);
const addMetadata = screen.getByText('Add metadata');
userEvent.click(addMetadata);
const field = screen.getByRole('textbox');
userEvent.type(field, 'somenewMeta');
fireEvent.keyDown(field, { key: 'Escape', code: 'Escape' });
expect(store.getState().projects[projectUuid].metadataKeys).toEqual(['metadata-1']);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { metadataNameToKey } from '../../../../utils/metadataUtils';
import { metadataNameToKey } from '../../../../utils/data-management/metadataUtils';
import deleteMetadataTrack from '../../../../redux/actions/projects/deleteMetadataTrack';
import initialProjectState from '../../../../redux/reducers/projects';
import initialSampleState from '../../../../redux/reducers/samples';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { metadataNameToKey } from '../../../../utils/metadataUtils';
import { metadataNameToKey } from '../../../../utils/data-management/metadataUtils';
import updateMetadataTrack from '../../../../redux/actions/projects/updateMetadataTrack';
import initialProjectState from '../../../../redux/reducers/projects';
import initialSampleState from '../../../../redux/reducers/samples';
Expand Down
2 changes: 1 addition & 1 deletion src/__test__/utils/exportQCParameters.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
exportQCParameters,
filterQCParameters,
} from '../../utils/exportQCParameters';
} from '../../utils/data-management/exportQCParameters';
import { qcSteps } from '../../utils/qcSteps';

describe('Export of QC parameters', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/__test__/utils/metadataUtils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
metadataKeyToName,
metadataNameToKey,
temporaryMetadataKey,
} from '../../utils/metadataUtils';
} from '../../utils/data-management/metadataUtils';

describe('metadataUtils', () => {
it('metadataKeyToName converts name correctly', () => {
Expand Down
169 changes: 169 additions & 0 deletions src/components/data-management/DownloadData.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/* eslint-disable import/no-unresolved */
import React, { useEffect, useState } from 'react';
import _ from 'lodash';
import {
Menu, Tooltip, Dropdown, Button,
} from 'antd';

import { useSelector, useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import { saveAs } from 'file-saver';
import downloadTypes from 'utils/data-management/downloadTypes';
import { getFromApiExpectOK } from 'utils/getDataExpectOK';
import pushNotificationMessage from 'utils/pushNotificationMessage';
import endUserMessages from 'utils/endUserMessages';
import pipelineStatus from '../../utils/pipelineStatusValues';
import { exportQCParameters, filterQCParameters } from '../../utils/data-management/exportQCParameters';
import { loadBackendStatus } from '../../redux/actions/backendStatus/index';

const DownloadData = (props) => {
const {
activeProjectUuid,
} = props;
const dispatch = useDispatch();
const activeProject = useSelector((state) => state.projects[activeProjectUuid]);
const experimentSettings = useSelector((state) => state.experimentSettings);
const backendStatus = useSelector((state) => state.backendStatus);
const samples = useSelector((state) => state.samples);
const projects = useSelector((state) => state.projects);
const [qcHasRun, setQcHasRun] = useState(false);
const [gem2sHasRun, setGem2sHasRun] = useState(false);
const [allSamplesAnalysed, setAllSamplesAnalysed] = useState(false);
// Change if we have more than one experiment per project
const experimentId = activeProject?.experiments[0];

useEffect(() => {
if (experimentId && !backendStatus[experimentId]) {
dispatch(loadBackendStatus(experimentId));
}
}, [experimentId]);

useEffect(() => {
setQcHasRun(experimentId
&& (backendStatus[experimentId]?.status.pipeline?.status === pipelineStatus.SUCCEEDED));
setGem2sHasRun(experimentId
&& (backendStatus[experimentId]?.status?.gem2s?.status === pipelineStatus.SUCCEEDED));
}, [backendStatus]);

useEffect(() => {
setAllSamplesAnalysed(getAllSamplesAnalysed());
}, [activeProject, experimentSettings]);

const getAllSamplesAnalysed = () => {
// Returns true only if there is at least one sample in the currently active
// project AND all samples in the project have been analysed.
if (!activeProject?.samples?.length) {
return false;
}
const steps = Object.values(_.omit(experimentSettings?.processing, ['meta']));
return steps.length > 0
// eslint-disable-next-line no-prototype-builtins
&& activeProject?.samples?.every((s) => steps[0].hasOwnProperty(s));
};

const downloadExperimentData = async (type) => {
try {
if (!experimentId) throw new Error('No experimentId specified');
if (!downloadTypes.has(type)) throw new Error('Invalid download type');

const { signedUrl } = await getFromApiExpectOK(`/v1/experiments/${experimentId}/download/${type}`);
const link = document.createElement('a');
link.style.display = 'none';
link.href = signedUrl;

document.body.appendChild(link);
link.click();

setTimeout(() => {
URL.revokeObjectURL(link.href);
link.parentNode.removeChild(link);
}, 0);
} catch (e) {
pushNotificationMessage('error', endUserMessages.ERROR_DOWNLOADING_DATA);
}
};

return (
<Dropdown
overlay={() => (
<Menu>
<Menu.Item
key='download-raw-seurat'
disabled={!gem2sHasRun}
onClick={() => {
downloadExperimentData('raw_seurat_object');
}}
>
<Tooltip
title={
gem2sHasRun
? 'Samples have been merged'
: 'Launch analysis to merge samples'
}
placement='left'
>
Raw Seurat object (.rds)
</Tooltip>
</Menu.Item>
<Menu.Item
key='download-processed-seurat'
disabled={
!qcHasRun
}
onClick={() => {
// Change if we have more than one experiment per project
downloadExperimentData('processed_seurat_object');
}}
>
<Tooltip
title={
qcHasRun
? 'With Data Processing filters and settings applied'
: 'Launch analysis to process data'
}
placement='left'
>
Processed Seurat object (.rds)
</Tooltip>
</Menu.Item>
<Menu.Item
disabled={!allSamplesAnalysed}
key='download-processing-settings'
onClick={() => {
const config = _.omit(experimentSettings.processing, ['meta']);
const filteredConfig = filterQCParameters(config, activeProject.samples, samples);
const blob = exportQCParameters(filteredConfig);
saveAs(blob, `${activeProjectUuid.split('-')[0]}_settings.txt`);
}}
>
{
allSamplesAnalysed
? 'Data Processing settings (.txt)'
: (
<Tooltip title='One or more of your samples has yet to be analysed' placement='left'>
Data Processing settings (.txt)
</Tooltip>
)
}
</Menu.Item>
</Menu>
)}
trigger={['click']}
placement='bottomRight'
disabled={
projects.ids.length === 0
|| activeProject?.samples?.length === 0
}
>
<Button>
Download
</Button>
</Dropdown>

);
};

DownloadData.propTypes = {
activeProjectUuid: PropTypes.string.isRequired,
};
export default React.memo(DownloadData);
57 changes: 57 additions & 0 deletions src/components/data-management/MetadataColumn.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { Space, Input } from 'antd';
import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import { rules } from '../../utils/validateInputs';
import EditableField from '../EditableField';
import MetadataEditor from './MetadataEditor';
import { DEFAULT_NA } from '../../redux/reducers/projects/initialState';

import {
updateMetadataTrack,
} from '../../redux/actions/projects';

const MetadataColumn = (props) => {
const dispatch = useDispatch();
const {
name, validateInput, setCells, deleteMetadataColumn, key, activeProjectUuid,
} = props;
const metadataNameValidation = [
rules.MIN_1_CHAR,
rules.ALPHANUM_SPACE,
rules.START_WITH_ALPHABET,
rules.UNIQUE_NAME_CASE_INSENSITIVE,
];
return (
<Space>
<EditableField
deleteEnabled
onDelete={(e, currentName) => deleteMetadataColumn(currentName)}
onAfterSubmit={(newName) => dispatch(
updateMetadataTrack(name, newName, activeProjectUuid),
)}
value={name}
validationFunc={
(newName) => validateInput(newName, metadataNameValidation)
}
/>
<MetadataEditor
onReplaceEmpty={(value) => setCells(value, key, 'REPLACE_EMPTY')}
onReplaceAll={(value) => setCells(value, key, 'REPLACE_ALL')}
onClearAll={() => setCells(DEFAULT_NA, key, 'CLEAR_ALL')}
massEdit
>
<Input />
</MetadataEditor>
</Space>
);
};
MetadataColumn.propTypes = {
name: PropTypes.string.isRequired,
validateInput: PropTypes.func.isRequired,
setCells: PropTypes.func.isRequired,
deleteMetadataColumn: PropTypes.func.isRequired,
key: PropTypes.string.isRequired,
activeProjectUuid: PropTypes.string.isRequired,
};
export default MetadataColumn;
2 changes: 1 addition & 1 deletion src/components/data-management/MetadataPopover.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Popover } from 'antd';
import EditableField from '../EditableField';
import { metadataKeyToName, metadataNameToKey } from '../../utils/metadataUtils';
import { metadataKeyToName, metadataNameToKey } from '../../utils/data-management/metadataUtils';
import validateInputs, { rules } from '../../utils/validateInputs';

const validationChecks = [
Expand Down
Loading

0 comments on commit 796e4a9

Please sign in to comment.