Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BIOMAGE-1273] Refactor ProjectDetails component #458

Merged
merged 27 commits into from
Aug 30, 2021
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e8f4518
some refactors
StefanBabukov Aug 19, 2021
58c090e
move samples table to separate component
StefanBabukov Aug 20, 2021
c26ea16
changes
StefanBabukov Aug 20, 2021
a0da69c
changes
StefanBabukov Aug 23, 2021
5d12627
refactor
StefanBabukov Aug 23, 2021
c1088a1
remove moved functions
StefanBabukov Aug 24, 2021
2e6a589
merge
StefanBabukov Aug 24, 2021
24db14e
merge
StefanBabukov Aug 24, 2021
ec9ed05
fix
StefanBabukov Aug 24, 2021
d063b2e
comment fixes
StefanBabukov Aug 25, 2021
59306f1
comment fixes
StefanBabukov Aug 25, 2021
49f84be
comment fix
StefanBabukov Aug 25, 2021
ee7f292
refactors
StefanBabukov Aug 26, 2021
70813e5
merge conflicts
StefanBabukov Aug 26, 2021
b19623a
add more tests
StefanBabukov Aug 26, 2021
ae0a974
change
StefanBabukov Aug 27, 2021
f9a0f12
Merge branch 'master' into rework-project-details
StefanBabukov Aug 27, 2021
38dcf8f
changes
StefanBabukov Aug 27, 2021
f8cf171
Merge branch 'rework-project-details' of https://github.com/biomage-l…
StefanBabukov Aug 27, 2021
ecb4e78
move download to util func
StefanBabukov Aug 30, 2021
51fdbff
fixes
StefanBabukov Aug 30, 2021
1ede5ec
Merge branch 'master' into rework-project-details
StefanBabukov Aug 30, 2021
a1c07ea
samples analysed also depends on the experimentSettings
StefanBabukov Aug 30, 2021
ac9c5c0
Merge branch 'rework-project-details' of https://github.com/biomage-l…
StefanBabukov Aug 30, 2021
9d0fd62
remove package lock
StefanBabukov Aug 30, 2021
c75f030
actually idk if it should be there - its in master
StefanBabukov Aug 30, 2021
f8c855e
fix
StefanBabukov Aug 30, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions src/__test__/components/data-management/ProjectDetails.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@ 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';
cosa65 marked this conversation as resolved.
Show resolved Hide resolved
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 projectsMetadataCreate from '../../../redux/actions/projects/createMetadataTrack';
StefanBabukov marked this conversation as resolved.
Show resolved Hide resolved
cosa65 marked this conversation as resolved.
Show resolved Hide resolved
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 { render, screen } = rtl;
const width = 600;
const height = 400;

Expand Down Expand Up @@ -99,6 +103,11 @@ const withDataState = {
};

describe('ProjectDetails', () => {
let metadataCreated;
beforeEach(() => {
jest.clearAllMocks();
metadataCreated = jest.spyOn(projectsMetadataCreate, 'default');
});
it('Has a title, project ID and description', () => {
render(
<Provider store={mockStore(noDataState)}>
Expand Down Expand Up @@ -246,4 +255,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
167 changes: 167 additions & 0 deletions src/components/data-management/DownloadData.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/* eslint-disable import/no-unresolved */
import React, { useEffect } 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) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any unit tests for this component

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can add them as part of your next PR

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);

// Change if we have more than one experiment per project
const experimentId = activeProject?.experiments[0];

useEffect(() => {
if (experimentId) {
dispatch(loadBackendStatus(experimentId));
StefanBabukov marked this conversation as resolved.
Show resolved Hide resolved
}
}, [activeProject]);
StefanBabukov marked this conversation as resolved.
Show resolved Hide resolved

const pipelineHasRun = () => (
experimentId
&& (backendStatus[experimentId]?.status.pipeline?.status === pipelineStatus.SUCCEEDED)

StefanBabukov marked this conversation as resolved.
Show resolved Hide resolved
);
const gem2sHasRun = () => (
experimentId
&& (backendStatus[experimentId]?.status?.gem2s?.status === pipelineStatus.SUCCEEDED)
);

const allSamplesAnalysed = () => {
// 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 (!Object.values(downloadTypes).includes(type)) throw new Error('Invalid download type');
StefanBabukov marked this conversation as resolved.
Show resolved Hide resolved

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 (
StefanBabukov marked this conversation as resolved.
Show resolved Hide resolved
<Dropdown
overlay={() => (
<Menu>
<Menu.Item
key='download-raw-seurat'
disabled={!gem2sHasRun()}
StefanBabukov marked this conversation as resolved.
Show resolved Hide resolved
onClick={() => {
downloadExperimentData(downloadTypes.RAW_SEURAT_OBJECT);
}}
>
<Tooltip
title={
gem2sHasRun()
StefanBabukov marked this conversation as resolved.
Show resolved Hide resolved
? '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={
!pipelineHasRun()
StefanBabukov marked this conversation as resolved.
Show resolved Hide resolved
}
onClick={() => {
// Change if we have more than one experiment per project
downloadExperimentData(downloadTypes.PROCESSED_SEURAT_OBJECT);
}}
>
<Tooltip
title={
pipelineHasRun(activeProject?.experiments[0])
StefanBabukov marked this conversation as resolved.
Show resolved Hide resolved
StefanBabukov marked this conversation as resolved.
Show resolved Hide resolved
? '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()}
StefanBabukov marked this conversation as resolved.
Show resolved Hide resolved
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, validateInputs, 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)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would keep the onDelete pattern in the prop we receive, so onDeleteMetadataColumn, if this pattern is used somewhere else I would also change it to on... which what is normally used.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EditableField is used in many places so passing onDeleteMetadataColumn won't be accurate for the other use cases

onAfterSubmit={(newName) => dispatch(
updateMetadataTrack(name, newName, activeProjectUuid),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be dispatched, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is when you make changes to the editable field we want to update it

)}
value={name}
validationFunc={
(newName) => validateInputs(newName, metadataNameValidation)
StefanBabukov marked this conversation as resolved.
Show resolved Hide resolved
}
/>
<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,
validateInputs: 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