From 38de1a429ba81e443b14917bc9872cbc30523ddf Mon Sep 17 00:00:00 2001 From: Saagar Arya <51128536+skarya22@users.noreply.github.com> Date: Wed, 16 Oct 2024 06:43:25 -0700 Subject: [PATCH 01/28] [User_Accounts] Fix examiner always pending & bugfix (#9409) - Fixed bug in user_accounts when examiner details are selected caused by the introduction of the /Database/Query class. It thought that new examiner's IDs existed and tried to use it, even when that examiner did not exist and needed to be created - Fixed bug where examiner status is always reset to pending from user accounts --- modules/user_accounts/php/edit_user.class.inc | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/modules/user_accounts/php/edit_user.class.inc b/modules/user_accounts/php/edit_user.class.inc index 0f6bc604d89..cf0ce3ff54a 100644 --- a/modules/user_accounts/php/edit_user.class.inc +++ b/modules/user_accounts/php/edit_user.class.inc @@ -362,6 +362,7 @@ class Edit_User extends \NDB_Form } } unset($values['examiner_radiologist']); + $ex_pending = $values['examiner_pending']; unset($values['examiner_pending']); // END SETUP EXAMINER VALUES @@ -407,8 +408,6 @@ class Edit_User extends \NDB_Form // START EXAMINER UPDATE if ($editor->hasPermission('examiner_multisite')) { - $ex_pending = 'Y'; - $examinerID = $DB->pselect( "SELECT e.examinerID FROM examiners e @@ -422,7 +421,7 @@ class Edit_User extends \NDB_Form if (!empty($ex_radiologist) && !empty($ex_pending) && !empty($ex_curr_sites) - && empty($examinerID) + && count($examinerID) === 0 ) { // If examiner not in table and radiologist, pending and current // sites fields set add the examiner to the examiner table @@ -441,7 +440,7 @@ class Edit_User extends \NDB_Form WHERE userID=:uid", ['uid' => $uid] ); - } elseif (!empty($examinerID) + } elseif (count($examinerID) > 0 && ((!empty($ex_radiologist) && !empty($ex_pending) && !empty($ex_curr_sites)) From 91c1158b349d6c9d5626723b4d1b3cabae2b948f Mon Sep 17 00:00:00 2001 From: Saagar Arya <51128536+skarya22@users.noreply.github.com> Date: Wed, 16 Oct 2024 12:28:09 -0700 Subject: [PATCH 02/28] [Statistics] Dashboard Override (#9010) - Brought over changes from [CCNA override](https://github.com/aces/CCNA/pull/6484) - Ability to download chart data as csv, and download chart as png - Click chart for pop-up - Change between pie and bar chart for applicable charts - Also added overrides for a filtering option on project, site, visit and status - Introduced ability to filter by cohort' - Refactored code to be more reusable - Two charts were added from CCNA (Ethnicity and Age). Note that the "unknown" values are those with null ethnicity, and the ones with "Other" have ethnicity "other" * Resolves #7984 --- modules/dashboard/test/DashboardTest.php | 28 +- modules/statistics/css/WidgetIndex.css | 3 + modules/statistics/jsx/WidgetIndex.js | 313 ++++++++++++++- .../jsx/widgets/helpers/chartBuilder.js | 371 ++++++++++++++++++ .../jsx/widgets/helpers/progressbarBuilder.js | 95 +++++ .../jsx/widgets/helpers/queryChartForm.js | 190 +++++++++ modules/statistics/jsx/widgets/recruitment.js | 302 ++++++-------- .../jsx/widgets/studyprogression.js | 154 +++++--- modules/statistics/php/charts.class.inc | 306 ++++++++++++++- modules/statistics/php/widgets.class.inc | 225 ++++++++++- 10 files changed, 1723 insertions(+), 264 deletions(-) create mode 100644 modules/statistics/css/WidgetIndex.css create mode 100644 modules/statistics/jsx/widgets/helpers/chartBuilder.js create mode 100644 modules/statistics/jsx/widgets/helpers/progressbarBuilder.js create mode 100644 modules/statistics/jsx/widgets/helpers/queryChartForm.js diff --git a/modules/dashboard/test/DashboardTest.php b/modules/dashboard/test/DashboardTest.php index 67989ac0d9e..17c74adefdf 100644 --- a/modules/dashboard/test/DashboardTest.php +++ b/modules/dashboard/test/DashboardTest.php @@ -377,30 +377,52 @@ public function testDashboardRecruitmentView() $this->safeGet($this->url . '/dashboard/'); $views = $this->safeFindElement( WebDriverBy::cssSelector( - "#statistics_widgets .panel:nth-child(1) .views button" + "#statistics_widgets .panel:nth-child(2) .views button" ) ); $views->click(); $assertText1 = $this->safeFindElement( WebDriverBy::cssSelector( - "#statistics_widgets .panel:nth-child(1)". + "#statistics_widgets .panel:nth-child(2)". " .dropdown-menu li:nth-child(1)" ) )->getText(); $assertText2 = $this->safeFindElement( WebDriverBy::cssSelector( - "#statistics_widgets .panel:nth-child(1)". + "#statistics_widgets .panel:nth-child(2)". " .dropdown-menu li:nth-child(2)" ) )->getText(); + $assertText3 = $this->safeFindElement( + WebDriverBy::cssSelector( + "#statistics_widgets .panel:nth-child(2)". + " .dropdown-menu li:nth-child(3)" + ) + )->getText(); + + $assertText4 = $this->safeFindElement( + WebDriverBy::cssSelector( + "#statistics_widgets .panel:nth-child(2)". + " .dropdown-menu li:nth-child(4)" + ) + )->getText(); + $this->assertStringContainsString("Recruitment - overall", $assertText1); $this->assertStringContainsString( "Recruitment - site breakdown", $assertText2 ); + $this->assertStringContainsString( + "Recruitment - project breakdown", + $assertText3 + ); + $this->assertStringContainsString( + "Recruitment - cohort breakdown", + $assertText4 + ); } /** diff --git a/modules/statistics/css/WidgetIndex.css b/modules/statistics/css/WidgetIndex.css new file mode 100644 index 00000000000..00a2306accc --- /dev/null +++ b/modules/statistics/css/WidgetIndex.css @@ -0,0 +1,3 @@ +.c3-tooltip-container { + top: 0px !important; +} \ No newline at end of file diff --git a/modules/statistics/jsx/WidgetIndex.js b/modules/statistics/jsx/WidgetIndex.js index 389fd6d90ec..e87bb527936 100644 --- a/modules/statistics/jsx/WidgetIndex.js +++ b/modules/statistics/jsx/WidgetIndex.js @@ -4,10 +4,13 @@ import PropTypes from 'prop-types'; import Recruitment from './widgets/recruitment'; import StudyProgression from './widgets/studyprogression'; import {fetchData} from './Fetch'; -import { - recruitmentCharts, - studyProgressionCharts, -} from './widgets/chartBuilder'; +import Modal from 'Modal'; +import Loader from 'Loader'; +import {SelectElement} from 'jsx/Form'; + +import '../css/WidgetIndex.css'; + +import {setupCharts} from './widgets/helpers/chartBuilder'; /** * WidgetIndex - the main window. @@ -18,6 +21,176 @@ import { const WidgetIndex = (props) => { const [recruitmentData, setRecruitmentData] = useState({}); const [studyProgressionData, setStudyProgressionData] = useState({}); + + const [modalChart, setModalChart] = useState(null); + + // used by recruitment.js and studyprogression.js to display each chart. + const showChart = (section, chartID, chartDetails, setChartDetails) => { + let {sizing, title, chartType, options} = chartDetails[section][chartID]; + return
+
+
+ {title} +
+ {Object.keys(chartDetails[section][chartID].options).length > 1 && + { + // set the chart type in the chartDetails object for that chartID + setChartDetails( + {...chartDetails, + [section]: { + ...chartDetails[section], + [chartID]: { + ...chartDetails[section][chartID], + chartType: options[value], + }, + }, + }); + setupCharts( + false, + {[section]: {[chartID]: { + ...chartDetails[section][chartID], + chartType: options[value]}, + }} + ); + }} + /> + } +
+ { + setModalChart(chartDetails[section][chartID]); + setupCharts( + true, + {[section]: {[chartID]: chartDetails[section][chartID]}} + ); + }} + id={chartID} + > + + +
; + }; + + const downloadAsCSV = (data, filename, dataType) => { + const convertBarToCSV = (data) => { + const csvRows = []; + + // Adding headers row + const headers = ['Labels', ...Object.keys(data.datasets)]; + csvRows.push(headers.join(',')); + + // Adding data rows + const maxDatasetLength = Math.max(...Object.values(data.datasets).map( + (arr) => arr.length) + ); + for (let i = 0; i < maxDatasetLength; i++) { + const values = [`"${data.labels[i]}"` || '']; // Label for this row + for (const datasetKey of Object.keys(data.datasets)) { + const value = data.datasets[datasetKey][i]; + values.push(`"${value}"` || ''); + } + csvRows.push(values.join(',')); + } + return csvRows.join('\n'); + }; + + const convertPieToCSV = (data) => { + const csvRows = []; + const headers = Object.keys(data[0]); + csvRows.push(headers.join(',')); + + for (const row of data) { + const values = headers.map((header) => { + const escapedValue = row[header].toString().replace(/"/g, '\\"'); + return `"${escapedValue}"`; + }); + csvRows.push(values.join(',')); + } + + return csvRows.join('\n'); + }; + + const convertLineToCSV = (data) => { + const csvRows = []; + + // Adding headers row + const headers = [ + 'Labels', + ...data.datasets.map((dataset) => dataset.name), + ]; + csvRows.push(headers.join(',')); + + // Adding data rows + for (let i = 0; i < data.labels.length; i++) { + const values = [data.labels[i]]; // Label for this row + for (const dataset of data.datasets) { + values.push(dataset.data[i] || ''); + } + csvRows.push(values.join(',')); + } + + return csvRows.join('\n'); + }; + + let csvData = ''; + if (dataType == 'pie') { + csvData = convertPieToCSV(data); + } else if (dataType == 'bar') { + csvData = convertBarToCSV(data); + } else if (dataType == 'line') { + csvData = convertLineToCSV(data); + } + const blob = new Blob([csvData], {type: 'text/csv'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + // used by recruitment.js and studyprogression.js to update the filters for each chart. + const updateFilters = ( + formDataObj, + section, + chartDetails, + setChartDetails + ) => { + let formObject = new FormData(); + for (const key in formDataObj) { + if (formDataObj[key] != '' && formDataObj[key] != ['']) { + formObject.append(key, formDataObj[key]); + } + } + const queryString = '?' + new URLSearchParams(formObject).toString(); + + let newChartDetails = {...chartDetails}; + Object.keys(chartDetails[section]).forEach((chart) => { + // update filters + let newChart = {...chartDetails[section][chart], filters: queryString}; + setupCharts(false, {[section]: {[chart]: newChart}}).then((data) => { + // update chart data + newChartDetails[section][chart] = data[section][chart]; + }); + }); + setChartDetails(newChartDetails); + }; + /** * Similar to componentDidMount and componentDidUpdate. */ @@ -33,9 +206,6 @@ const WidgetIndex = (props) => { ); setRecruitmentData(data); setStudyProgressionData(data); - // setup statistics for c3.js charts. - await studyProgressionCharts(); - await recruitmentCharts(); }; setup().catch((error) => { console.error(error); @@ -49,11 +219,80 @@ const WidgetIndex = (props) => { */ return ( <> + setModalChart(null)} + width={'1200px'} + title={modalChart && modalChart.title} + throwWarning={false} + > +
+
+ +
+
+ {modalChart && modalChart.chartType && + { + downloadAsCSV( + modalChart.data, + modalChart.title, + modalChart.dataType + ); + }} + className='btn btn-info'> + + } + {modalChart + && modalChart.chartType + && modalChart.chartType !== 'line' + && { + exportChartAsImage('dashboardModal'); + }} + className='btn btn-info'> + + } +
); @@ -74,3 +313,63 @@ window.addEventListener('load', () => { /> ); }); + +/** + * Helper function to export a chart as an image + * + * @param {string} chartId + */ +const exportChartAsImage = (chartId) => { + const chartContainer = document.getElementById(chartId); + + if (!chartContainer) { + console.error(`Chart with ID '${chartId}' not found.`); + return; + } + + // Get the SVG element that represents the chart + const svgNode = chartContainer.querySelector('svg'); + + // Clone the SVG node to avoid modifying the original chart + const clonedSvgNode = svgNode.cloneNode(true); + + // Modify the font properties of the text elements + const textElements = clonedSvgNode.querySelectorAll('text'); + textElements.forEach((textElement) => { + textElement.style.fontFamily = 'Arial, sans-serif'; + textElement.style.fontSize = '12px'; + }); + + // Create a canvas element + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + // Get the SVG as XML data + const svgData = new XMLSerializer().serializeToString(clonedSvgNode); + + // Create an image that can be used as the source for the canvas + const img = new Image(); + img.onload = () => { + // Set the canvas size to match the chart's size + canvas.width = img.width; + canvas.height = img.height; + + // Draw the image on the canvas + ctx.drawImage(img, 0, 0); + + // Export the canvas to a data URL + const dataURL = canvas.toDataURL('image/png'); + + // Create a link and trigger a download + const link = document.createElement('a'); + link.href = dataURL; + link.download = 'chart.png'; + link.click(); + + // Clean up + canvas.remove(); + }; + img.src = + 'data:image/svg+xml;base64,' + + btoa(unescape(encodeURIComponent(svgData))); +}; diff --git a/modules/statistics/jsx/widgets/helpers/chartBuilder.js b/modules/statistics/jsx/widgets/helpers/chartBuilder.js new file mode 100644 index 00000000000..062e2f02847 --- /dev/null +++ b/modules/statistics/jsx/widgets/helpers/chartBuilder.js @@ -0,0 +1,371 @@ +/* eslint-disable */ +import 'c3/c3.min.css'; +import c3 from 'c3'; +import {fetchData} from '../../Fetch'; + +const baseURL = window.location.origin; + +// Colours for all charts broken down by only by site +const siteColours = [ + '#F0CC00', '#27328C', '#2DC3D0', '#4AE8C2', '#D90074', '#7900DB', '#FF8000', + '#0FB500', '#CC0000', '#DB9CFF', '#8c564b', '#c49c94', '#e377c2', '#f7b6d2', + '#7f7f7f', '#c7c7c7', '#bcbd22', '#dbdb8d', '#17becf', '#9edae5', +]; + +// Colours for the recruitment bar chart: breakdown by sex +const sexColours = ['#2FA4E7', '#1C70B6']; + +/** + * onload - override link click to cancel any fetch for statistical data. + */ +window.onload = () => { + document.body.addEventListener('click', (e) => { + // User clicks on a link.. + if ( + e.target && + e.target.nodeName === 'A' && + e.target.hasAttribute('data-target') === false + ) { + window.stop(); + } else if ( + e.target && + e.target.nodeName === 'A' && + e.target.hasAttribute('data-target') === true + ) { + const myTimeout = setTimeout(() => { + resizeGraphs(); + clearTimeout(myTimeout); + }, 500); + } + }); +}; + +let charts = [] +const resizeGraphs = () => { + charts.forEach((chart) => { + if (chart !== undefined) { + elementVisibility(chart.element, (visible) => { + if (visible) { + chart.resize(); + } + }) + } + }) +}; + +/** + * elementVisibility - used to resize charts when element becomes visible. + * @param {HTMLElement} element + * @param {function} callback + */ +const elementVisibility = (element, callback) => { + const options = { + root: document.documentElement, + }; + const observer = new IntersectionObserver((entries, observer) => { + entries.forEach((entry) => { + callback(entry.intersectionRatio > 0); + }); + }, options); + observer.observe(element); +}; + +/** + * formatPieData - used for the recruitment widget + * @param {object} data + * @return {[]} + */ +const formatPieData = (data) => { + const processedData = []; + for (const [i] of Object.entries(data)) { + const siteData = [data[i].label, data[i].total]; + processedData.push(siteData); + } + return processedData.filter((item) => item[1] > 0); +}; + +/** + * formatBarData - used for the recruitment widget + * @param {object} data + * @return {[]} + */ +const formatBarData = (data) => { + const processedData = []; + if (data['datasets']) { + const females = ['Female']; + processedData.push(females.concat(data['datasets']['female'])); + } + if (data['datasets']) { + const males = ['Male']; + processedData.push(males.concat(data['datasets']['male'])); + } + return processedData; +}; + +const createPieChart = (columns, id, targetModal, colours) => { + let newChart = c3.generate({ + bindto: targetModal ? targetModal : id, + data: { + columns: columns, + type: 'pie', + }, + size: { + height: targetModal ? 700 : 350, + width: targetModal ? 700 : 350, + }, + color: { + pattern: colours, + }, + pie: { + label: { + format: function(value, ratio, id) { + return value + "("+Math.round(100*ratio)+"%)"; + } + } + }, + }); + charts.push(newChart); + resizeGraphs(); +} + +const createBarChart = (labels, columns, id, targetModal, colours, dataType) => { + let newChart = c3.generate({ + bindto: targetModal ? targetModal : id, + data: { + x: dataType == 'pie' && 'x', + columns: columns, + type: 'bar', + colors: dataType === 'pie' ? { + [columns[1][0]]: function (d) { + return colours[d.index]; + } + } : + { + [columns[0][0]]: colours[0], + [columns[1][0]]: colours[1], + } + }, + size: { + width: targetModal ? 1000 : 350, + height: targetModal ? 700 : 350, + }, + axis: { + x: { + type: 'category', + categories: labels, + }, + y: { + label: { + text: 'Candidates registered', + position: 'inner-top' + }, + }, + }, + color: { + pattern: colours, + }, + legend: dataType === 'bar' ? { + position: 'inset', + inset: { + anchor: 'top-right', + x: 20, + y: 10, + step: 2 + } + } : { + show: false + } + }); + charts.push(newChart); + resizeGraphs(); +} + +const createLineChart = (data, columns, id, label, targetModal) => { + let newChart = c3.generate({ + size: { + height: targetModal && 1000, + width: targetModal && 1000 + }, + bindto: targetModal ? targetModal : id, + data: { + x: 'x', + xFormat: '%m-%Y', + columns: columns, + type: 'area-spline', + }, + legend: { + show: targetModal ? true : false, + }, + axis: { + x: { + type: 'timeseries', + tick: { + format: '%m-%Y', + }, + }, + y: { + max: maxY(data), + label: label, + }, + }, + zoom: { + enabled: true, + }, + color: { + pattern: siteColours, + }, + tooltip: { + // hide if 0 + contents: function (d, defaultTitleFormat, defaultValueFormat, color) { + let $$ = this, + config = $$.config, + titleFormat = config.tooltip_format_title || defaultTitleFormat, + nameFormat = config.tooltip_format_name || function (name) { return name; }, + valueFormat = config.tooltip_format_value || defaultValueFormat, + text, i, title, value, name, bgcolor; + for (i = 0; i < d.length; i++) { + if (d[i] && d[i].value == 0) { continue; } + + if (! text) { + title = titleFormat ? titleFormat(d[i].x) : d[i].x; + text = "" + (title || title === 0 ? "" : ""); + } + + name = nameFormat(d[i].name); + value = valueFormat(d[i].value, d[i].ratio, d[i].id, d[i].index); + bgcolor = $$.levelColor ? $$.levelColor(d[i].value) : color(d[i].id); + + text += ""; + text += ""; + text += ""; + text += ""; + } + return text + "
" + title + "
" + name + "" + value + "
"; + } + + } + }); + charts.push(newChart); + resizeGraphs(); +} + +const getChartData = async (target, filters) => { + let query = `${baseURL}/statistics/charts/${target}` + if (filters) { + query = query + filters; + } + return await fetchData(query); +} + +/** + * setupCharts - fetch data for charts + * If data is provided, use that instead of fetching + * There are three types of data provided. Pie, bar and line + * This is determined by the original chart type of the data provided from the API + * If data was provided as a Pie, and the requested chartType is Bar, then the data will be reformatted + */ +const setupCharts = async (targetIsModal, chartDetails) => { + const chartPromises = []; + let newChartDetails = {...chartDetails} + Object.keys(chartDetails).forEach((section) => { + Object.keys(chartDetails[section]).forEach((chartID) => { + let chart = chartDetails[section][chartID]; + let data = chart.data; + const chartPromise = (data && !chart.filters ? Promise.resolve(data) : getChartData(chartID, chart.filters)) + .then((chartData) => { + let columns = {}; + let labels = []; + let colours = []; + if (chart.dataType === 'pie') { + columns = formatPieData(chartData); + colours = siteColours; + // reformating the columns for a bar chart when it was originally pie data + if (chart.chartType == 'bar') { + let newColumns = [['x'], [chart.label]]; + columns.forEach((column, index) => { + newColumns[0].push(column[0]); + newColumns[1].push(column[1]); + labels.push(column[0]); + }); + columns = newColumns; + } + } else if (chart.dataType === 'bar') { + columns = formatBarData(chartData); + labels = chartData.labels; + colours = sexColours; + } else if (chart.dataType === 'line') { + columns = formatLineData(chartData); + if (chart.chartType !== 'line') { + // remove first and last (x and total) + columns = columns.slice(1, columns.length - 1); + } + } + if (chart.chartType === 'pie') { + createPieChart(columns, `#${chartID}`, targetIsModal && '#dashboardModal', colours); + } else if (chart.chartType === 'bar') { + createBarChart(labels, columns, `#${chartID}`, targetIsModal && '#dashboardModal', colours, chart.dataType); + } else if (chart.chartType === 'line') { + createLineChart(chartData, columns, `#${chartID}`, chart.label, targetIsModal && '#dashboardModal'); + } + newChartDetails[section][chartID].data = chartData; + }); + + chartPromises.push(chartPromise); + }); + }); + + await Promise.all(chartPromises); + return newChartDetails; +}; + +/** + * formatLineData - used for the study progression widget + * @param {object} data + * @return {*[]} + */ +const formatLineData = (data) => { + const processedData = []; + const labels = []; + labels.push('x'); + for (const [i] of Object.entries(data.labels)) { + labels.push(data.labels[i]); + } + processedData.push(labels); + for (const [i] of Object.entries(data['datasets'])) { + const dataset = []; + dataset.push(data['datasets'][i].name); + processedData.push(dataset.concat(data['datasets'][i].data)); + } + const totals = []; + totals.push('Total'); + for (let j = 0; j < data['datasets'][0].data.length; j++) { + let total = 0; + for (let i = 0; i < data['datasets'].length; i++) { + total += parseInt(data['datasets'][i].data[j]); + } + totals.push(total); + } + processedData.push(totals); + return processedData; +}; + +/** + * maxY - used for the study progression widget + * @param {object} data + * @return {number} + */ +const maxY = (data) => { + let maxi = 0; + for (let j = 0; j < data['datasets'][0].data.length; j++) { + for (let i = 0; i < data['datasets'].length; i++) { + maxi = Math.max(maxi, parseInt(data['datasets'][i].data[j])); + } + } + return maxi; +}; + +export { + // following used by WidgetIndex.js, + // recruitment.js and studyProgression.js + setupCharts, +}; \ No newline at end of file diff --git a/modules/statistics/jsx/widgets/helpers/progressbarBuilder.js b/modules/statistics/jsx/widgets/helpers/progressbarBuilder.js new file mode 100644 index 00000000000..097f4804808 --- /dev/null +++ b/modules/statistics/jsx/widgets/helpers/progressbarBuilder.js @@ -0,0 +1,95 @@ +/** + * progressBarBuilder - generates the graph content. + * + * @param {object} data - data needed to generate the graph content. + * @return {JSX.Element} the charts to render to the widget panel. + */ +const progressBarBuilder = (data) => { + let title; + let content; + if (data['recruitment_target']) { + title =
+ {data['title']} +
; + if (data['surpassed_recruitment']) { + content = ( +
+

+ The recruitment target ( + {data['recruitment_target']} + ) has been passed. +

+
+
+

+ {data['female_total']}
Females +

+
+
+

+ {data['male_total']}
Males +

+
+

+ Target: {data['recruitment_target']} +

+
+
+ ); + } else { + content = ( +
+
+

+ {data['female_total']}
Females +

+
+
+

+ {data['male_total']}
Males +

+
+

+ Target: {data['recruitment_target']} +

+
+ ); + } + } else { + content = ( +
+ Please add a recruitment target for {data['title']}. +
+ ); + } + return ( + <> + {title} + {content} + + ); + }; + + export { + progressBarBuilder, + }; diff --git a/modules/statistics/jsx/widgets/helpers/queryChartForm.js b/modules/statistics/jsx/widgets/helpers/queryChartForm.js new file mode 100644 index 00000000000..294f719bd9e --- /dev/null +++ b/modules/statistics/jsx/widgets/helpers/queryChartForm.js @@ -0,0 +1,190 @@ +import React, {useEffect, useState} from 'react'; +import PropTypes from 'prop-types'; +import {SelectElement, FormElement, ButtonElement} from 'jsx/Form'; + + +/** + * QueryChartForm - a form used for statistics query to modify graphs/charts. + * + * @param {object} props + * @return {JSX.Element} + */ +const QueryChartForm = (props) => { + const [optionsProjects, setOptionsProjects] = useState({}); + const [optionsCohorts, setOptionsCohorts] = useState({}); + const [optionsSites, setOptionsSites] = useState({}); + const [optionsVisits, setOptionsVisits] = useState({}); + const [optionsStatus, setOptionsStatus] = useState({}); + const [formDataObj, setFormDataObj] = useState({}); + + /** + * useEffect - modified to run when props.data updates. + */ + useEffect(() => { + const json = props.data; + if (json && Object.keys(json).length !== 0) { + let projectOptions = {}; + for (const [key, value] of Object.entries(json['options']['projects'])) { + projectOptions[key] = value; + } + setOptionsProjects(projectOptions); + let cohortOptions = {}; + for ( + const [key, value] of Object.entries(json['options']['cohorts']) + ) { + cohortOptions[key] = value; + } + setOptionsCohorts(cohortOptions); + let siteOptions = {}; + for (const [key, value] of Object.entries(json['options']['sites'])) { + siteOptions[key] = value; + } + setOptionsSites(siteOptions); + let visitOptions = {}; + for (const [key, value] of Object.entries(json['options']['visits'])) { + visitOptions[key] = value; + } + setOptionsVisits(visitOptions); + let participantStatusOptions = {}; + for (const [key, value] of Object.entries( + json['options']['participantStatus'] + )) { + participantStatusOptions[key] = value; + } + setOptionsStatus(participantStatusOptions); + } + }, [props.data]); + + /** + * setFormData - Stores the value of the element in formDataObj state. + * + * @param {string} formElement - name of the form element + * @param {string} value - value of the form element + */ + const setFormData = (formElement, value) => { + setFormDataObj( (prevState) => ({ + ...prevState, + [formElement]: value, + })); + }; + + const resetFilters = () => { + setFormDataObj({}); + }; + + /** + * Renders the React component. + * + * @return {JSX.Element} - React markup for component. + */ + return ( + props.callback(formDataObj)} + method='GET' + > + {Object.keys(props.data['options']['projects']).length > 0 + ? <> +

Project

+ +
+ : null} + {Object.keys(props.data['options']['cohorts']).length > 0 + ? <> +

Cohort

+ +
+ + : null} + {Object.keys(props.data['options']['sites']).length > 0 + ? <> +

Site

+ +
+ + : null} + {Object.keys(props.data['options']['visits']).length > 0 + ? <> +

Visit

+ +
+ + : null} + {Object.keys(props.data['options']['participantStatus']).length > 0 + ? <> +

Status

+ +
+ + : null} +
+ + +
+
+ ); +}; +QueryChartForm.propTypes = { + data: PropTypes.object, + callback: PropTypes.func, + Module: PropTypes.string, + name: PropTypes.string, + id: PropTypes.string, +}; +QueryChartForm.defaultProps = { + data: {}, +}; + +export { + QueryChartForm, +}; diff --git a/modules/statistics/jsx/widgets/recruitment.js b/modules/statistics/jsx/widgets/recruitment.js index 6f4225edf6c..ae206512368 100644 --- a/modules/statistics/jsx/widgets/recruitment.js +++ b/modules/statistics/jsx/widgets/recruitment.js @@ -1,7 +1,11 @@ import React, {useEffect, useState} from 'react'; import PropTypes from 'prop-types'; import Loader from 'Loader'; -import Panel from 'jsx/Panel'; +import Panel from 'Panel'; +import {QueryChartForm} from './helpers/queryChartForm'; +import {progressBarBuilder} from './helpers/progressbarBuilder'; + +import {setupCharts} from './helpers/chartBuilder'; /** * Recruitment - a widget containing statistics for recruitment data. @@ -11,192 +15,144 @@ import Panel from 'jsx/Panel'; */ const Recruitment = (props) => { const [loading, setLoading] = useState(true); - const [overall, setOverall] = useState({}); - const [siteBreakdown, setSiteBreakdown] = useState({}); - const [projectBreakdown, setProjectBreakdown] = useState({}); + let json = props.data; + + const [chartDetails, setChartDetails] = useState({ + 'siteBreakdown': { + 'agerecruitment_pie': { + sizing: 5, + title: 'Total recruitment by Age', + filters: '', + chartType: 'pie', + dataType: 'pie', + label: 'Age (Years)', + options: {pie: 'pie', bar: 'bar'}, + legend: 'under', + }, + 'ethnicity_pie': { + sizing: 5, + title: 'Ethnicity at Screening', + filters: '', + chartType: 'pie', + dataType: 'pie', + label: 'Ethnicity', + options: {pie: 'pie', bar: 'bar'}, + legend: 'under', + }, + 'siterecruitment_pie': { + sizing: 5, + title: 'Total Recruitment per Site', + filters: '', + chartType: 'pie', + dataType: 'pie', + label: 'Participants', + legend: '', + options: {pie: 'pie', bar: 'bar'}, + }, + 'siterecruitment_bysex': { + sizing: 5, + title: 'Biological sex breakdown by site', + filters: '', + chartType: 'bar', + dataType: 'bar', + legend: 'under', + options: {bar: 'bar', pie: 'pie'}, + }, + }, + }); + + const showChart = (section, chartID) => { + return props.showChart(section, chartID, chartDetails, setChartDetails); + }; + + const updateFilters = (formDataObj, section) => { + props.updateFilters(formDataObj, section, chartDetails, setChartDetails); + }; - /** - * useEffect - modified to run when props.data updates. - */ useEffect(() => { - const json = props.data; if (json && Object.keys(json).length !== 0) { - const overallData = ( -
- {progressBarBuilder(json['recruitment']['overall'])} -
- ); - let siteBreakdownData; - if (json['recruitment']['overall'] && - json['recruitment']['overall']['total_recruitment'] > 0 - ) { - siteBreakdownData = ( - <> -
-
- Total recruitment per site -
-
-
-
-
- Biological sex breakdown by site -
-
-
- - ); - } else { - siteBreakdownData = ( -

There have been no candidates registered yet.

- ); - } - let projectBreakdownData = []; - for (const [key, value] of Object.entries(json['recruitment'])) { - if (key !== 'overall') { - projectBreakdownData.push( -
- {progressBarBuilder(value)} -
- ); - } - } - setProjectBreakdown(projectBreakdownData); - setOverall(overallData); - setSiteBreakdown(siteBreakdownData); + setupCharts(false, chartDetails).then((data) => { + setChartDetails(data); + }); + json = props.data; setLoading(false); } }, [props.data]); - /** - * progressBarBuilder - generates the graph content. - * - * @param {object} data - data needed to generate the graph content. - * @return {JSX.Element} the charts to render to the widget panel. - */ - const progressBarBuilder = (data) => { - let title; - let content; - if (data['recruitment_target']) { - title =
- {data['title']} -
; - if (data['surpassed_recruitment']) { - content = ( -
-

- The recruitment target ( - {data['recruitment_target']} - ) has been passed. -

-
-
-

- {data['female_total']}
Females -

-
-
-

- {data['male_total']}
Males -

-
-

- Target: {data['recruitment_target']} -

-
-
- ); - } else { - content = ( -
-
-

- {data['female_total']}
Females -

-
-
-

- {data['male_total']}
Males -

-
-

- Target: {data['recruitment_target']} -

-
- ); - } - } else { - content = ( -
- Please add a recruitment target for {data['title']}. -
- ); - } - return ( - <> - {title} - {content} - - ); - }; - - /** - * Renders the React component. - * - * @return {JSX.Element} - React markup for component. - */ return loading ? : ( - - {overall} - , - title: 'Recruitment - overall', - }, - { - content: + <> + { + setupCharts(false, chartDetails); + }} + views={[ + { + content: +
+ {progressBarBuilder(json['recruitment']['overall'])} +
, + title: 'Recruitment - overall', + }, + { + content: + json['recruitment']['overall'] + && json['recruitment']['overall']['total_recruitment'] > 0 ? + <> + { + updateFilters(formDataObj, 'siteBreakdown'); + }} + /> + {Object.keys(chartDetails['siteBreakdown']).map((chartID) => { + return showChart('siteBreakdown', chartID); + })} + : +

There have been no candidates registered yet.

, + title: 'Recruitment - site breakdown', + }, + { + content: <> - {siteBreakdown} + {Object.entries(json['recruitment']).map(([key, value]) => { + if (key !== 'overall') { + return
+ {progressBarBuilder(value)} +
; + } + })} , - title: 'Recruitment - site breakdown', - }, - { - content: - <> - {projectBreakdown} - , - title: 'Recruitment - project breakdown', - }, - ]} - /> + title: 'Recruitment - project breakdown', + }, + { + content: + <> + {Object.entries(json['recruitmentcohorts']) + .map(([key, value]) => { + return
+ {progressBarBuilder(value)} +
; + } + )} + , + title: 'Recruitment - cohort breakdown', + }, + ]} + /> + ); }; + Recruitment.propTypes = { data: PropTypes.object, + baseURL: PropTypes.string, + updateFilters: PropTypes.function, + showChart: PropTypes.function, }; Recruitment.defaultProps = { data: {}, diff --git a/modules/statistics/jsx/widgets/studyprogression.js b/modules/statistics/jsx/widgets/studyprogression.js index 8da62572a7b..a0964e31b1a 100644 --- a/modules/statistics/jsx/widgets/studyprogression.js +++ b/modules/statistics/jsx/widgets/studyprogression.js @@ -1,7 +1,9 @@ import React, {useEffect, useState} from 'react'; import PropTypes from 'prop-types'; import Loader from 'Loader'; -import Panel from 'jsx/Panel'; +import Panel from 'Panel'; +import {QueryChartForm} from './helpers/queryChartForm'; +import {setupCharts} from './helpers/chartBuilder'; /** * StudyProgression - a widget containing statistics for study data. @@ -11,78 +13,114 @@ import Panel from 'jsx/Panel'; */ const StudyProgression = (props) => { const [loading, setLoading] = useState(true); - const [siteScans, setSiteScans] = useState({}); - const [siteRecruitments, setSiteRecruitments] = useState({}); + + let json = props.data; + + const [chartDetails, setChartDetails] = useState({ + 'total_scans': { + 'scans_bymonth': { + sizing: 11, + title: 'Scan sessions per site', + filters: '', + chartType: 'line', + dataType: 'line', + label: 'Scans', + legend: 'under', + options: {line: 'line'}, + }, + }, + 'total_recruitment': { + 'siterecruitment_line': { + sizing: 11, + title: 'Recruitment per site', + filters: '', + chartType: 'line', + dataType: 'line', + legend: '', + options: {line: 'line'}, + }, + }, + }); + + const showChart = ((section, chartID) => { + return props.showChart(section, chartID, + chartDetails, setChartDetails); + }); /** * useEffect - modified to run when props.data updates. */ useEffect(() => { - const json = props.data; if (json && Object.keys(json).length !== 0) { - setSiteScans( - json['studyprogression']['total_scans'] > 0 - ?
-
Scan sessions per site
-
-
- - Note that the Recruitment and Study Progression charts -  include data from ineligible, excluded, and consent -  withdrawn candidates. - -
- :

There have been no scans yet.

- ); - setSiteRecruitments( - json['studyprogression']['recruitment']['overall'] - ['total_recruitment'] > 0 - ?
-
Recruitment per site
-
-
- - Note that the Recruitment and Study Progression charts -  include data from ineligible, excluded, and consent -  withdrawn candidates. - -
- :

There have been no candidates registered yet.

- ); + setupCharts(false, chartDetails).then((data) => { + setChartDetails(data); + }); + json = props.data; setLoading(false); } }, [props.data]); - /** - * Renders the React component. - * - * @return {JSX.Element} - React markup for component. - */ + const updateFilters = (formDataObj, section) => { + props.updateFilters(formDataObj, section, + chartDetails, setChartDetails); + }; + return loading ? : ( - - {siteScans} - , - title: 'Study Progression - site scans', - }, - { - content: <> - {siteRecruitments} - , - title: 'Study Progression - site recruitment', - }, - ]} - /> + <> + { + setupCharts(false, chartDetails); + }} + views={[ + { + content: json['studyprogression']['total_scans'] > 0 ? + <> + { + updateFilters(formDataObj, 'total_scans'); + }} + /> + {showChart('total_scans', 'scans_bymonth')} + : +

There have been no scans yet.

, + title: 'Study Progression - site scans', + }, + { + content: + json['studyprogression']['recruitment'] + ['overall']['total_recruitment'] + > 0 ? + <> + { + updateFilters(formDataObj, 'total_recruitment'); + }} + /> + {showChart('total_recruitment', 'siterecruitment_line')} + : +

There have been no candidates registered yet.

, + title: 'Study Progression - site recruitment', + }, + ]} + /> + ); }; StudyProgression.propTypes = { data: PropTypes.object, + baseURL: PropTypes.string, + updateFilters: PropTypes.function, + showChart: PropTypes.function, }; StudyProgression.defaultProps = { data: {}, diff --git a/modules/statistics/php/charts.class.inc b/modules/statistics/php/charts.class.inc index 29bc3985de3..12e79759e92 100644 --- a/modules/statistics/php/charts.class.inc +++ b/modules/statistics/php/charts.class.inc @@ -85,9 +85,14 @@ class Charts extends \NDB_Page if (count($pathparts) != 2) { return new \LORIS\Http\Response\JSON\NotFound(); } + switch ($pathparts[1]) { case 'siterecruitment_pie': return $this->_handleSitePieData(); + case 'agerecruitment_pie': + return $this->_handleAgePieData(); + case 'ethnicity_pie': + return $this->_handleEthnicityPieData(); case 'siterecruitment_bysex': return $this->_handleSiteSexBreakdown(); case 'scans_bymonth': @@ -108,7 +113,11 @@ class Charts extends \NDB_Page { $DB = \NDB_Factory::singleton()->database(); + $params = $this->_parseGetParameters(); + $conditions = $this->_buildQueryConditions($params); + $recruitmentBySiteData = []; + $user = \NDB_Factory::singleton()->user(); $list_of_sites = $user->getStudySites(); @@ -116,7 +125,14 @@ class Charts extends \NDB_Page " SELECT COUNT(c.CandID), c.RegistrationCenterID as CenterID FROM candidate c + {$conditions['cohortJoin']} + {$conditions['participantStatusJoin']} WHERE c.Active='Y' AND c.Entity_type='Human' + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']} GROUP BY c.RegistrationCenterID ", [], @@ -140,8 +156,12 @@ class Charts extends \NDB_Page */ private function _handleSiteSexBreakdown() { - $DB = \NDB_Factory::singleton()->database(); - $sexData = []; + $DB = \NDB_Factory::singleton()->database(); + $sexData = []; + + $params = $this->_parseGetParameters(); + $conditions = $this->_buildQueryConditions($params); + $user = \NDB_Factory::singleton()->user(); $list_of_sites = $user->getStudySites(); @@ -151,7 +171,14 @@ class Charts extends \NDB_Page c.RegistrationCenterID as SiteID, c.Sex as Sex FROM candidate c + {$conditions['cohortJoin']} + {$conditions['participantStatusJoin']} WHERE c.Active='Y' AND c.Entity_type='Human' + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']} GROUP BY c.RegistrationCenterID, c.Sex", [] ); @@ -178,9 +205,134 @@ class Charts extends \NDB_Page $sexData['datasets']['male'][] = $processed_data[$siteID]['male'] ?? 0; } + return (new \LORIS\Http\Response\JsonResponse($sexData)); } + /** + * Handle an incoming request for age pie data. + * + * @return ResponseInterface + */ + private function _handleAgePieData() + { + $params = $this->_parseGetParameters(); + + $conditions = $this->_buildQueryConditions($params); + + $DB = \NDB_Factory::singleton()->database(); + + $dates = $DB->pselect( + "SELECT DISTINCT c.CandID, c.DoB, c.Date_registered + FROM candidate c + {$conditions['cohortJoin']} + {$conditions['participantStatusJoin']} + WHERE c.DoB IS NOT NULL + AND c.DoB <= c.date_registered + AND c.Active='Y' + AND c.Entity_type='Human' + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']}", + [] + ); + + // Initialize an array to store the dynamic age splits + $ageSplits = []; + + foreach ($dates as $_ => $value) { + // Note that age is calculated from date registered, not today + $ageOb = \Utility::calculateAge( + $value['DoB'], + $value['Date_registered'] + ); + $age = $ageOb['year']; + + // Determine the starting point of the age split (a multiple of 5) + $startOfSplit = intval(floor($age / 5) * 5); + + // Check if the age split already exists in the array + if (!isset($ageSplits[$startOfSplit])) { + // If not, create a new entry with the starting age as the key + $ageSplits[$startOfSplit] = 0; + } + + // Increment the count for the corresponding age split + ++$ageSplits[$startOfSplit]; + } + + // Convert the dynamic age splits into the desired format + $recruitmentByAgeData = []; + foreach ($ageSplits as $startOfSplit => $count) { + $endOfSplit = $startOfSplit + 4; // Adjust the age range as needed + $label = $startOfSplit . '-' . $endOfSplit; + $recruitmentByAgeData[] = ["label" => $label, "total" => $count]; + } + + return (new \LORIS\Http\Response\JsonResponse($recruitmentByAgeData)); + } + + /** + * Handle an incoming request for ethnicity pie data. + * + * @return ResponseInterface + */ + private function _handleEthnicityPieData() + { + $params = $this->_parseGetParameters(); + + $conditions = $this->_buildQueryConditions($params); + + $DB = \NDB_Factory::singleton()->database(); + + $candidates = $DB->pselect( + "SELECT DISTINCT c.CandID, c.Ethnicity + FROM candidate c + {$conditions['cohortJoin']} + {$conditions['participantStatusJoin']} + WHERE c.Active='Y' + AND c.Entity_type='Human' + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']}", + [] + ); + + // Initialize an array to store the ethnicities + $ethnicities = []; + + foreach ($candidates as $_ => $value) { + // Check if the ethnicity already exists in the array + if (!isset($ethnicities[$value["Ethnicity"]])) { + // If not, create a new entry with the ethnicity as the key + $ethnicities[$value["Ethnicity"]] = 0; + } + + // Increment the count for the corresponding ethnicity + ++$ethnicities[$value["Ethnicity"]]; + } + + // Convert into the desired format + $recruitmentByEthnicityData = []; + foreach ($ethnicities as $id => $count) { + $id = str_replace("_", " ", $id); + $id = strtolower($id); + $id = ucwords($id); + if ($id == null) { + $id = "Unknown"; + } + $label = $id; + $recruitmentByEthnicityData[] = ["label" => $label, "total" => $count]; + } + + return (new \LORIS\Http\Response\JsonResponse($recruitmentByEthnicityData)); + } + + /** * Handle an incoming request for monthly progression * @@ -190,6 +342,12 @@ class Charts extends \NDB_Page { $DB = \NDB_Factory::singleton()->database(); + $params = $this->_parseGetParameters(); + $conditions = $this->_buildQueryConditions($params, true); + + $user = \NDB_Factory::singleton()->user(); + $list_of_sites = $user->getStudySites(); + $scanData = []; // Run a query to get all the data. Order matters to ensure that the // labels are calculated in the correct order. @@ -201,14 +359,21 @@ class Charts extends \NDB_Page FROM files f LEFT JOIN parameter_file pf USING (FileID) LEFT JOIN session s ON (s.ID=f.SessionID) + {$conditions['participantStatusJoin']} JOIN parameter_type pt USING (ParameterTypeID) WHERE pt.Name='acquisition_date' + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']} GROUP BY MONTH(pf.Value), YEAR(pf.Value), s.CenterID, datelabel ORDER BY YEAR(pf.Value), MONTH(pf.Value), s.CenterID", [] ) ); + // TODO: make this work as bar data // Create the labels. // // We want to ensure that every month label appear exactly once and @@ -225,8 +390,6 @@ class Charts extends \NDB_Page $scanData['labels'] = array_keys($labels); // Massage the data into the appropriate format per site. - $user = \NDB_Factory::singleton()->user(); - $list_of_sites = $user->getStudySites(); foreach ($list_of_sites as $siteID => $siteName) { $scanData['datasets'][] = [ "name" => $siteName, @@ -285,6 +448,9 @@ class Charts extends \NDB_Page { $DB = \NDB_Factory::singleton()->database(); + $params = $this->_parseGetParameters(); + $conditions = $this->_buildQueryConditions($params, true); + $recruitmentData = []; $recruitmentStartDate = $DB->pselectOne( "SELECT MIN(Date_registered) FROM candidate", @@ -313,7 +479,12 @@ class Charts extends \NDB_Page YEAR(c.Date_registered) as Year, c.RegistrationCenterID as SiteID FROM candidate c + {$conditions['participantStatusJoin']} + {$conditions['cohortJoin']} WHERE c.Entity_type='Human' + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['participantStatusQuery']} GROUP BY MONTH(c.Date_registered), YEAR(c.Date_registered), c.RegistrationCenterID", @@ -353,6 +524,133 @@ class Charts extends \NDB_Page return new \LORIS\Http\Response\JsonResponse($recruitmentData); } + /** + * Helper to parse the GET parameters from the incoming request. + * + * @return array + */ + private function _parseGetParameters() + { + $selectedProjects = empty($_GET['selectedProjects']) + || $_GET['selectedProjects'] === 'null' + || $_GET['selectedProjects'] === 'undefined' + ? null : explode(",", $_GET['selectedProjects']); + $selectedCohorts = empty($_GET['selectedCohorts']) + || $_GET['selectedCohorts'] === 'null' + || $_GET['selectedCohorts'] === 'undefined' + ? null : explode(",", $_GET['selectedCohorts']); + $selectedSites = empty($_GET['selectedSites']) + || $_GET['selectedSites'] === 'null' + || $_GET['selectedSites'] === 'undefined' + ? null : explode(",", $_GET['selectedSites']); + $selectedVisits = empty($_GET['selectedVisits']) + || $_GET['selectedVisits'] === 'null' + || $_GET['selectedVisits'] === 'undefined' + ? null : explode(",", $_GET['selectedVisits']); + $selectedParticipantStatus = empty( + $_GET['selectedParticipantStatus'] + ) + || $_GET['selectedParticipantStatus'] === 'null' + || $_GET['selectedParticipantStatus'] === 'undefined' ? null + : explode( + ",", + $_GET['selectedParticipantStatus'] + ); + + return [ + 'selectedProjects' => $selectedProjects, + 'selectedCohorts' => $selectedCohorts, + 'selectedSites' => $selectedSites, + 'selectedVisits' => $selectedVisits, + 'selectedParticipantStatus' => $selectedParticipantStatus, + ]; + } + + /** + * Helper to generate query conditions for for incoming requests. + * + * @param array $params The parameters from the incoming request. + * @param bool $scansbymonth Whether or not the request is for scans by month. + * + * @return array + */ + private function _buildQueryConditions( + $params, + $scansbymonth = false + ) { + $user = \NDB_Factory::singleton()->user(); + + $projectQuery = ''; + $cohortQuery = ''; + $cohortJoin = ''; + $visitQuery = ''; + $PSJoin = ''; + $participantStatusQuery = ''; + + if (!is_null($params['selectedProjects'])) { + $projectString = "'" . implode("','", $params['selectedProjects']) . "'"; + $projectQuery = " AND c.RegistrationProjectID IN ({$projectString}) "; + } + + if (!is_null($params['selectedCohorts'])) { + $cohortString = "'" . implode("','", $params['selectedCohorts']) . "'"; + $cohortQuery = " AND s.CohortID IN ({$cohortString}) "; + $cohortJoin = "JOIN session s ON s.CandID=c.CandID"; + } + if (!is_null($params['selectedSites'])) { + // Set site query if selected + $siteString = "'" . implode("','", $params['selectedSites']) . "'"; + $siteQuery = " AND c.RegistrationCenterID IN ({$siteString}) "; + if ($scansbymonth === true) { + $siteQuery = " AND s.CenterID IN ({$siteString}) "; + } + } else { + // If not selected, only take user sites + $centerIDs = $user->getCenterIDs(); + $centerList = "'" . implode("','", $centerIDs) . "'"; + $siteQuery = " AND c.RegistrationCenterID IN ({$centerList}) "; + if ($scansbymonth === true) { + $siteQuery = " AND s.CenterID IN ({$centerList}) "; + } + } + if (!is_null($params['selectedVisits'])) { + // Set visit query if visits selected + $visitString = "'" . implode("','", $params['selectedVisits']) . "'"; + $visitQuery = " AND s.Visit_label IN ({$visitString}) "; + // since they are the same, if visits are selected, + // then the cohort string is overwritten + $cohortJoin = "JOIN session s ON s.CandID=c.CandID"; + } + if (!is_null($params['selectedParticipantStatus'])) { + $PSJoin = 'LEFT JOIN participant_status ps ON c.CandID=ps.CandID'; + $participantStatusString = "'" . implode( + "','", + $params['selectedParticipantStatus'] + ) . "'"; + // Null participant status counts as Active because + // sometimes users do not update the participant_status tab + if (in_array('1', $params['selectedParticipantStatus'])) { + $participantStatusQuery = " AND ( + ps.participant_status IN ({$participantStatusString}) + OR ps.participant_status IS NULL + )"; + } else { + $participantStatusQuery = " AND ps.participant_status + IN ({$participantStatusString}) "; + } + } + + return [ + 'projectQuery' => $projectQuery, + 'cohortQuery' => $cohortQuery, + 'cohortJoin' => $cohortJoin, + 'visitQuery' => $visitQuery, + 'siteQuery' => $siteQuery, + 'participantStatusJoin' => $PSJoin, + 'participantStatusQuery' => $participantStatusQuery, + ]; + } + /** * Helper to generate labels for every month between startDate and endDate. * diff --git a/modules/statistics/php/widgets.class.inc b/modules/statistics/php/widgets.class.inc index 131429cf200..cc24e9443d3 100644 --- a/modules/statistics/php/widgets.class.inc +++ b/modules/statistics/php/widgets.class.inc @@ -89,38 +89,221 @@ class Widgets extends \NDB_Page implements ETagCalculator ) ]; - $projects = \Utility::getProjectList(); - foreach (array_keys($projects) as $projectID) { - $projectInfo = $config->getProjectSettings($projectID); + $user = $factory->user(); + $projects = $user->getProjectIDs(); + + $projectOptions = []; + $cohortOptions = []; + $visitOptions = []; + $recruitmentCohorts = []; + foreach ($projects as $projectID) { + // Set project recruitment data + $projectInfo = $config->getProjectSettings(intval(strval($projectID))); if (is_null($projectInfo)) { throw new \LorisException( 'No project settings exist in the Database for ' . - 'project ID ' . intval($projectID) + 'project ID ' . intval(strval($projectID)) ); } - $recruitment[$projectID] = $this->_createProjectProgressBar( - $projectID, - $projectInfo['Name'], - $projectInfo['recruitmentTarget'], - $this->getTotalRecruitmentByProject($recruitmentRaw, $projectID), - $recruitmentRaw - ); + $recruitment[intval(strval($projectID))] + = $this->_createProjectProgressBar( + strval($projectID), + $projectInfo['Name'], + $projectInfo['recruitmentTarget'], + $this->getTotalRecruitmentByProject( + $recruitmentRaw, + $projectID + ), + $recruitmentRaw + ); + + // Set cohort recruitment data + $project = \Project::getProjectFromID($projectID); + $cohorts = $project->getCohorts(); + + $projectOptions[strval($projectID)] = $project->getName(); + foreach ($cohorts AS $sp) { + $cohortOptions[$sp["cohortId"]] = $sp["title"]; + $recruitmentCohorts[$sp["cohortId"]] + = $this->_createCohortProgressBar( + $sp["cohortId"], + $sp["title"], + $sp["recruitmentTarget"], + $db + ); + $cohortVisits = \Utility::getVisitsForCohort( + intval($sp["cohortId"]) + ); + foreach ($cohortVisits as $visit) { + $visitOptions[$visit] = $cohortVisits[$visit]; + } + } } - $values = []; + $sites = \Utility::getSiteList(); + $userCenters = $user->getCenterIDs(); + + $siteOptions = array_intersect_key($sites, $userCenters); + + $participantStatusOptions + = \Candidate::getParticipantStatusOptions(); + + $options = [ + 'projects' => $projectOptions, + 'cohorts' => $cohortOptions, + 'sites' => $siteOptions, + 'visits' => $visitOptions, + 'participantStatus' => $participantStatusOptions + ]; + $values = []; // Used for the react widget recruitment.js $values['recruitment'] = $recruitment; // Used for the react widget studyprogression.js - $values['studyprogression'] = [ + $values['studyprogression'] = [ 'total_scans' => $totalScans, 'recruitment' => $recruitment ]; + $values['options'] = $options; + $values['recruitmentcohorts'] = $recruitmentCohorts; $this->_cache = new \LORIS\Http\Response\JsonResponse($values); return $this->_cache; } + /** + * Generates the template data for a progress bar. + * + * @param $ID The name of the progress bar being + * created. + * @param $title The title to add to the template variables. + * @param $recruitmentTarget The target for this recruitment type. + * @param \Database $db The database connection to get data from. + * + * @return array Smarty template data + */ + private function _createCohortProgressBar( + $ID, + $title, + $recruitmentTarget, + \Database $db + ) { + + $user = \User::singleton(); + $projectIDs = $user->getProjectIDs(); + $projectList = "'" . implode("','", $projectIDs) . "'"; + $centerIDs = $user->getCenterIDs(); + $centerList = "'" . implode("','", $centerIDs) . "'"; + + $totalRecruitment = intval( + $db->pselectOne( + "SELECT COUNT(DISTINCT s.CandID) + FROM session s JOIN candidate c + WHERE s.CohortID=:sid + AND s.CenterID IN ({$centerList}) + AND s.ProjectID IN ({$projectList}) + AND c.RegistrationCenterID <> 1", + ['sid' => $ID] + ) + ); + + $rv = [ + 'total_recruitment' => $totalRecruitment, + 'title' => $title, + ]; + + if (empty($recruitmentTarget)) { + $recruitmentTarget = $totalRecruitment; + } + + $rv['recruitment_target'] = $recruitmentTarget; + $totalFemales = $this->getTotalSexByCohort( + $db, + "Female", + intval($ID) + ); + $rv['female_total'] = $totalFemales; + $rv['female_percent'] = $recruitmentTarget ? + round($totalFemales / $recruitmentTarget * 100) : + null; + $totalMales = $this->getTotalSexByCohort( + $db, + "Male", + intval($ID) + ); + $rv['male_total'] = $totalMales; + $rv['male_percent'] = $recruitmentTarget ? + round($totalMales / $recruitmentTarget * 100) : + null; + if ($totalRecruitment > $recruitmentTarget) { + $rv['surpassed_recruitment'] = "true"; + + $rv['female_full_percent'] = $totalRecruitment ? + round($totalFemales / $totalRecruitment * 100) : + null; + + $rv['male_full_percent'] = $totalRecruitment ? + round($totalMales / $totalRecruitment * 100) : + null; + } + return $rv; + } + + /** + * Gets the total count of candidates of a specific sex, + * associated with a specific project + * + * @param \Database $DB A database connection to retrieve information + * from. + * @param string $sex A biological sex (male or female) + * @param int $cohortID Cohort ID + * + * @return int|string + */ + function getTotalSexByCohort(\Database $DB, string $sex, int $cohortID) + { + + $user = \User::singleton(); + $projectIDs = $user->getProjectIDs(); + $projectList = "'" . implode("','", $projectIDs) . "'"; + $centerIDs = $user->getCenterIDs(); + $centerList = "'" . implode("','", $centerIDs) . "'"; + + return intval( + $DB->pselectOne( + "SELECT COUNT(DISTINCT s.CandID) + FROM session s + JOIN candidate c ON c.CandID=s.CandID + WHERE CohortID=:sid + AND CenterID IN ({$centerList}) + AND ProjectID IN ({$projectList}) + AND c.Sex=:sex + AND c.RegistrationCenterID <> 1", + [ + 'sex' => $sex, + 'sid' => $cohortID + ] + ) + ); + } + + /** + * Gets the total count of candidates associated with a specific project + * + * @param \Database $DB The database connection to get the count from. + * + * @return int + */ + private function _getTotalRecruitment(\Database $DB): int + { + return $DB->pselectOneInt( + "SELECT COUNT(*) FROM candidate c + WHERE c.Active='Y' AND c.Entity_type='Human' + AND c.RegistrationCenterID <> 1", + [] + ) ?? 0; + } + /** * Generates the template data for a progress bar. * @@ -157,7 +340,7 @@ class Widgets extends \NDB_Page implements ETagCalculator $totalFemales = $this->getTotalSexByProject( $rawData, "Female", - intval($ID) + intval(strval($ID)) ); } $rv['female_total'] = $totalFemales; @@ -166,7 +349,11 @@ class Widgets extends \NDB_Page implements ETagCalculator if ($ID == 'overall') { $totalMales = $this->_getTotalSex($rawData, "Male"); } else { - $totalMales = $this->getTotalSexByProject($rawData, "Male", intval($ID)); + $totalMales = $this->getTotalSexByProject( + $rawData, + "Male", + intval(strval($ID)) + ); } $rv['male_total'] = $totalMales; $rv['male_percent'] @@ -226,16 +413,16 @@ class Widgets extends \NDB_Page implements ETagCalculator /** * Gets the total count of candidates associated with a specific project. * - * @param array $data The raw data returned from the SQL query. - * @param int $projectID The Project ID to get recruitment for. + * @param array $data The raw data returned from the SQL query. + * @param \ProjectID $projectID The Project ID to get recruitment for. * * @return int */ - function getTotalRecruitmentByProject(array $data, int $projectID): int + function getTotalRecruitmentByProject(array $data, \ProjectID $projectID): int { $sum = 0; foreach ($data as $row) { - if (intval($row['ProjectID']) == $projectID) { + if ($row['ProjectID'] == strval($projectID)) { $sum += intval($row['Count']); } } From d3fc6ffded6456b651d476eb8b3e058d4ec3f9ab Mon Sep 17 00:00:00 2001 From: Dave MacFarlane Date: Fri, 25 Oct 2024 14:16:19 -0400 Subject: [PATCH 03/28] [Statistics/Dashboard] Fix query (#9421) A query introduced in #9010 did not have any join condition between the candidate and session table, resulting in both incorrect data and slow query times. --- modules/statistics/php/widgets.class.inc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/statistics/php/widgets.class.inc b/modules/statistics/php/widgets.class.inc index cc24e9443d3..b23f990f9e1 100644 --- a/modules/statistics/php/widgets.class.inc +++ b/modules/statistics/php/widgets.class.inc @@ -199,6 +199,7 @@ class Widgets extends \NDB_Page implements ETagCalculator $db->pselectOne( "SELECT COUNT(DISTINCT s.CandID) FROM session s JOIN candidate c + ON (s.CandID=c.CandID) WHERE s.CohortID=:sid AND s.CenterID IN ({$centerList}) AND s.ProjectID IN ({$projectList}) @@ -273,7 +274,7 @@ class Widgets extends \NDB_Page implements ETagCalculator $DB->pselectOne( "SELECT COUNT(DISTINCT s.CandID) FROM session s - JOIN candidate c ON c.CandID=s.CandID + JOIN candidate c ON (c.CandID=s.CandID) WHERE CohortID=:sid AND CenterID IN ({$centerList}) AND ProjectID IN ({$projectList}) From 5986c003536733630a33769744a24da41a9d902d Mon Sep 17 00:00:00 2001 From: Shen Date: Mon, 28 Oct 2024 15:31:03 -0400 Subject: [PATCH 04/28] [Test] update selenium image from V3 to V4 (#9403) Update selenium image from V3 to V4 to support new JS fetures(i18next ...) in firefox browser. Selenium standalone-firefox-debug now is part of standalone-firefox: 4.25. --- docker-compose.yml | 62 ++------------------------------------- test/wait-for-services.sh | 3 +- 2 files changed, 4 insertions(+), 61 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 36f6a6d60c8..2b20f2ccfb5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '2' +version: '3.8' services: db: build: @@ -13,12 +13,9 @@ services: - MYSQL_RANDOM_ROOT_PASSWORD=yes selenium: - image: selenium/standalone-firefox-debug:3.141.59-zirconium - volumes: - - /dev/shm:/dev/shm + image: selenium/standalone-firefox:4.25 ports: - "5900:5900" - web: build: context: . @@ -62,58 +59,3 @@ services: - selenium - web entrypoint: /app/test/wait-for-services.sh - - selenium-debug: - image: selenium/standalone-firefox-debug:3.141.59-zirconium - links: - - web-debug:web - ports: - - "5901:5900" - - web-debug: - build: - context: . - dockerfile: Dockerfile.test.php8.debug - volumes: - - ./:/app - - ./test/test_instrument:/app/project/instruments - environment: - - LORIS_DB_CONFIG=/app/test/config.xml - - XDEBUG_CONFIG=remote_host=${XDEBUG_REMOTE_HOST} - - PHP_IDE_CONFIG=serverName=LorisTests - depends_on: - - db - command: php -S 0.0.0.0:8000 -t /app/htdocs /app/htdocs/router.php - - unit-tests-debug: - build: - context: . - dockerfile: Dockerfile.test.php8.debug - volumes: - - ./:/app - working_dir: /app - environment: - - LORIS_DB_CONFIG=test/config.xml - - XDEBUG_CONFIG=remote_host=${XDEBUG_REMOTE_HOST} - - PHP_IDE_CONFIG=serverName=LorisTests - depends_on: - - db - entrypoint: /app/test/wait-for-services.sh - - integration-tests-debug: - build: - context: . - dockerfile: Dockerfile.test.php8.debug - volumes: - - ./:/app - working_dir: /app - environment: - - LORIS_DB_CONFIG=test/config.xml - - SELENIUM_REQUIRED=true - - XDEBUG_CONFIG=remote_host=${XDEBUG_REMOTE_HOST} - - PHP_IDE_CONFIG=serverName=LorisTests - links: - - db - - selenium-debug:selenium - - web-debug:web - entrypoint: /app/test/wait-for-services.sh diff --git a/test/wait-for-services.sh b/test/wait-for-services.sh index 4b6baf087e6..6c32758278b 100755 --- a/test/wait-for-services.sh +++ b/test/wait-for-services.sh @@ -14,10 +14,11 @@ done if [ -v SELENIUM_REQUIRED ]; then echo "Waiting for Selenium..." - until $(curl --output /dev/null --silent --head --fail http://selenium:4444/wd/hub); do + until $(curl --output /dev/null --silent --head --fail http://selenium:4444/wd/hub/status); do sleep 1 done echo "Selenium is alive" fi exec $cmd + From a750267fec857551f55d22cc7d946d19d99918b1 Mon Sep 17 00:00:00 2001 From: Maxime Mulder Date: Tue, 29 Oct 2024 08:25:29 -0400 Subject: [PATCH 05/28] Add indentation to JS lint (#9430) Add rule to enforce 2 space indentation for js. --- .eslintrc.json | 1 + htdocs/js/advancedMenu.js | 8 +- htdocs/js/instrument_controlpanel_control.js | 2 +- htdocs/js/invalid_form_scroll.js | 24 +- htdocs/js/jquery.fileupload.js | 4 +- jslib/fetchDataStream.js | 124 +- jsx/Breadcrumbs.js | 14 +- jsx/CSSGrid.js | 176 +- jsx/Card.js | 14 +- jsx/DataTable.js | 184 +- jsx/Filter.js | 96 +- jsx/FilterableDataTable.js | 42 +- jsx/Form.js | 284 +-- jsx/InfoPanel.tsx | 45 +- jsx/Loader.js | 12 +- jsx/Modal.js | 2 +- jsx/MultiSelectDropdown.js | 30 +- jsx/PaginationLinks.js | 4 +- jsx/Panel.js | 44 +- jsx/StaticDataTable.js | 12 +- jsx/Tabs.js | 34 +- .../jsx/acknowledgementsIndex.js | 78 +- .../battery_manager/jsx/batteryManagerForm.js | 14 +- .../jsx/batteryManagerIndex.js | 232 +- .../jsx/tabs_content/behaviouralFeedback.js | 102 +- .../jsx/tabs_content/dataConflicts.js | 92 +- .../jsx/tabs_content/incompleteForms.js | 92 +- modules/brainbrowser/js/brainbrowser.loris.js | 136 +- modules/brainbrowser/jsx/Brainbrowser.js | 6 +- .../jsx/react.behavioural_feedback_panel.js | 12 +- .../candidate_list/jsx/candidateListIndex.js | 50 +- modules/candidate_list/jsx/openProfileForm.js | 8 +- .../candidate_parameters/jsx/CandidateDOB.js | 28 +- .../candidate_parameters/jsx/CandidateDOD.js | 22 +- .../candidate_parameters/jsx/CandidateInfo.js | 96 +- .../jsx/CandidateParameters.js | 4 +- .../candidate_parameters/jsx/ConsentStatus.js | 884 ++++---- .../candidate_parameters/jsx/ConsentWidget.js | 84 +- .../jsx/DiagnosisEvolution.js | 86 +- .../jsx/ParticipantStatus.js | 198 +- .../candidate_parameters/jsx/ProbandInfo.js | 94 +- .../candidate_profile/jsx/CandidateInfo.js | 360 ++-- modules/configuration/jsx/CohortRelations.js | 14 +- .../configuration/jsx/DiagnosisEvolution.js | 1066 ++++----- .../configuration/jsx/configuration_helper.js | 222 +- .../jsx/CandidateConflictsWidget.js | 176 +- .../jsx/conflict_resolver.js | 20 +- .../jsx/fix_conflict_form.js | 28 +- .../jsx/resolved_filterabledatatable.js | 8 +- .../jsx/unresolved_filterabledatatable.js | 22 +- .../jsx/createTimepointIndex.js | 28 +- modules/dashboard/jsx/welcome.js | 18 +- modules/data_release/jsx/dataReleaseIndex.js | 34 +- .../data_release/jsx/managePermissionsForm.js | 80 +- modules/data_release/jsx/uploadFileForm.js | 44 +- modules/datadict/jsx/dataDictIndex.js | 138 +- modules/dataquery/jsx/calcpayload.tsx | 70 +- .../jsx/components/expansionpanels.tsx | 2 +- .../jsx/components/filterableselectgroup.tsx | 96 +- modules/dataquery/jsx/criteriaterm.tsx | 226 +- modules/dataquery/jsx/definefields.tsx | 702 +++--- .../jsx/definefilters.addfiltermodal.tsx | 660 +++--- .../jsx/definefilters.importcsvmodal.tsx | 342 +-- modules/dataquery/jsx/definefilters.tsx | 606 +++--- modules/dataquery/jsx/fielddisplay.tsx | 30 +- .../jsx/getdictionarydescription.tsx | 18 +- .../dataquery/jsx/hooks/usebreadcrumbs.tsx | 146 +- .../dataquery/jsx/hooks/usedatadictionary.tsx | 124 +- modules/dataquery/jsx/hooks/usequery.tsx | 484 ++--- .../dataquery/jsx/hooks/usesharedqueries.tsx | 582 ++--- modules/dataquery/jsx/hooks/usevisits.tsx | 60 +- modules/dataquery/jsx/index.tsx | 356 +-- modules/dataquery/jsx/nextsteps.tsx | 266 +-- modules/dataquery/jsx/querydef.tsx | 48 +- modules/dataquery/jsx/querytree.tsx | 382 ++-- modules/dataquery/jsx/viewdata.tsx | 1910 ++++++++--------- .../dataquery/jsx/welcome.adminquerymodal.tsx | 138 +- .../dataquery/jsx/welcome.namequerymodal.tsx | 80 +- modules/dataquery/jsx/welcome.tsx | 1546 ++++++------- modules/dicom_archive/jsx/dicom_archive.js | 36 +- modules/dictionary/jsx/dataDictIndex.js | 368 ++-- .../document_repository/jsx/categoryForm.js | 64 +- modules/document_repository/jsx/childTree.js | 8 +- .../jsx/deleteCategoryForm.js | 25 +- modules/document_repository/jsx/docIndex.js | 156 +- .../jsx/editCategoryForm.js | 28 +- modules/document_repository/jsx/editForm.js | 142 +- modules/document_repository/jsx/parentTree.js | 2 +- modules/document_repository/jsx/uploadForm.js | 68 +- modules/dqt/jsx/components/expansionpanels.js | 4 +- .../dqt/jsx/components/searchabledropdown.js | 50 +- modules/dqt/jsx/components/stepper.js | 12 +- modules/dqt/jsx/react.app.js | 214 +- modules/dqt/jsx/react.fieldselector.js | 148 +- modules/dqt/jsx/react.filterBuilder.js | 188 +- modules/dqt/jsx/react.importCSV.js | 6 +- modules/dqt/jsx/react.navigationStepper.js | 94 +- modules/dqt/jsx/react.notice.js | 28 +- modules/dqt/jsx/react.savedqueries.js | 126 +- modules/dqt/jsx/react.sidebar.js | 16 +- modules/dqt/jsx/react.tabs.js | 384 ++-- .../jsx/components/SidebarContent.js | 6 +- .../electrophysiology_session_panels.js | 170 +- .../jsx/electrophysiologyBrowserIndex.js | 34 +- .../jsx/electrophysiologySessionView.js | 86 +- .../src/eeglab/EEGLabSeriesProvider.tsx | 10 +- .../src/series/store/logic/fetchChunks.tsx | 2 +- .../src/series/store/logic/filterEpochs.tsx | 8 +- .../src/series/store/logic/highLowPass.tsx | 2 +- .../src/series/store/logic/timeSelection.tsx | 2 +- .../src/series/store/state/channels.tsx | 58 +- .../series/store/state/currentAnnotation.tsx | 12 +- .../src/series/store/state/cursor.tsx | 18 +- .../src/series/store/state/dataset.tsx | 66 +- .../src/series/store/state/filters.tsx | 26 +- .../src/series/store/state/montage.tsx | 24 +- .../src/series/store/state/rightPanel.tsx | 12 +- .../src/series/store/state/timeSelection.tsx | 12 +- .../jsx/UploadForm.js | 10 +- .../jsx/UploadViewer.js | 22 +- modules/examiner/jsx/examinerIndex.js | 116 +- .../genomic_browser/jsx/tabs_content/cnv.js | 14 +- .../tabs_content/filemanager/uploadForm.js | 84 +- .../genomic_browser/jsx/tabs_content/files.js | 32 +- .../genomic_browser/jsx/tabs_content/gwas.js | 22 +- .../jsx/tabs_content/methylation.js | 14 +- .../jsx/tabs_content/profiles.js | 72 +- .../genomic_browser/jsx/tabs_content/snp.js | 14 +- modules/help_editor/jsx/helpEditorForm.js | 34 +- .../jsx/CandidateScanQCSummaryWidget.js | 238 +- modules/imaging_browser/jsx/ImagePanel.js | 336 +-- .../jsx/imagingBrowserIndex.js | 52 +- modules/imaging_qc/jsx/imagingQCIndex.js | 78 +- .../imaging_uploader/jsx/ImagingUploader.js | 4 +- modules/imaging_uploader/jsx/LogPanel.js | 6 +- modules/imaging_uploader/jsx/UploadForm.js | 24 +- .../jsx/react.instrument_builder.js | 126 +- .../instrument_builder/jsx/react.questions.js | 616 +++--- .../jsx/instrumentManagerIndex.js | 158 +- modules/instrument_manager/jsx/uploadForm.js | 64 +- .../jsx/CandidateInstrumentList.js | 40 +- .../jsx/ControlpanelDeleteInstrumentData.js | 118 +- .../instruments/jsx/VisitInstrumentList.js | 494 ++--- .../jsx/CandidateIssuesWidget.js | 34 +- modules/issue_tracker/jsx/CommentList.js | 24 +- modules/issue_tracker/jsx/IssueForm.js | 14 +- .../jsx/attachments/attachmentsList.js | 14 +- .../jsx/attachments/uploadForm.js | 18 +- .../issue_tracker/jsx/issueTrackerIndex.js | 28 +- modules/login/jsx/loginIndex.js | 38 +- modules/login/jsx/passwordExpiry.js | 12 +- modules/login/jsx/requestAccount.js | 20 +- modules/login/jsx/resetPassword.js | 12 +- modules/media/jsx/CandidateMediaWidget.js | 28 +- modules/media/jsx/editForm.js | 12 +- modules/media/jsx/mediaIndex.js | 36 +- modules/media/jsx/uploadForm.js | 30 +- modules/module_manager/jsx/modulemanager.js | 44 +- .../mri_violations/jsx/mriViolationsIndex.js | 330 +-- modules/mri_violations/jsx/protocolModal.js | 480 ++--- modules/new_profile/jsx/NewProfileIndex.js | 102 +- modules/publication/jsx/projectFields.js | 48 +- modules/publication/jsx/publicationIndex.js | 2 +- modules/publication/jsx/uploadForm.js | 8 +- modules/publication/jsx/viewProject.js | 10 +- .../jsx/server_processes_managerIndex.js | 20 +- modules/statistics/jsx/WidgetIndex.js | 62 +- .../jsx/widgets/helpers/progressbarBuilder.js | 130 +- .../jsx/widgets/helpers/queryChartForm.js | 6 +- modules/statistics/jsx/widgets/recruitment.js | 40 +- .../jsx/widgets/studyprogression.js | 54 +- .../jsx/surveyAccountsIndex.js | 36 +- .../user_accounts/jsx/userAccountsIndex.js | 92 +- npm-postinstall.js | 2 +- 174 files changed, 10950 insertions(+), 10945 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index cfb7b4048f9..37500d5dcf0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -47,6 +47,7 @@ "tabWidth": 2, "ignoreComments": true }], + "indent": ["error", 2], "no-unexpected-multiline": "off", "no-unused-vars": "error", "no-useless-escape": "off", diff --git a/htdocs/js/advancedMenu.js b/htdocs/js/advancedMenu.js index 5aa6dc082e4..e52a52a8e14 100644 --- a/htdocs/js/advancedMenu.js +++ b/htdocs/js/advancedMenu.js @@ -8,8 +8,8 @@ function isElementSet() { let set = 0; let options = $('.advancedOptions option:selected'); // get all the selected dropdowns for the TR with the ID advancedOptions let texts = $('.advancedOptions input[type=text]'); - // brows through the selected dropdowns - // if any of the dropdown is not equal to 'All' then set the variable set to true + // brows through the selected dropdowns + // if any of the dropdown is not equal to 'All' then set the variable set to true options.each(function() { let value = $(this).text(); if (value !== 'All') { @@ -17,8 +17,8 @@ function isElementSet() { return; } }); - // browse though the text elements - // /if any of the text element is not empty then set the variable set to true + // browse though the text elements + // /if any of the text element is not empty then set the variable set to true texts.each(function() { let value = $(this).val(); if (value !== '') { diff --git a/htdocs/js/instrument_controlpanel_control.js b/htdocs/js/instrument_controlpanel_control.js index 88b65888465..fe6e6983009 100644 --- a/htdocs/js/instrument_controlpanel_control.js +++ b/htdocs/js/instrument_controlpanel_control.js @@ -1,3 +1,3 @@ $(function() { - $('[data-toggle="tooltip"]').tooltip(); + $('[data-toggle="tooltip"]').tooltip(); }); diff --git a/htdocs/js/invalid_form_scroll.js b/htdocs/js/invalid_form_scroll.js index 046cc86fcee..d74c750af41 100644 --- a/htdocs/js/invalid_form_scroll.js +++ b/htdocs/js/invalid_form_scroll.js @@ -4,8 +4,8 @@ */ $(document).ready(function bindInvalidFormListeners() { - // This will make sure that the flag indicating whether we scrolled - // to an invalid element when the form is submitted is reset + // This will make sure that the flag indicating whether we scrolled + // to an invalid element when the form is submitted is reset document.getElementsByName('fire_away')[0].addEventListener( 'click', function() { @@ -13,26 +13,26 @@ $(document).ready(function bindInvalidFormListeners() { } ); - // Override default event handler for invalid input elements - // This will make sure that the invalid element appears at the top - // of the page. + // Override default event handler for invalid input elements + // This will make sure that the invalid element appears at the top + // of the page. let elements = document.querySelectorAll('input,select,textarea'); let navbarHeader = document.getElementsByClassName('navbar-header'); for (let i = elements.length; i--;) { elements[i].addEventListener('invalid', function() { - // Only make the uppermost invalid element visible when the - // form is submitted + // Only make the uppermost invalid element visible when the + // form is submitted if (!bindInvalidFormListeners.scrollingDone) { this.scrollIntoView(true); - // scrollingIntoView is not enough: the navigation bar will appear - // over the invalid element and hide it. - // We have to scroll an additional number of pixels down so that - // the elements becomes visible. + // scrollingIntoView is not enough: the navigation bar will appear + // over the invalid element and hide it. + // We have to scroll an additional number of pixels down so that + // the elements becomes visible. if (navbarHeader) { window.scrollBy(0, -$(navbarHeader).height() - 10); } - // Only scroll once + // Only scroll once bindInvalidFormListeners.scrollingDone = true; } }); diff --git a/htdocs/js/jquery.fileupload.js b/htdocs/js/jquery.fileupload.js index 4d95b85a8d9..b631cab4ae9 100644 --- a/htdocs/js/jquery.fileupload.js +++ b/htdocs/js/jquery.fileupload.js @@ -26,8 +26,8 @@ $(element).change(function() { let filename = $(this).val().split('\\').pop(); let placeHolder = $(this) - .parent().parent().parent() - .find('.file-caption-name'); + .parent().parent().parent() + .find('.file-caption-name'); $(placeHolder).html(filename); }); }); diff --git a/jslib/fetchDataStream.js b/jslib/fetchDataStream.js index 6cf94a5cf83..c86a6e57779 100644 --- a/jslib/fetchDataStream.js +++ b/jslib/fetchDataStream.js @@ -10,35 +10,35 @@ * indicating whether the end of the stream has been reached. */ async function processLines(data, rowcb, endstreamcb) { - const utf8Decoder = new TextDecoder('utf-8'); - let row = []; - let colStart = -1; - let rowStart = 0; - for (let i = 0; i < data.length; i++) { - switch (data[i]) { - case 0x1e: // end of column - const rowdata = data.slice(colStart+1, i); - const encoded = utf8Decoder.decode(rowdata); - colStart = i; - row.push(encoded); - continue; - case 0x1f: // end of row - const rowdata2 = data.slice(colStart+1, i); - const encoded2 = utf8Decoder.decode(rowdata2); - row.push(encoded2); + const utf8Decoder = new TextDecoder('utf-8'); + let row = []; + let colStart = -1; + let rowStart = 0; + for (let i = 0; i < data.length; i++) { + switch (data[i]) { + case 0x1e: // end of column + const rowdata = data.slice(colStart+1, i); + const encoded = utf8Decoder.decode(rowdata); + colStart = i; + row.push(encoded); + continue; + case 0x1f: // end of row + const rowdata2 = data.slice(colStart+1, i); + const encoded2 = utf8Decoder.decode(rowdata2); + row.push(encoded2); - rowcb(row); + rowcb(row); - rowStart = i+1; - colStart = i; - row = []; - continue; - case 0x04: // end of stream - endstreamcb(row); - return {remainder: [], eos: true}; - } + rowStart = i+1; + colStart = i; + row = []; + continue; + case 0x04: // end of stream + endstreamcb(row); + return {remainder: [], eos: true}; } - return {remainder: data.slice(rowStart), eos: false}; + } + return {remainder: data.slice(rowStart), eos: false}; } /** @@ -55,44 +55,44 @@ async function processLines(data, rowcb, endstreamcb) { * @param {string} method - the HTTP method to use for the request */ async function fetchDataStream(dataURL, rowcb, chunkcb, endstreamcb, method) { - const response = await fetch( - dataURL, - { - method: method || 'get', - credentials: 'same-origin', - }, - ); + const response = await fetch( + dataURL, + { + method: method || 'get', + credentials: 'same-origin', + }, + ); - const reader = response.body.getReader(); + const reader = response.body.getReader(); - let remainder = []; - let doneLoop = false; - while (!doneLoop) { - await reader.read().then(({done, value}) => { - let combined; - if (remainder.length == 0) { - combined = value; - } else { - combined = new Uint8Array( - value.length + remainder.length - ); - for (let i = 0; i < remainder.length; i++) { - combined[i] = remainder[i]; - } - for (let i = 0; i < value.length; i++) { - combined[i+remainder.length] = value[i]; - } - } - return processLines(combined, rowcb, endstreamcb); - }).then(({remainder: rem, eos}) => { - chunkcb(eos); - doneLoop = eos; - remainder = rem; - }).catch((err) => { - console.error(err); - doneLoop = true; - }); - } + let remainder = []; + let doneLoop = false; + while (!doneLoop) { + await reader.read().then(({done, value}) => { + let combined; + if (remainder.length == 0) { + combined = value; + } else { + combined = new Uint8Array( + value.length + remainder.length + ); + for (let i = 0; i < remainder.length; i++) { + combined[i] = remainder[i]; + } + for (let i = 0; i < value.length; i++) { + combined[i+remainder.length] = value[i]; + } + } + return processLines(combined, rowcb, endstreamcb); + }).then(({remainder: rem, eos}) => { + chunkcb(eos); + doneLoop = eos; + remainder = rem; + }).catch((err) => { + console.error(err); + doneLoop = true; + }); + } } export default fetchDataStream; diff --git a/jsx/Breadcrumbs.js b/jsx/Breadcrumbs.js index cef326354d6..c5ab758b09b 100644 --- a/jsx/Breadcrumbs.js +++ b/jsx/Breadcrumbs.js @@ -106,9 +106,9 @@ class Breadcrumbs extends Component { } else { breadcrumbs.push( + href={url} + className='btn btn-primary' + onClick={onClick}>
{element.text}
@@ -121,10 +121,10 @@ class Breadcrumbs extends Component { breadcrumbDropdown = (
diff --git a/jsx/CSSGrid.js b/jsx/CSSGrid.js index 5c95e8b8aec..b678901d60a 100644 --- a/jsx/CSSGrid.js +++ b/jsx/CSSGrid.js @@ -17,111 +17,111 @@ import PropTypes from 'prop-types'; * @return {object} - A React component for a CSS grid of cards */ function CSSGrid(props) { - const cardsRef = useRef(null); - const [cardWidth, setCardWidth] = useState(0); - const [panelHeights, setPanelHeights] = useState({}); + const cardsRef = useRef(null); + const [cardWidth, setCardWidth] = useState(0); + const [panelHeights, setPanelHeights] = useState({}); - useEffect(() => { - // Upon load, store the calculated height of every rendered panel - // in state, so that we can use it to dynamically set the heights - // (number of rows spanned) in the CSS grid. - if (cardsRef.current.childNodes.length < 1) { - return; - } + useEffect(() => { + // Upon load, store the calculated height of every rendered panel + // in state, so that we can use it to dynamically set the heights + // (number of rows spanned) in the CSS grid. + if (cardsRef.current.childNodes.length < 1) { + return; + } - // All rows in the width have the same width, so only look - // up the first. - const wSize = cardsRef.current.childNodes[0].clientWidth; + // All rows in the width have the same width, so only look + // up the first. + const wSize = cardsRef.current.childNodes[0].clientWidth; - // Do not change the state unless the width changed to avoid - // infinite re-render loops. - if (wSize == cardWidth) { - return; - } - setCardWidth(wSize); + // Do not change the state unless the width changed to avoid + // infinite re-render loops. + if (wSize == cardWidth) { + return; + } + setCardWidth(wSize); - // Store the height in pixels of each panel. The first node is - // the CSS grid element, the first child is the panel. - // The childNodes are the DOM elements, not the React elements, - // but we make the assumption that they're in the same order - // as props.Cards in the DOM, and any re-arranging was done by - // using the CSS order property. - const heights = Array.from(cardsRef.current.childNodes.values()).map( - (node) => (node.firstChild.clientHeight) - ); - setPanelHeights(heights); - }); - const grid = { - display: 'grid', - gridTemplateColumns: '33% 33% 33%', - gridAutoFlow: 'row dense', - gridRowGap: '1em', - rowGap: '1em', - }; + // Store the height in pixels of each panel. The first node is + // the CSS grid element, the first child is the panel. + // The childNodes are the DOM elements, not the React elements, + // but we make the assumption that they're in the same order + // as props.Cards in the DOM, and any re-arranging was done by + // using the CSS order property. + const heights = Array.from(cardsRef.current.childNodes.values()).map( + (node) => (node.firstChild.clientHeight) + ); + setPanelHeights(heights); + }); + const grid = { + display: 'grid', + gridTemplateColumns: '33% 33% 33%', + gridAutoFlow: 'row dense', + gridRowGap: '1em', + rowGap: '1em', + }; - let orderedCards = []; - for (let i = 0; i < props.Cards.length; i++) { - orderedCards.push(props.Cards[i]); - if (!props.Cards[i].Order) { - orderedCards[i].Order = 1; - } + let orderedCards = []; + for (let i = 0; i < props.Cards.length; i++) { + orderedCards.push(props.Cards[i]); + if (!props.Cards[i].Order) { + orderedCards[i].Order = 1; } - orderedCards.sort((a, b) => (a.Order - b.Order)); + } + orderedCards.sort((a, b) => (a.Order - b.Order)); - let lastLargeCardIdx = 0; - for (let i = 0; i < orderedCards.length; i++) { - if (orderedCards[i].Width >= 2) { - lastLargeCardIdx = i; - } + let lastLargeCardIdx = 0; + for (let i = 0; i < orderedCards.length; i++) { + if (orderedCards[i].Width >= 2) { + lastLargeCardIdx = i; } + } - const cards = orderedCards.map((value, idx) => { - let cardID = 'card' + idx; + const cards = orderedCards.map((value, idx) => { + let cardID = 'card' + idx; - let pSize; - let style = {}; - if (value.Width) { - style.gridColumnEnd = 'span ' + value.Width; - if (value.Width == 1 || value.Width === 3) { - if (idx < lastLargeCardIdx) { - style.gridColumnStart = 1; - } - } else if (value.Width == 2) { - style.gridColumnStart = 2; - } - } - - if (cardWidth != 0) { - const pxHeight = panelHeights[idx]; - let spanHeight = 1; - const hSpan = 100; - if ((pxHeight % hSpan) === 0) { - spanHeight = pxHeight / hSpan; - } else { - spanHeight = Math.floor(pxHeight / hSpan) + 1; - } - style.gridRowEnd = 'span ' + spanHeight; - pSize = spanHeight * hSpan; - } - if (value.Order) { - style.order = value.Order; + let pSize; + let style = {}; + if (value.Width) { + style.gridColumnEnd = 'span ' + value.Width; + if (value.Width == 1 || value.Width === 3) { + if (idx < lastLargeCardIdx) { + style.gridColumnStart = 1; } + } else if (value.Width == 2) { + style.gridColumnStart = 2; + } + } - style.alignSelf = 'stretch'; - return ( - - {value.Content} - - ); - }); + if (cardWidth != 0) { + const pxHeight = panelHeights[idx]; + let spanHeight = 1; + const hSpan = 100; + if ((pxHeight % hSpan) === 0) { + spanHeight = pxHeight / hSpan; + } else { + spanHeight = Math.floor(pxHeight / hSpan) + 1; + } + style.gridRowEnd = 'span ' + spanHeight; + pSize = spanHeight * hSpan; + } + if (value.Order) { + style.order = value.Order; + } + style.alignSelf = 'stretch'; return ( -
{cards}
+ + {value.Content} + ); + }); + + return ( +
{cards}
+ ); } CSSGrid.propTypes = { - Cards: PropTypes.array, + Cards: PropTypes.array, }; export default CSSGrid; diff --git a/jsx/Card.js b/jsx/Card.js index 8adcc594149..de2fb12f593 100644 --- a/jsx/Card.js +++ b/jsx/Card.js @@ -54,10 +54,10 @@ class Card extends Component { boxSizing: 'border-box', }; if (this.props.style) { - divStyling = {...divStyling, ...this.props.style}; + divStyling = {...divStyling, ...this.props.style}; } if (this.props.cardSize) { - divStyling.height = this.props.cardSize; + divStyling.height = this.props.cardSize; } return (
@@ -65,8 +65,8 @@ class Card extends Component { id={this.props.id} title={this.props.title} initCollapsed={this.props.initCollapsed} - style={{overflow: 'auto'}} - panelSize={this.props.cardSize} + style={{overflow: 'auto'}} + panelSize={this.props.cardSize} collapsing={this.props.collapsing} >
{this.state.hasError ?
- Something went wrong rendering this panel. + Something went wrong rendering this panel. Please open a bug report. -
- : this.props.children} +
+ : this.props.children}
diff --git a/jsx/DataTable.js b/jsx/DataTable.js index be7f98c52a3..152f69251fe 100644 --- a/jsx/DataTable.js +++ b/jsx/DataTable.js @@ -21,8 +21,8 @@ class DataTable extends Component { rows: 20, }, sort: { - column: -1, - ascending: true, + column: -1, + ascending: true, }, }; @@ -120,17 +120,17 @@ class DataTable extends Component { // Map cell data to proper values if applicable. if (this.props.getMappedCell) { csvData = csvData - .map((row, i) => this.props.fields - .flatMap((field, j) => this.props.getMappedCell( + .map((row, i) => this.props.fields + .flatMap((field, j) => this.props.getMappedCell( field.label, row[j], row, this.props.fields.map( - (val) => val.label, + (val) => val.label, ), j - )) - ); + )) + ); } let csvworker = new Worker(loris.BaseURL + '/js/workers/savecsv.js'); @@ -175,7 +175,7 @@ class DataTable extends Component { let hasFilters = (filterValuesCount !== 0); if (hasFilters === false) { for (let i = 0; i < tableData.length; i++) { - filteredIndexes.push(i); + filteredIndexes.push(i); } return filteredIndexes; } @@ -206,7 +206,7 @@ class DataTable extends Component { if (headerCount === filterValuesCount && ((useKeyword === true && keywordMatch > 0) || (useKeyword === false && keywordMatch === 0))) { - filteredIndexes.push(i); + filteredIndexes.push(i); } } @@ -338,31 +338,31 @@ class DataTable extends Component { if (typeof filterData === 'string') { searchKey = filterData.toLowerCase(); switch (typeof data) { - case 'object': - // Handles the case where the data is an array (typeof 'object') - // and you want to search through it for - // the string you are filtering by - let searchArray = data.map((e) => e.toLowerCase()); - if (exactMatch) { - result = searchArray.includes(searchKey); - } else { - result = ( - searchArray.find( - (e) => (e.indexOf(searchKey) > -1) - ) - ) !== undefined; - } - break; - default: - searchString = data ? data.toString().toLowerCase() : ''; - if (exactMatch) { - result = (searchString === searchKey); - } else if (opposite) { - result = searchString !== searchKey; - } else { - result = (searchString.indexOf(searchKey) > -1); - } - break; + case 'object': + // Handles the case where the data is an array (typeof 'object') + // and you want to search through it for + // the string you are filtering by + let searchArray = data.map((e) => e.toLowerCase()); + if (exactMatch) { + result = searchArray.includes(searchKey); + } else { + result = ( + searchArray.find( + (e) => (e.indexOf(searchKey) > -1) + ) + ) !== undefined; + } + break; + default: + searchString = data ? data.toString().toLowerCase() : ''; + if (exactMatch) { + result = (searchString === searchKey); + } else if (opposite) { + result = searchString !== searchKey; + } else { + result = (searchString.indexOf(searchKey) > -1); + } + break; } } @@ -459,9 +459,9 @@ class DataTable extends Component { if (this.props.fields[i].freezeColumn === true) { headers.push( { - this.setSortColumn(i); - }}> + onClick={() => { + this.setSortColumn(i); + }}> {this.props.fields[i].label} ); @@ -485,59 +485,59 @@ class DataTable extends Component { // Format each cell for the data table. for (let i = currentPageRow; - (i < filteredCount) && (rows.length < rowsPerPage); - i++ + (i < filteredCount) && (rows.length < rowsPerPage); + i++ ) { - let rowIndex = index[i].RowIdx; - let rowData = this.props.data[rowIndex]; - let curRow = []; - - // Iterates through headers to populate row columns - // with corresponding data - for (let j = 0; j < this.props.fields.length; j += 1) { - if (this.props.fields[j].show === false) { - continue; - } - - let celldata = rowData[j]; - let cell = null; - - let row = {}; - this.props.fields - .forEach((field, k) => row[field.label] = rowData[k]); - - const headers = this.props.fields.map( - (val) => val.label - ); - - // Get custom cell formatting if available - if (this.props.getFormattedCell) { - cell = this.props.getFormattedCell( - this.props.fields[j].label, - celldata, - row, - headers, - j - ); - } else { - cell = {celldata}; - } - if (cell !== null) { - curRow.push(React.cloneElement(cell, {key: 'td_col_' + j})); - } else { - curRow.push(createFragment({celldata})); - } + let rowIndex = index[i].RowIdx; + let rowData = this.props.data[rowIndex]; + let curRow = []; + + // Iterates through headers to populate row columns + // with corresponding data + for (let j = 0; j < this.props.fields.length; j += 1) { + if (this.props.fields[j].show === false) { + continue; } - const rowIndexDisplay = index[i].Content; - rows.push( - - {this.props.hide.defaultColumn === true ? null : ( - {rowIndexDisplay} - )} - {curRow} - + let celldata = rowData[j]; + let cell = null; + + let row = {}; + this.props.fields + .forEach((field, k) => row[field.label] = rowData[k]); + + const headers = this.props.fields.map( + (val) => val.label ); + + // Get custom cell formatting if available + if (this.props.getFormattedCell) { + cell = this.props.getFormattedCell( + this.props.fields[j].label, + celldata, + row, + headers, + j + ); + } else { + cell = {celldata}; + } + if (cell !== null) { + curRow.push(React.cloneElement(cell, {key: 'td_col_' + j})); + } else { + curRow.push(createFragment({celldata})); + } + } + + const rowIndexDisplay = index[i].Content; + rows.push( + + {this.props.hide.defaultColumn === true ? null : ( + {rowIndexDisplay} + )} + {curRow} + + ); } let rowsPerPageDropdown = ( @@ -586,12 +586,12 @@ class DataTable extends Component { }}> {this.renderActions()} {this.props.hide.downloadCSV === true ? '' : ( - ) + ) } {headers} - {this.props.folder} + {this.props.folder} {rows} diff --git a/jsx/Filter.js b/jsx/Filter.js index 42f0122cd4f..db0edf90698 100644 --- a/jsx/Filter.js +++ b/jsx/Filter.js @@ -1,14 +1,14 @@ import React, {useEffect} from 'react'; import PropTypes from 'prop-types'; import { - CheckboxElement, - DateElement, - FieldsetElement, - TimeElement, - FormElement, - NumericElement, - SelectElement, - TextboxElement, + CheckboxElement, + DateElement, + FieldsetElement, + TimeElement, + FormElement, + NumericElement, + SelectElement, + TextboxElement, } from 'jsx/Form'; import DateTimePartialElement from 'jsx/form/DateTimePartialElement'; @@ -70,47 +70,47 @@ function Filter(props) { if (filter && filter.hide !== true) { let element; switch (filter.type) { - case 'text': - element = ; - break; - case 'select': - element = ( - - ); - break; - case 'multiselect': - element = ( - - ); - break; - case 'numeric': - element = ; + break; + case 'select': + element = ( + ; - break; - case 'date': - element = ; - break; - case 'datetime': - element = ; - break; - case 'checkbox': - element = ; - break; - case 'time': - element = ; - break; - default: - element = ; + sortByValue={filter.sortByValue} + autoSelect={false} + /> + ); + break; + case 'multiselect': + element = ( + + ); + break; + case 'numeric': + element = ; + break; + case 'date': + element = ; + break; + case 'datetime': + element = ; + break; + case 'checkbox': + element = ; + break; + case 'time': + element = ; + break; + default: + element = ; } // The value prop has to default to false if the first two options diff --git a/jsx/FilterableDataTable.js b/jsx/FilterableDataTable.js index be3cb26adeb..2f64dd1f5ee 100644 --- a/jsx/FilterableDataTable.js +++ b/jsx/FilterableDataTable.js @@ -103,28 +103,28 @@ class FilterableDataTable extends Component { * @return {object} */ validFilters() { - let filters = {}; - this.props.fields.forEach((field) => { - if (!field.filter) { - return; - } - const filtername = field.filter.name; - const filterval = this.state.filters[filtername]; - if (!filterval) { - return; - } - - if (field.filter.type !== 'select') { - filters[filtername] = filterval; - return; - } - - if (!(filterval.value in field.filter.options)) { - return; - } + let filters = {}; + this.props.fields.forEach((field) => { + if (!field.filter) { + return; + } + const filtername = field.filter.name; + const filterval = this.state.filters[filtername]; + if (!filterval) { + return; + } + + if (field.filter.type !== 'select') { filters[filtername] = filterval; - }); - return filters; + return; + } + + if (!(filterval.value in field.filter.options)) { + return; + } + filters[filtername] = filterval; + }); + return filters; } /** diff --git a/jsx/Form.js b/jsx/Form.js index 9d089623914..79795f578d6 100644 --- a/jsx/Form.js +++ b/jsx/Form.js @@ -903,20 +903,20 @@ export class TagsElement extends Component { itmTxt = item; } return ( - + + ); }, this); return ( @@ -934,7 +934,7 @@ export class TagsElement extends Component { id={this.props.id + 'Add'} type="button" onClick={this.handleAdd} - > + > {this.props.btnLabel} @@ -1599,13 +1599,13 @@ export class DateElement extends Component { let labelHTML; let classSz = 'col-sm-12'; if (this.props.label) { - labelHTML = ; - classSz = 'col-sm-9'; + labelHTML = ; + classSz = 'col-sm-9'; } return (
@@ -1703,14 +1703,14 @@ export class TimeElement extends Component { requiredHTML = *; } if (this.props.label) { - label = ; - classSz = 'col-sm-9'; + label = ; + classSz = 'col-sm-9'; } else { - classSz = 'col-sm-12'; + classSz = 'col-sm-12'; } return ( @@ -1799,14 +1799,14 @@ export class DateTimeElement extends Component { requiredHTML = *; } if (this.props.label) { - label = ; - classSz = 'col-sm-9'; + label = ; + classSz = 'col-sm-9'; } else { - classSz = 'col-sm-12'; + classSz = 'col-sm-12'; } return ( @@ -1897,13 +1897,13 @@ export class NumericElement extends Component { let labelHTML; let classSz = 'col-sm-12'; if (this.props.label) { - labelHTML = ; - classSz = 'col-sm-9'; + labelHTML = ; + classSz = 'col-sm-9'; } return ( @@ -1998,21 +1998,21 @@ export class FileElement extends Component { if (this.props.value) { switch (typeof this.props.value) { - case 'string': - fileName = this.props.value; - break; - - case 'object': - if (this.props.value instanceof FileList) { - const files = this.props.value; - fileName = Array.from(files).map((file) => file.name).join(', '); - } else { - fileName = this.props.value.name; - } - break; - - default: - break; + case 'string': + fileName = this.props.value; + break; + + case 'object': + if (this.props.value instanceof FileList) { + const files = this.props.value; + fileName = Array.from(files).map((file) => file.name).join(', '); + } else { + fileName = this.props.value.name; + } + break; + + default: + break; } } @@ -2072,13 +2072,13 @@ export class FileElement extends Component { let labelHTML; let classSz; if (this.props.label) { - labelHTML = ; - classSz = 'col-sm-9'; + labelHTML = ; + classSz = 'col-sm-9'; } else { - classSz = 'col-sm-12'; + classSz = 'col-sm-12'; } return ( @@ -2087,7 +2087,7 @@ export class FileElement extends Component {
+ className="form-control file-caption kv-fileinput-caption">
{fileName}
@@ -2488,30 +2488,30 @@ export class CTA extends Component { * * @return {JSX} - React markup for the component */ - render() { - return ( - - ); - } - } - - CTA.propTypes = { - label: PropTypes.string, - buttonClass: PropTypes.string, - onUserInput: PropTypes.func, - }; - - CTA.defaultProps = { - buttonClass: 'btn btn-primary', - onUserInput: function() { - console.warn('onUserInput() callback is not set'); - }, - }; + render() { + return ( + + ); + } +} + +CTA.propTypes = { + label: PropTypes.string, + buttonClass: PropTypes.string, + onUserInput: PropTypes.func, +}; + +CTA.defaultProps = { + buttonClass: 'btn btn-primary', + onUserInput: function() { + console.warn('onUserInput() callback is not set'); + }, +}; /** * Generic form element. @@ -2538,58 +2538,58 @@ export class LorisElement extends Component { let elementHtml =
; switch (elementProps.type) { - case 'text': - elementHtml = (); - break; - case 'email': - elementHtml = (); - break; - case 'password': - elementHtml = (); - break; - case 'tags': - elementHtml = (); - break; - case 'select': - elementHtml = (); - break; - case 'search': - elementHtml = (); - break; - case 'date': - elementHtml = (); - break; - case 'time': - elementHtml = (); - break; - case 'numeric': - elementHtml = (); - break; - case 'textarea': - elementHtml = (); - break; - case 'file': - elementHtml = (); - break; - case 'static': - elementHtml = (); - break; - case 'header': - elementHtml = (); - break; - case 'link': - elementHtml = (); - break; - case 'advcheckbox': - elementHtml = (); - break; - default: - console.warn( - 'Element of type ' + + case 'text': + elementHtml = (); + break; + case 'email': + elementHtml = (); + break; + case 'password': + elementHtml = (); + break; + case 'tags': + elementHtml = (); + break; + case 'select': + elementHtml = (); + break; + case 'search': + elementHtml = (); + break; + case 'date': + elementHtml = (); + break; + case 'time': + elementHtml = (); + break; + case 'numeric': + elementHtml = (); + break; + case 'textarea': + elementHtml = (); + break; + case 'file': + elementHtml = (); + break; + case 'static': + elementHtml = (); + break; + case 'header': + elementHtml = (); + break; + case 'link': + elementHtml = (); + break; + case 'advcheckbox': + elementHtml = (); + break; + default: + console.warn( + 'Element of type ' + elementProps.type + ' is not currently implemented!' - ); - break; + ); + break; } return elementHtml; @@ -2673,7 +2673,7 @@ export class RadioElement extends React.Component { const checked = this.props.checked === key; content.push(
+ style={styleColumn}>
@@ -2699,7 +2699,7 @@ export class RadioElement extends React.Component { layout.push(
+ style={styleRow}> {content}
); @@ -2824,7 +2824,7 @@ export class SliderElement extends React.Component { return (
- ); + return ( +
+ ); } Loader.propTypes = {size: PropTypes.string}; diff --git a/jsx/Modal.js b/jsx/Modal.js index 5b7a42ca6c6..2aacf8cb958 100644 --- a/jsx/Modal.js +++ b/jsx/Modal.js @@ -132,7 +132,7 @@ class Modal extends Component { const submitButton = () => { if (onSubmit) { const submit = () => onSubmit().then(() => this.props.onClose()) - .catch(() => {}); + .catch(() => {}); return (
- ) : null; +
+ ) : null; return ( <>
    + role='menu'> {views}
@@ -81,9 +81,9 @@ const Panel = (props) => { // Add panel header, if title is set const panelHeading = props.title || props.views ? (
+ data-parent={props.parentId + ? `#${props.parentId}` + : null}>

{props.views && props.views[activeView]['title'] ? props.views[activeView]['title'] @@ -94,10 +94,10 @@ const Panel = (props) => { ? + onClick={toggleCollapsed} + data-toggle='collapse' + data-target={`#${props.id}`} + style={{cursor: 'pointer'}}/> : null}

) : ''; @@ -109,16 +109,16 @@ const Panel = (props) => { */ return (
+ style={{height: props.panelSize}}> {panelHeading}
+ className={props.collapsed ? + 'panel-collapse collapse' : + 'panel-collapse collapse in'} + role='tabpanel' + style={{height: 'calc(100% - 3em)'}}>
+ style={{...props.style, height: props.height}}> {content.length > 0 ? content : props.children}
diff --git a/jsx/StaticDataTable.js b/jsx/StaticDataTable.js index 50f3dae0448..fb64bba1238 100644 --- a/jsx/StaticDataTable.js +++ b/jsx/StaticDataTable.js @@ -233,8 +233,8 @@ class StaticDataTable extends Component { let useKeyword = false; let filterMatchCount = 0; let filterValuesCount = (this.props.Filter ? - Object.keys(this.props.Filter).length : - 0 + Object.keys(this.props.Filter).length : + 0 ); let tableData = this.props.Data; let headersData = this.props.Headers; @@ -450,7 +450,7 @@ class StaticDataTable extends Component { if (this.props.Headers[i] === this.props.freezeColumn) { headers.push( + onClick={this.setSortColumn(i).bind(this)}> {this.props.Headers[i]} ); @@ -482,8 +482,8 @@ class StaticDataTable extends Component { // Push rows to data table for (let i = 0; - (i < this.props.Data.length) && (rows.length < rowsPerPage); - i++ + (i < this.props.Data.length) && (rows.length < rowsPerPage); + i++ ) { curRow = []; @@ -549,7 +549,7 @@ class StaticDataTable extends Component { if (matchesFound > currentPageRow) { const rowIndex = index[i].Content; const rowCell = this.state.Hide.defaultColumn !== true ? - {rowIndex} : null; + {rowIndex} : null; rows.push( diff --git a/jsx/Tabs.js b/jsx/Tabs.js index 0ee5d730f47..7d551878e38 100644 --- a/jsx/Tabs.js +++ b/jsx/Tabs.js @@ -103,10 +103,10 @@ class Tabs extends Component { key={tab.id} >
{tab.label} @@ -253,10 +253,10 @@ class VerticalTabs extends Component { key={tab.id} > {tab.label} @@ -282,7 +282,7 @@ class VerticalTabs extends Component { key: key, }); } - }.bind(this)); + }.bind(this)); return tabPanes; } @@ -304,9 +304,9 @@ class VerticalTabs extends Component {
    + className="nav nav-pills nav-stacked" + role="tablist" + style={tabStyle}> {tabs}
@@ -336,11 +336,11 @@ VerticalTabs.defaultProps = { * Used to wrap content for every tab. */ class TabPane extends Component { - /** - * React lifecycle method - * - * @return {object} - */ + /** + * React lifecycle method + * + * @return {object} + */ render() { let classList = 'tab-pane'; let title; diff --git a/modules/acknowledgements/jsx/acknowledgementsIndex.js b/modules/acknowledgements/jsx/acknowledgementsIndex.js index 01920b30643..6051ba96c53 100644 --- a/modules/acknowledgements/jsx/acknowledgementsIndex.js +++ b/modules/acknowledgements/jsx/acknowledgementsIndex.js @@ -8,11 +8,11 @@ import Panel from 'Panel'; import Loader from 'Loader'; import FilterableDataTable from 'FilterableDataTable'; import { - SelectElement, - FormElement, - TextboxElement, - DateElement, - ButtonElement, + SelectElement, + FormElement, + TextboxElement, + DateElement, + ButtonElement, } from 'jsx/Form'; /** @@ -161,27 +161,27 @@ class AcknowledgementsIndex extends Component { credentials: 'same-origin', body: formObject, }) - .then((resp) => { - if (resp.ok && resp.status === 200) { - swal.fire( - 'Success!', - 'Acknowledgement added.', - 'success' - ).then((result) => { - if (result.value) { - this.closeModalForm(); - this.fetchData(); - } - }); - } else { - resp.text().then((message) => { - swal.fire('Error!', message, 'error'); - }); - } - }) - .catch((error) => { - console.error(error); - }); + .then((resp) => { + if (resp.ok && resp.status === 200) { + swal.fire( + 'Success!', + 'Acknowledgement added.', + 'success' + ).then((result) => { + if (result.value) { + this.closeModalForm(); + this.fetchData(); + } + }); + } else { + resp.text().then((message) => { + swal.fire('Error!', message, 'error'); + }); + } + }) + .catch((error) => { + console.error(error); + }); } /** @@ -220,16 +220,16 @@ class AcknowledgementsIndex extends Component { let result = {cell}; switch (column) { - case 'Affiliations': - result = {this.parseMultiple(cell, 'affiliationsOptions')}; - break; - case 'Degrees': - result = {this.parseMultiple(cell, 'degreesOptions')}; - break; + case 'Affiliations': + result = {this.parseMultiple(cell, 'affiliationsOptions')}; + break; + case 'Degrees': + result = {this.parseMultiple(cell, 'degreesOptions')}; + break; - case 'Roles': - result = {this.parseMultiple(cell, 'rolesOptions')}; - break; + case 'Roles': + result = {this.parseMultiple(cell, 'rolesOptions')}; + break; } return result; } @@ -386,10 +386,10 @@ class AcknowledgementsIndex extends Component { return ; } - /** - * XXX: Currently, the order of these fields MUST match the order of the - * queried columns in _setupVariables() in acknowledgements.class.inc - */ + /** + * XXX: Currently, the order of these fields MUST match the order of the + * queried columns in _setupVariables() in acknowledgements.class.inc + */ const options = this.state.data.fieldOptions; const fields = [ {label: 'Ordering', show: true}, diff --git a/modules/battery_manager/jsx/batteryManagerForm.js b/modules/battery_manager/jsx/batteryManagerForm.js index c41342c18dd..7277ab11b38 100644 --- a/modules/battery_manager/jsx/batteryManagerForm.js +++ b/modules/battery_manager/jsx/batteryManagerForm.js @@ -1,11 +1,11 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import { - ButtonElement, - FormElement, - StaticElement, - SelectElement, - NumericElement, + ButtonElement, + FormElement, + StaticElement, + SelectElement, + NumericElement, } from 'jsx/Form'; /** @@ -43,7 +43,7 @@ class BatteryManagerForm extends Component { entry.
If the duplicate entry is inactive, you will be given the option to active it. -
+

); @@ -145,7 +145,7 @@ class BatteryManagerForm extends Component { max={127} // max value allowed by default column type of instr_order value={test.instrumentOrder} /> - diff --git a/modules/battery_manager/jsx/batteryManagerIndex.js b/modules/battery_manager/jsx/batteryManagerIndex.js index 794ba742f61..c93e8030d07 100644 --- a/modules/battery_manager/jsx/batteryManagerIndex.js +++ b/modules/battery_manager/jsx/batteryManagerIndex.js @@ -51,8 +51,8 @@ class BatteryManagerIndex extends Component { */ componentDidMount() { this.fetchData(this.props.testEndpoint, 'GET', 'tests') - .then(() => this.fetchData(this.props.optionEndpoint, 'GET', 'options')) - .then(() => this.setState({isLoaded: true})); + .then(() => this.fetchData(this.props.optionEndpoint, 'GET', 'options')) + .then(() => this.setState({isLoaded: true})); } /** @@ -66,12 +66,12 @@ class BatteryManagerIndex extends Component { fetchData(url, method, state) { return new Promise((resolve, reject) => { return fetch(url, {credentials: 'same-origin', method: method}) - .then((resp) => resp.json()) - .then((data) => this.setState({[state]: data}, resolve)) - .catch((error) => { - this.setState({error: true}, reject); - console.error(error); - }); + .then((resp) => resp.json()) + .then((data) => this.setState({[state]: data}, resolve)) + .catch((error) => { + this.setState({error: true}, reject); + console.error(error); + }); }); } @@ -91,23 +91,23 @@ class BatteryManagerIndex extends Component { method: method, body: JSON.stringify(dataClone), }) - .then((response) => response.text() - .then((body) => { - body = JSON.parse(body); - if (response.ok) { - swal.fire('Submission successful!', body.message, 'success') - .then((result) => { - if (result.value) { - this.closeForm(); - resolve(body.message); + .then((response) => response.text() + .then((body) => { + body = JSON.parse(body); + if (response.ok) { + swal.fire('Submission successful!', body.message, 'success') + .then((result) => { + if (result.value) { + this.closeForm(); + resolve(body.message); + } + }); + } else { + swal.fire(body.error, '', 'error'); + reject(body.error); } - }); - } else { - swal.fire(body.error, '', 'error'); - reject(body.error); - } - }) - .catch((e) => reject(e))); + }) + .catch((e) => reject(e))); }); } @@ -120,28 +120,28 @@ class BatteryManagerIndex extends Component { */ mapColumn(column, value) { switch (column) { - case 'First Visit': - switch (value) { - case 'Y': - return 'Yes'; - case 'N': - return 'No'; - } - break; - case 'Active': - switch (value) { - case 'Y': - return 'Yes'; - case 'N': - return 'No'; - } - break; - case 'Change Status': - return ''; - case 'Edit Metadata': - return ''; - default: - return value; + case 'First Visit': + switch (value) { + case 'Y': + return 'Yes'; + case 'N': + return 'No'; + } + break; + case 'Active': + switch (value) { + case 'Y': + return 'Yes'; + case 'N': + return 'No'; + } + break; + case 'Change Status': + return ''; + case 'Edit Metadata': + return ''; + default: + return value; } } @@ -158,33 +158,33 @@ class BatteryManagerIndex extends Component { let result = {cell}; const testId = row['ID']; switch (column) { - case 'Instrument': - result = {this.state.options.instruments[cell]}; - break; - case 'Cohort': - result = {this.state.options.cohorts[cell]}; - break; - case 'Site': - result = {this.state.options.sites[cell]}; - break; - case 'Change Status': - if (row.Active === 'Y') { - result = { - this.deactivateTest(testId); - }}/>; - } else if (row.Active === 'N') { - result = { - this.activateTest(testId); - }}/>; - } - break; - case 'Edit Metadata': - const editButton = { - this.loadTest(testId); - this.setState({edit: true}); - }}/>; - result = {editButton}; - break; + case 'Instrument': + result = {this.state.options.instruments[cell]}; + break; + case 'Cohort': + result = {this.state.options.cohorts[cell]}; + break; + case 'Site': + result = {this.state.options.sites[cell]}; + break; + case 'Change Status': + if (row.Active === 'Y') { + result = { + this.deactivateTest(testId); + }}/>; + } else if (row.Active === 'N') { + result = { + this.activateTest(testId); + }}/>; + } + break; + case 'Edit Metadata': + const editButton = { + this.loadTest(testId); + this.setState({edit: true}); + }}/>; + result = {editButton}; + break; } return result; @@ -257,15 +257,15 @@ class BatteryManagerIndex extends Component { } }); this.checkDuplicate(test) - .then((test) => this.validateTest(test)) - .then((test) => this.postData( + .then((test) => this.validateTest(test)) + .then((test) => this.postData( this.props.testEndpoint+(test.id || ''), test, request - )) - .then(() => this.fetchData(this.props.testEndpoint, 'GET', 'tests')) - .then(() => resolve()) - .catch((e) => reject(e)); + )) + .then(() => this.fetchData(this.props.testEndpoint, 'GET', 'tests')) + .then(() => resolve()) + .catch((e) => reject(e)); }); } @@ -295,52 +295,52 @@ class BatteryManagerIndex extends Component { const fields = [ {label: 'ID', show: false}, {label: 'Instrument', show: true, filter: { - name: 'testName', - type: 'select', - options: options.instruments, - }}, + name: 'testName', + type: 'select', + options: options.instruments, + }}, {label: 'Minimum Age', show: true, filter: { - name: 'minimumAge', - type: 'numeric', - }}, + name: 'minimumAge', + type: 'numeric', + }}, {label: 'Maximum Age', show: true, filter: { - name: 'maximumAge', - type: 'numeric', - }}, + name: 'maximumAge', + type: 'numeric', + }}, {label: 'Stage', show: true, filter: { - name: 'stage', - type: 'select', - options: options.stages, - }}, + name: 'stage', + type: 'select', + options: options.stages, + }}, {label: 'Cohort', show: true, filter: { - name: 'cohort', - type: 'select', - options: options.cohorts, - }}, + name: 'cohort', + type: 'select', + options: options.cohorts, + }}, {label: 'Visit Label', show: true, filter: { - name: 'visitLabel', - type: 'select', - options: options.visits, - }}, + name: 'visitLabel', + type: 'select', + options: options.visits, + }}, {label: 'Site', show: true, filter: { - name: 'site', - type: 'select', - options: options.sites, - }}, + name: 'site', + type: 'select', + options: options.sites, + }}, {label: 'First Visit', show: true, filter: { - name: 'firstVisit', - type: 'select', - options: options.firstVisit, - }}, + name: 'firstVisit', + type: 'select', + options: options.firstVisit, + }}, {label: 'Instrument Order', show: true, filter: { - name: 'instrumentOrder', - type: 'text', - }}, + name: 'instrumentOrder', + type: 'text', + }}, {label: 'Active', show: true, filter: { - name: 'active', - type: 'select', - options: options.active, - }}, + name: 'active', + type: 'select', + options: options.active, + }}, {label: 'Change Status', show: hasPermission('battery_manager_edit')}, {label: 'Edit Metadata', show: hasPermission('battery_manager_edit')}, ]; diff --git a/modules/behavioural_qc/jsx/tabs_content/behaviouralFeedback.js b/modules/behavioural_qc/jsx/tabs_content/behaviouralFeedback.js index 5ce9a202c6a..3d7fba2661a 100644 --- a/modules/behavioural_qc/jsx/tabs_content/behaviouralFeedback.js +++ b/modules/behavioural_qc/jsx/tabs_content/behaviouralFeedback.js @@ -82,35 +82,35 @@ class BehaviouralFeedback extends Component { formatColumn(column, cell, rowData, rowHeaders) { let reactElement; switch (column) { - case 'PSCID': - reactElement = ( - - + - {rowData['PSCID']} - - - ); - break; - case 'DCCID': - reactElement = ( - - + {rowData['PSCID']} + + + ); + break; + case 'DCCID': + reactElement = ( + + - {rowData['DCCID']} - - - ); - break; - case 'Feedback Level': - let bvlLink = ''; - let bvlLevel = ''; - if (rowData['Instrument']) { - bvlLink = this.props.baseURL + + }> + {rowData['DCCID']} + + + ); + break; + case 'Feedback Level': + let bvlLink = ''; + let bvlLevel = ''; + if (rowData['Instrument']) { + bvlLink = this.props.baseURL + '/instruments/' + rowData['Test Name'] + '/?candID=' + @@ -119,36 +119,36 @@ class BehaviouralFeedback extends Component { rowData['sessionID'] + '&commentID=' + rowData['commentID']; - // Open feedback panel - bvlLink += '&showFeedback=true'; - bvlLevel ='Instrument : ' + rowData['Instrument']; - } else if (rowData['Visit']) { - bvlLink = this.props.baseURL + + // Open feedback panel + bvlLink += '&showFeedback=true'; + bvlLevel ='Instrument : ' + rowData['Instrument']; + } else if (rowData['Visit']) { + bvlLink = this.props.baseURL + '/instrument_list/' + '?candID=' + rowData['DCCID'] + '&sessionID=' + rowData['sessionID']; - // Open feedback panel - bvlLink += '&showFeedback=true'; - bvlLevel ='Visit : ' + rowData['Visit']; - } else { - bvlLink = this.props.baseURL + + // Open feedback panel + bvlLink += '&showFeedback=true'; + bvlLevel ='Visit : ' + rowData['Visit']; + } else { + bvlLink = this.props.baseURL + '/' + rowData['DCCID']; - // Open feedback panel - bvlLink += '/?showFeedback=true'; - bvlLevel ='Profile : ' + rowData['PSCID']; - } - reactElement = ( - - {bvlLevel} - - ); - break; - default: - reactElement = ( - {cell} - ); + // Open feedback panel + bvlLink += '/?showFeedback=true'; + bvlLevel ='Profile : ' + rowData['PSCID']; + } + reactElement = ( + + {bvlLevel} + + ); + break; + default: + reactElement = ( + {cell} + ); } return reactElement; } @@ -173,8 +173,8 @@ class BehaviouralFeedback extends Component { name: 'Instrument', type: 'select', options: Object.assign({}, ...Object.entries( - {...Object.values(options.instruments)}) - .map(([, b]) => ({[b]: b})) + {...Object.values(options.instruments)}) + .map(([, b]) => ({[b]: b})) ), }, }, diff --git a/modules/behavioural_qc/jsx/tabs_content/dataConflicts.js b/modules/behavioural_qc/jsx/tabs_content/dataConflicts.js index 0bebd905a08..c2b4d061185 100644 --- a/modules/behavioural_qc/jsx/tabs_content/dataConflicts.js +++ b/modules/behavioural_qc/jsx/tabs_content/dataConflicts.js @@ -82,48 +82,48 @@ class DataConflicts extends Component { formatColumn(column, cell, rowData, rowHeaders) { let reactElement = null; switch (column) { - case 'Visit': - reactElement = ( - - + - {rowData['Visit']} - - - ); - break; - case 'PSCID': - reactElement = ( - - + {rowData['Visit']} + + + ); + break; + case 'PSCID': + reactElement = ( + + - {rowData['PSCID']} - - - ); - break; - case 'DCCID': - reactElement = ( - - + {rowData['PSCID']} + + + ); + break; + case 'DCCID': + reactElement = ( + + - {rowData['DCCID']} - - - ); - break; - case 'Instrument': - reactElement = ( - - + {rowData['DCCID']} + + + ); + break; + case 'Instrument': + reactElement = ( + + - {rowData['Instrument']} - - - ); - break; - default: - reactElement = ( - {cell} - ); + }> + {rowData['Instrument']} + + + ); + break; + default: + reactElement = ( + {cell} + ); } return reactElement; } @@ -167,8 +167,8 @@ class DataConflicts extends Component { name: 'Instrument', type: 'select', options: Object.assign({}, ...Object.entries( - {...Object.values(options.instruments)}) - .map(([, b]) => ({[b]: b})) + {...Object.values(options.instruments)}) + .map(([, b]) => ({[b]: b})) ), }, }, diff --git a/modules/behavioural_qc/jsx/tabs_content/incompleteForms.js b/modules/behavioural_qc/jsx/tabs_content/incompleteForms.js index 4e6de6d5c55..8bf0ac9f615 100644 --- a/modules/behavioural_qc/jsx/tabs_content/incompleteForms.js +++ b/modules/behavioural_qc/jsx/tabs_content/incompleteForms.js @@ -82,48 +82,48 @@ class IncompleteForms extends Component { formatColumn(column, cell, rowData, rowHeaders) { let reactElement; switch (column) { - case 'Visit': - reactElement = ( - - + - {rowData['Visit']} - - - ); - break; - case 'PSCID': - reactElement = ( - - + {rowData['Visit']} + + + ); + break; + case 'PSCID': + reactElement = ( + + - {rowData['PSCID']} - - - ); - break; - case 'DCCID': - reactElement = ( - - + {rowData['PSCID']} + + + ); + break; + case 'DCCID': + reactElement = ( + + - {rowData['DCCID']} - - - ); - break; - case 'Instrument': - reactElement = ( - - + {rowData['DCCID']} + + + ); + break; + case 'Instrument': + reactElement = ( + + - {rowData['Instrument']} - - - ); - break; - default: - reactElement = ( - {cell} - ); + }> + {rowData['Instrument']} + + + ); + break; + default: + reactElement = ( + {cell} + ); } return reactElement; } @@ -167,8 +167,8 @@ class IncompleteForms extends Component { name: 'Instrument', type: 'select', options: Object.assign({}, ...Object.entries( - {...Object.values(options.instruments)}) - .map(([, b]) => ({[b]: b})) + {...Object.values(options.instruments)}) + .map(([, b]) => ({[b]: b})) ), }, }, diff --git a/modules/brainbrowser/js/brainbrowser.loris.js b/modules/brainbrowser/js/brainbrowser.loris.js index 53d37e2449b..b9a0e00a24a 100644 --- a/modules/brainbrowser/js/brainbrowser.loris.js +++ b/modules/brainbrowser/js/brainbrowser.loris.js @@ -13,7 +13,7 @@ function getQueryVariable(variable) { for (i = 0; i < vars.length; i += 1) { pair = vars[i].split('='); if (pair[0] === variable) { - return unescape(pair[1]); + return unescape(pair[1]); } } } @@ -349,7 +349,7 @@ $(function() { value = volume.getVoxelMin(); } value = Math.max(volume.getVoxelMin(), - Math.min(value, volume.getVoxelMax())); + Math.min(value, volume.getVoxelMax())); this.value = value; // Update the slider. @@ -368,7 +368,7 @@ $(function() { value = volume.getVoxelMax(); } value = Math.max(volume.getVoxelMin(), - Math.min(value, volume.getVoxelMax())); + Math.min(value, volume.getVoxelMax())); this.value = value; // Update the slider. @@ -569,44 +569,44 @@ $(function() { fileNameID.tooltip(); $('#filename-'+volID).on('click', function() { - $('#filename-additional-info-'+volID).slideToggle('fast'); - let arrow = $(this).siblings('.arrow'); - if (arrow.hasClass('glyphicon-chevron-down')) { - arrow - .removeClass('glyphicon-chevron-down') - .addClass('glyphicon-chevron-up'); - } else { - arrow - .removeClass('glyphicon-chevron-up') - .addClass('glyphicon-chevron-down'); - } - }); - $('.filename-overlay').on('click', function() { - $('.filename-overlay-additional-info').slideToggle('fast'); - let arrow = $(this).siblings('.arrow'); - if (arrow.hasClass('glyphicon-chevron-down')) { - arrow - .removeClass('glyphicon-chevron-down') - .addClass('glyphicon-chevron-up'); - } else { - arrow - .removeClass('glyphicon-chevron-up') - .addClass('glyphicon-chevron-down'); - } - }); - - $('.arrow').on('click', function() { - $('#filename-additional-info-'+volID).slideToggle('fast'); - if ($('.arrow').hasClass('glyphicon-chevron-down')) { - $('.arrow') - .removeClass('glyphicon-chevron-down') - .addClass('glyphicon-chevron-up'); - } else { - $('.arrow') - .removeClass('glyphicon-chevron-up') - .addClass('glyphicon-chevron-down'); - } - }); + $('#filename-additional-info-'+volID).slideToggle('fast'); + let arrow = $(this).siblings('.arrow'); + if (arrow.hasClass('glyphicon-chevron-down')) { + arrow + .removeClass('glyphicon-chevron-down') + .addClass('glyphicon-chevron-up'); + } else { + arrow + .removeClass('glyphicon-chevron-up') + .addClass('glyphicon-chevron-down'); + } + }); + $('.filename-overlay').on('click', function() { + $('.filename-overlay-additional-info').slideToggle('fast'); + let arrow = $(this).siblings('.arrow'); + if (arrow.hasClass('glyphicon-chevron-down')) { + arrow + .removeClass('glyphicon-chevron-down') + .addClass('glyphicon-chevron-up'); + } else { + arrow + .removeClass('glyphicon-chevron-up') + .addClass('glyphicon-chevron-down'); + } + }); + + $('.arrow').on('click', function() { + $('#filename-additional-info-'+volID).slideToggle('fast'); + if ($('.arrow').hasClass('glyphicon-chevron-down')) { + $('.arrow') + .removeClass('glyphicon-chevron-down') + .addClass('glyphicon-chevron-up'); + } else { + $('.arrow') + .removeClass('glyphicon-chevron-up') + .addClass('glyphicon-chevron-down'); + } + }); // Contrast controls container.find('.contrast-div').each(function() { @@ -760,9 +760,9 @@ $(function() { let fgColor = getContrastYIQ(bgColor); $('#intensity-value-' + volID) - .css('background-color', '#' + bgColor) - .css('color', fgColor) - .html(Math.floor(value)); + .css('background-color', '#' + bgColor) + .css('color', fgColor) + .html(Math.floor(value)); if (volume.header && volume.header.time) { $('#time-slider-' + volID).slider( @@ -812,43 +812,43 @@ $(function() { 'imageinfo?fileids=' + mincIDs + '&fileurls=' + fileUrls, {credentials: 'same-origin', method: 'GET'} ) - .then((resp) => resp.json()) - .then((data) => { - for (const file of data) { + .then((resp) => resp.json()) + .then((data) => { + for (const file of data) { let volume = { - type: file.type, - template: { - element_id: 'volume-ui-template4d', - viewer_insert_class: 'volume-viewer-display', - }, + type: file.type, + template: { + element_id: 'volume-ui-template4d', + viewer_insert_class: 'volume-viewer-display', + }, }; if (file.type == 'nifti1') { - volume.nii_url = file.URL; + volume.nii_url = file.URL; } else { - volume.raw_data_url = file.URL; + volume.raw_data_url = file.URL; } mincVolumes.push(volume); mincFilenames.push(file.Filename); - } - bboptions.volumes = mincVolumes; - - // //////////////////////////// - // Load the default color map and then call - // render only after it's been loaded - // //////////////////////////// - viewer.loadDefaultColorMapFromURL( - colorMapConfig.url, - colorMapConfig.cursor_color, - function() { + } + bboptions.volumes = mincVolumes; + + // //////////////////////////// + // Load the default color map and then call + // render only after it's been loaded + // //////////////////////////// + viewer.loadDefaultColorMapFromURL( + colorMapConfig.url, + colorMapConfig.cursor_color, + function() { // /////////////////// // Load the volumes. // /////////////////// viewer.render(); // start the rendering viewer.loadVolumes(bboptions); // load the volumes - } - ); - }); + } + ); + }); return viewer; }); diff --git a/modules/brainbrowser/jsx/Brainbrowser.js b/modules/brainbrowser/jsx/Brainbrowser.js index ada35b847f4..4415748695b 100644 --- a/modules/brainbrowser/jsx/Brainbrowser.js +++ b/modules/brainbrowser/jsx/Brainbrowser.js @@ -88,9 +88,9 @@ class BrainBrowser extends Component { @@ -1128,43 +1086,32 @@ export class TextboxElement extends Component { * @return {JSX} - React markup for the component */ render() { - let disabled = this.props.disabled ? 'disabled' : null; - let required = this.props.required ? 'required' : null; let errorMessage = null; - let requiredHTML = null; let elementClass = 'row form-group'; - // Add required asterix - if (required) { - requiredHTML = *; - } - // Add error message if (this.props.errorMessage) { errorMessage = {this.props.errorMessage}; elementClass = 'row form-group has-error'; } - // Label prop needs to be provided to render label // (including empty label i.e. ) // and retain formatting. If label prop is not provided at all, the input // element will take up the whole row. - let label = null; let inputClass = this.props.class; if (this.props.label || this.props.label == '') { - label = ( - - ); inputClass = 'col-sm-9'; } return (
- {label} + {(this.props.label || this.props.label == '') && ( + + )}
*; - } - // Add error message if (this.props.errorMessage) { errorMessage = {this.props.errorMessage}; elementClass = 'row form-group has-error'; } - // Label prop needs to be provided to render label // (including empty label i.e. ) // and retain formatting. If label prop is not provided at all, the input // element will take up the whole row. - let label = null; let inputClass = this.props.class; if (this.props.label || this.props.label == '') { - label = ( - - ); inputClass = 'col-sm-9'; } return (
- {label} + {(this.props.label || this.props.label == '') && ( + + )}
*; - } - // Add error message if (this.props.errorMessage) { errorMessage = {this.props.errorMessage}; elementClass = 'row form-group has-error'; } - let label = null; - if (this.props.label) { - label = ( - - ); - } const passwordDisplayType = this.state.active ? this.state.on.type : this.state.off.type; const passwordDisplayIcon = this.state.active ? this.state.on.icon : this.state.off.icon; + return (
- {label} + {this.props.label && ( + + )}
*; - } - // Add error message if (this.props.errorMessage || (this.props.required && this.props.value === '') @@ -1593,21 +1510,16 @@ export class DateElement extends Component { maxFullDate = maxYear + '-' + currentMonth; } - let labelHTML; - let classSz = 'col-sm-12'; - if (this.props.label) { - labelHTML = ; - classSz = 'col-sm-9'; - } + const wrapperClass = this.props.label ? 'col-sm-9' : 'col-sm-12'; return (
- {labelHTML} -
+ {this.props.label && ( + + )} +
{errorMessage}
@@ -1687,31 +1599,16 @@ export class TimeElement extends Component { * @return {JSX} - React markup for the component */ render() { - let disabled = this.props.disabled ? 'disabled' : null; - let required = this.props.required ? 'required' : null; - let requiredHTML = null; - let label; - let classSz; - - // Add required asterix - if (required) { - requiredHTML = *; - } - if (this.props.label) { - label = ; - classSz = 'col-sm-9'; - } else { - classSz = 'col-sm-12'; - } - + const wrapperClass = this.props.label ? 'col-sm-9' : 'col-sm-12'; return (
- {label} -
+ {this.props.label && ( + + )} +
*; - } - if (this.props.label) { - label = ; - classSz = 'col-sm-9'; - } else { - classSz = 'col-sm-12'; - } - + const wrapperClass = this.props.label ? 'col-sm-9' : 'col-sm-12'; return (
- {label} -
+ {this.props.label && ( + + )} +
* : null; let errorMessage = null; let elementClass = 'row form-group'; + const wrapperClass = this.props.label ? 'col-sm-9' : 'col-sm-12'; // Add error message if (this.props.errorMessage) { @@ -1889,22 +1770,15 @@ export class NumericElement extends Component { elementClass = 'row form-group has-error'; } - let labelHTML; - let classSz = 'col-sm-12'; - if (this.props.label) { - labelHTML = ; - classSz = 'col-sm-9'; - } - return (
- {labelHTML} -
+ {this.props.label && ( + + )} +
{errorMessage} @@ -1987,8 +1861,6 @@ export class FileElement extends Component { * @return {JSX} - React markup for the component */ render() { - const required = this.props.required ? 'required' : null; - let fileName = undefined; if (this.props.value) { @@ -2011,15 +1883,9 @@ export class FileElement extends Component { } } - let requiredHTML = null; let errorMessage = ''; let elementClass = 'row form-group'; - // Add required asterix - if (required) { - requiredHTML = *; - } - const truncateEllipsis = { display: 'table', tableLayout: 'fixed', @@ -2052,9 +1918,7 @@ export class FileElement extends Component { truncateEllipsis.paddingTop = '7px'; return (
- +
{fileName} @@ -2064,22 +1928,13 @@ export class FileElement extends Component { ); } - let labelHTML; - let classSz; - if (this.props.label) { - labelHTML = ; - classSz = 'col-sm-9'; - } else { - classSz = 'col-sm-12'; - } - + const wrapperClass = this.props.label ? 'col-sm-9' : 'col-sm-12'; return (
- {labelHTML} -
+ {this.props.label && ( + + )} +
@@ -2096,7 +1951,7 @@ export class FileElement extends Component { className="fileUpload" name={this.props.name} onChange={this.handleChange} - required={required} + required={this.props.required} multiple={this.props.allowMultiple} />
@@ -2168,17 +2023,11 @@ export class StaticElement extends Component { * @return {JSX} - React markup for the component */ render() { - let label = null; - if (this.props.label) { - label = ( - - ); - } return (
- {label} + {this.props.label && ( + + )}
{this.props.text} @@ -2270,9 +2119,7 @@ export class LinkElement extends Component { render() { return (
- +

{this.props.text} @@ -2326,8 +2173,6 @@ export class CheckboxElement extends React.Component { * @return {JSX} - React markup for the component */ render() { - let disabled = this.props.disabled ? 'disabled' : null; - let required = this.props.required ? 'required' : null; let errorMessage = null; let requiredHTML = null; let elementClass = this.props.class + ' ' + this.props.offset; @@ -2336,7 +2181,7 @@ export class CheckboxElement extends React.Component { : {paddingRight: '5px', display: 'inline-block'}; // Add required asterix - if (required) { + if (this.props.required) { requiredHTML = *; } @@ -2348,7 +2193,7 @@ export class CheckboxElement extends React.Component { return (

-
+