diff --git a/CHANGELOG.md b/CHANGELOG.md index 36bffc8ece..d8e4e7c1d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## dev +## 10/28/24 +* Update RNA Tissue Type choices (REQUIRES DB MIGRATION) + ## 9/19/24 * Update Biosample choices (REQUIRES DB MIGRATION) * Add support for Azure OAuth diff --git a/seqr/migrations/0077_alter_rnasample_tissue_type.py b/seqr/migrations/0077_alter_rnasample_tissue_type.py new file mode 100644 index 0000000000..04623da42d --- /dev/null +++ b/seqr/migrations/0077_alter_rnasample_tissue_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-10-27 17:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('seqr', '0076_alter_individual_sex'), + ] + + operations = [ + migrations.AlterField( + model_name='rnasample', + name='tissue_type', + field=models.CharField(choices=[('WB', 'whole_blood'), ('F', 'fibroblasts'), ('M', 'muscle'), ('L', 'lymphocytes'), ('A', 'airway_cultured_epithelium'), ('B', 'brain')], max_length=2), + ), + ] diff --git a/seqr/models.py b/seqr/models.py index 267ec0d247..5461deec8c 100644 --- a/seqr/models.py +++ b/seqr/models.py @@ -762,6 +762,7 @@ class RnaSample(ModelWithGUID): ('M', 'muscle'), ('L', 'lymphocytes'), ('A', 'airway_cultured_epithelium'), + ('B', 'brain'), ) individual = models.ForeignKey('Individual', on_delete=models.PROTECT) diff --git a/seqr/views/apis/project_api.py b/seqr/views/apis/project_api.py index 7d923fdb53..f5e388962c 100644 --- a/seqr/views/apis/project_api.py +++ b/seqr/views/apis/project_api.py @@ -187,7 +187,7 @@ def project_page_data(request, project_guid): 'parental_ids': { 'agg': ArrayAgg(JSONObject(**{k: k for k in ['id', 'guid', 'father_id', 'mother_id']})), 'format': lambda parental_ids, id_guid_map: [ - {'paternalGuid': id_guid_map.get(p['father_id']), 'maternalGuid': id_guid_map.get(p['mother_id'])} + {'paternalGuid': id_guid_map.get(p['father_id']), 'maternalGuid': id_guid_map.get(p['mother_id']), 'individualGuid': p['guid']} for p in parental_ids if p['father_id'] or p['mother_id'] ], 'response_key': 'parents', diff --git a/seqr/views/apis/project_api_tests.py b/seqr/views/apis/project_api_tests.py index 48e2251e7b..73c5916b5c 100644 --- a/seqr/views/apis/project_api_tests.py +++ b/seqr/views/apis/project_api_tests.py @@ -402,7 +402,7 @@ def test_project_families(self): self.assertTrue(family_1['hasRequiredMetadata']) self.assertFalse(family_3['hasRequiredMetadata']) self.assertFalse(empty_family['hasRequiredMetadata']) - self.assertListEqual(family_1['parents'], [{'maternalGuid': 'I000003_na19679', 'paternalGuid': 'I000002_na19678'}]) + self.assertListEqual(family_1['parents'], [{'maternalGuid': 'I000003_na19679', 'paternalGuid': 'I000002_na19678', 'individualGuid': 'I000001_na19675'}]) self.assertListEqual(family_3['parents'], []) self.assertListEqual(empty_family['parents'], []) self.assertEqual(family_1['hasPhenotypePrioritization'], True) diff --git a/ui/pages/Project/components/FamilyTable/IndividualRow.jsx b/ui/pages/Project/components/FamilyTable/IndividualRow.jsx index 1b5bccec3b..a7c6dfdda0 100644 --- a/ui/pages/Project/components/FamilyTable/IndividualRow.jsx +++ b/ui/pages/Project/components/FamilyTable/IndividualRow.jsx @@ -495,9 +495,9 @@ const EDIT_INDIVIDUAL_FIELDS = [INDIVIDUAL_FIELD_SEX, INDIVIDUAL_FIELD_AFFECTED] ))) const mapIgvOptionsStateToProps = (state) => { - const { namespace, name } = getCurrentProject(state) + const { workspaceNamespace, workspaceName } = getCurrentProject(state) return { - url: `/api/anvil_workspace/${namespace}/${name}/get_igv_options`, + url: `/api/anvil_workspace/${workspaceNamespace}/${workspaceName}/get_igv_options`, } } diff --git a/ui/pages/Project/components/ProjectOverview.jsx b/ui/pages/Project/components/ProjectOverview.jsx index 41e93ad990..1701ec1253 100644 --- a/ui/pages/Project/components/ProjectOverview.jsx +++ b/ui/pages/Project/components/ProjectOverview.jsx @@ -26,8 +26,8 @@ import { updateProjectMmeContact, loadMmeSubmissions, updateAnvilWorkspace } fro import { getCurrentProject, getAnalysisStatusCounts, - getProjectAnalysisGroupFamilyIndividualCounts, - getProjectAnalysisGroupDataLoadedFamilyIndividualCounts, + getProjectAnalysisGroupFamilySizeHistogram, + getProjectAnalysisGroupDataLoadedFamilySizeHistogram, getProjectAnalysisGroupSamplesByTypes, getProjectAnalysisGroupMmeSubmissionDetails, getMmeSubmissionsLoading, @@ -46,17 +46,15 @@ const FAMILY_SIZE_LABELS = { } const FAMILY_STRUCTURE_SIZE_LABELS = { - 2: plural => ` duo${plural ? 's' : ''}`, - 3: plural => ` trio${plural ? 's' : ''}`, - 4: plural => ` quad${plural ? 's' : ''}`, - 5: plural => ` trio${plural ? 's' : ''}+`, + 2: 'duo', + 3: 'trio', + 4: 'quad', } const FAMILY_STRUCTURE_HOVER = { 2: 'A family with one parent and one child', 3: 'A family with two parents and one child', 4: 'A family with two parents and two children', - 5: 'A family with two parents and three or more other family members', } const SAMPLE_TYPE_LOOKUP = SAMPLE_TYPE_OPTIONS.reduce( @@ -160,19 +158,24 @@ const MatchmakerSubmissionOverview = connect( mapMatchmakerSubmissionsStateToProps, mapDispatchToProps, )(BaseMatchmakerSubmissionOverview) -const FamiliesIndividuals = React.memo(({ canEdit, hasCaseReview, familyCounts, user, title }) => { - const familySizeHistogram = familyCounts.reduce((acc, { size, numParents }) => { - const familySize = Math.min(size, 5) - const sizeAcc = acc[familySize] || { total: 0, withParents: 0 } - sizeAcc.total += 1 - if (familySize === 2 && numParents) { - sizeAcc.withParents += 1 - } else if (familySize > 2 && numParents === 2) { - sizeAcc.withParents += 1 +const MAX_FAMILY_HIST_SIZE = 5 + +const FamiliesIndividuals = React.memo(({ canEdit, hasCaseReview, familySizes, user, title }) => { + const familiesCount = Object.values(familySizes).reduce((acc, { total }) => acc + total, 0) + const individualsCount = Object.entries(familySizes).reduce((acc, [size, { total }]) => acc + (size * total), 0) + const familySizeHistogram = Object.entries(familySizes).reduce((acc, [size, counts]) => { + if (size <= MAX_FAMILY_HIST_SIZE) { + return { ...acc, [size]: counts } + } + if (!acc[MAX_FAMILY_HIST_SIZE]) { + acc[MAX_FAMILY_HIST_SIZE] = { total: 0, withParents: 0, trioPlus: 0, quadPlus: 0 } } - return { ...acc, [familySize]: sizeAcc } + acc[MAX_FAMILY_HIST_SIZE].total += counts.total + acc[MAX_FAMILY_HIST_SIZE].trioPlus += counts.trioPlus + acc[MAX_FAMILY_HIST_SIZE].quadPlus += acc[MAX_FAMILY_HIST_SIZE].withParents + counts.withParents + counts.quadPlus + acc[MAX_FAMILY_HIST_SIZE].withParents = 0 + return acc }, {}) - const individualsCount = familyCounts.reduce((acc, { size }) => acc + size, 0) let editIndividualsButton = null if (user && (user.isPm || (hasCaseReview && canEdit))) { @@ -185,25 +188,29 @@ const FamiliesIndividuals = React.memo(({ canEdit, hasCaseReview, familyCounts, - {`${Object.keys(familyCounts).length} Families${title || ''},`} + {`${familiesCount} Families${title || ''},`}
{`${individualsCount} Individuals${title || ''}`} )} content={ - sortBy(Object.entries(familySizeHistogram)).map(([size, { total, withParents }]) => ( + sortBy(Object.entries(familySizeHistogram)).map(([size, { total, withParents, trioPlus, quadPlus }]) => (
{`${total} famil${total === 1 ? 'y' : 'ies'} with ${FAMILY_SIZE_LABELS[size] || size} individual${size === '1' ? '' : 's'}`} - {withParents > 0 && ( -
+ {[ + [withParents, FAMILY_STRUCTURE_SIZE_LABELS[size], FAMILY_STRUCTURE_HOVER[size], total > 1], + [trioPlus, 'trio+', 'A family with two parents, one child, and other family members'], + [quadPlus, 'quad+', 'A family with two parents, at least two children, and other family members'], + ].filter(([count]) => count > 0).map(([count, label, hover, plural]) => ( +
     - {withParents} + {count} {FAMILY_STRUCTURE_SIZE_LABELS[size](total > 1)}} - content={FAMILY_STRUCTURE_HOVER[size]} + trigger={{` ${label}${plural ? 's' : ''}`}} + content={hover} />
- )} + ))}
)) } @@ -213,7 +220,7 @@ const FamiliesIndividuals = React.memo(({ canEdit, hasCaseReview, familyCounts, }) FamiliesIndividuals.propTypes = { - familyCounts: PropTypes.arrayOf(PropTypes.object).isRequired, + familySizes: PropTypes.object.isRequired, canEdit: PropTypes.bool, hasCaseReview: PropTypes.bool, user: PropTypes.object, @@ -222,12 +229,12 @@ FamiliesIndividuals.propTypes = { const mapFamiliesStateToProps = (state, ownProps) => ({ user: getUser(state), - familyCounts: getProjectAnalysisGroupFamilyIndividualCounts(state, ownProps), + familySizes: getProjectAnalysisGroupFamilySizeHistogram(state, ownProps), }) const mapDataLoadedFamiliesStateToProps = (state, ownProps) => ({ title: ' With Data', - familyCounts: getProjectAnalysisGroupDataLoadedFamilyIndividualCounts(state, ownProps), + familySizes: getProjectAnalysisGroupDataLoadedFamilySizeHistogram(state, ownProps), }) const FamiliesIndividualsOverview = connect(mapFamiliesStateToProps)(FamiliesIndividuals) diff --git a/ui/pages/Project/selectors.js b/ui/pages/Project/selectors.js index 703423596a..1ba63efaa2 100644 --- a/ui/pages/Project/selectors.js +++ b/ui/pages/Project/selectors.js @@ -97,30 +97,65 @@ export const getProjectAnalysisGroupFamiliesByGuid = createSelector( }, ) -export const getProjectAnalysisGroupFamilyIndividualCounts = createSelector( +const getFamilySizeHistogram = familyCounts => familyCounts.reduce((acc, { size, parents }) => { + const parentCounts = Object.values(parents.reduce( + (parentAcc, { maternalGuid, paternalGuid }) => { + const parentKey = `${maternalGuid || ''}-${paternalGuid || ''}` + const parent = parentAcc[parentKey] || { + numParents: [maternalGuid, paternalGuid].filter(g => g).length, + numChildren: 0, + } + parent.numChildren += 1 + return { ...parentAcc, [parentKey]: parent } + }, {}, + )) + const sizeAcc = acc[size] || { total: 0, withParents: 0, trioPlus: 0, quadPlus: 0 } + sizeAcc.total += 1 + const mainParentCount = parentCounts.find(({ numParents }) => numParents === (size === 2 ? 1 : 2)) + const mainFamilySize = mainParentCount ? mainParentCount.numChildren + mainParentCount.numParents : 0 + if (mainFamilySize === size) { + sizeAcc.withParents += 1 + } else if (mainFamilySize === 3) { + sizeAcc.trioPlus += 1 + } else if (mainFamilySize > 3) { + sizeAcc.quadPlus += 1 + } + return { ...acc, [size]: sizeAcc } +}, {}) + +export const getProjectAnalysisGroupFamilySizeHistogram = createSelector( getProjectAnalysisGroupFamiliesByGuid, - familiesByGuid => Object.values(familiesByGuid).map(family => ({ + familiesByGuid => getFamilySizeHistogram(Object.values(familiesByGuid).map(family => ({ size: (family.individualGuids || []).length, - numParents: (family.parents || []).length === 1 ? - [family.parents[0].maternalGuid, family.parents[0].paternalGuid].filter(g => g).length : 0, - })), + parents: family.parents || [], + }))), ) -export const getProjectAnalysisGroupDataLoadedFamilyIndividualCounts = createSelector( +export const getProjectAnalysisGroupDataLoadedFamilySizeHistogram = createSelector( getProjectAnalysisGroupFamiliesByGuid, getSamplesByFamily, - (familiesByGuid, samplesByFamily) => Object.values(familiesByGuid).map(((family) => { + (familiesByGuid, samplesByFamily) => getFamilySizeHistogram(Object.values(familiesByGuid).map(((family) => { const sampleIndividuals = new Set((samplesByFamily[family.familyGuid] || []).filter( sample => sample.isActive, ).map(sample => sample.individualGuid)) - const hasSampleParentCounts = (family.parents || []).map( - ({ maternalGuid, paternalGuid }) => [maternalGuid, paternalGuid].filter(guid => sampleIndividuals.has(guid)), - ).filter(parents => parents.length > 0) + const hasSampleParents = (family.parents || []).reduce( + (acc, { individualGuid, maternalGuid, paternalGuid }) => { + const hasSampleMaternal = sampleIndividuals.has(maternalGuid) + const hasSamplePaternal = sampleIndividuals.has(paternalGuid) + if (sampleIndividuals.has(individualGuid) && (hasSampleMaternal || hasSamplePaternal)) { + acc.push({ + maternalGuid: hasSampleMaternal ? maternalGuid : null, + paternalGuid: hasSamplePaternal ? paternalGuid : null, + }) + } + return acc + }, [], + ) return { size: sampleIndividuals.size, - numParents: hasSampleParentCounts.length === 1 ? hasSampleParentCounts[0].length : 0, + parents: hasSampleParents, } - })).filter(({ size }) => size > 0), + })).filter(({ size }) => size > 0)), ) export const getProjectAnalysisGroupIndividualsByGuid = createSelector( diff --git a/ui/shared/utils/constants.js b/ui/shared/utils/constants.js index dde73acbc9..7b9cad9c60 100644 --- a/ui/shared/utils/constants.js +++ b/ui/shared/utils/constants.js @@ -396,6 +396,7 @@ export const CATEGORY_FAMILY_FILTERS = { // INDIVIDUAL FIELDS const SEX_MALE = 'M' const SEX_FEMALE = 'F' +const SEX_UNKNOWN = 'U' const MALE_ANEUPLOIDIES = ['XXY', 'XYY'] const FEMALE_ANEUPLOIDIES = ['XXX', 'X0'] export const SEX_OPTIONS = [ @@ -409,6 +410,7 @@ export const SEX_OPTIONS = [ export const SIMPLIFIED_SEX_LOOKUP = { ...[SEX_MALE, ...MALE_ANEUPLOIDIES].reduce((acc, val) => ({ ...acc, [val]: SEX_MALE }), {}), ...[SEX_FEMALE, ...FEMALE_ANEUPLOIDIES].reduce((acc, val) => ({ ...acc, [val]: SEX_FEMALE }), {}), + [SEX_UNKNOWN]: SEX_UNKNOWN, } export const SEX_LOOKUP = SEX_OPTIONS.reduce( @@ -2004,6 +2006,8 @@ export const TISSUE_DISPLAY = { F: 'Fibroblast', M: 'Muscle', L: 'Lymphocyte', + A: 'Airway Cultured Epithelium', + B: 'Brain', } export const RNASEQ_JUNCTION_PADDING = 200