Skip to content

Commit e84cc13

Browse files
authored
Merge pull request #2309 from broadinstitute/development
Release 1.103
2 parents 91c751a + 022394a commit e84cc13

27 files changed

+525
-288
lines changed

Gemfile.lock

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ GEM
372372
puma (5.6.9)
373373
nio4r (~> 2.0)
374374
racc (1.8.1)
375-
rack (2.2.14)
375+
rack (2.2.19)
376376
rack-brotli (1.1.0)
377377
brotli (>= 0.1.7)
378378
rack (>= 1.4)
@@ -546,6 +546,7 @@ GEM
546546
PLATFORMS
547547
arm64-darwin-21
548548
arm64-darwin-23
549+
arm64-darwin-24
549550
x86_64-darwin-19
550551
x86_64-darwin-20
551552
x86_64-darwin-21

app/controllers/api/v1/search_controller.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,14 @@ def index
282282
sort_type = params[:order].to_sym
283283
end
284284

285+
result_types = params[:data_types]&.split(',')&.map(&:to_sym) || []
286+
@studies = StudySearchService.filter_results_by_data_type(@studies, result_types)
287+
285288
# convert to array to allow appending external search results (Azul, TDR, etc.)
286289
@studies = @studies.to_a
287290

291+
# filter results by file type, if requested
292+
288293
# perform Azul search if there are facets/terms provided by user, and they requested HCA results
289294
# run this before inferred search so that they are weighted and sorted correctly
290295
# skip if user is searching inside a collection or they are performing global gene search
@@ -391,6 +396,7 @@ def index
391396
# only show results where we found a hit in gene search
392397
@inferred_studies = Study.where(:id.in => new_genes[:study_ids])
393398
end
399+
@inferred_studies = StudySearchService.filter_results_by_data_type(@inferred_studies, result_types)
394400
@inferred_accessions = @inferred_studies.pluck(:accession)
395401
logger.info "Found #{@inferred_accessions.count} inferred matches: #{@inferred_accessions}"
396402
@matching_accessions += @inferred_accessions
@@ -399,7 +405,6 @@ def index
399405
end
400406

401407
@matching_accessions = @studies.map { |study| self.class.get_study_attribute(study, :accession) }
402-
logger.info "studies_by_facet: #{@studies_by_facet}"
403408
logger.info "Final list of matching studies: #{@matching_accessions}"
404409
@results = @studies.paginate(page: params[:page], per_page: Study.per_page)
405410
if params[:export].present?

app/controllers/api/v1/study_files_controller.rb

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,19 @@ def perform_update!(study_file)
331331
end
332332
if safe_file_params[:custom_color_updates]
333333
parsed_update = JSON.parse(safe_file_params[:custom_color_updates])
334-
safe_file_params['cluster_file_info'] = {custom_colors: ClusterFileInfo.merge_color_updates(study_file, parsed_update)}
334+
safe_file_params['cluster_file_info'] = {
335+
custom_colors: ClusterFileInfo.merge_color_updates(study_file, parsed_update)
336+
}
335337
safe_file_params.delete(:custom_color_updates)
338+
if safe_file_params[:global_color_update]
339+
@study.clustering_files.reject { |f| f.id.to_s == params[:id] }.each do |file|
340+
update_params = {
341+
'cluster_file_info' => { custom_colors: ClusterFileInfo.merge_color_updates(file, parsed_update) }
342+
}
343+
file.delay.update(update_params)
344+
end
345+
end
346+
safe_file_params.delete(:global_color_update)
336347
end
337348

338349
# manually check first if species/assembly was supplied by name
@@ -741,7 +752,7 @@ def study_file_params
741752
:human_fastq_url, :human_data, :use_metadata_convention, :cluster_type, :generation, :x_axis_label,
742753
:y_axis_label, :z_axis_label, :x_axis_min, :x_axis_max, :y_axis_min, :y_axis_max, :z_axis_min, :z_axis_max,
743754
:species, :assembly, :external_link_url, :external_link_title, :external_link_description, :parse_on_upload,
744-
:custom_color_updates, :reference_anndata_file,
755+
:custom_color_updates, :global_color_update, :reference_anndata_file,
745756
spatial_cluster_associations: [],
746757
options: [
747758
:cluster_group_id, :font_family, :font_size, :font_color, :matrix_id, :submission_id, :bam_id, :bed_id,

app/javascript/components/search/controls/OptionsButton.jsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,24 @@ export default function OptionsButton() {
1010
const searchContext = useContext(StudySearchContext)
1111
const [showOptions, setShowOptions] = useState(false)
1212
const configuredOptions = [
13-
{ searchProp: 'external', value: 'hca', label: 'Include HCA results' }
13+
{ searchProp: 'external', value: 'hca', label: 'Include HCA results' },
14+
{ searchProp: 'data_types', value: 'raw_counts', label: 'Has raw counts', multiple: true },
15+
{ searchProp: 'data_types', value: 'diff_exp', label: 'Has differential expression', multiple: true },
16+
{ searchProp: 'data_types', value: 'spatial', label: 'Has spatial data', multiple: true }
1417
]
1518

1619
const optionsPopover = <Popover data-analytics-name='search-options-menu' id='search-options-menu'>
1720
<ul className="facet-filter-list">
1821
{
19-
configuredOptions.map((option) => {
22+
configuredOptions.map((option, index) => {
2023
return <OptionsControl
21-
key={option.searchProp}
24+
key={`${option.searchProp}-${index}`}
2225
searchContext={searchContext}
2326
searchProp={option.searchProp}
2427
value={option.value}
25-
label={option.label}/>
28+
label={option.label}
29+
multiple={option.multiple}
30+
/>
2631
})
2732
}
2833
</ul>

app/javascript/components/search/controls/OptionsControl.jsx

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,44 @@
11
import React, { useState } from 'react'
22

3-
export default function OptionsControl({searchContext, searchProp, value, label}) {
4-
const defaultChecked = searchContext.params[searchProp] === value
3+
/** checkbox control for adding optional parameters to search query */
4+
export default function OptionsControl({ searchContext, searchProp, value, label, multiple = false }) {
5+
const defaultChecked = isDefaultChecked()
56
const [isChecked, setIsChecked] = useState(defaultChecked)
67

8+
/** return existing url query params for this option */
9+
function getExistingOpts() {
10+
return searchContext.params[searchProp]?.split(',').filter(o => o !== '') || []
11+
}
12+
13+
/** set the default state for this option checkbox */
14+
function isDefaultChecked() {
15+
if (multiple) {
16+
return getExistingOpts().includes(value)
17+
} else {
18+
return searchContext.params[searchProp] === value
19+
}
20+
}
21+
722
/** toggle state of checkbox */
823
function toggleCheckbox(checked) {
924
setIsChecked(checked)
10-
searchContext.updateSearch({ [searchProp] : checked ? value : null })
25+
if (multiple) {
26+
const existingOpts = getExistingOpts()
27+
const newOpts = checked ? existingOpts.concat(value) : existingOpts.filter(v => v !== value)
28+
searchContext.updateSearch({ [searchProp] : newOpts.join(',') })
29+
} else {
30+
searchContext.updateSearch({ [searchProp] : checked ? value : null })
31+
}
1132
}
1233

1334
return (
1435
<li id={`options-control-${searchProp}`} key={`options-control-${searchProp}`}>
1536
<label>
16-
<input type="checkbox" checked={isChecked} onChange={() => {toggleCheckbox(!isChecked)}}/>
17-
<span onClick={() => {toggleCheckbox(!isChecked)}} >{ label }</span>
37+
<input data-testid={`options-checkbox-${searchProp}-${value}`}
38+
type="checkbox"
39+
checked={isChecked}
40+
onChange={() => {toggleCheckbox(!isChecked)}}/>
41+
<span onClick={() => {toggleCheckbox(!isChecked)}} >{ label }</span>
1842
</label>
1943
</li>
2044
)

app/javascript/components/search/results/SearchQueryDisplay.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export const ClearAllButton = () => {
7878
const emptySearchParams = {
7979
terms: '',
8080
genes: '',
81+
external: '',
82+
data_types: '',
8183
facets: emptyFilters
8284
}
8385
selectionContext.updateSelection(emptySearchParams, true)

app/javascript/components/visualization/ScatterPlot.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,14 @@ function RawScatterPlot({
143143
}, [editedCustomColors])
144144

145145
/** Save any changes to the legend colors */
146-
async function saveCustomColors(newColors) {
146+
async function saveCustomColors(newColors, globalColorUpdate = false) {
147147
const colorObj = {}
148148
// read the annotation name off of scatterData to ensure it's the real name, and not '' or '_default'
149149
colorObj[scatterData?.annotParams?.name] = newColors
150150
const newFileObj = {
151151
_id: scatterData?.clusterFileId,
152-
custom_color_updates: colorObj
152+
custom_color_updates: colorObj,
153+
global_color_update: globalColorUpdate
153154
}
154155
setIsLoading(true)
155156
try {

app/javascript/components/visualization/controls/ScatterPlotLegend.jsx

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import React, { useEffect, useState } from 'react'
22
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
3-
import { faPalette, faExternalLinkAlt, faTimes, faSearch } from '@fortawesome/free-solid-svg-icons'
3+
import {
4+
faPalette,
5+
faExternalLinkAlt,
6+
faTimes,
7+
faSearch,
8+
faFileUpload,
9+
faGlobe, faFileDownload
10+
} from '@fortawesome/free-solid-svg-icons'
411
import Modal from 'react-bootstrap/lib/Modal'
512
import { HexColorPicker, HexColorInput } from 'react-colorful'
613
import Button from 'react-bootstrap/lib/Button'
@@ -215,6 +222,9 @@ export default function ScatterPlotLegend({
215222
}) {
216223
// is the user currently in color-editing mode
217224
const [showColorControls, setShowColorControls] = useState(false)
225+
const [globalColorUpdate, setGlobalColorUpdate] = useState(false)
226+
const [toggleClassName, setToggleClassName] = useState('fa-toggle-off')
227+
218228
// whether a request to the server to save colors is pending
219229
const labels = getLegendSortedLabels(countsByLabel)
220230
const numLabels = labels.length
@@ -246,18 +256,40 @@ export default function ScatterPlotLegend({
246256
/** resets any unsaved changes to user colors and clears custom colors */
247257
async function resetColors() {
248258
setEditedCustomColors({})
249-
await saveCustomColors({})
259+
await saveCustomColors({}, globalColorUpdate)
250260
setShowColorControls(false)
251261
}
252262

253263
/** save the colors to the server */
254264
async function saveColors() {
255265
// merge the user picked colors with existing custom colors so previously saved values are preserved
256266
const colorsToSave = Object.assign(customColors, editedCustomColors)
257-
await saveCustomColors(colorsToSave)
267+
await saveCustomColors(colorsToSave, globalColorUpdate)
258268
setShowColorControls(false)
259269
}
260270

271+
function exportColors() {
272+
const colorMap = Object.keys(customColors).length > 0 ? customColors : refColorMap
273+
const lines = Object.entries(colorMap).map(([label, color]) => {
274+
return `${label}\t${color}\n`
275+
})
276+
277+
// Create an element with an anchor link and connect this to the blob
278+
const element = document.createElement('a')
279+
const colorExport = new Blob(lines, { type: 'text/plain' })
280+
element.href = URL.createObjectURL(colorExport)
281+
282+
// name the file and indicate it should download
283+
element.download = `${name}_color_map.tsv`
284+
285+
// Simulate clicking the link resulting in downloading the file
286+
document.body.appendChild(element)
287+
element.click()
288+
289+
// Cleanup
290+
document.body.removeChild(element)
291+
}
292+
261293
/** collect general information when a user's mouse enters the legend */
262294
function logMouseEnter() {
263295
log('hover:scatterlegend', { numLabels })
@@ -323,6 +355,40 @@ export default function ScatterPlotLegend({
323355
setLabelsToShow(labels)
324356
}
325357

358+
/** handle clicking global color update toggle */
359+
function handleToggleGlobalColor() {
360+
const toggleClass = toggleClassName === 'fa-toggle-on' ? 'fa-toggle-off' : 'fa-toggle-on'
361+
setGlobalColorUpdate(!globalColorUpdate)
362+
setToggleClassName(toggleClass)
363+
}
364+
365+
/** read uploaded manifest and apply colors to current scatter plot */
366+
function readColorManifest(file) {
367+
const colorUpdate = {}
368+
const fileReader = new FileReader()
369+
fileReader.onloadend = () => {
370+
const lines = fileReader.result.trim().split(/\n/)
371+
lines.map((line, _) => {
372+
const entry = line.split(/[\t,]/).map((l, _) => {return l.trim()})
373+
const label = entry[0]
374+
const color = entry[1]
375+
colorUpdate[label] = color
376+
})
377+
saveCustomColors(colorUpdate, globalColorUpdate)
378+
}
379+
fileReader.readAsText(file)
380+
}
381+
382+
const globalSwitch =
383+
<label htmlFor="global-color-update"
384+
data-toggle="tooltip"
385+
className="color-update-toggle"
386+
title="Apply color changes globally for this annotation">
387+
<span className={globalColorUpdate ? 'text-info' : 'text-muted'} onClick={handleToggleGlobalColor}>Global&nbsp;
388+
<i className={`fa fa-fw ${toggleClassName}`}></i>
389+
</span>
390+
</label>
391+
326392
return (
327393
<div
328394
className={`scatter-legend ${filteredClass}`}
@@ -379,18 +445,41 @@ export default function ScatterPlotLegend({
379445
<a role="button" className="pull-right" data-analytics-name="legend-color-picker-cancel" onClick={cancelColors}>
380446
Cancel
381447
</a><br/>
382-
&nbsp;
448+
{globalSwitch}
383449
<a role="button" className="pull-right" data-analytics-name="legend-color-picker-reset" onClick={resetColors}>
384450
Reset to defaults
385451
</a>
386452
</div>
387453
</>
388454
}
389455
{ !showColorControls &&
390-
<a role="button" data-analytics-name="legend-color-picker-show" onClick={() => setShowColorControls(true)}>
391-
Customize colors <FontAwesomeIcon icon={faPalette}/>
392-
</a>
393-
}
456+
<>
457+
<a role="button"
458+
className='customize-color-palette'
459+
data-analytics-name="legend-color-picker-show"
460+
onClick={() => setShowColorControls(true)}
461+
>
462+
Customize <FontAwesomeIcon icon={faPalette}/>
463+
</a>
464+
{globalSwitch}
465+
<label htmlFor="color-manifest-upload"
466+
data-toggle="tooltip"
467+
className="icon-button"
468+
title="Upload a manifest of annotation labels to color hex codes">
469+
<input id="color-manifest-upload"
470+
type="file"
471+
onChange={e => readColorManifest(e.target.files[0])}/>
472+
<FontAwesomeIcon className="action fa-lg" icon={faFileUpload} />
473+
</label>
474+
<label htmlFor="color-manifest-export"
475+
data-toggle="tooltip"
476+
title="Export current color manifest"
477+
onClick={exportColors}
478+
>
479+
<FontAwesomeIcon className="action fa-lg" icon={faFileDownload} />
480+
</label>
481+
</>
482+
}
394483
</div>
395484
}
396485
<div>

app/javascript/lib/scp-api.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -926,7 +926,7 @@ export async function fetchSearch(type, searchParams, mock=false) {
926926
export function buildSearchQueryString(type, searchParams) {
927927
const facetsParam = buildFacetQueryString(searchParams.facets)
928928

929-
const params = ['page', 'order', 'terms', 'external', 'export', 'preset', 'genes', 'genePage']
929+
const params = ['page', 'order', 'terms', 'external', 'export', 'data_types', 'preset', 'genes', 'genePage']
930930
let otherParamString = params.map(param => {
931931
return searchParams[param] ? `&${param}=${searchParams[param]}` : ''
932932
}).join('')

app/javascript/providers/GeneSearchProvider.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export function buildParamsFromQuery(query, preset) {
128128
genes: cleanGeneParams,
129129
terms: queryParams.terms ? queryParams.terms : '',
130130
external: queryParams.external ? queryParams.external : '',
131+
data_types: queryParams.data_types ? queryParams.data_types : '',
131132
facets: buildFacetsFromQueryString(queryParams.facets),
132133
preset: preset ? preset : queryString.preset_search
133134
}

0 commit comments

Comments
 (0)