diff --git a/Gemfile b/Gemfile
index 54f28bf..243c202 100644
--- a/Gemfile
+++ b/Gemfile
@@ -45,6 +45,9 @@ gem 'exifr', '~> 1.3', '>= 1.3.9'
gem "aws-sdk-s3", require: false
+# Vessel for webcrawling
+gem "ferrum"
+
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
diff --git a/Gemfile.lock b/Gemfile.lock
index e78ca24..ce4201f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -106,6 +106,11 @@ GEM
erubi (1.10.0)
execjs (2.8.1)
exifr (1.3.9)
+ ferrum (0.15)
+ addressable (~> 2.5)
+ concurrent-ruby (~> 1.1)
+ webrick (~> 1.7)
+ websocket-driver (~> 0.7)
ffi (1.15.4)
globalid (1.0.0)
activesupport (>= 5.0)
@@ -234,6 +239,7 @@ GEM
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
+ webrick (1.8.2)
websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -254,6 +260,7 @@ DEPENDENCIES
byebug
capybara (>= 3.26)
exifr (~> 1.3, >= 1.3.9)
+ ferrum
image_processing (~> 1.12, >= 1.12.2)
jbuilder (~> 2.7)
listen (~> 3.3)
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 1ba37ce..7e5cb8a 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -26,6 +26,19 @@ img.flipped {
transform: scaleX(-1);
}
+.GradeableImages {
+ .ImageThumbnail {
+ cursor: pointer;
+ }
+ .ImageFill {
+ width: 100%;
+ height: 100%;
+ }
+ .ImageFullscreen {
+ width: 100%;
+ }
+}
+
// Style overrides
body {
@@ -72,3 +85,6 @@ body {
text-decoration: none;
}
}
+.list-unstyled {
+ margin-bottom: 0;
+}
diff --git a/app/controllers/grading_controller.rb b/app/controllers/grading_controller.rb
index fa15e03..34d9fb3 100644
--- a/app/controllers/grading_controller.rb
+++ b/app/controllers/grading_controller.rb
@@ -1,5 +1,7 @@
class GradingController < ApplicationController
+ skip_before_action :verify_authenticity_token, only: [:grade]
+
def index
@user_grading_set = current_user.user_grading_set_for(params[:grading_set_id])
if !@user_grading_set.image_complete?
@@ -34,15 +36,7 @@ def grade
)
end
@user_grading_set_image.flipped = params[:flipped] == '1'
- @user_grading_set_image.grading_data = params.permit(grading_data: [
- :photo_quality,
- :is_everted,
- :tf_grade,
- :ti_grade,
- :ts_grade,
- :upper_lid_tt_grade,
- :lower_lid_tt_grade
- ])['grading_data']
+ @user_grading_set_image.grading_data = JSON.parse(params[:grading_data])
@user_grading_set_image.save!
flash.notice = "#{@user_grading_set.grading_set.name} image grade saved!"
redirect_to action: 'index', grading_set_id: params[:grading_set_id]
diff --git a/app/controllers/grading_sets_controller.rb b/app/controllers/grading_sets_controller.rb
index 610b75b..04083e2 100644
--- a/app/controllers/grading_sets_controller.rb
+++ b/app/controllers/grading_sets_controller.rb
@@ -51,9 +51,11 @@ def create
end
def data
- @grading_set = GradingSet.find params[:id]
+ # @grading_set = GradingSet.find params[:id]
stream_csv_response filename: 'report.csv',
- enumerator: @grading_set.csv_enumerator
+ enumerator: UserGradingSetImage.data_csv_enumerator({
+ grading_sets: {id: params[:id]}
+ })
end
def destroy
@@ -96,16 +98,12 @@ def removeuser
end
def removeimage
- @grading_set = GradingSet.find params[:id]
- @image = Image.find params[:image_id]
- @grading_set_image = GradingSetImage.where({
- image: @image,
- grading_set: @grading_set
- }).first
+ @grading_set_image = GradingSetImage.find params[:grading_set_image_id]
+ name = @grading_set_image.gradeable.name
if @grading_set_image && @grading_set_image.destroy
- flash.notice = "#{@image.filename} removed from grading set"
+ flash.notice = "#{name} removed from grading set"
else
- flash.alert = "Unable to remove #{@image.filename} from grading set"
+ flash.alert = "Unable to remove #{name} from grading set"
end
redirect_to action: 'show'
end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index cbf7de3..abf7f70 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -5,11 +5,12 @@ class HomeController < ApplicationController
skip_before_action :require_login
def index
- if current_user
- @user_grading_sets = current_user.user_grading_sets
- .order('created_at desc')
- .limit(3)
- end
+ redirect_to controller: 'dashboard'
+ # if current_user
+ # @user_grading_sets = current_user.user_grading_sets
+ # .order('created_at desc')
+ # .limit(3)
+ # end
end
end
diff --git a/app/controllers/image_sets_controller.rb b/app/controllers/image_sets_controller.rb
new file mode 100644
index 0000000..8a6c8a1
--- /dev/null
+++ b/app/controllers/image_sets_controller.rb
@@ -0,0 +1,112 @@
+class ImageSetsController < ApplicationController
+ include CsvStreamable
+
+ before_action :require_image_viewer, only: [:index, :show]
+ before_action :require_admin, only: [:addtogradingset]
+
+ skip_before_action :verify_authenticity_token, only: [:addtogradingset, :gradingdata, :metadata]
+
+ def index
+ @pagesize = 50
+ @limit = (params[:limit] || @pagesize).to_i
+ @offset = (params[:offset] || 0).to_i
+ @image_sets = search_image_sets
+ .includes(:image_set_images, :grading_sets)
+ .limit(@limit).offset(@offset)
+ @image_sets_count = search_image_sets.count
+ @image_sources = ImageSource.active.order('name desc')
+ @metadata_keys = ImageSet.all_metadata_keys
+ respond_to do |format|
+ format.html
+ format.json { render json: {images: @images} }
+ end
+
+ end
+
+ def show
+ @image_set = ImageSet.find params[:id]
+ end
+
+ def new
+
+ end
+
+ def update
+
+ end
+
+ def create
+
+ end
+
+ def destroy
+
+ end
+
+ def set_images
+
+ end
+
+ def download
+
+ end
+
+ def metadata
+ stream_csv_response filename: 'metadata.csv',
+ enumerator: ImageSet.csv_metadata_enumerator(search_image_set_ids)
+ end
+
+
+ def addtogradingset
+ @grading_set = GradingSet.find params[:grading_set_id]
+ unless @grading_set
+ return redirect_to({ action: 'index' }, flash: { error: "No such grading set" })
+ end
+ if params[:image_set_id_all] == 'all'
+ @image_set_ids = search_image_sets.select(:id).map(&:id)
+ @count = GradingSetImage.upsert_all(@image_set_ids.map {|image_set_id|
+ {
+ gradeable_id: image_set_id,
+ gradeable_type: 'ImageSet',
+ grading_set_id: @grading_set.id,
+ created_at: Time.zone.now,
+ updated_at: Time.zone.now
+ }
+ }, unique_by: [:grading_set_id, :gradeable_id], returning: [:id]).count
+ else
+ @count = GradingSetImage.upsert_all(params[:image_set_ids].map {|image_set_id|
+ {
+ gradeable_id: image_set_id,
+ gradeable_type: 'ImageSet',
+ grading_set_id: @grading_set.id,
+ created_at: Time.zone.now,
+ updated_at: Time.zone.now
+ }
+ }, unique_by: [:grading_set_id, :gradeable_id], returning: [:id]).count
+ end
+ return redirect_to({ action: 'index' }, flash: {
+ success: "Successfully added #{@count} image sets to #{@grading_set.name}"
+ })
+ end
+
+ def gradingdata
+ stream_csv_response filename: 'image_set_gradingdata.csv',
+ enumerator: UserGradingSetImage.data_csv_enumerator({
+ image_sets: params
+ })
+ end
+
+ private
+
+ def image_set_params
+ params.require(:image_set).permit(:name, :image_source_id, :metadata)
+ end
+
+ def search_image_sets
+ ImageSet.search(params)
+ end
+
+ def search_image_set_ids
+ search_image_sets.select(:id).map(&:id)
+ end
+end
\ No newline at end of file
diff --git a/app/controllers/image_sources_controller.rb b/app/controllers/image_sources_controller.rb
index a8f4686..7aa49ab 100644
--- a/app/controllers/image_sources_controller.rb
+++ b/app/controllers/image_sources_controller.rb
@@ -76,7 +76,12 @@ def image_urls
private
def image_source_params
- params.require(:image_source).permit(:name, :active)
+ params.require(:image_source).permit(
+ :name,
+ :active,
+ :create_image_sets,
+ :create_image_sets_metadata_field
+ )
end
def metadata_params
diff --git a/app/controllers/images_controller.rb b/app/controllers/images_controller.rb
index ccbc826..3085f44 100644
--- a/app/controllers/images_controller.rb
+++ b/app/controllers/images_controller.rb
@@ -6,7 +6,7 @@ class ImagesController < ApplicationController
before_action :require_admin, only: [:addtogradingset]
skip_before_action :verify_authenticity_token,
- only: [:addtogradingset, :metadata, :exif_data]
+ only: [:addtogradingset, :metadata, :exif_data, :gradingdata]
skip_forgery_protection only: [:index]
@@ -14,8 +14,8 @@ def index
@pagesize = 50
@limit = (params[:limit] || @pagesize).to_i
@offset = (params[:offset] || 0).to_i
- @images = search_images.limit(@limit).offset(@offset)
- @images_count = search_images.count
+ @images = Image.search(params).limit(@limit).offset(@offset)
+ @images_count = Image.search(params).count
@image_sources = ImageSource.active.order('name desc')
@metadata_keys = Image.all_metadata_keys
respond_to do |format|
@@ -61,24 +61,26 @@ def addtogradingset
return redirect_to({ action: 'index' }, flash: { error: "No such grading set" })
end
if params[:image_id_all] == 'all'
- @image_ids = search_images.select(:id).map(&:id)
+ @image_ids = Image.search(params).select(:id).map(&:id)
@count = GradingSetImage.upsert_all(@image_ids.map {|image_id|
{
- image_id: image_id,
+ gradeable_id: image_id,
+ gradeable_type: 'Image',
grading_set_id: @grading_set.id,
created_at: Time.zone.now,
updated_at: Time.zone.now
}
- }, unique_by: [:grading_set_id, :image_id], returning: [:id]).count
+ }, unique_by: [:grading_set_id, :gradeable_id], returning: [:id]).count
else
@count = GradingSetImage.upsert_all(params[:image_ids].map {|image_id|
{
- image_id: image_id,
+ gradeable_id: image_id,
+ gradeable_type: 'Image',
grading_set_id: @grading_set.id,
created_at: Time.zone.now,
updated_at: Time.zone.now
}
- }, unique_by: [:grading_set_id, :image_id], returning: [:id]).count
+ }, unique_by: [:grading_set_id, :gradeable_id], returning: [:id]).count
end
return redirect_to({ action: 'index' }, flash: {
success: "Successfully added #{@count} images to #{@grading_set.name}"
@@ -87,12 +89,12 @@ def addtogradingset
def metadata
stream_csv_response filename: 'metadata.csv',
- enumerator: Image.csv_metadata_enumerator(search_image_ids)
+ enumerator: Image.csv_metadata_enumerator(search_image_ids(params))
end
def exif_data
stream_csv_response filename: 'exif_data.csv',
- enumerator: Image.csv_exif_data_enumerator(search_image_ids)
+ enumerator: Image.csv_exif_data_enumerator(search_image_ids(params))
end
def download
@@ -100,6 +102,13 @@ def download
@image_source_id = params[:image_source_id]
end
+ def gradingdata
+ stream_csv_response filename: 'image_gradingdata.csv',
+ enumerator: UserGradingSetImage.data_csv_enumerator({
+ images: params
+ })
+ end
+
# def update
# @image = Image.find params[:id]
# respond_to do |format|
@@ -122,44 +131,8 @@ def new_image_params
)
end
- def search_images
- wheres = ["1=1"]
- wheres_params = {}
- joins = []
- unless params[:metadata_key].blank?
- safe_key = params[:metadata_key].gsub("'", "") # remove single quotes
- wheres << "images.metadata->>'#{safe_key}' like :metadata_value"
- wheres_params[:metadata_value] = "%#{params[:metadata_value]}%"
- end
- unless params[:image_ids].blank?
- wheres << 'images.id in (:image_ids)'
- wheres_params[:image_ids] = params[:image_ids]
- end
- unless params[:filename].blank?
- wheres << 'filename ilike :filename'
- wheres_params[:filename] = "%#{params[:filename]}%"
- end
- unless params[:image_source_id].blank?
- wheres << 'image_source_id = :image_source_id'
- wheres_params[:image_source_id] = params[:image_source_id]
- end
- unless params[:image_source].blank?
- joins << :image_source
- wheres << 'image_sources.name ilike :image_source'
- wheres_params[:image_source] = "%#{params[:image_source]}%"
- end
- unless params[:grading_set].blank?
- joins << :grading_sets
- wheres << 'grading_sets.name ilike :grading_set'
- wheres_params[:grading_set] = "%#{params[:grading_set]}%"
- end
- Image.active.joins(joins)
- .order("images.filename asc")
- .where(wheres.join(" and "), wheres_params)
- end
-
- def search_image_ids
- search_images.select(:id).map(&:id)
+ def search_image_ids(params)
+ Image.search(params).select(:id).map(&:id)
end
end
diff --git a/app/controllers/metadata_controller.rb b/app/controllers/metadata_controller.rb
index 98a46dc..385ac57 100644
--- a/app/controllers/metadata_controller.rb
+++ b/app/controllers/metadata_controller.rb
@@ -8,26 +8,49 @@ def index
end
def update
- @image = Image.where(
- image_source_id: params[:image_source_id],
- filename: params[:filename]
- ).first
- unless @image
- render json: {filename: params[:filename], error: 'Image not found'}, staus: :unprocessable_entity
+ if params[:type] == 'image_set'
+ @entity = image_set_by_params
+ entity_name = @entity&.name
else
- @image.metadata = params[:metadata]
- if @image.save
- render json: {filename: params[:filename], success: true}
+ @entity = image_by_params
+ entity_name = @entity&.filename
+ end
+ unless @entity
+ render json: {filename: entity_name, error: "#{params[:type] == 'image_set' ? 'Image Set' : 'Image'} not found"}, staus: :unprocessable_entity
+ else
+ if @entity.update_metadata metadata_params[:metadata], params[:merge_metadata], !!params[:id]
+ render json: {filename: entity_name, success: true}
else
- render json: {filename: params[:filename], error: @image.errors}, staus: :unprocessable_entity
+ render json: {filename: entity_name, error: @entity.errors}, staus: :unprocessable_entity
end
end
end
private
- def metadata_params
- params.require(:filename, metadata: [])
+ def image_by_params
+ if params[:id]
+ Image.find(params[:id])
+ else
+ Image.where(
+ image_source_id: params[:image_source_id],
+ filename: params[:filename] || params[:name]
+ ).first
+ end
end
+ def image_set_by_params
+ if params[:id]
+ ImageSet.find(params[:id])
+ else
+ ImageSet.where(
+ image_source_id: params[:image_source_id],
+ name: params[:filename] || params[:name]
+ ).first
+ end
+ end
+
+ def metadata_params
+ params.permit(metadata: {})
+ end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 68e92ae..e104c62 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -8,4 +8,12 @@ def grading_set_link grading_set
end
end
+ def gradeable_url gradeable
+ if gradeable.class == Image
+ url_for controller: 'images', action: 'show', id: gradeable.id
+ elsif gradeable.class == ImageSet
+ url_for controller: 'image_sets', action: 'show', id: gradeable.id
+ end
+ end
+
end
diff --git a/app/javascript/packs/grading.jsx b/app/javascript/packs/grading.jsx
new file mode 100644
index 0000000..6078925
--- /dev/null
+++ b/app/javascript/packs/grading.jsx
@@ -0,0 +1,38 @@
+import React, { useEffect, useState } from 'react'
+import ReactDOM from 'react-dom'
+
+import { FGSGrading } from './grading/FGSGrading'
+import { TrachomaGrading } from './grading/TrachomaGrading'
+
+const Grading = ({
+ gradingType,
+ submitUrl,
+ gradingSetImage
+}) => {
+
+ return (
+ <>
+ {gradingType === 'FGSGrading' &&
+
+ }
+ {gradingType === 'TrachomaGrading' &&
+
+ }
+ >
+ )
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('react-app-container')
+ ReactDOM.render(
+ ,
+ el.appendChild(document.createElement('div'))
+ )
+})
\ No newline at end of file
diff --git a/app/javascript/packs/grading/FGSGrading.jsx b/app/javascript/packs/grading/FGSGrading.jsx
new file mode 100644
index 0000000..57fdae6
--- /dev/null
+++ b/app/javascript/packs/grading/FGSGrading.jsx
@@ -0,0 +1,285 @@
+import React, { useEffect, useRef, useState } from 'react'
+import ReactDOM from 'react-dom'
+
+import { GradeableImages } from './GradeableImages'
+import { GradeableQuestionnaire } from './GradeableQuestionnaire'
+
+const selected = (values, key, value) => {
+ return values[key] && (
+ (Array.isArray(values[key]) && values[key].indexOf(value) > -1) ||
+ (values[key] === value)
+ )
+
+}
+
+const Options = {
+ image: [
+ {value: '1', label: 'Yes'},
+ {value: '-1', label: 'No, not available'},
+ {value: '0', label: 'No, unclear image'}
+ ],
+ yesno: [
+ {value: '1', label: 'Yes'},
+ {value: '0', label: 'No'}
+ ],
+ location: [
+ {value: 'left_lateral_fornix', label: 'Left lateral fornix'},
+ {value: 'right_lateral_fornix', label: 'Right lateral fornix'},
+ {value: 'anterior_fornix', label: 'Anterior fornix'},
+ {value: 'posterior_fornix', label: 'Posterior fornix'},
+ {value: 'cervix_q1', label: 'Cervix Q1'},
+ {value: 'cervix_q2', label: 'Cervix Q2'},
+ {value: 'cervix_q3', label: 'Cervix Q3'},
+ {value: 'cervix_q4', label: 'Cervix Q4'},
+ {value: 'vaginal_walls', label: 'Vaginal walls'}
+ ],
+ sti: [
+ {value: 'suspected_bacterial_vaginosis', label: 'Suspected Bacterial Vaginosis'},
+ {value: 'suspected_trichomonas', label: 'Suspected Trichomonas'},
+ {value: 'candidiasis', label: 'Candidiasis'},
+ {value: 'suspected_gonorrhoea', label: 'Suspected Gonorrhoea'},
+ {value: 'suspected_chlamydia', label: 'Suspected Chlamydia'},
+ {value: 'suspected_herpes', label: 'Suspected Herpes'},
+ {value: 'genital_warts', label: 'Genital Warts'},
+ {value: 'chancroid', label: 'Chancroid'}
+ ],
+ repro: [
+ {value: 'buboes', label: 'Buboes'},
+ {value: 'lymphogranuloma', label: 'Lymphogranuloma'},
+ {value: 'scabies', label: 'Scabies'},
+ {value: 'crabs', label: 'Crabs'},
+ {value: 'polyp', label: 'Polyp'},
+ {value: 'ectropion', label: 'Ectropion'},
+ {value: 'nabothian_cyst', label: 'Nabothian Cyst'},
+ {value: 'prolapsed_uterus', label: 'Prolapsed Uterus'},
+ {value: 'other_please_list', label: 'Other: please list'}
+ ],
+ sti_repro: [
+ {value: '1', label: 'Cervicitis'},
+ {value: '2', label: 'Suspected Trichomonas'},
+ {value: '3', label: 'Candidiasis'},
+ {value: '4', label: 'Suspected Herpes'},
+ {value: '5', label: 'Genital Warts'},
+ {value: '6', label: 'Chancroid'},
+ {value: '7', label: 'Buboes'},
+ {value: '8', label: 'Lymphogranuloma'},
+ {value: '9', label: 'Scabies'},
+ {value: '10', label: 'Crabs'},
+ {value: '11', label: 'Polyp'},
+ {value: '12', label: 'Ectropion'},
+ {value: '13', label: 'Nabothian Cyst'},
+ {value: '14', label: 'Prolapsed Uterus'},
+ {value: '95', label: 'Other: please list'}
+ ]
+}
+const Groups = [
+ {
+ name: 'image_quality',
+ label: 'Image Quality'
+ }, {
+ name: 'exam_findings',
+ label: 'FGS Examination Findings',
+ relevant: ({cervical_images_assessed, vaginal_wall_images_assessed}) => (
+ cervical_images_assessed === '1' || vaginal_wall_images_assessed === '1'
+ )
+ }, {
+ name: 'results',
+ label: 'Results',
+ relevant: ({cervical_images_assessed, vaginal_wall_images_assessed}) => (
+ cervical_images_assessed === '1' || vaginal_wall_images_assessed === '1'
+ )
+ }
+]
+const Questions = [
+ {
+ group: 'image_quality',
+ type: 'select_one',
+ options: Options.image,
+ name: 'cervical_images_assessed',
+ label: 'Were cervical images able to be assessed?',
+ required: true
+ }, {
+ group: 'image_quality',
+ type: 'text',
+ name: 'cervical_image_comments',
+ label: 'Please enter any comments about cervical image quality.',
+ required: false
+ }, {
+ group: 'image_quality',
+ type: 'select_one',
+ options: Options.image,
+ name: 'vaginal_wall_images_assessed',
+ label: 'Were vaginal wall images able to be assessed?',
+ required: true
+ }, {
+ group: 'image_quality',
+ type: 'text',
+ name: 'image_comments',
+ label: 'Please enter any comments about vaginal wall image quality.',
+ required: false
+ }, {
+ group: 'exam_findings',
+ type: 'select_one',
+ options: Options.yesno,
+ name: 'grainy_sandy_patches',
+ label: 'Grainy sandy patches',
+ required: true
+ }, {
+ group: 'exam_findings',
+ type: 'select_multiple',
+ options: Options.location,
+ name: 'location_grainy_sandy_patches',
+ label: 'Specify Location: Grainy Sandy Patches',
+ relevant: values => values.grainy_sandy_patches === '1',
+ required: true
+ }, {
+ group: 'exam_findings',
+ type: 'select_one',
+ options: Options.yesno,
+ name: 'homogeneous_yellow_patches',
+ label: 'Homogeneous yellow patches',
+ required: true
+ }, {
+ group: 'exam_findings',
+ type: 'select_multiple',
+ options: Options.location,
+ name: 'location_homogeneous_yellow',
+ label: 'Specify Location: Homogeneous Yellow Patches',
+ relevant: values => values.homogeneous_yellow_patches === '1',
+ required: true
+ }, {
+ group: 'exam_findings',
+ type: 'select_one',
+ options: Options.yesno,
+ name: 'rubbery_papules',
+ label: 'Rubbery papules',
+ required: true
+ }, {
+ group: 'exam_findings',
+ type: 'select_multiple',
+ options: Options.location,
+ name: 'location_rubbery_papules',
+ label: 'Specify Location: Rubbery papules',
+ relevant: values => values.rubbery_papules === '1',
+ required: true
+ }, {
+ group: 'exam_findings',
+ type: 'select_one',
+ options: Options.yesno,
+ name: 'abnormal_blood_vessels',
+ label: 'Abnormal blood vessels',
+ required: true
+ }, {
+ group: 'exam_findings',
+ type: 'select_multiple',
+ options: Options.location,
+ name: 'location_abnormal_blood_vessel',
+ label: 'Specify Location: Abnormal blood vessels',
+ relevant: values => values.abnormal_blood_vessels === '1',
+ required: true
+ }, {
+ group: 'results',
+ type: 'select_one',
+ options: Options.yesno,
+ name: 'fgs_status',
+ label: 'Does the patient have FGS?',
+ constraint: (value, values) => {
+ return (
+ (
+ selected(values, 'grainy_sandy_patches', '1') ||
+ selected(values, 'homogeneous_yellow_patches', '1') ||
+ selected(values, 'rubbery_papules', '1') ||
+ selected(values, 'abnormal_blood_vessels', '1')
+ ) &&
+ value === '1'
+ ) ||
+ (
+ (
+ selected(values, 'grainy_sandy_patches', '0') &&
+ selected(values, 'homogeneous_yellow_patches', '0') &&
+ selected(values, 'rubbery_papules', '0') &&
+ selected(values, 'abnormal_blood_vessels', '0')
+ ) &&
+ value === '0'
+ );
+ },
+ constraint_message: "Must select: 'Yes' if patient has any of: grainy sandy patches, homogeneous yellow patches, rubbery papules, abnormal blood vessels, or must select 'No' if none of those conditions are present.",
+ required: true
+ }, {
+ group: 'results',
+ type: 'select_one',
+ options: Options.yesno,
+ name: 'sus_sti_repro',
+ label: 'Does the patient have any suspected reproductive health problem, includings STIs?',
+ required: true
+ }, {
+ group: 'results',
+ type: 'select_multiple',
+ options: Options.sti_repro,
+ name: 'sti_repro_list',
+ label: 'Specify suspected reproductive health problem',
+ relevant: values => values.sus_sti_repro === '1',
+ required: true
+ }, {
+ group: 'results',
+ type: 'text',
+ name: 'repro_other_desc',
+ label: 'Please list other',
+ relevant: values => (
+ selected(values, 'sti_repro_list', '95')
+ ),
+ required: true
+ }, {
+ group: 'results',
+ type: 'text',
+ name: 'additional_comments',
+ label: 'Please enter any additional comments',
+ required: false
+ }
+];
+
+
+export const FGSGrading = ({
+ authenticityToken,
+ gradingSetImage,
+ submitUrl
+}) => {
+
+ const [gradingData, setGradingData] = useState({});
+ const formRef = useRef(null);
+ const gradingDataInputRef = useRef(null);
+
+ const setCurrentValues = (values) => {
+ setGradingData(values)
+ gradingDataInputRef.current.value = JSON.stringify(values)
+ formRef.current.submit()
+ }
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/app/javascript/packs/grading/GradeableImages.jsx b/app/javascript/packs/grading/GradeableImages.jsx
new file mode 100644
index 0000000..e38268d
--- /dev/null
+++ b/app/javascript/packs/grading/GradeableImages.jsx
@@ -0,0 +1,98 @@
+import React, { useEffect, useState } from 'react'
+
+import { ImageThumbnail } from './ImageThumbnail'
+
+export const GradeableImages = ({
+ gradingSetImage
+}) => {
+
+ const [zoomedIndex, setZoomedIndex] = useState();
+
+
+ const escFunction = () => setZoomedIndex();
+
+ const getZoomedImage = () => {
+ return gradingSetImage.images_for_grading[zoomedIndex]
+ }
+
+ const hasPrev = (index) => {
+ return index - 1 >= 0;
+ }
+
+ const hasNext = (index) => {
+ return index + 1 < gradingSetImage.images_for_grading.length;
+ }
+
+ let els = [];
+ for (let i = 0; i < gradingSetImage.images_for_grading.length; i++) {
+ const image = gradingSetImage.images_for_grading[i];
+ els.push(
+ setZoomedIndex(i)}
+ >
+
+
+ );
+ }
+
+ useEffect(() => {
+ document.addEventListener("keydown", escFunction, false);
+ return () => {
+ document.removeEventListener("keydown", escFunction, false);
+ };
+ }, [escFunction]);
+
+
+ return (
+
+ {typeof zoomedIndex != 'undefined' &&
+
+
+
+
+
+ {getZoomedImage().filename}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+ {els}
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/javascript/packs/grading/GradeableQuestionnaire.jsx b/app/javascript/packs/grading/GradeableQuestionnaire.jsx
new file mode 100644
index 0000000..a806d45
--- /dev/null
+++ b/app/javascript/packs/grading/GradeableQuestionnaire.jsx
@@ -0,0 +1,119 @@
+import React, { useEffect, useState, useRef } from 'react'
+
+import { Question } from './Question'
+
+export const GradeableQuestionnaire = ({
+ groups,
+ questions,
+ gradeable,
+ values,
+ setValues
+}) => {
+
+ const [inputValues, setInputValues] = useState(values);
+ const elRef = useRef()
+
+ const getQuestionGroup = (question) => {
+ if (!question.group) return null;
+ for (let i = 0; i < groups.length; i++) {
+ if (question.group === groups[i].name) return groups[i];
+ }
+ }
+
+ // Question states:
+ // answered, skipped, current
+ const getQuestionState = (question) => {
+ // If question is in a group check group relevance first:
+ const group = getQuestionGroup(question);
+ if (group && group.relevant && !group.relevant(inputValues) ) {
+ return 'skipped';
+ }
+
+ if (question.relevant && !question.relevant(inputValues)) {
+ return 'skipped';
+ } else if (typeof inputValues[question.name] != 'undefined') {
+ return 'answered';
+ } else {
+ return 'current';
+ }
+ }
+
+ const getCurrentQuestionData = () => {
+ let currentQuestions = [];
+ for (let i = 0; i < questions.length; i++) {
+ const state = getQuestionState(questions[i]);
+ if (state != 'skipped') {
+ currentQuestions.push({
+ question: questions[i],
+ state: state
+ })
+ if (state === 'current') break;
+ }
+ }
+ return currentQuestions
+ }
+
+ const getCurrentQuestions = () => {
+ const currentQuestions = getCurrentQuestionData();
+ let groupTitle = null;
+
+ let els = []
+ for (let i = 0; i < currentQuestions.length; i++) {
+ const prevQuestion = i > 0 ? currentQuestions[i-1].question : null;
+ const question = currentQuestions[i].question;
+ const state = currentQuestions[i].state;
+ const group = getQuestionGroup(question);
+ const last = state != 'current' && i == currentQuestions.length - 1;
+ els.push(
+ {
+ let newValues = {...inputValues};
+ newValues[question.name] = value;
+ setInputValues(newValues);
+ if (last) setValues(inputValues);
+ }}
+ onCancel={prevQuestion ? (value) => {
+ let newValues = {...inputValues};
+ delete newValues[prevQuestion.name];
+ setInputValues(newValues);
+ } : null}
+ />
+ )
+ if (group && group.label && group.label != groupTitle) {
+ groupTitle = group.label;
+ }
+ }
+ return els;
+ }
+
+ const confirmNavAway = () => {
+ const currentQuestions = getCurrentQuestionData();
+ const lastIndex = currentQuestions.length - 1;
+ const last = currentQuestions[lastIndex].state != 'current';
+ if (!last) {
+ return "Are you sure you want to navigate away from this page?\n\nAny responses entered will not be saved.\n\nPress OK to continue or Cancel to stay on the current page.";
+ }
+ }
+
+ useEffect(() => {
+ window.onbeforeunload = confirmNavAway;
+ return () => { window.onbeforeunload = null; };
+ }, [confirmNavAway]);
+
+ useEffect(() => {
+ elRef.current?.scrollIntoView({block: "end", inline: "nearest"})
+ }, [inputValues])
+
+ return (
+
+ {getCurrentQuestions()}
+
+ );
+
+}
\ No newline at end of file
diff --git a/app/javascript/packs/grading/ImageThumbnail.jsx b/app/javascript/packs/grading/ImageThumbnail.jsx
new file mode 100644
index 0000000..66beafe
--- /dev/null
+++ b/app/javascript/packs/grading/ImageThumbnail.jsx
@@ -0,0 +1,14 @@
+import React, { useEffect, useState } from 'react'
+
+export const ImageThumbnail = ({
+ image
+}) => {
+
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/javascript/packs/grading/Question.jsx b/app/javascript/packs/grading/Question.jsx
new file mode 100644
index 0000000..dc9f6ae
--- /dev/null
+++ b/app/javascript/packs/grading/Question.jsx
@@ -0,0 +1,194 @@
+import React, { useEffect, useState } from 'react'
+
+const TYPES = {
+ TEXT: 'text',
+ SELECT_ONE: 'select_one',
+ SELECT_MULTIPLE: 'select_multiple'
+};
+
+const LEFT_COLS = 5;
+const RIGHT_COLS = 7;
+
+// Question attributes:
+// {
+// group,
+// name,
+// type,
+// label,
+// options,
+// hint,
+// relevant,
+// constraint,
+// constrain_message,
+// required
+// }
+
+export const Question = ({
+ value,
+ values,
+ state,
+ last,
+ question,
+ groupTitle,
+ labelClassName,
+ inputClassName,
+ onCancel,
+ onSubmit
+}) => {
+
+ const [editingValue, setEditingValue] = useState(value ||
+ question.type === TYPES.SELECT_MULTIPLE ? [] : ''
+ );
+ const [error, setError] = useState();
+
+ const setMultipleValue = (value, checked) => {
+ if (checked && editingValue.indexOf(value) < 0) {
+ setEditingValue(editingValue.concat([value]))
+ } else if (!checked && editingValue.indexOf(value) > -1) {
+ setEditingValue(editingValue.filter(v => v != value))
+ }
+ }
+
+ const submit = () => {
+ setError();
+ if (question.constraint) {
+ if (!question.constraint(editingValue, values)) {
+ setError(question.constraint_message);
+ return;
+ }
+ }
+ if (question.required && (!editingValue || /^\s*$/.test(editingValue))) {
+ setError("A response is required.");
+ return;
+ }
+ onSubmit(editingValue);
+ }
+
+ useEffect(() => {
+ if (question.type === TYPES.SELECT_ONE &&
+ editingValue &&
+ editingValue != '' &&
+ editingValue != value
+ ) {
+ submit();
+ }
+ }, [editingValue])
+
+ let el = Question type "{question.type}" not found.;
+ if (question.type === TYPES.TEXT) {
+ el = (
+
+
+
+ setEditingValue(e.target.value)}
+ onKeyPress={e => {if (e.key === 'Enter') submit();} }
+ />
+
+
+ )
+ } else if (question.type === TYPES.SELECT_ONE) {
+ el = (
+
+
+
+
+
+
+ )
+ } else if (question.type === TYPES.SELECT_MULTIPLE) {
+ el = (
+
+
+
+ {question.options.map(({value, label}) => (
+
+ -1}
+ id={`${question.name}.${value}`}
+ onChange={e => setMultipleValue(value, e.target.checked)}
+ />
+
+
+ ))}
+
+
+ )
+ }
+
+ return (
+
+ {groupTitle &&
+
+ }
+ {el}
+ {error &&
+
+ {error}
+
+ }
+ {(state === 'current' || last) && (
+
+
+
+
+
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/app/javascript/packs/grading/TrachomaGrading.jsx b/app/javascript/packs/grading/TrachomaGrading.jsx
new file mode 100644
index 0000000..e3a6c82
--- /dev/null
+++ b/app/javascript/packs/grading/TrachomaGrading.jsx
@@ -0,0 +1,13 @@
+import React, { useEffect, useState } from 'react'
+import ReactDOM from 'react-dom'
+
+export const TrachomaGrading = ({
+ userGradingSet
+}) => {
+
+ return (
+
+ Trachoma Grading
+
+ )
+}
\ No newline at end of file
diff --git a/app/javascript/packs/image_scripts.jsx b/app/javascript/packs/image_scripts.jsx
index 54611d1..60ff0d3 100644
--- a/app/javascript/packs/image_scripts.jsx
+++ b/app/javascript/packs/image_scripts.jsx
@@ -60,6 +60,11 @@ $(function () {
$("input[name='image_ids[]'").prop('checked', e.target.checked)
$("input[name='image_ids[]'").prop('disabled', e.target.checked)
});
+ // Handle imageid-all checked/unchecked
+ $("input[name='image_set_id_all']").on('change', function(e) {
+ $("input[name='image_set_ids[]'").prop('checked', e.target.checked)
+ $("input[name='image_set_ids[]'").prop('disabled', e.target.checked)
+ });
// Handle page links
$("a.page-link").on('click', function(e) {
diff --git a/app/javascript/packs/metadata_upload.jsx b/app/javascript/packs/metadata_upload.jsx
index 9839d31..b152fb4 100644
--- a/app/javascript/packs/metadata_upload.jsx
+++ b/app/javascript/packs/metadata_upload.jsx
@@ -9,12 +9,20 @@ const doUpload = async ({
authenticityToken,
imageSourceId,
filename,
+ id,
+ name,
+ type,
+ merge_metadata,
metadata
}) => {
const result = await post('/metadata', {
authenticity_token: authenticityToken,
+ type: type,
+ id: id,
image_source_id: imageSourceId,
filename: filename,
+ name: name,
+ merge_metadata: merge_metadata,
metadata: metadata
})
return result.data
@@ -22,28 +30,28 @@ const doUpload = async ({
const getMetadata = ({headers, row}) => {
let metadata = {}
- for (let i = 1; i < headers.length; i++) {
+ for (let i = 0; i < headers.length; i++) {
metadata[headers[i]] = row[i]
}
return metadata
}
-const Result = ({filename, success, error}) => (
+const Result = ({filename, name, success, error}) => (
<>
{success &&
<>
Success
- {filename}
+ {filename || name}
>
}
{!success &&
<>
Error
- {filename}
+ {filename || name}
- {error}
+ {(typeof error === 'string' && error) || JSON.stringify(error)}
>
}
>
@@ -56,6 +64,8 @@ const MetadataUpload = ({
}) => {
const [sourceId, setSourceId] = useState(imageSourceId || -1)
+ const [type, setType] = useState('image')
+ const [mergeMetadata, setMergeMetadata] = useState(true)
const [data, setData] = useState(null)
const [uploading, setUploading] = useState(false)
@@ -64,6 +74,9 @@ const MetadataUpload = ({
const [results, setResults] = useState([])
const [finished, setFinished] = useState(false)
+ const newUpload = () => {
+ window.location.reload()
+ }
useEffect(() => {
const doEffect = async () => {
@@ -71,12 +84,17 @@ const MetadataUpload = ({
const headers = data[0]
for (let i = 1; i < data.length; i++) {
setCurrent(i)
+ const metadata = getMetadata({headers, row: data[i]})
const result = await doUpload({
authenticityToken,
+ type,
active: '1',
+ id: metadata['id'],
imageSourceId: sourceId,
- filename: data[i][0],
- metadata: getMetadata({headers, row: data[i]})
+ filename: metadata['filename'],
+ name: metadata['name'],
+ merge_metadata: mergeMetadata,
+ metadata: metadata
})
results.push(result)
setResults(results)
@@ -91,21 +109,54 @@ const MetadataUpload = ({
<>
{!uploading &&
-
-