Skip to content

Commit 84f236f

Browse files
authored
Merge pull request #2292 from broadinstitute/development
Release 1.101.0
2 parents 5c25ff2 + ad95a1b commit 84f236f

23 files changed

+356
-43
lines changed

Dockerfile

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# use SCP base Rails image, configure only project-specific items here
2-
FROM gcr.io/broad-singlecellportal-staging/rails-baseimage:3.0.0
2+
FROM gcr.io/broad-singlecellportal-staging/rails-baseimage:3.0.1
33

44
# Set ruby version
55
RUN bash -lc 'rvm --default use ruby-3.4.2'
@@ -8,8 +8,6 @@ RUN bash -lc 'rvm rvmrc warning ignore /home/app/webapp/Gemfile'
88
# Set up project dir, install gems, set up script to migrate database and precompile static assets on run
99
RUN mkdir /home/app/webapp
1010
RUN sudo chown app:app /home/app/webapp # fix permission issues in local development on MacOSX
11-
RUN gem update --system
12-
RUN gem install bundler
1311
COPY Gemfile /home/app/webapp/Gemfile
1412
COPY Gemfile.lock /home/app/webapp/Gemfile.lock
1513
WORKDIR /home/app/webapp

Gemfile.lock

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -331,11 +331,11 @@ GEM
331331
net-protocol
332332
netrc (0.11.0)
333333
nio4r (2.7.4)
334-
nokogiri (1.18.8-arm64-darwin)
334+
nokogiri (1.18.9-arm64-darwin)
335335
racc (~> 1.4)
336-
nokogiri (1.18.8-x86_64-darwin)
336+
nokogiri (1.18.9-x86_64-darwin)
337337
racc (~> 1.4)
338-
nokogiri (1.18.8-x86_64-linux-gnu)
338+
nokogiri (1.18.9-x86_64-linux-gnu)
339339
racc (~> 1.4)
340340
oauth2 (1.4.7)
341341
faraday (>= 0.8, < 2.0)
@@ -505,7 +505,7 @@ GEM
505505
systemu (2.6.5)
506506
test-unit (3.4.1)
507507
power_assert
508-
thor (1.3.2)
508+
thor (1.4.0)
509509
tilt (2.0.10)
510510
time_difference (0.5.0)
511511
activesupport
@@ -641,4 +641,4 @@ RUBY VERSION
641641
ruby 3.4.2p28
642642

643643
BUNDLED WITH
644-
2.6.2
644+
2.7.1

app/controllers/api/v1/search_controller.rb

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ class SearchController < ApiBaseController
2929
parameter do
3030
key :name, :type
3131
key :in, :query
32-
key :description, 'Type of query to perform (study- or cell-based)'
32+
key :description, 'Type of query to perform (study- or gene-based)'
3333
key :required, true
3434
key :type, :string
35-
key :enum, ['study', 'cell']
35+
key :enum, %w[study gene]
3636
end
3737
parameter do
3838
key :name, :facets
@@ -94,6 +94,14 @@ class SearchController < ApiBaseController
9494
key :type, :string
9595
key :enum, [:recent, :popular]
9696
end
97+
parameter do
98+
key :name, :export
99+
key :in, :query
100+
key :description, 'Export results as a file'
101+
key :required, false
102+
key :type, :string
103+
key :enum, %w[tsv]
104+
end
97105
response 200 do
98106
key :description, 'Search parameters, Studies and StudyFiles'
99107
schema do
@@ -391,10 +399,17 @@ def index
391399
end
392400

393401
@matching_accessions = @studies.map { |study| self.class.get_study_attribute(study, :accession) }
394-
402+
logger.info "studies_by_facet: #{@studies_by_facet}"
395403
logger.info "Final list of matching studies: #{@matching_accessions}"
396404
@results = @studies.paginate(page: params[:page], per_page: Study.per_page)
397-
render json: search_results_obj, status: 200
405+
if params[:export].present?
406+
send_data results_text_export,
407+
filename: "scp_search_results_#{Time.now.strftime('%Y%m%d_%H%M%S')}.#{params[:export]}",
408+
status: :ok,
409+
x_sendfile: true
410+
else
411+
render json: search_results_obj, status: 200
412+
end
398413
end
399414

400415
swagger_path '/search/facets' do
@@ -657,7 +672,9 @@ def self.match_results_by_filter(search_result:, result_key:, facets:)
657672
else
658673
matching_facet[:filters].detect do |filter|
659674
filters = search_result[result_key].is_a?(Array) ? search_result[result_key] : [search_result[result_key]]
660-
filters.include?(filter[:id]) || filters.include?(filter[:name])
675+
filters.include?(filter[:id]) ||
676+
filters.include?(filter[:name]) ||
677+
filters.include?(filter[:id].gsub(/_/, ':')) # handle malformed IDs
661678
end
662679
end
663680
end

app/controllers/api/v1/study_search_results_objects.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ module StudySearchResultsObjects
88
# list of metadata names to include in cohort responses
99
COHORT_METADATA = %w[disease__ontology_label organ__ontology_label species__ontology_label sex
1010
library_preparation_protocol__ontology_label].freeze
11+
# headers for TSV text export of search results
12+
TEXT_HEADERS = [
13+
'Study source', 'Accession', 'Name', 'Description', 'Public', 'Detached', 'Cell count', 'Gene count',
14+
'Study URL', 'Disease', 'Organ', 'Species', 'Sex', 'Library preparation protocol', 'Facet matches',
15+
'Term matches'
16+
].freeze
17+
COMMA_SPACE = ', '.freeze
1118

1219
def search_results_obj
1320
response_obj = {
@@ -112,6 +119,79 @@ def cohort_metadata(study)
112119
cohort_entries
113120
end
114121

122+
def results_text_export
123+
lines = [TEXT_HEADERS.join("\t")]
124+
@studies.each { |study| lines << study_text_export(study).join("\t") }
125+
lines.join("\n")
126+
end
127+
128+
def study_text_export(study)
129+
if study.is_a?(Study)
130+
metadata = cohort_metadata(study).with_indifferent_access
131+
term_matches = study.search_weight(@term_list || [])
132+
facet_data = @studies_by_facet&.[](study.accession) || {}
133+
text_to_facet = @metadata_matches&.[](study.accession) || {}
134+
facet_matches = Api::V1::StudySearchResultsObjects.merge_facet_matches(facet_data, text_to_facet)
135+
inferred = @inferred_accessions&.include?(study.accession)
136+
[
137+
'SCP',
138+
study.accession,
139+
study.name,
140+
study.description,
141+
study.public,
142+
study.detached,
143+
study.cell_count,
144+
study.gene_count,
145+
result_url_for(study),
146+
metadata[:disease].join(COMMA_SPACE),
147+
metadata[:organ].join(COMMA_SPACE),
148+
metadata[:species].join(COMMA_SPACE),
149+
metadata[:sex].join(COMMA_SPACE),
150+
metadata[:library_preparation_protocol].join(COMMA_SPACE),
151+
Api::V1::StudySearchResultsObjects.facet_results_as_text(facet_matches),
152+
inferred ? "inferred text match on #{@inferred_terms.join(COMMA_SPACE)}" : term_matches[:terms].keys.join(COMMA_SPACE)
153+
]
154+
else
155+
[
156+
study[:hca_result] ? 'HCA' : 'TDR',
157+
study[:accession],
158+
study[:name],
159+
study[:description].gsub(/\n/, ''),
160+
true,
161+
false,
162+
0,
163+
0,
164+
result_url_for(study),
165+
study.dig(:metadata, :disease).join(COMMA_SPACE),
166+
study.dig(:metadata, :organ).join(COMMA_SPACE),
167+
study.dig(:metadata, :species).join(COMMA_SPACE),
168+
study.dig(:metadata, :sex).join(COMMA_SPACE),
169+
study.dig(:metadata, :library_preparation_protocol).join(COMMA_SPACE),
170+
Api::V1::StudySearchResultsObjects.facet_results_as_text(@studies_by_facet[study[:accession]]),
171+
nil
172+
]
173+
end
174+
end
175+
176+
# returns a URL for the study, either SCP or HCA
177+
def result_url_for(study)
178+
if study.is_a?(Study)
179+
view_study_url(accession: study.accession, study_name: study.url_safe_name)
180+
else
181+
"https://data.humancellatlas.org/explore/projects/#{study[:hca_project_id]}"
182+
end
183+
end
184+
185+
# flatten facet matches into a text string for export
186+
def self.facet_results_as_text(facets)
187+
entries = []
188+
facets.delete(:facet_search_weight)
189+
facets.each do |facet_name, filters|
190+
entries << "#{facet_name}:#{filters.map { |f| f[:name] }.join('|')}"
191+
end
192+
entries.join(COMMA_SPACE)
193+
end
194+
115195
# merge in multiple facet match data objects into a single merged entity for a given study
116196
def self.merge_facet_matches(existing_data, new_data)
117197
study_data = existing_data || {}

app/controllers/site_controller.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,8 @@ def study_params
695695
:default_options => [:cluster, :annotation, :color_profile, :expression_label,
696696
:deliver_emails, :cluster_point_size, :cluster_point_alpha,
697697
:cluster_point_border, :precomputed_heatmap_label,
698-
:expression_sort, override_viz_limit_annotations: []],
698+
:expression_sort, override_viz_limit_annotations: [],
699+
cluster_order: [], spatial_order: []],
699700
study_shares_attributes: [:id, :_destroy, :email, :permission],
700701
study_detail_attributes: [:id, :full_description],
701702
reviewer_access_attributes: [:id, :expires_at],

app/controllers/studies_controller.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1039,7 +1039,8 @@ def default_options_params
10391039
params.require(:study_default_options).permit(:cluster, :annotation, :color_profile, :expression_label,
10401040
:cluster_point_size, :cluster_point_alpha, :cluster_point_border,
10411041
:precomputed_heatmap_label, :expression_sort,
1042-
override_viz_limit_annotations: [])
1042+
override_viz_limit_annotations: [], cluster_order: [],
1043+
spatial_order: [])
10431044
end
10441045

10451046
def set_file_types
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React, { useState } from 'react'
2+
import Button from 'react-bootstrap/lib/Button'
3+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
4+
import { faFileExport } from '@fortawesome/free-solid-svg-icons'
5+
import { exportSearchResultsText } from '~/lib/scp-api'
6+
7+
export default function ResultsExport({ studySearchState }) {
8+
const hasResults = studySearchState?.results?.studies && studySearchState.results.studies.length > 0
9+
const [exporting, setExporting] = useState(false)
10+
11+
/** export results to a file */
12+
async function exportResults() {
13+
setExporting(true)
14+
await exportSearchResultsText(studySearchState.params).then(() => {
15+
setExporting(false)
16+
})
17+
}
18+
19+
return (
20+
<Button
21+
onClick={async () => {await exportResults()}}
22+
disabled={exporting || !hasResults}
23+
data-testid="export-search-results-tsv"
24+
data-analytics-name="export-search-results-tsv"
25+
data-original-title="Export search results to TSV file"
26+
data-toggle="tooltip"
27+
>
28+
<FontAwesomeIcon icon={faFileExport} /> {exporting ? 'Exporting...' : 'Export'}
29+
</Button>
30+
)
31+
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ const ResultsPanel = ({ studySearchState, studyComponent, noResultsDisplay, book
5151
} else if (results.studies && results.studies.length > 0) {
5252
panelContent = (
5353
<>
54-
{ <SearchQueryDisplay terms={results.termList} facets={results.facets} bookmarks={bookmarks}/> }
54+
{ <SearchQueryDisplay terms={results.termList}
55+
facets={results.facets}
56+
bookmarks={bookmarks}
57+
studySearchState={studySearchState}/> }
58+
{ }
5559
<StudyResults
5660
results={results}
5761
StudyComponent={studyComponent ? studyComponent : StudySearchResult}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import _flatten from 'lodash/flatten'
77
import { getDisplayNameForFacet } from '~/providers/SearchFacetProvider'
88
import { SearchSelectionContext } from '~/providers/SearchSelectionProvider'
99
import BookmarkManager from '~/components/bookmarks/BookmarkManager'
10+
import ResultsExport from '~/components/search/results/ResultsExport'
1011

1112
/** joins texts by wrapping them in a span with itemClass className, and then
1213
* inserting spans with the joinText
@@ -88,7 +89,7 @@ export const ClearAllButton = () => {
8889
/** displays a summary of an executed search.
8990
* e.g. (Text contains (stomach)) AND (Metadata contains (organ: brain))
9091
*/
91-
export default function SearchQueryDisplay({ terms, facets, bookmarks }) {
92+
export default function SearchQueryDisplay({ terms, facets, bookmarks, studySearchState }) {
9293
const hasFacets = facets && facets.length > 0
9394
const hasTerms = terms && terms.length > 0
9495
if (!hasFacets && !hasTerms) {
@@ -133,6 +134,7 @@ export default function SearchQueryDisplay({ terms, facets, bookmarks }) {
133134
<FontAwesomeIcon icon={faSearch}/>: <span className="query-text">
134135
{termsDisplay}{facetsDisplay}
135136
</span> <ClearAllButton/>
137+
<ResultsExport studySearchState={studySearchState} />
136138
<BookmarkManager bookmarks={bookmarks} />
137139
</div>
138140
)

app/javascript/lib/scp-api.jsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,34 @@ export async function downloadBucketFile(bucketId, filePath) {
510510
document.body.removeChild(element)
511511
}
512512

513+
export async function exportSearchResultsText(searchParams, mock=false) {
514+
searchParams.export = 'tsv'
515+
const path = `/search?${buildSearchQueryString('study', searchParams)}`
516+
const init = {
517+
method: 'GET',
518+
headers: {
519+
Authorization: `Bearer ${getOAuthToken()}`
520+
}
521+
}
522+
const [searchResults, perfTimes] = await scpApi(path, init, mock, false, false)
523+
const fileName = searchResults.headers.get('Content-Disposition').split('"')[1]
524+
const dataBlob = await searchResults.blob()
525+
526+
// Create an element with an anchor link and connect this to the blob
527+
const element = document.createElement('a')
528+
element.href = URL.createObjectURL(dataBlob)
529+
530+
// name the file and indicate it should download
531+
element.download = fileName
532+
533+
// Simulate clicking the link resulting in downloading the file
534+
document.body.appendChild(element)
535+
element.click()
536+
537+
// Cleanup
538+
document.body.removeChild(element)
539+
}
540+
513541
/**
514542
* Returns initial content for the "Explore" tab in Study Overview
515543
*
@@ -898,7 +926,7 @@ export async function fetchSearch(type, searchParams, mock=false) {
898926
export function buildSearchQueryString(type, searchParams) {
899927
const facetsParam = buildFacetQueryString(searchParams.facets)
900928

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

0 commit comments

Comments
 (0)