Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 31 additions & 5 deletions app/assets/javascripts/components/cdx_select_autocomplete.js.jsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
var CdxSelectAutocomplete = React.createClass({
getInitialState: function () {
return {
options: null,
};
},

render: function () {
return (<Select
ref={this.setSelectRef}
className={this.props.className}
name={this.props.name}
value={this.props.value}
placeholder={this.props.placeholder}
searchable={true}
clearable={true}
asyncOptions={this.asyncOptions.bind(this)}
asyncOptions={this.asyncOptions}
value={this.props.value || ""}
onChange={this.onChange}
/>);
},

asyncOptions: function (query, callback) {
if (!query || /^\s*$/.test(query)) {
return callback(null, []);
}
query = query.trim();
if (!query) return callback(null, []);

var autoselect = this.props.autoselect;
var prepareOptions = this.props.prepareOptions;
var url = this.props.url;
url += (url.includes("?") ? "&query=" : "?query=") + encodeURIComponent(query);

Expand All @@ -24,15 +34,31 @@ var CdxSelectAutocomplete = React.createClass({
url: url,

success: function (options) {
if (prepareOptions) {
options = prepareOptions.call(null, options);
}
callback(null, {
options: options,
complete: options.size < 10,
});
},
if (autoselect && options.length === 1 && options[0].value === query) {
this.selectRef.setValue(query);
}
}.bind(this),

error: function (_, error) {
callback(error);
},
})
},

onChange: function (value, options) {
if (this.props.onSelect) {
this.props.onSelect.call(null, value, options);
}
},

setSelectRef: function (ref) {
this.selectRef = ref;
}
});
102 changes: 102 additions & 0 deletions app/assets/javascripts/components/samples_selector.js.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
var SamplesSelector = React.createClass({
getInitialState: function () {
return {
samples: this.props.samples,
};
},

render: function () {
return (<div className="samples-selector">
{this.renderTitle()}
{this.state.samples.map(this.renderSample)}

<a className="add-samples" href="#" onClick={this.addSample}>
<div className="add-samples">
<div className="icon-circle-plus icon-blue icon-margin"></div>
<div className="add-sample-link">ADD SAMPLE</div>
</div>
</a>
</div>);
},

renderTitle() {
var samples = this.state.samples;
if (!samples.length) return;

var count = samples.reduce(function (a, e) {
return e.uuid ? a + 1 : a;
}, 0);

return (<div className="samples-count">
<div className="title">{count}&nbsp;{count == 1 ? "sample" : "samples"}</div>
</div>);
},

renderSample(sample, index) {
if (sample.uuid) {
function removeSample(event) {
this.removeSample(event, index);
}
return (<div className="batches-samples" key={"samples-selector-" + index}>
<div className="samples-row">
<div className="samples-item">{sample.uuid}</div>
<div className="samples-row-actions">
<input type="hidden" name={this.props.name + "[" + index + "]"} value={sample.uuid}/>
<span>{sample.batch_number}</span>
<a href="#" onClick={removeSample.bind(this)} title="Remove this sample">
<i className="icon-close icon-gray bigger"></i>
</a>
</div>
</div>
</div>);
} else {
function selectSample(_, options) {
this.selectSample(index, options && options[0]);
}
return (<div className="batches-samples" key={"samples-selector-" + index}>
<div className="samples-row">
<CdxSelectAutocomplete
className={this.props.className}
url={this.props.url}
placeholder={this.props.placeholder}
value={sample.uuid}
prepareOptions={this.prepareOptions}
autoselect={true}
onSelect={selectSample.bind(this)}
/>
</div>
</div>);
}
},

prepareOptions: function (options) {
return options.map(function (option) {
option.value = option.uuid;
option.label = option.uuid + " (" + option.batch_number + ")";
return option;
});
},

addSample: function (event) {
event.preventDefault();

var samples = this.state.samples;
samples.push({ uuid: "" });
this.setState({ samples: samples });
},

selectSample: function (index, sample) {
var samples = this.state.samples;
samples[index] = sample;
this.setState({ samples: samples });
},

removeSample: function (event, index) {
event.preventDefault();

var samples = this.state.samples;
samples.splice(index, 1);

this.setState({ samples: samples });
},
});
5 changes: 5 additions & 0 deletions app/assets/stylesheets/_batches.scss
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@
align-items: center;
min-height: 40px;
}
.samples-row-actions {
display: flex;
align-items: center;
gap: 10px;
}

.transfer-data {
color: black;
Expand Down
3 changes: 3 additions & 0 deletions app/assets/stylesheets/_sprites.scss
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
color: inherit;
}

&.bigger {
font-size: 24px;
}
&.medium {
font-size: 35px;
}
Expand Down
24 changes: 22 additions & 2 deletions app/controllers/boxes_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class BoxesController < ApplicationController
before_action :load_box, except: %i[index new create bulk_destroy]
helper_method :samples_data

def index
@can_create = has_access?(@navigation_context.institution, CREATE_INSTITUTION_BOX)
Expand Down Expand Up @@ -59,6 +60,7 @@ def create

@box_form = BoxForm.build(@navigation_context, box_params)
@box_form.batches = check_access(load_batches, READ_BATCH)
@box_form.samples = check_access(load_samples, READ_SAMPLE)

if @box_form.valid?
@box_form.build_samples
Expand Down Expand Up @@ -106,13 +108,31 @@ def load_batches
.where(uuid: @box_form.batch_uuids.values.reject(&:blank?))
end

def load_samples
Sample
.within(@navigation_context.entity, @navigation_context.exclude_subsites)
.find_all_by_any_uuid(@box_form.sample_uuids.values.reject(&:blank?))
end

def box_params
if Rails::VERSION::MAJOR == 5 && Rails::VERSION::MINOR == 0
params.require(:box).permit(:purpose, :media).tap do |allowed|
allowed[:batch_uuids] = params[:box][:batch_uuids].permit!
allowed[:batch_uuids] = params[:box][:batch_uuids].try(&:permit!)
allowed[:sample_uuids] = params[:box][:sample_uuids].try(&:permit!)
end
else
params.require(:box).permit(:purpose, :media, batch_uuids: {})
params.require(:box).permit(:purpose, :media, batch_uuids: {}, sample_uuids: [])
end
end

def samples_data(samples)
# NOTE: duplicates the samples/autocomplete template (but returns an
# Array<Hash> instead of rendering to a JSON String)
samples.map do |sample|
{
uuid: sample.uuid,
batch_number: sample.batch_number,
}
end
end
end
14 changes: 14 additions & 0 deletions app/controllers/samples_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ def index
.preload(:batch, :sample_identifiers)
end

def autocomplete
samples = Sample
.where(institution: @navigation_context.institution)
.within(@navigation_context.entity, @navigation_context.exclude_subsites)

samples = samples.without_qc if params[:qc] == "0"

@samples = check_access(samples, READ_SAMPLE)
.joins(:sample_identifiers)
.autocomplete(params[:query])
.limit(10)
.preload(:batch)
end

def edit_or_show
sample = Sample.find(params[:id])

Expand Down
2 changes: 1 addition & 1 deletion app/models/box.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def blind_attributes
%i[concentration concentration_formula replicate]
when "Variants"
%i[batch_number virus_lineage]
when "Challenge"
when "Challenge", "Other"
%i[batch_number concentration concentration_formula replicate virus_lineage]
else
[]
Expand Down
43 changes: 36 additions & 7 deletions app/models/box_form.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class BoxForm
attr_reader :box, :batch_uuids
attr_reader :box, :batch_uuids, :sample_uuids
attr_accessor :media

delegate :purpose, :purpose=, to: :box
Expand All @@ -18,6 +18,7 @@ def initialize(box, params)
@box = box
@media = params[:media].presence
@batch_uuids = params[:batch_uuids].presence.to_h
@sample_uuids = params[:sample_uuids].presence.to_h
end

def batches=(relation)
Expand All @@ -28,6 +29,14 @@ def batches=(relation)
end.compact
end

def samples=(relation)
records = relation.to_a

@samples = @sample_uuids.transform_values do |batch_uuid|
records.find { |b| b.uuid == batch_uuid }
end.compact
end

def build_samples
case @box.purpose
when "LOD"
Expand All @@ -46,13 +55,17 @@ def build_samples
@box.build_samples(batch, concentration_exponents: [1, 4, 8], replicates: 3, media: media)
end
end

when "Other"
@box.samples = @samples.values
end
end

def valid?
@box.valid?
validate_existence_of_batches
validate_batches_for_purpose
validate_existence_of_samples
validate_batches_or_samples_for_purpose
@box.errors.empty?
end

Expand All @@ -74,21 +87,37 @@ def validate_existence_of_batches
end
end

def validate_batches_for_purpose
count = @batches.map { |_, b| b.try(&:uuid) }.uniq.size
def validate_existence_of_samples
@sample_uuids.each do |key, sample_uuid|
unless sample_uuid.blank? || @samples[key]
@box.errors.add(key, "Sample doesn't exist")
end
end
end

def validate_batches_or_samples_for_purpose
case @box.purpose
when "LOD"
@box.errors.add(:lod, "A batch is required") unless @batches["lod"] || @box.errors.include?(:lod)
when "Variants"
@box.errors.add(:base, "You must select at least two batches") unless count >= 2
@box.errors.add(:base, "You must select at least two batches") unless unique_batch_count >= 2
when "Challenge"
if @batches["virus"]
@box.errors.add(:base, "You must select at least one distractor batch") unless count >= 2
@box.errors.add(:base, "You must select at least one distractor batch") unless unique_batch_count >= 2
else
@box.errors.add(:virus, "A virus batch is required") unless @box.errors.include?(:virus)
@box.errors.add(:base, "You must select at least one distractor batch") unless count >= 1
@box.errors.add(:base, "You must select at least one distractor batch") unless unique_batch_count >= 1
end
when "Other"
if @samples.empty?
@box.errors.add(:base, "You must select at least one sample")
elsif @samples.any? { |_, sample| sample.is_quality_control? }
@box.errors.add(:base, "You can't select a QC sample")
end
end
end

def unique_batch_count
@batches.map { |_, b| b.try(&:uuid) }.uniq.size
end
end
2 changes: 2 additions & 0 deletions app/models/sample.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def self.find_all_by_any_uuid(uuids)
joins(:sample_identifiers).order("sample_identifiers.uuid")
}

scope :without_qc, -> { where.not(specimen_role: "q") }

def self.media
entity_fields.find { |f| f.name == 'media' }.options
end
Expand Down
2 changes: 1 addition & 1 deletion app/views/batches/_samples.haml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
%label.row{for: "sample_ids_#{sample.id}"}
= check_box_tag 'destroy_sample_ids[]', sample.id, false, { id: "destroy_sample_ids_#{sample.id}", class: "destroy-checkbox" }
= sample.uuid
.sample-row-actions
.samples-row-actions
- if sample.is_quality_control?
.icon-test.icon-gray{title: 'Q - Control specimen'}
= link_to edit_sample_path(sample.id) do
Expand Down
2 changes: 1 addition & 1 deletion app/views/batches/_show_samples.haml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
.samples-row
.samples-item
= sample.uuid
.sample-row-actions
.samples-row-actions
- if sample.is_quality_control?
.icon-test.icon-gray{title: 'Q - Control specimen'}
= link_to sample_path(sample.id) do
Expand Down
Loading