diff --git a/.eslintrc.json b/.eslintrc.json index cfb7b4048f9..5057ae7524b 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", @@ -110,8 +111,9 @@ }} ], "jsdoc/require-param-description": "error", - "jsdoc/require-returns": "error", "jsdoc/require-returns-description": "error", + "jsdoc/require-param-type": "off", + "jsdoc/require-returns-type": "off", "no-undef": "off", "@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/no-explicit-any": "off" diff --git a/SQL/0000-00-00-schema.sql b/SQL/0000-00-00-schema.sql index 81e732446e8..ef64552e16c 100644 --- a/SQL/0000-00-00-schema.sql +++ b/SQL/0000-00-00-schema.sql @@ -341,6 +341,7 @@ CREATE TABLE `test_battery` ( `CenterID` int(11) default NULL, `firstVisit` enum('Y','N') default NULL, `instr_order` tinyint(4) default NULL, + `DoubleDataEntryEnabled` enum('Y','N') default 'N', PRIMARY KEY (`ID`), KEY `age_test` (`AgeMinDays`,`AgeMaxDays`,`Test_name`), KEY `FK_test_battery_1` (`Test_name`), @@ -1529,6 +1530,7 @@ CREATE TABLE `issues` ( `candID` int(6) DEFAULT NULL, `category` varchar(255) DEFAULT NULL, `description` longtext DEFAULT NULL, + `instrument` int(10) unsigned DEFAULT NULL, PRIMARY KEY (`issueID`), KEY `fk_issues_1` (`reporter`), KEY `fk_issues_2` (`assignee`), @@ -1537,20 +1539,22 @@ CREATE TABLE `issues` ( KEY `fk_issues_5` (`centerID`), KEY `fk_issues_6` (`lastUpdatedBy`), KEY `fk_issues_8` (`category`), + KEY `fk_issues_instrument` (`instrument`), CONSTRAINT `fk_issues_8` FOREIGN KEY (`category`) REFERENCES `issues_categories` (`categoryName`), CONSTRAINT `fk_issues_1` FOREIGN KEY (`reporter`) REFERENCES `users` (`UserID`), CONSTRAINT `fk_issues_2` FOREIGN KEY (`assignee`) REFERENCES `users` (`UserID`), CONSTRAINT `fk_issues_3` FOREIGN KEY (`candID`) REFERENCES `candidate` (`CandID`), CONSTRAINT `fk_issues_4` FOREIGN KEY (`sessionID`) REFERENCES `session` (`ID`), CONSTRAINT `fk_issues_5` FOREIGN KEY (`centerID`) REFERENCES `psc` (`CenterID`), - CONSTRAINT `fk_issues_6` FOREIGN KEY (`lastUpdatedBy`) REFERENCES `users` (`UserID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; + CONSTRAINT `fk_issues_6` FOREIGN KEY (`lastUpdatedBy`) REFERENCES `users` (`UserID`), + CONSTRAINT `fk_issues_instrument` FOREIGN KEY (`instrument`) REFERENCES `test_names` (`ID`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `issues_history` ( `issueHistoryID` int(11) unsigned NOT NULL AUTO_INCREMENT, `newValue` longtext NOT NULL, `dateAdded` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `fieldChanged` enum('assignee','status','comment','sessionID','centerID','title','category','module','lastUpdatedBy','priority','candID', 'description','watching') NOT NULL DEFAULT 'comment', + `fieldChanged` enum('assignee','status','comment','sessionID','centerID','title','category','module','lastUpdatedBy','priority','candID', 'description','watching','instrument') NOT NULL DEFAULT 'comment', `issueID` int(11) unsigned NOT NULL, `addedBy` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`issueHistoryID`), @@ -2096,7 +2100,9 @@ CREATE TABLE `data_release` ( `file_name` varchar(255), `version` varchar(255), `upload_date` date, - PRIMARY KEY (`id`) + `ProjectID` INT(10) UNSIGNED NULL, + PRIMARY KEY (`id`), + FOREIGN KEY (ProjectID) REFERENCES Project (ProjectID) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `data_release_permissions` ( diff --git a/SQL/0000-00-03-ConfigTables.sql b/SQL/0000-00-03-ConfigTables.sql index f170f9775bf..e7b4d637a0d 100644 --- a/SQL/0000-00-03-ConfigTables.sql +++ b/SQL/0000-00-03-ConfigTables.sql @@ -51,7 +51,6 @@ INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'useConsent', 'Enable if the study uses the loris architecture for consent', 1, 0, 'boolean', ID, 'Use consent', 16 FROM ConfigSettings WHERE Name="study"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'additional_user_info', 'Display additional user profile fields on the User accounts page (e.g. Institution, Position, Country, Address)', 1, 0, 'boolean', ID, 'Additional user information', 17 FROM ConfigSettings WHERE Name="study"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'excluded_instruments', "Instruments to be excluded from the Data Dictionary and download via the Data Query Tool", 1, 1, 'instrument', ID, 'Excluded instruments', 18 FROM ConfigSettings WHERE Name="study"; -INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'DoubleDataEntryInstruments', "Instruments for which double data entry should be enabled", 1, 1, 'instrument', ID, 'Double data entry instruments', 19 FROM ConfigSettings WHERE Name="study"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'InstrumentResetting', 'Allows resetting of instrument data', 1, 0, 'boolean', ID, 'Instrument Resetting', 20 FROM ConfigSettings WHERE Name="study"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'SupplementalSessionStatus', 'Display supplemental session status information on Timepoint List page', 1, 0, 'boolean', ID, 'Use Supplemental Session Status', 21 FROM ConfigSettings WHERE Name="study"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'useScanDone', 'Used for identifying timepoints that have (or should have) imaging data', 1, 0, 'boolean', ID, 'Use Scan Done', 22 FROM ConfigSettings WHERE Name="study"; @@ -63,6 +62,7 @@ INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'dateDisplayFormat', 'The date format to use throughout LORIS for displaying date information - formats for date inputs are browser- and locale-dependent.', 1, 0, 'text', ID, 'Date display format', 28 FROM ConfigSettings WHERE Name="study"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'adminContactEmail', 'An email address that users can write to in order to report issues or ask question', 1, 0, 'text', ID, 'Administrator Email', 29 FROM ConfigSettings WHERE Name="study"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'UserMaximumDaysInactive', 'The maximum number of days since last login before making a user inactive', 1, 0, 'text', ID, 'Maximum Days Before Making User Inactive', 30 FROM ConfigSettings WHERE Name="study"; +INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'useDoB', 'Use DoB (Date of Birth)', 1, 0, 'boolean', ID, 'Use DoB', 31 FROM ConfigSettings WHERE Name="study"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, Label, OrderNumber) VALUES ('paths', 'Specify directories where LORIS-related files are stored or created. Take care when editing these fields as changing them incorrectly can cause certain modules to lose functionality.', 1, 0, 'Paths', 2); INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'imagePath', 'Path to images for display in Imaging Browser (e.g. /data/$project/data/) ', 1, 0, 'text', ID, 'Images', 9 FROM ConfigSettings WHERE Name="paths"; @@ -188,6 +188,7 @@ INSERT INTO Config (ConfigID, Value) SELECT ID, "Example Study" FROM ConfigSetti INSERT INTO Config (ConfigID, Value) SELECT ID, "

Example Study Description

\r\n

This is a sample description for this study, because it is a new LORIS install that has not yet customized this text.

\r\n

A LORIS administrator can customize this text in the configuration module, under the configuration option labeled \"Study Description\"

\r\n

Useful Links

\r\n " FROM ConfigSettings WHERE Name="StudyDescription"; INSERT INTO Config (ConfigID, Value) SELECT ID, "images/neurorgb_web.jpg" FROM ConfigSettings WHERE Name="studylogo"; INSERT INTO Config (ConfigID, Value) SELECT ID, "false" FROM ConfigSettings WHERE Name="useEDC"; +INSERT INTO Config (ConfigID, Value) SELECT ID, "false" FROM ConfigSettings WHERE Name="useDoB"; INSERT INTO Config (ConfigID, Value) SELECT ID, 8 FROM ConfigSettings WHERE Name="ageMin"; INSERT INTO Config (ConfigID, Value) SELECT ID, 11 FROM ConfigSettings WHERE Name="ageMax"; INSERT INTO Config (ConfigID, Value) SELECT ID, "false" FROM ConfigSettings WHERE Name="useFamilyID"; diff --git a/SQL/New_patches/2023-12-08_useDoB.sql b/SQL/New_patches/2023-12-08_useDoB.sql new file mode 100644 index 00000000000..e96a7cf02e7 --- /dev/null +++ b/SQL/New_patches/2023-12-08_useDoB.sql @@ -0,0 +1,2 @@ +INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'useDoB', 'Use DoB (Date of Birth)', 1, 0, 'boolean', ID, 'Use DoB', 31 FROM ConfigSettings WHERE Name="study"; +INSERT INTO Config (ConfigID, Value) SELECT ID, "false" FROM ConfigSettings WHERE Name="useDoB"; diff --git a/SQL/New_patches/2024-07-19-issuetracker_AddsInstrumentToIssuesTable.sql b/SQL/New_patches/2024-07-19-issuetracker_AddsInstrumentToIssuesTable.sql new file mode 100644 index 00000000000..e0a8045d543 --- /dev/null +++ b/SQL/New_patches/2024-07-19-issuetracker_AddsInstrumentToIssuesTable.sql @@ -0,0 +1,11 @@ +ALTER TABLE `issues` + ADD `instrument` int(10) unsigned DEFAULT NULL + AFTER `description`; + +ALTER TABLE `issues` + ADD CONSTRAINT `fk_issues_instrument` + FOREIGN KEY (`instrument`) REFERENCES `test_names` (`ID`) + ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE `issues_history` + MODIFY `fieldChanged` enum('assignee','status','comment','sessionID','centerID','title','category','module','lastUpdatedBy','priority','candID', 'description','watching','instrument') NOT NULL DEFAULT 'comment'; diff --git a/SQL/New_patches/2024-10-02-Data_Release_Project_separation.sql b/SQL/New_patches/2024-10-02-Data_Release_Project_separation.sql new file mode 100644 index 00000000000..d067ce80fc8 --- /dev/null +++ b/SQL/New_patches/2024-10-02-Data_Release_Project_separation.sql @@ -0,0 +1,4 @@ +ALTER TABLE data_release +ADD COLUMN ProjectID INT(10) UNSIGNED NULL DEFAULT NULL, +ADD CONSTRAINT FK_ProjectID +FOREIGN KEY (ProjectID) REFERENCES Project (ProjectID); \ No newline at end of file diff --git a/SQL/New_patches/2024_05_13_Add_DDE_To_Battery.sql b/SQL/New_patches/2024_05_13_Add_DDE_To_Battery.sql new file mode 100644 index 00000000000..5e508c7ef28 --- /dev/null +++ b/SQL/New_patches/2024_05_13_Add_DDE_To_Battery.sql @@ -0,0 +1,9 @@ +ALTER TABLE test_battery ADD COLUMN DoubleDataEntryEnabled enum("Y", "N") DEFAULT "N"; + +UPDATE test_battery SET DoubleDataEntryEnabled = 'Y' WHERE Test_name IN ( + SELECT Value from Config WHERE ConfigID = (SELECT ID FROM ConfigSettings WHERE Name = 'DoubleDataEntryInstruments') +); + +DELETE FROM Config WHERE ConfigID IN (SELECT ID FROM ConfigSettings WHERE Name = 'DoubleDataEntryInstruments'); + +DELETE FROM ConfigSettings WHERE Name = 'DoubleDataEntryInstruments'; 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/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..e0feb0cde8e 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'; @@ -49,7 +49,7 @@ function Filter(props) { const type = fields .find((field) => (field.filter||{}).name == name).filter.type; const exactMatch = (!(type === 'text' || type === 'date' - || type === 'datetime')); + || type === 'datetime' || type === 'multiselect')); if (value === null || value === '' || (value.constructor === Array && value.length === 0) || (type === 'checkbox' && value === false)) { @@ -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..086bab73b58 100644 --- a/jsx/FilterableDataTable.js +++ b/jsx/FilterableDataTable.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import Panel from 'jsx/Panel'; import DataTable from 'jsx/DataTable'; import Filter from 'jsx/Filter'; +import ProgressBar from 'jsx/ProgressBar'; /** * FilterableDataTable component. @@ -103,28 +104,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; } /** @@ -149,7 +150,10 @@ class FilterableDataTable extends Component { /> ); - const dataTable = ( + const {progress} = this.props; + const dataTable = !isNaN(progress) && progress < 100 ? ( + + ) : ( void noMargins?: boolean placeholder?: string @@ -306,7 +305,6 @@ type fileElementProps = { disabled?: boolean required?: boolean allowMultiple?: boolean - hasError?: boolean errorMessage?: string onUserInput: (name: string, value: any) => void }; @@ -407,7 +405,6 @@ type dateElementProps = { dateFormat?: string disabled?: boolean required?: boolean - hasError?: boolean errorMessage?: string onUserInput: (name: string, value: any) => void }; @@ -595,7 +592,7 @@ type radioElementProps = { required?: boolean vertical?: boolean checked: boolean - errorMessage?: boolean + errorMessage?: string elementClass?: boolean onUserInput: (name: string, value: any) => void } diff --git a/jsx/Form.js b/jsx/Form.js index 9d089623914..9b9bf0bc6c6 100644 --- a/jsx/Form.js +++ b/jsx/Form.js @@ -7,6 +7,7 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; +import InputLabel from 'jsx/form/InputLabel'; /** * Form Component. @@ -329,20 +330,12 @@ export class SearchableDropdown extends Component { * @return {JSX} - React markup for the component */ render() { - let required = this.props.required ? 'required' : null; - let disabled = this.props.disabled ? 'disabled' : null; let sortByValue = this.props.sortByValue; let options = this.props.options; let strictMessage = 'Entry must be included in provided list of options.'; 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}; @@ -391,10 +384,7 @@ export class SearchableDropdown extends Component { return (
- +
{optionList} @@ -451,7 +441,7 @@ SearchableDropdown.defaultProps = { disabled: false, required: false, sortByValue: true, - errorMessage: '', + errorMessage: null, placeHolder: '', onUserInput: function() { console.warn('onUserInput() callback is not set'); @@ -532,29 +522,20 @@ export class SelectElement extends Component { * @return {JSX} - React markup for the component */ render() { - let multiple = this.props.multiple ? 'multiple' : null; - let required = this.props.required ? 'required' : null; - let disabled = this.props.disabled ? 'disabled' : null; let sortByValue = this.props.sortByValue; let options = this.props.options; let disabledOptions = this.props.disabledOptions; let errorMessage = null; let emptyOptionHTML = null; - let requiredHTML = null; let elementClass = this.props.noMargins ? '' : 'row form-group'; - // Add required asterisk - if (required) { - requiredHTML = *; - } - // Add empty option if (this.props.emptyOption) { emptyOptionHTML = ; } // Add error message - if (this.props.hasError + if (this.props.errorMessage || (this.props.required && this.props.value === '') ) { errorMessage = {this.props.errorMessage}; @@ -603,37 +584,35 @@ export class SelectElement extends Component { } // Default to empty string for regular select and to empty array for 'multiple' select - const value = this.props.value || (multiple ? [] : ''); + const value = this.props.value || (this.props.multiple ? [] : ''); // 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.noMargins ? '' : 'col-sm-12'; - if (this.props.label && this.props.label != '') { - label = ( - - ); + if (this.props.label) { inputClass = 'col-sm-9'; } return (
- {label} + {this.props.label && ( + + )}
@@ -1131,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.hasError + if (this.props.errorMessage || (this.props.required && this.props.value === '') ) { errorMessage = {this.props.errorMessage}; @@ -1596,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}
@@ -1640,7 +1549,6 @@ DateElement.propTypes = { dateFormat: PropTypes.string, disabled: PropTypes.bool, required: PropTypes.bool, - hasError: PropTypes.bool, errorMessage: PropTypes.string, onUserInput: PropTypes.func, }; @@ -1655,8 +1563,7 @@ DateElement.defaultProps = { dateFormat: 'YMd', disabled: false, required: false, - hasError: false, - errorMessage: 'The field is required!', + errorMessage: null, onUserInput: function() { console.warn('onUserInput() callback is not set'); }, @@ -1692,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) { @@ -1894,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} @@ -1992,39 +1861,31 @@ 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) { 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; } } - let requiredHTML = null; let errorMessage = ''; let elementClass = 'row form-group'; - // Add required asterix - if (required) { - requiredHTML = *; - } - const truncateEllipsis = { display: 'table', tableLayout: 'fixed', @@ -2039,7 +1900,7 @@ export class FileElement extends Component { }; // Add error message - if (this.props.hasError) { + if (this.props.errorMessage) { errorMessage = this.props.errorMessage; elementClass = 'row form-group has-error'; } @@ -2057,9 +1918,7 @@ export class FileElement extends Component { truncateEllipsis.paddingTop = '7px'; return (
- +
{fileName} @@ -2069,25 +1928,16 @@ 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 && ( + + )} +
+ className="form-control file-caption kv-fileinput-caption">
{fileName}
@@ -2101,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} />
@@ -2125,7 +1975,6 @@ FileElement.propTypes = { disabled: PropTypes.bool, required: PropTypes.bool, allowMultiple: PropTypes.bool, - hasError: PropTypes.bool, errorMessage: PropTypes.string, onUserInput: PropTypes.func, }; @@ -2138,8 +1987,7 @@ FileElement.defaultProps = { disabled: false, required: false, allowMultiple: false, - hasError: false, - errorMessage: 'The field is required!', + errorMessage: null, onUserInput: function() { console.warn('onUserInput() callback is not set'); }, @@ -2175,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} @@ -2277,9 +2119,7 @@ export class LinkElement extends Component { render() { return (
- +

{this.props.text} @@ -2333,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; @@ -2343,7 +2181,7 @@ export class CheckboxElement extends React.Component { : {paddingRight: '5px', display: 'inline-block'}; // Add required asterix - if (required) { + if (this.props.required) { requiredHTML = *; } @@ -2355,7 +2193,7 @@ export class CheckboxElement extends React.Component { return (

-
+
+ style={styleColumn}>
@@ -2699,7 +2535,7 @@ export class RadioElement extends React.Component { layout.push(
+ style={styleRow}> {content}
); @@ -2760,7 +2596,7 @@ RadioElement.defaultProps = { disabled: false, required: false, vertical: false, - errorMessage: '', + errorMessage: null, elementClass: 'row form-group', onUserInput: function() { console.warn('onUserInput() callback is not set'); @@ -2809,10 +2645,8 @@ export class SliderElement extends React.Component { let errorMessage = null; let requiredHTML = null; let elementClass = this.props.elementClass; - let disabled = this.props.disabled ? 'disabled' : null; - let required = this.props.required ? 'required' : null; // Add required asterix - if (required) { + if (this.props.required) { requiredHTML = *; } // Add error message @@ -2824,7 +2658,7 @@ export class SliderElement extends React.Component { return (
- ); -} - -Loader.propTypes = {size: PropTypes.string}; -Loader.defaultProps = {size: '120'}; - -export default Loader; diff --git a/jsx/Loader.tsx b/jsx/Loader.tsx new file mode 100644 index 00000000000..14eb6c1e6bf --- /dev/null +++ b/jsx/Loader.tsx @@ -0,0 +1,21 @@ +interface LoaderProps { + size?: number; +} + +/** + * Loader component renders a spinner wheel of a specified size. + * + * @param {LoaderProps} props - The properties for the Loader component + * @returns {JSX.Element} A div representing the loading spinner + */ +const Loader = ({size = 120}: LoaderProps) => { + const loaderStyle = { + width: size, + height: size, + borderWidth: size/15, + }; + + return
; +}; + +export default Loader; diff --git a/jsx/Modal.d.ts b/jsx/Modal.d.ts deleted file mode 100644 index 6cb1ba08e73..00000000000 --- a/jsx/Modal.d.ts +++ /dev/null @@ -1,48 +0,0 @@ -import {ReactNode} from 'react'; - -type ModalProps = { - title?: string - onSubmit: () => Promise, - onClose: () => void, - show: boolean, - throwWarning?: boolean, - children: React.ReactNode, - width?: string - -} - -/** - * The Modal class. See Modal.js - */ -class Modal { - props: ModalProps - state: any - context: object - refs: {[key: string]: ReactInstance} - - /** - * Construct a new modal - */ - constructor(props: ModalProps) - - /** - * React lifecycle method - * - * @returns {ReactNode} - */ - render(): ReactNode - - /** - * React lifecycle method - * - * @param {object} newstate - the state to overwrite - */ - setState(newstate: object): void - - /** - * React lifecycle method - */ - forceUpdate(): void -} - -export default Modal; diff --git a/jsx/Modal.js b/jsx/Modal.js deleted file mode 100644 index 5b7a42ca6c6..00000000000 --- a/jsx/Modal.js +++ /dev/null @@ -1,185 +0,0 @@ -/** - * This file contains the React Component for a Modal Window. - * - * @author Henri Rabalais - * @version 1.1.0 - */ -import React, {Component} from 'react'; -import PropTypes from 'prop-types'; -import swal from 'sweetalert2'; -import {ButtonElement} from 'jsx/Form'; - -/** - * Modal Component. - * React wrapper for a Modal Window. Allows to dynamically toggle a Modal - * window. - * - * ================================================ - * Usage: - * - Wrap the contents to be displayed by the Modal Window by the - * Modal Component. - * - Use the 'title' prop to set a title for the Modal Component. - * - Use the 'onSubmit' prop to set a submission *promise* object for the - * Modal's contents. - * - Use the 'onClose' prop to set a function that triggers upon Modal closure. - * - Use the 'throwWarning' prop to throw a warning upon closure of the - * Modal Window. - * ================================================= - * - */ -class Modal extends Component { - /** - * @constructor - */ - constructor() { - super(); - this.handleClose = this.handleClose.bind(this); - } - - /** - * Display a warning message on close - */ - handleClose() { - if (this.props.throwWarning) { - swal.fire({ - title: 'Are You Sure?', - text: 'Leaving the form will result in the loss of any information ' + - 'entered.', - type: 'warning', - showCancelButton: true, - confirmButtonText: 'Proceed', - cancelButtonText: 'Cancel', - }).then((result) => result.value && this.props.onClose()); - } else { - this.props.onClose(); - } - } - - /** - * Renders the React component. - * - * @return {JSX} - React markup for the component - */ - render() { - const {show, children, onSubmit, title, width} = this.props; - - const headerStyle = { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - height: '40px', - borderTopRightRadius: '10', - fontSize: 24, - padding: 35, - borderBottom: '1px solid #DDDDDD', - }; - - const glyphStyle = { - marginLeft: 'auto', - cursor: 'pointer', - }; - - const bodyStyle = { - padding: 15, - maxHeight: '75vh', - overflowY: 'scroll', - }; - - const modalContainer = { - display: 'block', - position: 'fixed', - zIndex: 9999, - paddingTop: '65px', - left: 0, - top: 0, - width: '100%', - height: '100%', - overflow: 'auto', - backgroundColor: 'rgba(0,0,0,0.7)', - visibility: show ? 'visible' : 'hidden', - }; - - const modalContent = { - opacity: show ? 1 : 0, - top: show ? 0 : '-300px', - position: 'relative', - backgroundColor: '#fefefe', - borderRadius: '7px', - margin: 'auto', - padding: 0, - border: '1px solid #888', - width: width || '700px', - boxShadow: '0 4px 8px 0 rbga(0,0,0,0.2), 0 6px 20px 0 rgba(0,0,0,0.19)', - transition: 'top 0.4s, opacity 0.4s', - }; - - const renderChildren = () => show && children; - - const footerStyle = { - borderTop: '1px solid #DDDDDD', - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - height: '40px', - padding: '35px 35px 20px 35px', - }; - - const submitStyle = { - marginLeft: 'auto', - marginRight: '20px', - }; - - const submitButton = () => { - if (onSubmit) { - const submit = () => onSubmit().then(() => this.props.onClose()) - .catch(() => {}); - return ( -
- -
- ); - } - }; - - return ( -
-
e.stopPropagation()} - > -
- {title} - - × - -
-
- {renderChildren()} -
-
- {submitButton()} -
-
-
- ); - } -} - -Modal.propTypes = { - title: PropTypes.string, - onSubmit: PropTypes.func, - onClose: PropTypes.func.isRequired, - show: PropTypes.bool.isRequired, - throwWarning: PropTypes.bool, - children: PropTypes.node, - width: PropTypes.string, -}; - -Modal.defaultProps = { - throwWarning: false, -}; - -export default Modal; diff --git a/jsx/Modal.tsx b/jsx/Modal.tsx new file mode 100644 index 00000000000..ca7f683d711 --- /dev/null +++ b/jsx/Modal.tsx @@ -0,0 +1,216 @@ +import {useState, PropsWithChildren, CSSProperties} from 'react'; +import Swal from 'sweetalert2'; +import Loader from './Loader'; +import { + ButtonElement, +} from 'jsx/Form'; + +type ModalProps = PropsWithChildren<{ + throwWarning?: boolean; + show: boolean; + onClose: () => void; + onSubmit?: () => Promise; + onSuccess?: (data: any) => void; + title?: string; +}>; + +/** + * Modal Component + * + * A React functional component that renders a modal dialog with optional + * form submission and loading indicators. Supports asynchronous form submission + * with loading and success feedback. + * + * @param {ModalProps} props - Properties for the modal component + * @returns {JSX.Element} - A modal dialog box w/ optional submit functionality + */ +const Modal = ({ + throwWarning = false, + show = false, + onClose, + onSubmit, + onSuccess, + title, + children, +}: ModalProps) => { + const [loading, setLoading] = useState(false); // Tracks loading during submit + const [success, setSuccess] = useState(false); // Tracks success after submit + + /** + * Handles modal close event. Shows a confirmation if `throwWarning` is true. + */ + const handleClose = () => { + if (throwWarning) { // Display warning if enabled + Swal.fire({ + title: 'Are You Sure?', + text: 'Leaving the form will result in the loss of any information ' + + 'entered.', + type: 'warning', + showCancelButton: true, + confirmButtonText: 'Proceed', + cancelButtonText: 'Cancel', + }).then((result) => result.value && onClose()); + } else { + onClose(); // Close immediately if no warning + } + }; + + /** + * Manages form submission with loading and success states, calling + * `onSubmit` and handling modal state based on success or failure. + */ + const handleSubmit = async () => { + if (!onSubmit) return; // Ensure onSubmit exists + + setLoading(true); // Show loader + + try { + const data = await onSubmit(); + setLoading(false); + setSuccess(true); // Show success + + await new Promise((resolve) => setTimeout(resolve, 2000)); // Close delay + + setSuccess(false); // Reset success state + onClose(); // Close modal + onSuccess?.(data); // call onSuccess if defined + } catch { + setLoading(false); + } + }; + + /** + * Renders submit button if `onSubmit` is provided and no loading or success. + * + * @returns {JSX.Element | undefined} - The submit button if conditions are met + */ + const submitButton = () => { + if (onSubmit && !(loading || success)) { // Show button if conditions met + return ( +
+ +
+ ); + } + }; + + const headerStyle: CSSProperties = { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + height: '40px', + borderTopRightRadius: '10', + fontSize: 24, + padding: 35, + borderBottom: '1px solid #DDDDDD', + }; + + const glyphStyle: CSSProperties = { + marginLeft: 'auto', + cursor: 'pointer', + }; + + const bodyStyle: CSSProperties = { + padding: success ? 0 : '15px 15px', + maxHeight: success ? 0 : '75vh', + overflow: 'scroll', + opacity: success ? 0 : 1, + transition: '1s ease, opacity 0.3s', + }; + + const modalContainer: CSSProperties = { + display: 'block', + position: 'fixed', + zIndex: 9999, + paddingTop: '100px', + paddingBottom: '100px', + left: 0, + top: 0, + width: '100%', + height: '100%', + overflow: 'auto', + backgroundColor: 'rgba(0,0,0,0.7)', + visibility: show ? 'visible' : 'hidden', + }; + + const modalContent: CSSProperties = { + opacity: show ? 1 : 0, + top: show ? 0 : '-300px', + position: 'relative', + backgroundColor: '#fefefe', + borderRadius: '7px', + margin: 'auto', + padding: 0, + border: '1px solid #888', + width: '700px', + boxShadow: '0 4px 8px 0 rbga(0,0,0,0.2), 0 6px 20px 0 rgba(0,0,0,0.19)', + transition: '0.4s ease', + }; + + const footerStyle: CSSProperties = { + borderTop: '1px solid #DDDDDD', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + height: '40px', + padding: '35px', + backgroundColor: success ? '#e0ffec' : undefined, + }; + + const submitStyle: CSSProperties = { + marginLeft: 'auto', + marginRight: '20px', + }; + + const processStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-evenly', + margin: '0px auto', + width: '90px', + }; + + /** + * Loader element displayed during form submission. + */ + const loader = loading && ( +
+ +
Saving
+
+ ); + + /** + * Success display element shown after successful form submission. + */ + const successDisplay = success && ( +
+ +
Success!
+
+ ); + + return ( +
+
e.stopPropagation()}> +
+ {title} + × +
+
+
{show && children}
+
+ {loader} + {successDisplay} + {submitButton()} +
+
+
+
+ ); +}; + +export default Modal; diff --git a/jsx/MultiSelectDropdown.js b/jsx/MultiSelectDropdown.js index 2658d60de95..06f1e4f1790 100644 --- a/jsx/MultiSelectDropdown.js +++ b/jsx/MultiSelectDropdown.js @@ -310,26 +310,26 @@ export class SelectDropdown extends Component { } } const overlay = this.state.open ? ( -
- ) : 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/jsx/form/DateTimePartialElement.tsx b/jsx/form/DateTimePartialElement.tsx index b103efb0b6d..0dc33802e0a 100644 --- a/jsx/form/DateTimePartialElement.tsx +++ b/jsx/form/DateTimePartialElement.tsx @@ -1,4 +1,5 @@ -import {ChangeEvent, ReactNode, useState} from 'react'; +import React, {ChangeEvent, ReactNode, useState} from 'react'; +import InputLabel from 'jsx/form/InputLabel'; const format = 'YYYY-MM-DD hh:mm:ss'; @@ -91,7 +92,7 @@ function formatDatetime(oldDateTime: string, newDateTime: string) { return newDateTime; } -interface MaskProps { +type MaskProps = { value: string; children: ReactNode; } @@ -102,34 +103,32 @@ interface MaskProps { * @param props The props of the component * @returns The corresponding React element */ -function Mask(props: MaskProps) { - // '\u00A0' is a non-breakable space. - return ( -
- {props.children} -
= ({value, children}) => ( +
+ {children} +
+
-
- {'\u00A0'.repeat(props.value.length)} - {format.slice(props.value.length)} -
+ {/* '\u00A0' is a non-breakable space */} + {'\u00A0'.repeat(value.length)} + {format.slice(value.length)}
- ); -} +
+); -interface DateTimePartialElementProps { +type DateTimePartialElementProps = { name: string; label: string; value?: string; @@ -137,7 +136,6 @@ interface DateTimePartialElementProps { dateFormat: string; required?: boolean; disabled?: boolean; - hasError?: boolean; errorMessage?: string; onUserInput: (name: string, value: string) => void; } @@ -150,7 +148,9 @@ interface DateTimePartialElementProps { * @param props The props of the component * @returns The corresponding React element */ -function DateTimePartialElement(props: DateTimePartialElementProps) { +const DateTimePartialElement: React.FC = ( + props, +) => { const onUserInput = props.onUserInput !== undefined ? props.onUserInput : () => console.warn('onUserInput() callback is not set'); @@ -177,40 +177,24 @@ function DateTimePartialElement(props: DateTimePartialElementProps) { ); } - const required = props.required ?? false; - const disabled = props.disabled ?? false; let errorMessage = null; let elementClass = 'row form-group'; if (props.required && value == '') { errorMessage = This field is required; elementClass += ' has-error'; - } else if (props.hasError) { + } else if (props.errorMessage) { errorMessage = {props.errorMessage}; elementClass += ' has-error'; } - let labelHTML; - let classSz = 'col-sm-12'; - if (props.label) { - classSz = 'col-sm-9'; - labelHTML = ( - - ); - } - + const wrapperClass = props.label ? 'col-sm-9' : 'col-sm-12'; return (
- {labelHTML} -
+ {props.label && ( + + )} +
@@ -227,6 +211,6 @@ function DateTimePartialElement(props: DateTimePartialElementProps) {
); -} +}; export default DateTimePartialElement; diff --git a/jsx/form/InputLabel.tsx b/jsx/form/InputLabel.tsx new file mode 100644 index 00000000000..4e9ecc08313 --- /dev/null +++ b/jsx/form/InputLabel.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +type InputLabelProps = { + // The label to be displayed to the user + label: string; + // Whether or not the input is required, `false` by default + required?: boolean; +}; + +/** + * Input label React component + * + * @param props The props of the component + * @returns The corresponding React element + */ +const InputLabel: React.FC = ({label, required}) => ( + +); + +export default InputLabel; 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/api/docs/LorisRESTAPI_v0.0.4-dev.md b/modules/api/docs/LorisRESTAPI_v0.0.4-dev.md index e445316e5b5..6edea3e19a0 100644 --- a/modules/api/docs/LorisRESTAPI_v0.0.4-dev.md +++ b/modules/api/docs/LorisRESTAPI_v0.0.4-dev.md @@ -189,11 +189,13 @@ Will return a JSON object of the form: "FullName" : "Long Name", "Subgroup" : "Subgroup Name", "DoubleDataEntryEnabled" : boolean + "DoubleDataEntryVisits" : array }, "Instrument2" : { "FullName" : "Long Name", "Subgroup" : "Subgroup Name", "DoubleDataEntryEnabled" : boolean + "DoubleDataEntryVisits" : array }, ... } diff --git a/modules/api/php/endpoints/project/instruments.class.inc b/modules/api/php/endpoints/project/instruments.class.inc index 14eacd3be10..1b2d891b641 100644 --- a/modules/api/php/endpoints/project/instruments.class.inc +++ b/modules/api/php/endpoints/project/instruments.class.inc @@ -42,10 +42,13 @@ class Instruments extends Endpoint implements \LORIS\Middleware\ETagCalculator /** * Contructor * - * @param \Project $project The requested project + * @param \Project $project The requested project + * @param string $apiversion The version of the API being used */ - public function __construct(\Project $project) - { + public function __construct( + \Project $project, + private string $apiversion = 'v0.0.3' + ) { $this->_project = $project; } @@ -146,7 +149,8 @@ class Instruments extends Endpoint implements \LORIS\Middleware\ETagCalculator $array = (new \LORIS\api\Views\Project\Instruments( $this->_project, - iterator_to_array($instruments) + iterator_to_array($instruments), + $this->apiversion ))->toArray(); $this->_cache = new \LORIS\Http\Response\JsonResponse($array); diff --git a/modules/api/php/endpoints/project/project.class.inc b/modules/api/php/endpoints/project/project.class.inc index c7baf1f10a8..105483a0ac7 100644 --- a/modules/api/php/endpoints/project/project.class.inc +++ b/modules/api/php/endpoints/project/project.class.inc @@ -100,6 +100,8 @@ class Project extends Endpoint implements \LORIS\Middleware\ETagCalculator } } + $apiversion = $request->getAttribute("LORIS-API-Version") ?? "unknown"; + // Delegate to sub-endpoints $subendpoint = array_shift($pathparts); switch ($subendpoint) { @@ -110,7 +112,7 @@ class Project extends Endpoint implements \LORIS\Middleware\ETagCalculator $handler = new Images($this->_project); break; case 'instruments': - $handler = new Instruments($this->_project); + $handler = new Instruments($this->_project, $apiversion); break; case 'visits': $handler = new Visits($this->_project); diff --git a/modules/api/php/models/projectinstrumentsrow.class.inc b/modules/api/php/models/projectinstrumentsrow.class.inc index 58bd8634d51..76be7c809f7 100644 --- a/modules/api/php/models/projectinstrumentsrow.class.inc +++ b/modules/api/php/models/projectinstrumentsrow.class.inc @@ -27,25 +27,26 @@ class ProjectInstrumentsRow implements \LORIS\Data\DataInstance private $_fullname; private $_subgroupname; private $_isDDE; + private $_ddeVisits; /** * Create a new ProjectImagesRow. * - * @param array $row An array of image properties + * @param array $row An array of image properties + * @param string $apiversion The version of the API being used */ - public function __construct(array $row) - { - $shortname = $row['shortname'] ?? null; - $ddeinstruments = array_keys(\Utility::getAllDDEInstruments()); + public function __construct( + array $row, + private string $apiversion = 'v0.0.3' + ) { + $this->_shortname = $row['shortname'] ?? null; + $this->_ddeVisits = \NDB_BVL_Battery::getDDEVisitsForInstrument( + $this->_shortname + ); - $this->_shortname = $shortname; $this->_fullname = $row['fullname'] ?? null; $this->_subgroupname = $row['subgroupname'] ?? null; - $this->_isDDE = in_array( - $shortname, - $ddeinstruments, - true - ); + $this->_isDDE = count($this->_ddeVisits) > 0; } /** @@ -88,6 +89,16 @@ class ProjectInstrumentsRow implements \LORIS\Data\DataInstance return $this->_isDDE; } + /** + * Accessor for ddeVisits. + * + * @return array + */ + public function getddeVisits(): array + { + return $this->_ddeVisits; + } + /** * Implements \LORIS\Data\DataInstance interface for this row. * @@ -95,11 +106,17 @@ class ProjectInstrumentsRow implements \LORIS\Data\DataInstance */ public function jsonSerialize() : array { - return [ + $obj = [ 'shortname' => $this->_shortname, 'fullname' => $this->_fullname, 'subgroup' => $this->_subgroupname, 'ddeenable' => $this->_isDDE, + 'ddevisits' => $this->_ddeVisits, ]; + if ($this->apiversion != 'v0.0.3') { + // api version >= v0.0.4 + $obj['ddevisits'] = $this->_ddeVisits; + } + return $obj; } } diff --git a/modules/api/php/views/project/instruments.class.inc b/modules/api/php/views/project/instruments.class.inc index c117d463404..49a3ceb0c8f 100644 --- a/modules/api/php/views/project/instruments.class.inc +++ b/modules/api/php/views/project/instruments.class.inc @@ -45,9 +45,13 @@ class Instruments * * @param \Project $project The requested project * @param ProjectInstrumentsRow[] $instruments An array of ProjectInstrumentsRow + * @param string $apiversion The version of the API being used */ - public function __construct(\Project $project, array $instruments) - { + public function __construct( + \Project $project, + array $instruments, + private string $apiversion = 'v0.0.3' + ) { $this->_project = $project; $this->_instruments = $instruments; } @@ -70,6 +74,10 @@ class Instruments 'Subgroup' => $instrument->getSubgroupname(), 'DoubleDataEntryEnabled' => $instrument->isDDE(), ]; + if ($this->apiversion != 'v0.0.3') { + // >= v0.0.4 + $item['DoubleDataEntryVisits'] = $instrument->getddeVisits(); + } if (!is_null($shortname)) { $instruments[$shortname] = $item; diff --git a/modules/battery_manager/jsx/batteryManagerForm.js b/modules/battery_manager/jsx/batteryManagerForm.js index c41342c18dd..7e7761f4928 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. -
+

); @@ -68,7 +68,6 @@ class BatteryManagerForm extends Component { required={true} value={test.testName} errorMessage={errors.testName} - hasError={errors.testName} /> - + diff --git a/modules/battery_manager/jsx/batteryManagerIndex.js b/modules/battery_manager/jsx/batteryManagerIndex.js index 794ba742f61..40b3be79693 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,57 @@ 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: 'Double Data Entry Enabled', show: true, filter: { + name: 'DoubleDataEntryEnabled', + type: 'select', + options: options.DoubleDataEntryEnabled, + }}, {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')}, ]; @@ -365,6 +370,7 @@ class BatteryManagerIndex extends Component { test.centerId, test.firstVisit, test.instrumentOrder, + test.DoubleDataEntryEnabled, test.active, ]; }); @@ -420,7 +426,8 @@ class BatteryManagerIndex extends Component { test.cohort == testCheck.cohort && test.visitLabel == testCheck.visitLabel && test.centerId == testCheck.centerId && - test.firstVisit == testCheck.firstVisit + test.firstVisit == testCheck.firstVisit && + test.DoubleDataEntryEnabled == testCheck.DoubleDataEntryEnabled ) { duplicate = testCheck; } diff --git a/modules/battery_manager/php/test.class.inc b/modules/battery_manager/php/test.class.inc index 2c948025fca..f83fa27abb3 100644 --- a/modules/battery_manager/php/test.class.inc +++ b/modules/battery_manager/php/test.class.inc @@ -90,17 +90,19 @@ class Test implements public function toSQL() : array { return [ - 'ID' => $this->row['id'] ?? null, - 'Test_name' => $this->row['testName'] ?? null, - 'AgeMinDays' => $this->row['ageMinDays'] ?? null, - 'AgeMaxDays' => $this->row['ageMaxDays'] ?? null, - 'Stage' => $this->row['stage'] ?? null, - 'CohortID' => $this->row['cohort'] ?? null, - 'Visit_label' => $this->row['visitLabel'] ?? null, - 'CenterID' => $this->row['centerId'] ?? null, - 'firstVisit' => $this->row['firstVisit'] ?? null, - 'instr_order' => $this->row['instrumentOrder'] ?? null, - 'Active' => $this->row['active'] ?? null, + 'ID' => $this->row['id'] ?? null, + 'Test_name' => $this->row['testName'] ?? null, + 'AgeMinDays' => $this->row['ageMinDays'] ?? null, + 'AgeMaxDays' => $this->row['ageMaxDays'] ?? null, + 'Stage' => $this->row['stage'] ?? null, + 'CohortID' => $this->row['cohort'] ?? null, + 'Visit_label' => $this->row['visitLabel'] ?? null, + 'CenterID' => $this->row['centerId'] ?? null, + 'firstVisit' => $this->row['firstVisit'] ?? null, + 'DoubleDataEntryEnabled' => $this->row['DoubleDataEntryEnabled'] + ?? null, + 'instr_order' => $this->row['instrumentOrder'] ?? null, + 'Active' => $this->row['active'] ?? null, ]; } } diff --git a/modules/battery_manager/php/testendpoint.class.inc b/modules/battery_manager/php/testendpoint.class.inc index 2e18205844a..b3c17c3b0d9 100644 --- a/modules/battery_manager/php/testendpoint.class.inc +++ b/modules/battery_manager/php/testendpoint.class.inc @@ -219,7 +219,8 @@ class TestEndpoint extends \NDB_Page implements RequestHandlerInterface CohortID, Visit_label, CenterID, - firstVisit + firstVisit, + DoubleDataEntryEnabled as DDE_enabled FROM test_battery"; // Select duplicate entry from Test Battery $entries = $this->db->pselect($query, []); @@ -235,6 +236,7 @@ class TestEndpoint extends \NDB_Page implements RequestHandlerInterface && $testArray['Visit_label'] == $entry['Visit_label'] && $testArray['CenterID'] == $entry['CenterID'] && $testArray['firstVisit'] == $entry['firstVisit'] + && $testArray['DoubleDataEntryEnabled'] == $entry['DDE_enabled'] ) { return true; } diff --git a/modules/battery_manager/php/testoptionsendpoint.class.inc b/modules/battery_manager/php/testoptionsendpoint.class.inc index 77af508917c..6b2ba925349 100644 --- a/modules/battery_manager/php/testoptionsendpoint.class.inc +++ b/modules/battery_manager/php/testoptionsendpoint.class.inc @@ -64,15 +64,16 @@ class TestOptionsEndpoint extends \NDB_Page $this->loris->getDatabaseConnection() ); return [ - 'instruments' => \NDB_BVL_Instrument::getInstrumentNamesList( + 'instruments' => \NDB_BVL_Instrument::getInstrumentNamesList( $this->loris ), - 'stages' => $this->_getStageList(), - 'cohorts' => \Utility::getCohortList(null), - 'visits' => $visitController->getVisitlabels(), - 'sites' => \Utility::getSiteList(false), - 'firstVisit' => $this->_getYesNoList(), - 'active' => $this->_getYesNoList(), + 'stages' => $this->_getStageList(), + 'cohorts' => \Utility::getCohortList(null), + 'visits' => $visitController->getVisitlabels(), + 'sites' => \Utility::getSiteList(false), + 'firstVisit' => $this->_getYesNoList(), + 'DoubleDataEntryEnabled' => $this->_getYesNoList(), + 'active' => $this->_getYesNoList(), ]; } diff --git a/modules/battery_manager/php/testprovisioner.class.inc b/modules/battery_manager/php/testprovisioner.class.inc index 777e52e347c..2ee3944850e 100644 --- a/modules/battery_manager/php/testprovisioner.class.inc +++ b/modules/battery_manager/php/testprovisioner.class.inc @@ -51,6 +51,7 @@ class TestProvisioner extends \LORIS\Data\Provisioners\DBRowProvisioner b.Visit_label as visitLabel, p.CenterID as centerId, b.firstVisit, + b.DoubleDataEntryEnabled, b.instr_order as instrumentOrder, b.Active as active FROM test_battery b diff --git a/modules/battery_manager/test/BatteryManagerTest.php b/modules/battery_manager/test/BatteryManagerTest.php index ce7e9327206..ac2775621bf 100644 --- a/modules/battery_manager/test/BatteryManagerTest.php +++ b/modules/battery_manager/test/BatteryManagerTest.php @@ -138,7 +138,7 @@ function testLoadsWithPermissionEdit() ); $this->safeClick( WebDriverBy::cssSelector( - "#dynamictable > tbody > tr:nth-child(1) > td:nth-child(13) > button" + "#dynamictable > tbody > tr:nth-child(1) > td:nth-child(14) > button" ) ); $bodyText = $this->safeFindElement( @@ -165,55 +165,50 @@ function testEditform() $this->safeGet($this->url . "/battery_manager/"); $this->safeClick( WebDriverBy::cssSelector( - "#dynamictable > tbody > tr > td:nth-child(13) > button" + "#dynamictable > tbody > tr > td:nth-child(14) > button" ) ); $this->safeClick( WebDriverBy::cssSelector( - "#lorisworkspace > div>div:nth-child(2)>div>div:nth-child(2)>form>". - " div > div:nth-child(2) > div > div > select > option:nth-child(2)" - ) + "#lorisworkspace > div:nth-child(1) > div:nth-child(2) > ". + "div:nth-child(1) > div:nth-child(2) > div:nth-child(1) >". + " form:nth-child(1) > div:nth-child(1) >div:nth-child(2) >". + " div:nth-child(1) > div:nth-child(2) > select:nth-child(1) >" . + " option:nth-child(3)" + ), + 5 ); - $this->safeFindElement( WebDriverBy::cssSelector( - "#lorisworkspace > div> div:nth-child(2) > div > div:nth-child(2)". - " > form > div > div:nth-child(3) > div > div > input" + "div:nth-child(3) > div:nth-child(1) >". + " div:nth-child(2) > input:nth-child(1)", + 1 ) )->clear()->sendKeys('0'); + $this->safeFindElement( WebDriverBy::cssSelector( - "#lorisworkspace>div> div:nth-child(2) > div > div:nth-child(2) ". - "> form > div > div:nth-child(4) > div > div > input" - ) + "div.col-sm-12:nth-child(4)>div:nth-child(1)". + ">div:nth-child(2)>input:nth-child(1)" + ), + 1 )->clear()->sendKeys('1'); $this->safeClick( WebDriverBy::cssSelector( - "#lorisworkspace>div>div:nth-child(2)>div>div:nth-child(2)>form>". - "div>div:nth-child(5) > div > div > select > option:nth-child(7)" - ) + "div:nth-child(5) > div:nth-child(1) > div:nth-child(2) > ". + "select:nth-child(1) > option:nth-child(2)" + ), + 1 ); $this->safeClick( WebDriverBy::cssSelector( - "#lorisworkspace > div > div:nth-child(2) > div > div:nth-child(2)>". - "form>div>div:nth-child(6) > div > div >select> option:nth-child(2)" - ) - ); - $this->safeClick( - WebDriverBy::cssSelector( - "#lorisworkspace > div >div:nth-child(2) > div > div:nth-child(2) >". - "form>div>div:nth-child(7) >div > div > select > option:nth-child(5)" - ) - ); - - $this->safeClick( - WebDriverBy::cssSelector( - "#lorisworkspace>div>div:nth-child(2)>div>div:nth-child(2)>form>". - " div > div:nth-child(11) > div > div > button" - ) + "div.col-sm-9:nth-child(1) > button:nth-child(1)" + ), + 1 ); $bodyText = $this->safeFindElement( - WebDriverBy::cssSelector("#swal2-title") + WebDriverBy::cssSelector("#swal2-title"), + 1 )->getText(); $this->assertStringContainsString( "Submission successful!", @@ -238,37 +233,47 @@ function testAddNew() ); $this->safeClick( WebDriverBy::cssSelector( - "#lorisworkspace > div > div:nth-child(2) > div > div:nth-child(2) ". - "> form > div > div:nth-child(2)>div>div>select>option:nth-child(2)" - ) + "#lorisworkspace > div:nth-child(1) > div:nth-child(2) >". + " div:nth-child(1) > div:nth-child(2) > div:nth-child(1) >". + " form:nth-child(1) > div:nth-child(1) >div:nth-child(2) > ". + "div:nth-child(1) > div:nth-child(2) > select:nth-child(1) >". + " option:nth-child(3)" + ), + 5 ); $this->safeFindElement( WebDriverBy::cssSelector( - "#lorisworkspace > div > div:nth-child(2) > div ". - "> div:nth-child(2)>form>div>div:nth-child(3) > div > div > input" + "div:nth-child(3) > div:nth-child(1) > ". + "div:nth-child(2) > input:nth-child(1)", + 1 ) )->clear()->sendKeys('0'); + $this->safeFindElement( WebDriverBy::cssSelector( - "#lorisworkspace > div > div:nth-child(2) > div >". - " div:nth-child(2)>form > div > div:nth-child(4) > div > div >input" - ) + "div.col-sm-12:nth-child(4)>div:nth-child(1)>". + "div:nth-child(2) >input:nth-child(1)" + ), + 1 )->clear()->sendKeys('1'); $this->safeClick( WebDriverBy::cssSelector( - "#lorisworkspace > div > div:nth-child(2)>div>div:nth-child(2)>form". - " > div > div:nth-child(5) > div > div >select > option:nth-child(7)" - ) + "div:nth-child(5) > div:nth-child(1) > div:nth-child(2) >". + " select:nth-child(1) > option:nth-child(2) +" + ), + 1 ); $this->safeClick( WebDriverBy::cssSelector( - "#lorisworkspace > div >div:nth-child(2)>div>div:nth-child(2)>form ". - "> div > div:nth-child(11) > div > div > button" - ) + "div.col-sm-9:nth-child(1) > button:nth-child(1)" + ), + 1 ); $bodyText = $this->safeFindElement( - WebDriverBy::cssSelector("#swal2-title") + WebDriverBy::cssSelector("#swal2-title"), + 1 )->getText(); $this->assertStringContainsString( "Submission successful!", @@ -286,7 +291,7 @@ function testActivebtn() $this->safeGet($this->url . "/battery_manager/"); $this->safeClick( WebDriverBy::cssSelector( - "#dynamictable > tbody > tr:nth-child(1) > td:nth-child(12) > button" + "#dynamictable > tbody > tr:nth-child(1) > td:nth-child(13) > button" ) ); $bodyText = $this->safeFindElement( @@ -312,7 +317,7 @@ function testFilter() self::$display, self::$clearFilter, 'AOSI', - '4 rows' + '2 rows' ); $this->_filterTest( self::$minimumAge, 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/behavioural_qc/php/provisioners/incompleteprovisioner.class.inc b/modules/behavioural_qc/php/provisioners/incompleteprovisioner.class.inc index 57ab07a50cd..f22f3096846 100644 --- a/modules/behavioural_qc/php/provisioners/incompleteprovisioner.class.inc +++ b/modules/behavioural_qc/php/provisioners/incompleteprovisioner.class.inc @@ -22,22 +22,6 @@ class IncompleteProvisioner extends \LORIS\Data\Provisioners\DBObjectProvisioner */ function __construct() { - $config =& \NDB_Config::singleton(); - $ddeInstruments = $config->getSetting('DoubleDataEntryInstruments'); - $db = \NDB_Factory::singleton()->database(); - for ($i=0; $iquote($ddeInstruments[$i]); - } - $where = " - AND (f.commentid NOT LIKE 'DDE_%') - "; - if (count($ddeInstruments) > 0) { - $ddeInstruments = implode(',', $ddeInstruments); - $where = " - AND (t.test_name IN ($ddeInstruments) OR - f.commentid NOT LIKE 'DDE_%') - "; - } parent::__construct( " SELECT DISTINCT @@ -59,13 +43,20 @@ class IncompleteProvisioner extends \LORIS\Data\Provisioners\DBObjectProvisioner JOIN candidate c ON (c.candid = s.candid) JOIN test_names t ON (t.ID = f.TestID) JOIN psc ON (s.CenterID = psc.CenterID) + JOIN test_battery ON ( + t.Test_name = test_battery.Test_name + AND test_battery.Visit_label = s.Visit_label + ) WHERE s.Active = 'Y' AND c.Active = 'Y' AND coalesce(f.data_entry, 'In Progress') = 'In Progress' AND psc.Centerid != '1' AND c.Entity_type != 'Scanner' - $where + AND ( + test_battery.DoubleDataEntryEnabled = 'Y' + OR f.commentid NOT LIKE 'DDE_%' + ) ORDER BY f.commentid ", 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 {