diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d75c931249..69bb846efe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ changes in the following format: PR #1234*** - Fix examiner site display (PR #8967) - bvl_feedback updates in real-time (PR #8966) - DoB and DoD format respected in candidate parameters (PR #9001) +- Fix delete file in upload (PR #9181) +- Fix profile level feedback display in behavioural QC module (PR #9192) ## LORIS 25.0 (Release Date: ????-??-??) ### Core diff --git a/SQL/0000-00-05-ElectrophysiologyTables.sql b/SQL/0000-00-05-ElectrophysiologyTables.sql index 3860a11e1ad..8a2d0810464 100644 --- a/SQL/0000-00-05-ElectrophysiologyTables.sql +++ b/SQL/0000-00-05-ElectrophysiologyTables.sql @@ -74,7 +74,8 @@ CREATE TABLE `physiological_split_file` ( -- information that accompanies the BIDS physiological dataset CREATE TABLE `physiological_parameter_file` ( `PhysiologicalParameterFileID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, - `PhysiologicalFileID` INT(10) UNSIGNED NOT NULL, + `PhysiologicalFileID` INT(10) UNSIGNED DEFAULT NULL, + `ProjectID` INT(10) UNSIGNED DEFAULT NULL, `ParameterTypeID` INT(10) UNSIGNED NOT NULL, `InsertTime` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `Value` TEXT, @@ -85,7 +86,10 @@ CREATE TABLE `physiological_parameter_file` ( ON DELETE CASCADE, CONSTRAINT `FK_param_type_ParamTypeID` FOREIGN KEY (`ParameterTypeID`) - REFERENCES `parameter_type` (`ParameterTypeID`) + REFERENCES `parameter_type` (`ParameterTypeID`), + CONSTRAINT `FK_ppf_project_ID` + FOREIGN KEY (`ProjectID`) + REFERENCES `Project` (`ProjectID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; @@ -271,7 +275,8 @@ CREATE TABLE IF NOT EXISTS `physiological_coord_system_electrode_rel` ( -- Create `physiological_event_file` table CREATE TABLE `physiological_event_file` ( `EventFileID` int(10) unsigned NOT NULL AUTO_INCREMENT, - `PhysiologicalFileID` int(10) unsigned NOT NULL, + `PhysiologicalFileID` int(10) unsigned DEFAULT NULL, + `ProjectID` int(10) unsigned DEFAULT NULL, `FileType` varchar(20) NOT NULL, `FilePath` varchar(255) DEFAULT NULL, `LastUpdate` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -280,7 +285,8 @@ CREATE TABLE `physiological_event_file` ( KEY `FK_physio_file_ID` (`PhysiologicalFileID`), KEY `FK_event_file_type` (`FileType`), CONSTRAINT `FK_event_file_type` FOREIGN KEY (`FileType`) REFERENCES `ImagingFileTypes` (`type`), - CONSTRAINT `FK_physio_file_ID` FOREIGN KEY (`PhysiologicalFileID`) REFERENCES `physiological_file` (`PhysiologicalFileID`) + CONSTRAINT `FK_physio_file_ID` FOREIGN KEY (`PhysiologicalFileID`) REFERENCES `physiological_file` (`PhysiologicalFileID`), + CONSTRAINT `FK_pef_project_ID` FOREIGN KEY (`ProjectID`) REFERENCES `Project` (`ProjectID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 ; @@ -299,9 +305,10 @@ CREATE TABLE `physiological_task_event` ( `EventType` VARCHAR(50) DEFAULT NULL, `TrialType` VARCHAR(255) DEFAULT NULL, `ResponseTime` TIME DEFAULT NULL, - `AssembledHED` TEXT DEFAULT NULL, PRIMARY KEY (`PhysiologicalTaskEventID`), KEY `FK_event_file` (`EventFileID`), + INDEX idx_pte_EventValue (`EventValue`), + INDEX idx_pte_TrialType (`TrialType`), CONSTRAINT `FK_phys_file_FileID_4` FOREIGN KEY (`PhysiologicalFileID`) REFERENCES `physiological_file` (`PhysiologicalFileID`) @@ -382,105 +389,6 @@ CREATE TABLE `physiological_archive` ( ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; --- SQL tables for BIDS derivative file structure --- Create physiological_annotation_file_type table -CREATE TABLE `physiological_annotation_file_type` ( - `FileType` VARCHAR(20) NOT NULL UNIQUE, - `Description` VARCHAR(255), - PRIMARY KEY (`FileType`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create physiological_annotation_file table -CREATE TABLE `physiological_annotation_file` ( - `AnnotationFileID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, - `PhysiologicalFileID` INT(10) UNSIGNED NOT NULL, - `FileType` VARCHAR(20) NOT NULL, - `FilePath` VARCHAR(255), - `LastUpdate` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `LastWritten` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`AnnotationFileID`), - CONSTRAINT `FK_phys_file_ID` - FOREIGN KEY (`PhysiologicalFileID`) - REFERENCES `physiological_file` (`PhysiologicalFileID`), - CONSTRAINT `FK_annotation_file_type` - FOREIGN KEY (`FileType`) - REFERENCES `physiological_annotation_file_type` (`FileType`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create annotation_archive which will store archives of all the annotation files for --- Front-end download -CREATE TABLE `physiological_annotation_archive` ( - `AnnotationArchiveID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, - `PhysiologicalFileID` INT(10) UNSIGNED NOT NULL, - `Blake2bHash` VARCHAR(128) NOT NULL, - `FilePath` VARCHAR(255) NOT NULL, - PRIMARY KEY (`AnnotationArchiveID`), - CONSTRAINT `FK_physiological_file_ID` - FOREIGN KEY (`PhysiologicalFileID`) - REFERENCES `physiological_file` (`PhysiologicalFileID`) - ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create annotation_parameter table --- Note: This corresponds with the JSON annotation files -CREATE TABLE `physiological_annotation_parameter` ( - `AnnotationParameterID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, - `AnnotationFileID` INT(10) UNSIGNED NOT NULL, - `Description` TEXT DEFAULT NULL, - `Sources` VARCHAR(255), - `Author` VARCHAR(255), - PRIMARY KEY (`AnnotationParameterID`), - CONSTRAINT `FK_annotation_file_ID` - FOREIGN KEY (`AnnotationFileID`) - REFERENCES `physiological_annotation_file` (`AnnotationFileID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create an annotation_label_type table -CREATE TABLE `physiological_annotation_label` ( - `AnnotationLabelID` INT(5) UNSIGNED NOT NULL AUTO_INCREMENT, - `AnnotationFileID` INT(10) UNSIGNED DEFAULT NULL, - `LabelName` VARCHAR(255) NOT NULL, - `LabelDescription` TEXT DEFAULT NULL, - PRIMARY KEY (`AnnotationLabelID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create annotation_tsv table --- Note: This corresponds with the .tsv annotation files -CREATE TABLE `physiological_annotation_instance` ( - `AnnotationInstanceID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, - `AnnotationFileID` INT(10) UNSIGNED NOT NULL, - `AnnotationParameterID` INT(10) UNSIGNED NOT NULL, - `Onset` DECIMAL(10, 4), - `Duration` DECIMAL(10, 4) DEFAULT 0, - `AnnotationLabelID` INT(5) UNSIGNED NOT NULL, - `Channels` TEXT, - `AbsoluteTime` TIMESTAMP, - `Description` VARCHAR(255), - PRIMARY KEY (`AnnotationInstanceID`), - CONSTRAINT `FK_annotation_parameter_ID` - FOREIGN KEY (`AnnotationParameterID`) - REFERENCES `physiological_annotation_parameter` (`AnnotationParameterID`), - CONSTRAINT `FK_annotation_file` - FOREIGN KEY (`AnnotationFileID`) - REFERENCES `physiological_annotation_file` (`AnnotationFileID`), - CONSTRAINT `FK_annotation_label_ID` - FOREIGN KEY (`AnnotationLabelID`) - REFERENCES `physiological_annotation_label` (`AnnotationLabelID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create physiological_annotation_rel table -CREATE TABLE `physiological_annotation_rel` ( - `AnnotationTSV` INT(10) UNSIGNED NOT NULL, - `AnnotationJSON` INT(10) UNSIGNED NOT NULL, - PRIMARY KEY (`AnnotationTSV`, `AnnotationJSON`), - CONSTRAINT `FK_AnnotationTSV` - FOREIGN KEY (`AnnotationTSV`) - REFERENCES `physiological_annotation_file` (`AnnotationFileID`), - CONSTRAINT `FK_AnnotationJSON` - FOREIGN KEY (`AnnotationJSON`) - REFERENCES `physiological_annotation_file` (`AnnotationFileID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - -- Create EEG upload table CREATE TABLE `electrophysiology_uploader` ( `UploadID` int(10) unsigned NOT NULL AUTO_INCREMENT, @@ -635,38 +543,83 @@ INSERT INTO ImagingFileTypes ('cnt', 'Neuroscan CNT data format (EEG)'), ('archive', 'Archive file'); --- Insert into annotation_file_type -INSERT INTO physiological_annotation_file_type - (FileType, Description) - VALUES - ('tsv', 'TSV File Type, contains information about each annotation'), - ('json', 'JSON File Type, metadata for annotations'); - --- Insert into annotation_label_type -INSERT INTO physiological_annotation_label - (AnnotationLabelID, LabelName, LabelDescription) - VALUES - (1, 'artifact', 'artifactual data'), - (2, 'motion', 'motion related artifact'), - (3, 'flux_jump', 'artifactual data due to flux jump'), - (4, 'line_noise', 'artifactual data due to line noise (e.g., 50Hz)'), - (5, 'muscle', 'artifactual data due to muscle activity'), - (6, 'epilepsy_interictal', 'period deemed interictal'), - (7, 'epilepsy_preictal', 'onset of preictal state prior to onset of epilepsy'), - (8, 'epilepsy_seizure', 'onset of epilepsy'), - (9, 'epilepsy_postictal', 'postictal seizure period'), - (10, 'epileptiform', 'unspecified epileptiform activity'), - (11, 'epileptiform_single', 'a single epileptiform graphoelement (including possible slow wave)'), - (12, 'epileptiform_run', 'a run of one or more epileptiform graphoelements'), - (13, 'eye_blink', 'Eye blink'), - (14, 'eye_movement', 'Smooth Pursuit / Saccadic eye movement'), - (15, 'eye_fixation', 'Fixation onset'), - (16, 'sleep_N1', 'sleep stage N1'), - (17, 'sleep_N2', 'sleep stage N2'), - (18, 'sleep_N3', 'sleep stage N3'), - (19, 'sleep_REM', 'REM sleep'), - (20, 'sleep_wake', 'sleep stage awake'), - (21, 'sleep_spindle', 'sleep spindle'), - (22, 'sleep_k-complex', 'sleep K-complex'), - (23, 'scorelabeled', 'a global label indicating that the EEG has been annotated with SCORE.'); +-- Create `hed_schema` table +CREATE TABLE `hed_schema` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `Name` varchar(255) NOT NULL, + `Version` varchar(255) NOT NULL, + `Description` text NULL, + `URL` varchar(255) NOT NULL UNIQUE, + PRIMARY KEY (`ID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Create `hed_schema_nodes` table +CREATE TABLE `hed_schema_nodes` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `ParentID` int(10) unsigned NULL, + `SchemaID` int(10) unsigned NOT NULL, + `Name` varchar(255) NOT NULL, + `LongName` varchar(255) NOT NULL, + `Description` text NOT NULL, + PRIMARY KEY (`ID`), + CONSTRAINT `FK_hed_parent_node` + FOREIGN KEY (`ParentID`) + REFERENCES `hed_schema_nodes` (`ID`), + CONSTRAINT `FK_hed_schema` FOREIGN KEY (`SchemaID`) REFERENCES `hed_schema` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Create `physiological_task_event_hed_rel` table +CREATE TABLE `physiological_task_event_hed_rel` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `PhysiologicalTaskEventID` int(10) unsigned NOT NULL, + `HEDTagID` int(10) unsigned NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` text NULL, -- For value tags + `HasPairing` boolean DEFAULT FALSE, -- Is grouped with #AdditionalMembers# members + `PairRelID` int(10) unsigned NULL, -- The `ID` of right side of the pair + `AdditionalMembers` int(10) unsigned DEFAULT 0, -- Number of additional members to encapsulate + PRIMARY KEY (`ID`), + CONSTRAINT `FK_physiological_task_event_hed_rel_pair` FOREIGN KEY (`PairRelID`) + REFERENCES `physiological_task_event_hed_rel` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE, + KEY `FK_physiological_task_event_hed_rel_2` (`HEDTagID`), + CONSTRAINT `FK_physiological_task_event_hed_rel_2` FOREIGN KEY (`HEDTagID`) + REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_physiological_task_event_hed_rel_1` FOREIGN KEY (`PhysiologicalTaskEventID`) + REFERENCES `physiological_task_event` (`PhysiologicalTaskEventID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +-- Create `bids_event_dataset_mapping` table +CREATE TABLE `bids_event_dataset_mapping` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `ProjectID` int(10) unsigned NOT NULL, + `PropertyName` varchar(50) NOT NULL, + `PropertyValue` varchar(255) NOT NULL, + `HEDTagID` int(10) unsigned NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` text NULL, -- For value tags + `Description` TEXT NULL, -- Level Description + `HasPairing` BOOLEAN DEFAULT FALSE, -- Is grouped with #AdditionalMembers# members + `PairRelID` int(10) unsigned NULL, -- The `ID` of right side of the pair + `AdditionalMembers` int(10) unsigned DEFAULT 0, -- Number of additional members to encapsulate + PRIMARY KEY (`ID`), + INDEX idx_event_dataset_PropertyName_PropertyValue (`PropertyName`, `PropertyValue`), + CONSTRAINT `FK_project_id` FOREIGN KEY (`ProjectID`) REFERENCES `Project` (`ProjectID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_dataset_hed_tag_id` FOREIGN KEY (`HEDTagID`) REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + +-- Create `bids_event_file_mapping` table +CREATE TABLE `bids_event_file_mapping` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `EventFileID` int(10) unsigned NOT NULL, + `PropertyName` varchar(50) NOT NULL, + `PropertyValue` varchar(255) NOT NULL, + `HEDTagID` int(10) unsigned NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` text NULL, -- For value tags + `Description` TEXT NULL, -- Level Description + `HasPairing` BOOLEAN DEFAULT FALSE, -- Is grouped with #AdditionalMembers# members + `PairRelID` int(10) unsigned NULL, -- The `ID` of right side of the pair + `AdditionalMembers` int(10) unsigned DEFAULT 0, -- Number of additional members to encapsulate + PRIMARY KEY (`ID`), + INDEX idx_event_file_PropertyName_PropertyValue (`PropertyName`, `PropertyValue`), + CONSTRAINT `FK_event_mapping_file_id` FOREIGN KEY (`EventFileID`) REFERENCES `physiological_event_file` (`EventFileID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_file_hed_tag_id` FOREIGN KEY (`HEDTagID`) REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/SQL/New_patches/2024-01-29-Physiological-Events-Replace-Annotations.sql b/SQL/New_patches/2024-01-29-Physiological-Events-Replace-Annotations.sql new file mode 100644 index 00000000000..3ca77e98491 --- /dev/null +++ b/SQL/New_patches/2024-01-29-Physiological-Events-Replace-Annotations.sql @@ -0,0 +1,17 @@ +-- Dropping all tables regarding annotations +DROP TABLE physiological_annotation_archive; +DROP TABLE physiological_annotation_rel; +DROP TABLE physiological_annotation_instance; +DROP TABLE physiological_annotation_parameter; +DROP TABLE physiological_annotation_label; +DROP TABLE physiological_annotation_file; +DROP TABLE physiological_annotation_file_type; + +-- Event files are always associated to Projects, sometimes exclusively (dataset-scope events.json files) +-- Add ProjectID and make PhysiologicalFileID DEFAULT NULL (ProjectID should ideally not be NULLable) +ALTER TABLE `physiological_event_file` + CHANGE `PhysiologicalFileID` `PhysiologicalFileID` int(10) unsigned DEFAULT NULL, + ADD COLUMN `ProjectID` int(10) unsigned DEFAULT NULL AFTER `PhysiologicalFileID`, + ADD KEY `FK_physiological_event_file_project_id` (`ProjectID`), + ADD CONSTRAINT `FK_physiological_event_file_project_id` + FOREIGN KEY (`ProjectID`) REFERENCES `Project` (`ProjectID`); diff --git a/SQL/New_patches/2024-01-30-HED-Tag-Support.sql b/SQL/New_patches/2024-01-30-HED-Tag-Support.sql new file mode 100644 index 00000000000..aea5395fcb2 --- /dev/null +++ b/SQL/New_patches/2024-01-30-HED-Tag-Support.sql @@ -0,0 +1,105 @@ +-- Remove unused column +ALTER TABLE `physiological_task_event` DROP COLUMN `AssembledHED`; + +-- Add indices for performance improvement +ALTER TABLE `physiological_task_event` ADD INDEX idx_pte_EventValue (`EventValue`); +ALTER TABLE `physiological_task_event` ADD INDEX idx_pte_TrialType (`TrialType`); + +-- Add ProjectID and make PhysiologicalFileID DEFAULT NULL +ALTER TABLE `physiological_parameter_file` + CHANGE `PhysiologicalFileID` `PhysiologicalFileID` int(10) unsigned DEFAULT NULL, + ADD COLUMN `ProjectID` int(10) unsigned DEFAULT NULL AFTER `PhysiologicalFileID`, + ADD KEY `FK_physiological_parameter_file_project_id` (`ProjectID`), + ADD CONSTRAINT `FK_physiological_parameter_file_project_id` + FOREIGN KEY (`ProjectID`) REFERENCES `Project` (`ProjectID`); + +-- Create `hed_schema` table +CREATE TABLE `hed_schema` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `Name` varchar(255) NOT NULL, + `Version` varchar(255) NOT NULL, + `Description` text NULL, + `URL` varchar(255) NOT NULL UNIQUE, + PRIMARY KEY (`ID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Create `hed_schema_nodes` table +CREATE TABLE `hed_schema_nodes` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `ParentID` int(10) unsigned NULL, + `SchemaID` int(10) unsigned NOT NULL, + `Name` varchar(255) NOT NULL, + `LongName` varchar(255) NOT NULL, + `Description` text NOT NULL, + PRIMARY KEY (`ID`), + CONSTRAINT `FK_hed_parent_node` + FOREIGN KEY (`ParentID`) + REFERENCES `hed_schema_nodes` (`ID`), + CONSTRAINT `FK_hed_schema` FOREIGN KEY (`SchemaID`) REFERENCES `hed_schema` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Create `physiological_task_event_hed_rel` table +CREATE TABLE `physiological_task_event_hed_rel` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `PhysiologicalTaskEventID` int(10) unsigned NOT NULL, + `HEDTagID` int(10) unsigned NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` text NULL, -- For value tags + `HasPairing` boolean DEFAULT FALSE, -- Is grouped with #AdditionalMembers# members + `PairRelID` int(10) unsigned NULL, -- The `ID` of right side of the pair + `AdditionalMembers` int(10) unsigned DEFAULT 0, -- Number of additional members to encapsulate + PRIMARY KEY (`ID`), + CONSTRAINT `FK_physiological_task_event_hed_rel_pair` FOREIGN KEY (`PairRelID`) + REFERENCES `physiological_task_event_hed_rel` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE, + KEY `FK_physiological_task_event_hed_rel_2` (`HEDTagID`), + CONSTRAINT `FK_physiological_task_event_hed_rel_2` FOREIGN KEY (`HEDTagID`) + REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_physiological_task_event_hed_rel_1` FOREIGN KEY (`PhysiologicalTaskEventID`) + REFERENCES `physiological_task_event` (`PhysiologicalTaskEventID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Create `bids_event_dataset_mapping` table +CREATE TABLE `bids_event_dataset_mapping` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `ProjectID` int(10) unsigned NOT NULL, + `PropertyName` varchar(50) NOT NULL, + `PropertyValue` varchar(255) NOT NULL, + `HEDTagID` int(10) unsigned NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` text NULL, -- For value tags + `Description` TEXT NULL, -- Level Description + `HasPairing` BOOLEAN DEFAULT FALSE, -- Is grouped with #AdditionalMembers# members + `PairRelID` int(10) unsigned NULL, -- The `ID` of right side of the pair + `AdditionalMembers` int(10) unsigned DEFAULT 0, -- Number of additional members to encapsulate + PRIMARY KEY (`ID`), + CONSTRAINT `FK_bids_event_dataset_mapping_pair` FOREIGN KEY (`PairRelID`) + REFERENCES `bids_event_dataset_mapping` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE, + INDEX idx_event_dataset_PropertyName_PropertyValue (`PropertyName`, `PropertyValue`), + CONSTRAINT `FK_project_id` FOREIGN KEY (`ProjectID`) REFERENCES `Project` (`ProjectID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_dataset_hed_tag_id` FOREIGN KEY (`HEDTagID`) REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + +-- Create `bids_event_file_mapping` table +CREATE TABLE `bids_event_file_mapping` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `EventFileID` int(10) unsigned NOT NULL, + `PropertyName` varchar(50) NOT NULL, + `PropertyValue` varchar(255) NOT NULL, + `HEDTagID` int(10) unsigned NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` text NULL, -- For value tags + `Description` TEXT NULL, -- Level Description + `HasPairing` BOOLEAN DEFAULT FALSE, -- Is grouped with #AdditionalMembers# members + `PairRelID` int(10) unsigned NULL, -- The `ID` of right side of the pair + `AdditionalMembers` int(10) unsigned DEFAULT 0, -- Number of additional members to encapsulate + PRIMARY KEY (`ID`), + CONSTRAINT `FK_bids_event_file_mapping_pair` FOREIGN KEY (`PairRelID`) + REFERENCES `bids_event_file_mapping` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE, + INDEX idx_event_file_PropertyName_PropertyValue (`PropertyName`, `PropertyValue`), + CONSTRAINT `FK_event_mapping_file_id` FOREIGN KEY (`EventFileID`) REFERENCES `physiological_event_file` (`EventFileID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_file_hed_tag_id` FOREIGN KEY (`HEDTagID`) REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + + + + + diff --git a/SQL/New_patches/2024-02-31-api_docs_permission_visible.sql b/SQL/New_patches/2024-02-31-api_docs_permission_visible.sql new file mode 100644 index 00000000000..7158950fa0f --- /dev/null +++ b/SQL/New_patches/2024-02-31-api_docs_permission_visible.sql @@ -0,0 +1,2 @@ +UPDATE permissions SET moduleID = (SELECT ID FROM modules WHERE Name='api_docs'), description = "LORIS API Manual", `action` = "View" WHERE code = "api_docs"; + diff --git a/SQL/New_patches/2024-03-11-changePermissionCodeToDictionary.sql b/SQL/New_patches/2024-03-11-changePermissionCodeToDictionary.sql new file mode 100644 index 00000000000..c228a83f5f5 --- /dev/null +++ b/SQL/New_patches/2024-03-11-changePermissionCodeToDictionary.sql @@ -0,0 +1,3 @@ +UPDATE permissions +SET moduleID = (select ID FROM modules WHERE Name = 'dictionary') +WHERE moduleID IN (SELECT ID FROM modules WHERE Name = 'datadict'); diff --git a/htdocs/.htaccess b/htdocs/.htaccess index 673f34ed716..389687ba1cc 100644 --- a/htdocs/.htaccess +++ b/htdocs/.htaccess @@ -10,6 +10,6 @@ RewriteRule ^([a-zA-Z_-]+)/ajax/([a-zA-Z0-9_.-]+)$ AjaxHelper.php?Module=$1&scri RewriteCond "%{REQUEST_FILENAME}" "!-f" [OR] # Redirect homepage URLs to also use index.php RewriteCond "%{REQUEST_FILENAME}" "(\/main\.php)$" -RewriteRule ^(.*)$ index.php?lorispath=$1 [QSA,L] +RewriteRule ^(.*)$ index.php?lorispath=$1 "[QSA,L,B= ?,BNP]" diff --git a/htdocs/index.php b/htdocs/index.php index 2370f1b6837..7c2b3cfc2e7 100644 --- a/htdocs/index.php +++ b/htdocs/index.php @@ -41,6 +41,24 @@ $serverrequest = \Laminas\Diactoros\ServerRequestFactory::fromGlobals(); +// Remove the lorispath from the URI query parameters. +// Both the request query parameters and the URI query string must be updated. +$params = $serverrequest->getQueryParams(); +unset($params['lorispath']); +$serverrequest = $serverrequest->withQueryParams($params); + +$query = implode( + "&", + array_map( + fn ($key, $value) => $key . "=" . $value, + array_keys($params), + $params + ) +); + +$uri = $serverrequest->getUri(); +$serverrequest = $serverrequest->withUri($uri->withQuery($query)); + // Now that we've created the ServerRequest, handle it. $factory = \NDB_Factory::singleton(); $user = $factory->user(); diff --git a/htdocs/main.css b/htdocs/main.css index 994bfb6636e..f2d6b7091e9 100644 --- a/htdocs/main.css +++ b/htdocs/main.css @@ -647,7 +647,6 @@ td.MenuWidth2 input[type=text] { height: 3em; width: 100%; font-size: 14px; - font-color #000000: color: #000; text-align: center; vertical-align: bottom; @@ -683,12 +682,13 @@ tr.directentry { border-color: #246EB6; border-top: none; border-width: 1px; - position: absolute; - left: 0px; + position: fixed; + right: 0; overflow-y: auto; z-index: 999; transition: all 0.6s ease 0s; width: 320px; + max-height: 100vh; } .help-content h1 { diff --git a/jsx/MultiSelectDropdown.js b/jsx/MultiSelectDropdown.js index 69badd050d1..2658d60de95 100644 --- a/jsx/MultiSelectDropdown.js +++ b/jsx/MultiSelectDropdown.js @@ -8,7 +8,7 @@ import PropTypes from 'prop-types'; * Note this is only used in DQT * For generic SelectDropdown, see Select in Form.js */ -class SelectField extends Component { +export class SelectField extends Component { /** * @constructor * @param {object} props - React Component properties @@ -64,7 +64,7 @@ SelectField.propTypes = { /** * Search Field React component */ -class SearchField extends Component { +export class SearchField extends Component { /** * @constructor * @param {object} props - React Component properties @@ -127,7 +127,7 @@ SearchField.propTypes = { /** * Select Dropdown React component */ -class SelectDropdown extends Component { +export class SelectDropdown extends Component { /** * @constructor * @param {object} props - React Component properties diff --git a/modules/battery_manager/jsx/batteryManagerForm.js b/modules/battery_manager/jsx/batteryManagerForm.js index 78713371c66..c41342c18dd 100644 --- a/modules/battery_manager/jsx/batteryManagerForm.js +++ b/modules/battery_manager/jsx/batteryManagerForm.js @@ -1,6 +1,12 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; -import {NumericElement} from 'jsx/Form'; +import { + ButtonElement, + FormElement, + StaticElement, + SelectElement, + NumericElement, +} from 'jsx/Form'; /** * Battery Manager Form diff --git a/modules/behavioural_qc/jsx/tabs_content/behaviouralFeedback.js b/modules/behavioural_qc/jsx/tabs_content/behaviouralFeedback.js index e966aebd16b..ff9523d3e4b 100644 --- a/modules/behavioural_qc/jsx/tabs_content/behaviouralFeedback.js +++ b/modules/behavioural_qc/jsx/tabs_content/behaviouralFeedback.js @@ -128,6 +128,10 @@ class BehaviouralFeedback extends Component { '&sessionID=' + rowData['sessionID']; bvlLevel ='Visit : ' + rowData['Visit']; + } else { + bvlLink = this.props.baseURL + + '/' + rowData['DCCID']; + bvlLevel ='Profile : ' + rowData['PSCID']; } reactElement = ( diff --git a/modules/behavioural_qc/php/provisioners/behaviouralprovisioner.class.inc b/modules/behavioural_qc/php/provisioners/behaviouralprovisioner.class.inc index ed705c2a150..727573fbd2a 100644 --- a/modules/behavioural_qc/php/provisioners/behaviouralprovisioner.class.inc +++ b/modules/behavioural_qc/php/provisioners/behaviouralprovisioner.class.inc @@ -29,9 +29,9 @@ class BehaviouralProvisioner extends \LORIS\Data\Provisioners\DBObjectProvisione fb.CandID AS _candID, ca.PSCID AS _pscID, s.Visit_label AS _visit, - s.ProjectID AS _project, + COALESCE(s.ProjectID, ca.RegistrationProjectID) AS _project, s.CohortID AS _cohort, - psc.CenterID AS _site, + COALESCE(psc.CenterID, psc2.CenterID) AS _site, fb.FeedbackID AS _feedbackID, fb.SessionID AS _sessionID, fb.CommentID AS _commentID, @@ -42,10 +42,9 @@ class BehaviouralProvisioner extends \LORIS\Data\Provisioners\DBObjectProvisione FROM feedback_bvl_thread AS fb JOIN candidate ca ON (ca.CandID = fb.CandID) - JOIN session s ON (s.ID = fb.SessionID) - JOIN psc ON (s.CenterID = psc.CenterID) - JOIN project_cohort_rel psr ON - (s.CohortID = psr.CohortID) + LEFT JOIN session s ON (s.ID = fb.SessionID) + LEFT JOIN psc ON (s.CenterID = psc.CenterID) + LEFT JOIN psc psc2 ON (ca.RegistrationCenterID=psc2.CenterID) LEFT JOIN flag f ON (fb.CommentID = f.CommentID) LEFT JOIN test_names tn ON (tn.Test_name = f.Test_name) WHERE diff --git a/modules/bvl_feedback/test/TestPlan.md b/modules/bvl_feedback/test/TestPlan.md index 49a6334c8a1..f11e02d28a5 100644 --- a/modules/bvl_feedback/test/TestPlan.md +++ b/modules/bvl_feedback/test/TestPlan.md @@ -13,7 +13,9 @@ * Feedback Threads 5. Click on the chevron arrow on each section and make sure it toggles open/closed. 6. Type something in the 'New profile level feedback' text box and choose a 'Feedback Type' from the dropdown. Click 'Create thread'. -7. 'Open Thread Summary' and 'Feedback Threads' should update with the submitted thread. +7. Make sure that the thread was inserted in the `feedback_bvl_thread` table, with `Feedback_level`: 'profile' and the correct `Feedback_type`. + Also make sure that an appropriate `feedback_bvl_entry` was created that references the thread's `FeedbackID`. +8. 'Open Thread Summary' and 'Feedback Threads' should update with the submitted thread. * Open Thread Summary * QC class should be what page the feedback was submitted on (i.e. profile, instrument) * Instrument should be populated if QC class is instrument @@ -25,14 +27,15 @@ * The Feedback Type should appear under 'Type' * Current user and date should appear under 'Author' * 'Status' should be set to 'opened' -8. You should be able to see the original text box thread entry. If you click the chevron the thread comments should be hidden. -9. Click on the comment icon and 'Add a comment'. Click on 'Submit'. You should be able to see the original text box thread entry and the one you just entered. -10. Click on 'opened' and choose a different status. Status should be updated. -11. Click on the pencil icon and you should be able to update the comment. -12. Click on the delete icon and the comment should be deleted. +9. You should be able to see the original text box thread entry. If you click the chevron the thread comments should be hidden. +10. Click on the comment icon and 'Add a comment'. Click on 'Submit'. You should be able to see the original text box thread entry and the one you just entered. +11. Click on 'opened' and choose a different status. Status should be updated. +12. Click on the pencil icon and you should be able to update the comment. +13. Click on the delete icon and the comment should be deleted. +14. Make sure the update and delete operations are properly reflected in the `feedback_bvl_entry` table. ### Widget registration on the dashboard page -11. Verify that if a user has the 'bvl_feedback' permission, the latest Behavioural Feedback Notifications are displayed (4 at most) in the Behavioural Feedback panel. Clicking on a feedback thread will take you to the proper page. -12. Check that if a document notification occurred since the last login, it is labeled as 'New' in the Behavioural Feedback panel. -13. Check that a 'New' notification is not labeled 'New' anymore after login in again. +15. Verify that if a user has the 'bvl_feedback' permission, the latest Behavioural Feedback Notifications are displayed (4 at most) in the Behavioural Feedback panel. Clicking on a feedback thread will take you to the proper page. +16. Check that if a document notification occurred since the last login, it is labeled as 'New' in the Behavioural Feedback panel. +17. Check that a 'New' notification is not labeled 'New' anymore after login in again. diff --git a/modules/candidate_profile/jsx/CandidateInfo.js b/modules/candidate_profile/jsx/CandidateInfo.js index 06dfdef57b9..05f196a5c10 100644 --- a/modules/candidate_profile/jsx/CandidateInfo.js +++ b/modules/candidate_profile/jsx/CandidateInfo.js @@ -136,7 +136,7 @@ export class CandidateInfo extends Component { }, { label: subprojlabel, - value: cohorts, + value: cohorts.join(', '), }, { label: 'Site', diff --git a/modules/configuration/jsx/configuration_helper.js b/modules/configuration/jsx/configuration_helper.js index 4be71bf2273..45c6aacc3e8 100644 --- a/modules/configuration/jsx/configuration_helper.js +++ b/modules/configuration/jsx/configuration_helper.js @@ -39,11 +39,9 @@ $(function() { $('.btn-remove').on('click', function(e) { e.preventDefault(); - let options = $(this).parent().parent().children().prop('options'); - let selectedIndex = $(this) - .parent().parent().children() - .prop('selectedIndex'); - let selectedOption = options[selectedIndex].text; + let selectedOption = $(this).parent().parent().children() + .prop('value'); + let fieldName = $(this) .parent().parent().parent().parent().parent().children() .attr('data-original-title'); diff --git a/modules/conflict_resolver/README.md b/modules/conflict_resolver/README.md index 7bfaa9d48e9..cc49440324d 100644 --- a/modules/conflict_resolver/README.md +++ b/modules/conflict_resolver/README.md @@ -34,7 +34,7 @@ will not be enforced by LORIS. ## Permissions -Accessing the module requires the `conflict_resolver` permission. +Accessing the module requires the `conflict_resolver` permission, as well as either 'Dictionary: Edit Parameter Type Descriptions' or 'Dictionary: Edit Parameter Type Descriptions'. A user should only see conflicts at the sites at which the user is a member. diff --git a/modules/conflict_resolver/php/conflict_resolver.class.inc b/modules/conflict_resolver/php/conflict_resolver.class.inc index b3583cdd77c..08c3ced9548 100644 --- a/modules/conflict_resolver/php/conflict_resolver.class.inc +++ b/modules/conflict_resolver/php/conflict_resolver.class.inc @@ -38,7 +38,10 @@ class Conflict_Resolver extends \NDB_Page */ function _hasAccess(\User $user) : bool { - return $user->hasPermission('conflict_resolver'); + $hasConflictPerm = $user->hasPermission('conflict_resolver'); + $dictPerms = ['data_dict_edit', 'data_dict_view']; + $hasDictPerms = $user->hasAnyPermission($dictPerms); + return $hasConflictPerm && $hasDictPerms; } /** diff --git a/modules/conflict_resolver/test/TestPlan.md b/modules/conflict_resolver/test/TestPlan.md index 2705e920492..28aa1a1bdb1 100644 --- a/modules/conflict_resolver/test/TestPlan.md +++ b/modules/conflict_resolver/test/TestPlan.md @@ -5,6 +5,7 @@ 2. Menu item loads the module page 3. Check for existence of 'Resolving conflicts' permission checkbox on 'User Accounts > Edit User' Verify that the permission works as expected (hides menu item and blocks page access) + 4. Check that access will be blocked for users who dont have either the 'Dictionary: Edit Parameter Type Descriptions' or 'Dictionary: View Parameter Type Descriptions' 2. Verify the following conditions are required for creation of new unresolved conflict to be instantiated in the module table:[Manual Testing] 1. Double data entry active on instrument diff --git a/modules/conflict_resolver/test/conflict_resolverTest.php b/modules/conflict_resolver/test/conflict_resolverTest.php index 1b04224da90..6725d808ae9 100644 --- a/modules/conflict_resolver/test/conflict_resolverTest.php +++ b/modules/conflict_resolver/test/conflict_resolverTest.php @@ -94,13 +94,16 @@ function tearDown(): void */ function testConflictResolverPermission() { - $this->checkPagePermissions( - '/conflict_resolver/', - [ - 'conflict_resolver' - ], - "Conflict Resolver" - ); + $permissionList = ["conflict_resolver","data_dict_edit","data_dict_view"]; + $this->setupPermissions($permissionList); + $this->safeGet($this->url . "/conflict_resolver/"); + $bodyElement = $this->safeFindElement(WebDriverBy::cssSelector("body")); + $bodyText = $bodyElement->getText(); + $accessError = "You do not have access to this page."; + $this->assertStringNotContainsString($accessError, $bodyText); + $loadingError = "An error occured while loading the page."; + $this->assertStringNotContainsString($loadingError, $bodyText); + $this->resetPermissions(); } /** * Tests clear button in the form diff --git a/modules/dashboard/test/DashboardTest.php b/modules/dashboard/test/DashboardTest.php index 4a42da2ca7c..add728ee714 100644 --- a/modules/dashboard/test/DashboardTest.php +++ b/modules/dashboard/test/DashboardTest.php @@ -429,6 +429,8 @@ public function testConflictResolver() [ "conflict_resolver", "access_all_profiles", + "data_dict_edit", + "data_dict_view" ] ); $this->safeGet($this->url . '/dashboard/'); diff --git a/modules/data_release/php/data_release.class.inc b/modules/data_release/php/data_release.class.inc index 4234b671416..63bb7ec4f8c 100644 --- a/modules/data_release/php/data_release.class.inc +++ b/modules/data_release/php/data_release.class.inc @@ -24,7 +24,7 @@ namespace LORIS\data_release; * @link https://www.github.com/aces/Loris */ -class Data_Release extends \NDB_Menu_Filter +class Data_Release extends \DataFrameworkMenu { public $AjaxModule = true; public $skipTemplate = true; @@ -48,55 +48,6 @@ class Data_Release extends \NDB_Menu_Filter ); } - /** - * Setup all the class variables needed for the data release menu page - * - * @return void - */ - function _setupVariables() - { - $user =& \User::singleton(); - $DB = $this->loris->getDatabaseConnection(); - - // set the class variables - $this->columns = [ - 'file_name AS fileName', - 'IF(version is null or version ="","Unversioned", version) AS version', - 'upload_date AS uploadDate', - 'dr.id as dataReleaseID', - ]; - $this->query = " FROM data_release dr"; - - if (!$user->hasPermission("superuser")) { - $this->query .= " JOIN data_release_permissions drp - ON (dr.id=drp.data_release_id) - JOIN users u ON (u.ID=drp.userid) - WHERE u.UserID=".$DB->quote($user->getUsername()); - } - - $this->group_by = ''; - $this->order_by = 'uploadDate'; - } - - /** - * Create the form for the data release menu page - * - * @return void - **/ - function setup() - { - parent::setup(); - - $db = $this->loris->getDatabaseConnection(); - - $this->fieldOptions = [ - 'users' => $this->getUsersList($db), - 'versions' => $this->getVersionsList($db), - 'filenames' => $this->getFilesList($db), - ]; - } - - /** * Greps the list of users available in the users database table. * @@ -143,7 +94,7 @@ class Data_Release extends \NDB_Menu_Filter * @param \Database $DB database handle * * @return array $versionList Array of version names indexed by version - * name. + * name. */ function getVersionsList(\Database $DB) { @@ -247,6 +198,62 @@ class Data_Release extends \NDB_Menu_Filter return $userFiles; } + /** + * Function getFieldOptions + * + * @return array + */ + protected function getFieldOptions() : array + { + $db = $this->loris->getDatabaseConnection(); + return [ + 'users' => $this->getUsersList($db), + 'versions' => $this->getVersionsList($db), + 'filenames' => $this->getFilesList($db), + ]; + } + + /** + * Tells the base class that this page's provisioner can support + * the UserSiteMatch filter. + * + * @return bool always false + */ + public function useSiteFilter() : bool + { + return false; + } + + /** + * {@inheritDoc} + * + * @return ?array of site permissions or null + */ + public function allSitePermissionNames() : ?array + { + return null; + } + + /** + * {@inheritDoc} + * + * @return bool + */ + public function useProjectFilter() : bool + { + return false; + } + + /** + * {@inheritDoc} + * + * @return \Loris\Data\Provisioner + */ + public function getBaseDataProvisioner(): \LORIS\Data\Provisioner + { + return new DataReleaseProvisioner(); + } + /** * Include the column formatter * diff --git a/modules/data_release/php/datareleaseprovisioner.class.inc b/modules/data_release/php/datareleaseprovisioner.class.inc new file mode 100644 index 00000000000..73939c2c7bf --- /dev/null +++ b/modules/data_release/php/datareleaseprovisioner.class.inc @@ -0,0 +1,76 @@ + + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ + +namespace LORIS\data_release; + +/** + * This class implements a data provisioner to get all data released files + * for the data_release menu page. + * + * PHP Version 7 + * + * @category Core + * @package Main + * @subpackage Core + * @author Rolando Acosta + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ + +class DataReleaseProvisioner extends \LORIS\Data\Provisioners\DBRowProvisioner +{ + /** + * Create a DataReleaseProvisioner, which gets releases for the + * data release menu table. + */ + function __construct() + { + $user =& \User::singleton(); + $query = " + SELECT + file_name AS fileName, + IF(version is null or version ='','Unversioned', version) AS version, + upload_date AS uploadDate, + dr.id as dataReleaseID + FROM data_release dr"; + + if (!$user->hasPermission("superuser")) { + $query .= " + INNER JOIN + data_release_permissions drp + ON + (dr.id=drp.data_release_id) + WHERE + drp.UserID=".$user->getID(); + } + + $query .= " ORDER BY uploadDate"; + + parent::__construct($query, []); + } + + /** + * Returns an instance of a DataReleaseRow object for a given + * table row. + * + * @param array $row The database row from the LORIS Database class. + * + * @return \LORIS\Data\DataInstance An instance representing this row. + */ + public function getInstance($row) : \LORIS\Data\DataInstance + { + return new DataReleaseRow($row); + } +} diff --git a/modules/data_release/php/datareleaserow.class.inc b/modules/data_release/php/datareleaserow.class.inc new file mode 100644 index 00000000000..f1ec2d5c702 --- /dev/null +++ b/modules/data_release/php/datareleaserow.class.inc @@ -0,0 +1,51 @@ + + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ + +namespace LORIS\data_release; + +/** + * A DataReleaseRow represents a row in the data_release menu table. + * + * @category Core + * @package Main + * @subpackage Core + * @author Rolando Acosta + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ +class DataReleaseRow implements \LORIS\Data\DataInstance +{ + protected $DBRow; + + /** + * Create a new DataReleaseRow + * + * @param array $row The row + */ + public function __construct(array $row) + { + $this->DBRow = $row; + } + + /** + * Implements \LORIS\Data\DataInstance interface for this row. + * + * @return array which can be serialized by json_encode() + */ + public function jsonSerialize() : array + { + return $this->DBRow; + } +} diff --git a/modules/dataquery/help/dataquery.md b/modules/dataquery/help/dataquery.md new file mode 100644 index 00000000000..18dba33c2da --- /dev/null +++ b/modules/dataquery/help/dataquery.md @@ -0,0 +1,31 @@ +# Data Query + +This module allows you to query data within LORIS. +There are three steps to defining a query: + +- First, you must select the fields that you're + interested in on the **Define Fields** page. +- Next, you can optionally define filters on the + **Define Filters** page to restrict the population + that is returned. +- Finally, you view your query results on the **View Data** page + +The **Next Steps** on the bottom right of your screen always the +context-sensitive next steps that you can do to build your query. + +Your recently run queries will be displayed in the **Recent Queries** panel +on the first screen of the module (you can return to it at any time by using +the LORIS breadcrumbs.) Instead of building a new query, you can reload a +query that you've recently run by clicking on the load icon next to the query. + +Queries can be shared with others by clicking the share icon. +This will cause the query to be shared with all users who +have access to the fields used by the query. It will display +in a **Shared Queries** panel below the **Recent Queries**. + +You may also give a query a name at any time by clicking the +pencil icon. This makes it easier to find queries you care +about by giving them an easier to remember name that can be used +for filtering. When you share a query, the name will be shared +along with it. + diff --git a/modules/dataquery/jsx/components/expansionpanels.tsx b/modules/dataquery/jsx/components/expansionpanels.tsx index c8a5510fa73..960b8b90755 100644 --- a/modules/dataquery/jsx/components/expansionpanels.tsx +++ b/modules/dataquery/jsx/components/expansionpanels.tsx @@ -16,6 +16,7 @@ const ExpansionPanels = (props: { content: React.ReactElement, defaultOpen?: boolean, alwaysOpen: boolean, + id: string, }[] }) => { return ( @@ -24,6 +25,7 @@ const ExpansionPanels = (props: { { props.panels.map((panel, index) => ( diff --git a/modules/dataquery/jsx/definefilters.tsx b/modules/dataquery/jsx/definefilters.tsx index 3e97e519ab7..bce5c2cc9ea 100644 --- a/modules/dataquery/jsx/definefilters.tsx +++ b/modules/dataquery/jsx/definefilters.tsx @@ -289,6 +289,7 @@ function DefineFilters(props: { 0 ); setModalGroup(newquery); + setDeleteItemIndex(null); }} onMouseEnter={() => setDeleteItemIndex(0)} onMouseLeave={() => setDeleteItemIndex(null)} diff --git a/modules/dataquery/jsx/nextsteps.tsx b/modules/dataquery/jsx/nextsteps.tsx index 6293533bc46..0264501e693 100644 --- a/modules/dataquery/jsx/nextsteps.tsx +++ b/modules/dataquery/jsx/nextsteps.tsx @@ -145,6 +145,7 @@ function NextSteps(props: { display: 'flex', alignItems: 'stretch', height: 120, + paddingRight: '14px', }}>

Next Steps

diff --git a/modules/dataquery/jsx/welcome.tsx b/modules/dataquery/jsx/welcome.tsx index c5b0a0bea36..bcd70584916 100644 --- a/modules/dataquery/jsx/welcome.tsx +++ b/modules/dataquery/jsx/welcome.tsx @@ -57,6 +57,7 @@ function Welcome(props: { content: React.ReactElement, alwaysOpen: boolean, defaultOpen: boolean, + id: string, }[] = []; if (props.topQueries.length > 0) { panels.push({ @@ -82,6 +83,7 @@ function Welcome(props: { ), alwaysOpen: false, defaultOpen: true, + id: 'p1', }); } panels.push({ @@ -92,6 +94,7 @@ function Welcome(props: { />, alwaysOpen: false, defaultOpen: true, + id: 'p2', }); panels.push({ title: 'Recent Queries', @@ -120,6 +123,7 @@ function Welcome(props: { ), alwaysOpen: false, defaultOpen: true, + id: 'p3', }); if (props.sharedQueries.length > 0) { @@ -145,6 +149,7 @@ function Welcome(props: { ), alwaysOpen: false, defaultOpen: true, + id: 'p4', }); } diff --git a/modules/dataquery/test/TestPlan.md b/modules/dataquery/test/TestPlan.md new file mode 100644 index 00000000000..bf919a98b8b --- /dev/null +++ b/modules/dataquery/test/TestPlan.md @@ -0,0 +1,118 @@ +# Data Query - Test Plan + +## Welcome page + +1. Ensure the module loads only for a user that has the `dataquery_view` permission. +2. Assert that: `Instructions` panel, `Recent Queries` panel, and `Next Steps` panel (bottom-right corner) collapse as expected. +3. Assert that: `Continue to Define Fields` button in the main panel, and `Choose Fields` button in the `Next Steps` panel are redirecting to the same page. +4. `Recent Queries` panel + 1. If not queries are available, make some so they will be added to this section. + 2. Assert that: queries you made have their parameters correctly displayed (i.e. fields and filters). + 3. Assert that: `text filter` immediately filter the queries. + 4. Assert that: clicking `Collapse queries` effectively collapse all queries. + 5. Make a mix of the following action on different queries: `Star` some queries, `Name` some queries, `Share` some queries, `Rerun` some queries. + 6. Assert that: starred queries have a yellow star, shared queries have a blue shared state. + 7. Assert that using the `Starred Only` checkbox filter only keep the starred one. + 8. Assert that removing the `Starred Only` checkbox prints the same queries originally printed. + 9. Repeat 3.5. and 3.6. for `Shared Only`, `Named Only` and `No run times` checkboxes. + 10. Assert that: mixing checkboxes returns the right query selection. Only queries that match all of the checked conditions should be displayed. + 11. Remove all pinned queries. + 12. Assert that: there is no `Study Queries` panel at the top of the page. + 13. Click the `Pin` icon to pin some queries. + 1. With and empty text in the `query name` text field, click the `Submit` button. + 2. Assert that: the error message `Must provide a query name to pin query as.` is triggered. + 3. Unchecking all checkboxes (i.e. `Pin Study Query` and `Pin Dashboard Summary`). + 4. Assert that: clicking `Submit` triggers the error message `Must pin as study query or pin to dashboard.`. + 5. Check the `Pin Study Query` checkbox and click the submit button. + 6. Assert that: the query is now pinned at the top of the page in the `Study Queries` panel. + 7. Go to LORIS main page by clicking the `LORIS` name in the top-left corner. + 8. Assert that: the query is **NOT** displayed inside the right-side `Study Queries` panel. + 9. Go back to the module. + 10. Create a new named pinned query, only checking the `Pin Dashboard Summary` this time. + 11. Assert that: the query is **NOT** pinned at the top of the page in the `Study Queries` panel. + 12. Go to LORIS main page by clicking the `LORIS` name in the top-left corner. + 13. Assert that: the query is displayed inside the right-side `Study Queries` panel. + 14. Click the pinned query. + 15. Assert that: the confirmation message `Query loaded` is displayed and query can immediately be executed. + 16. Try pinning a query with both `Pin Study Query` and `Pin Dashboard Summary` options. + 17. Assert that: both `Study Queries` in the dataquery module **AND** `Study Queries` in LORIS welcome page are displayed. + 14. Assert that: the query is now pinned at the top of the page, in `Study Queries` panel. + 15. Go back to `LORIS main page`. + 16. Assert that: `starred queries` are available in the right side `Starred Queries` panel. + 17. Assert that: clinking on any `starred query` send you back to dataquery module with the selected query loaded (bottom-right panel already with the `Run Query` button active). + 18. Go back to `LORIS main page`. + 19. Assert that: `pinned queries` are available in the right side `Study Queries` panel. + 20. Assert that: clinking on any `study query` send you back to dataquery module with the selected query loaded (bottom-right panel already with the `Run Query` button active). + +## Fields selection page + +### No visits + +1. Select the `Candidate Identifiers` field category in the top dropdown bar. +2. Assert that: default visits are loaded, fields for this category are loaded. +3. Assert that: clicking on a candidate-level field such as `CandID` or `PSCID` only highlights the line **AND DOES NOT** add visit information on the same line. +4. Assert that: clicking `Add all` button adds all displayed fields in the right column. +5. Assert that: clicking `Remove all` button removes all selected fields from the right column. +6. Assert that: clicking `Remove all` button another time with no selected fields does nothing. +7. Enter some text in the fields search bar with `Filter within category` placeholder. +8. Assert that: clicking `Add all` button only adds the displayed filtered fields in the right column. +9. Assert that: clicking `Add all` button another time does nothing +10. Remove all selected fields from right column. +11. Remove text from fields filter bar. +12. Click the `Add all` button. +13. Assert that: clicking the `Clear` button in the `Selected Fields` column effectively clears all selected fields. +14. Click the `Add all` button. +15. Assert that: clicking the `trashbin icon` in the `Selected Fields` column effectively removes the selected field **AND** the active selection in the main table. +16. Assert that: clicking some selected fields in the main table (grey-ish lines in the table) also remove them from the selected column (toggle interaction). + +### Visits + +1. Select the `Other parameters` field category in the top dropdown bar. +2. Assert that: default visits are loaded, fields for this category are loaded. +3. Assert that: clicking on a field such as `VisitLabel` or `Project` greys the line **AND** adds visit information on the same line. +4. Assert that: removing visits from `Default Visits` allows to reselect them, either by clicking the right arrow, or by entering corresponding text in the text area. +5. Make sure the `Sync with selected fields` is **NOT** checked out. +6. Add all visits from `Default Visits` box. +7. Click some fields in the list. +8. Assert that: selected fields should have a visit selection box each. +9. Assert that: all visits should be displayed in the field lines (same as those ni the `Default Visits` box). +10. Assert that: all selected fields in the right column have all visits described. +11. Remove some visits from `Default Visits` box. +12. Click on other fields. +13. Assert that: new field visits only reflects what the `Default Visits` box is showing. +14. Assert that: new selected fields in the right column only have the newly default visits. +15. Check `Sync with selected field` checkbox. +16. Assert that: fields that were previously with all visits now are only with the selected visits in `Default Visits`. +17. Make sure field visits are still updatable independently (line by line). +18. Make sure if `Default Visits` are changed, it affects all fields. + + +## Filters selection page + +1. Make sure no filter are already selected. The sentence `Currently querying for ALL candidates` should be displayed. +2. Make sure the blue notification saying `Note that only candidates which you have permission to access in LORIS are included in results. Number of results may vary from other users running the same query.` is there. +3. Make sure a preview of the number of candidates matched is displayed in the top-right hand corner. +4. Make sure the `Add Condition` button triggers field select modal. + 1. Add a field as a filter. + 2. Assert that: the condition now appears in the filter list. + 3. Assert that: `Add Condition` and `Import from CSV` buttons are now replaced by `Add "and" condition` and `Add "or" condition`. + 4. Assert that: the top-right corner is updated. +5. Remove added conditions with the trashbin icon on the right. +6. Make sure `Import from CSV` button feature an upload modal. + 1. Assert that: sending something different than CSV ends with an `Invalid CSV` alert. + 2. Test file import by creating and importing different files with the preset options (candidates vs. sessions, DCCID vs. PSCID, with or without headers). +7. Ensure conditions are organized by making several queries with various operators (AND/OR) and depths (condition groups). +8. Click on `Run query` button. + + +## Run query page + +1. Assert that: the message `Query not yet run` is displayed when page loads. +2. Assert that: changing the `header display format` dropdown immediately changes the table header. +3. Assert that: changing the `Display visits as` dropdown immediately changes the table organization. +4. Assert that: `Display visits as = inline values (no download)` has a `Display empty visits?` checkbox. +5. Make sure the checkbox update the table. +6. Assert that: the `Download table as CSV` button triggers the file download with the right information in it. +7. Assert that: table pagination buttons work. +8. Assert that: table maximum number of rows per page dropdown modifies the number of displayed rows. + diff --git a/modules/dicom_archive/js/view_details_link.js b/modules/dicom_archive/js/view_details_link.js deleted file mode 100644 index 8ec6ab21e61..00000000000 --- a/modules/dicom_archive/js/view_details_link.js +++ /dev/null @@ -1,53 +0,0 @@ -$(document).ready(function() { - // Filters will only get applied on a POST, so - // on click we need to fake a form which posts - // to the mri_violations in order to get filters - $(".dicom_archive").click(function(e) { - e.preventDefault(); - var form = $('
', { - "action" : loris.BaseURL + "/mri_violations/", - "method" : "post" - }); - var values = { - "reset" : "true", - "PatientName" : this.dataset.patientname, - "SeriesUID" : this.dataset.seriesuid, - "filter" : "Show Data" - }; - - $.each(values, function(name, value) { - $("", { - type: 'hidden', - name: name, - value: value - }).appendTo(form); - }); - - form.appendTo('body').submit(); - }); - - $(".dicom_archive_datasets").click(function(e) { - e.preventDefault(); - var form = $('', { - "action" : loris.BaseURL + "/mri_violations/", - "method" : "post" - }); - var values = { - "reset" : "true", - "PatientName" : this.dataset.patientname, - "filter" : "Show Data" - }; - - $.each(values, function(name, value) { - $("", { - type: 'hidden', - name: name, - value: value - }).appendTo(form); - }); - - form.appendTo('body').submit(); - }); - -}); - diff --git a/modules/dicom_archive/php/viewdetails.class.inc b/modules/dicom_archive/php/viewdetails.class.inc index 388c544ea43..816a34ec329 100644 --- a/modules/dicom_archive/php/viewdetails.class.inc +++ b/modules/dicom_archive/php/viewdetails.class.inc @@ -357,24 +357,6 @@ class ViewDetails extends \NDB_Form ); } - /** - * Overrides base getJSDependencies() to add additional JS files - * - * @return array of extra JS files that this page depends on - */ - function getJSDependencies() - { - $factory = \NDB_Factory::singleton(); - $baseURL = $factory->settings()->getBaseURL(); - $deps = parent::getJSDependencies(); - return array_merge( - $deps, - [ - $baseURL . "/dicom_archive/js/view_details_link.js", - ] - ); - } - /** * Generate a breadcrumb trail for this page. * @@ -392,4 +374,3 @@ class ViewDetails extends \NDB_Form ); } } - diff --git a/modules/dicom_archive/templates/form_viewDetails.tpl b/modules/dicom_archive/templates/form_viewDetails.tpl index 4ce6792c2a9..61636b253e9 100644 --- a/modules/dicom_archive/templates/form_viewDetails.tpl +++ b/modules/dicom_archive/templates/form_viewDetails.tpl @@ -3,9 +3,8 @@ Acquisition ID - - {$archive.DicomArchiveID} + + {$archive.DicomArchiveID} @@ -124,9 +123,7 @@ {$archive_series[record].PhaseEncoding} {$archive_series[record].NumberOfFiles} - + {$archive_series[record].SeriesUID} diff --git a/modules/document_repository/jsx/editForm.js b/modules/document_repository/jsx/editForm.js index e6caa3184ec..1cf138aeac3 100644 --- a/modules/document_repository/jsx/editForm.js +++ b/modules/document_repository/jsx/editForm.js @@ -2,6 +2,14 @@ import Loader from 'Loader'; import PropTypes from 'prop-types'; import swal from 'sweetalert2'; +import { + FormElement, + TextboxElement, + TextareaElement, + SelectElement, + ButtonElement, + FileElement, +} from 'jsx/Form'; /** * Document Edit Form * diff --git a/modules/document_repository/jsx/uploadForm.js b/modules/document_repository/jsx/uploadForm.js index 7785e00d445..beb5b893ad3 100644 --- a/modules/document_repository/jsx/uploadForm.js +++ b/modules/document_repository/jsx/uploadForm.js @@ -240,6 +240,12 @@ class DocUploadForm extends Component { 'error' ); } + if (resp.status == 400) { + swal.fire('Something went wrong', + JSON.parse(resp.response).message, + 'error' + ); + } } }).catch((error) => { console.error(error); diff --git a/modules/document_repository/php/files.class.inc b/modules/document_repository/php/files.class.inc index 3eb5efc6a97..7dd50b224f1 100644 --- a/modules/document_repository/php/files.class.inc +++ b/modules/document_repository/php/files.class.inc @@ -316,20 +316,18 @@ class Files extends \NDB_Page if (!is_array($body)) { throw new \LorisException("Expected parsed body to be an array"); } - - $category = $body['category']; // required - if (!\Utility::valueIsPositiveInteger($category)) { - // $category is a string representation of an ID, and so should be - // at least equal to zero. - return new \LORIS\Http\Response\JSON\BadRequest(self::BAD_CATEGORY); - } - $uploadedFiles = $request->getUploadedFiles()['files'] ?? null; if (is_null($uploadedFiles)) { header("HTTP/1.1 413 Too_large"); exit; } + $category = $body['category']; // required + if (!\Utility::valueIsPositiveInteger(strval($category))) { + // $category is a string representation of an ID, and so should be + // at least equal to zero. + return new \LORIS\Http\Response\JSON\BadRequest(self::BAD_CATEGORY); + } $uploadedFiles = is_array($uploadedFiles) ? $uploadedFiles : [$uploadedFiles]; diff --git a/modules/electrophysiology_browser/README.md b/modules/electrophysiology_browser/README.md index 25bb1398b44..c1c7d89a618 100644 --- a/modules/electrophysiology_browser/README.md +++ b/modules/electrophysiology_browser/README.md @@ -4,14 +4,14 @@ The Electrophysiology Browser is intended to allow users to view candidate electrophysiology (EEG, MEG...) sessions collected for a study and any associated -annotations for each recording. +events for each recording. ## Intended Users The primary types of users are: 1. Electrophysiology researchers who want to know details about the inserted datasets. 2. Site coordinators or researchers ensuring the uploaded electrophysiology data have -been correctly inserted into LORIS. + been correctly inserted into LORIS. ## Scope @@ -26,22 +26,22 @@ sufficient to provide access to view data in the module. The third permission pr permissions to add or modify annotations for data from the sites the user has access to in this module. electrophysiology_browser_view_allsites - - This permission gives the user access to all electrophysiology datasets present in the database. + - This permission gives the user access to all electrophysiology datasets present in the database. electrophysiology_browser_view_site - - This permission gives the user access to electrophysiology datasets from their own site(s) only. + - This permission gives the user access to electrophysiology datasets from their own site(s) only. electrophysiology_browser_edit_annotations - - This permission allows the user to add, edit, and delete annotations for raw or derived datasets + - This permission allows the user to add, edit, and delete annotations for raw or derived datasets ## Download You can download all the files related to a recording (channel information, -electrode information, task event information, the actual recording) -- as well as its annotations and their related metadata. +electrode information, task event information, the actual recording) -- as well as its events and their related metadata. ## Updating Derivative Files -New annotations or edits to existing annotations made through the browser must also be updated in the derivative files stored in the filesystem, before a user tries to download a derivative file package. To do this automatically, a script is provided under `tools/update_annotation_files.php`, and a cron job should be set up to execute it regularly, e.g. every evening. +New events or edits to existing events made through the browser must also be updated in the derivative files stored in the filesystem, before a user tries to download a derivative file package. To do this automatically, a script is provided under `tools/update_event_files.php`, and a cron job should be set up to execute it regularly, e.g. every evening. ## Installation requirements to use the visualization features diff --git a/modules/electrophysiology_browser/css/electrophysiology_browser.css b/modules/electrophysiology_browser/css/electrophysiology_browser.css index b5516024df9..fdfe1375164 100644 --- a/modules/electrophysiology_browser/css/electrophysiology_browser.css +++ b/modules/electrophysiology_browser/css/electrophysiology_browser.css @@ -3,7 +3,7 @@ } .react-series-data-viewer-scoped .dropdown-menu li { - margin-top: 0; + margin: 0; padding: 0 10px; } @@ -14,6 +14,38 @@ width: 100%; } +.checkbox-flex-label > div > input[type="checkbox"] { + vertical-align: top; +} + +.checkbox-flex-label { + display: flex; + align-items: center; + margin-bottom: 0; + justify-content: flex-end; +} + +.btn-dropdown-toggle { + padding: 5px 10%; +} + +.col-xs-12 > .btn-dropdown-toggle { + padding: 5px; + max-width: fit-content; +} + +.col-xs-12 > .dropdown-menu { + width: max-content; + line-height: 14px; + padding: 0 +} + +.col-xs-12 > .dropdown-menu li { + margin: 0; + padding: 0; +} + + .btn.btn-xs { font-size: 12px; } @@ -46,42 +78,215 @@ svg:not(:root) { overflow: clip; } -.list-group-item { +.annotation.list-group-item { position: relative; display: flex; flex-direction: column; justify-content: space-between; align-items: center; + padding: 0; + width: 100%; } .annotation { background: #fffae6; - border-left: 5px solid #ff6600; + border-left: 5px solid #8eecfa; } .epoch-details { - padding-right: 100px; + display: flex; + width: 100%; + padding: 10px 0; } .epoch-action { display: flex; flex-direction: row; - justify-content: center; + justify-content: end; align-items: center; - position: absolute; - right: 10px; } .epoch-tag { - padding: 5px; + padding: 10px; background: #e7e4e4; - border-left: 5px solid #797878; + word-wrap: break-word; width: 100%; } -.epoch-tag p { - word-wrap: break-word; - width: 95%; +.badge-pill { + max-width: 100%; +} + +.badge-pill p { + overflow: hidden; + text-overflow: ellipsis; + margin: 0; +} + +.badge-property { + background-color: #690096; +} + +.badge-hed { + background-color: #d54a08; + cursor: help; + position: relative; + display: inline-block; +} + +.tag-hed, .dataset-tag-hed { + cursor: pointer; + position: relative; + display: inline-block; +} + +.toggle-long-hed label > div { + padding-right: 10px !important; +} + +.toggle-long-hed input[type='checkbox'] { + vertical-align: top; +} + +.tag-hed-score { + cursor: pointer; +} + +.badge-hed .badge-hed-tooltip, +.tag-hed .badge-hed-tooltip { + visibility: hidden; + background-color: #555555; + color: #fff; + padding: 10px; + border-radius: 6px; + position: absolute; + z-index: 1; + top: -50px; + right: 105%; + width: 500px; + max-height: 350px; + overflow-y: scroll; + white-space: normal; + text-align: left; +} + +.badge-hed:hover .badge-hed-tooltip, +.tag-hed:hover .badge-hed-tooltip { + visibility: visible; +} + +.badge-hed-tooltip .tooltip-title { + font-size: 16px; +} + +.tooltip-description { + font-weight: normal; + font-style: italic; +} + +.badge-hed-add { + color: #246EB6 !important; + border: solid 1px #246EB6; + background-color: #fff !important; + cursor: pointer; +} + +.selection-filter-tags { + margin: 0; + font-weight: bold; +} + +.tag-remove-button { + cursor: pointer; + margin-left: 5px; + padding-left: 7.5px; + border-left: #b0885d solid 1px; + float: right; + width: 15px; +} + +.dataset-tag-remove-button { + border-left: #999 solid 1px; +} + +.selection-filter-tag { + display: inline-block; + margin: 2px 5px; + padding: 5px 10px; + color: black; + background-color: #E89A0C; + border-radius: 5px; + height: 30px; + max-width: 100vw; +} + +.selection-filter-dataset-tag { + color: #fff; + background-color: #A9A9A9; +} + +.dataset-tag-selected { + background-color: rgb(24, 99, 0); +} + +.filter-tag-name { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + float: left; + max-width: 12vw; +} + +.dataset-tag-dirty { + opacity: 0.6; +} + +.tag-modal-container-dirty:before { + content: '*'; +} + +#select_column { + height: 27px; + text-align: center; +} + +.dataset-filter-tags { + margin: 0 2px; +} + +.dataset-filter-tag { + max-width: 52vw; +} + +.dataset-tag-name { + max-width: calc(100% - 20px); +} + +#tag-modal-container { + margin-bottom: 15px; +} + +#tag-modal-container > div > button { + position: unset; + width: unset; +} + + +#tag-modal-container > div > div > div { + width: 75vw !important; + height: 75vh; +} + +.line-height-14 { + line-height: 14px; +} + +.margin-top-10 { + margin-top: 10px; +} + +.flex-basis-45 { + flex-basis: 45% } .event-list .btn.btn-primary { @@ -111,8 +316,9 @@ svg:not(:root) { .btn-zoom { margin: 0 auto 3px auto; - width: 50px; + width: 55px; text-align: center; + text-wrap: unset; } .col-xs-title { @@ -139,7 +345,7 @@ svg:not(:root) { .electrode:hover circle { stroke: #064785; cursor: pointer; - fill: #E4EBF2 + fill: #E4EBF2; } .electrode:hover text { @@ -181,6 +387,10 @@ svg:not(:root) { width: auto; } +.cursor-default { + cursor: default; +} + /* Custom, iPhone Retina */ @media only screen and (min-width : 320px) { .pagination-nav { @@ -206,6 +416,14 @@ svg:not(:root) { #page.eegBrowser { margin-left: 150px; } + + #tag-modal-container > div > button { + position: fixed; + left: 15px; + bottom: 20px; + width: 120px; + white-space: normal; + } } /* Medium Devices, Desktops */ diff --git a/modules/electrophysiology_browser/help/sessions.md b/modules/electrophysiology_browser/help/sessions.md index 9a02d6dca8d..7aa591e5f2d 100644 --- a/modules/electrophysiology_browser/help/sessions.md +++ b/modules/electrophysiology_browser/help/sessions.md @@ -10,5 +10,5 @@ Files can be downloaded containing only the recording signal, the events, or oth - EEG: the file containing the session recording data. - Electrode info (tsv): contains electrode locations. - Channels info (tsv): channel status and filter settings. -- Events (tsv): events (both stimuli and responses) recorded during the session. -- Annotations (tsv): annotations (both stimuli and responses) recorded during the session. +- Events (tsv): events (both stimuli and responses) recorded during the session. + diff --git a/modules/electrophysiology_browser/jsx/components/DownloadPanel.js b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js index 027395c1c0b..a254e1c8cbf 100644 --- a/modules/electrophysiology_browser/jsx/components/DownloadPanel.js +++ b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js @@ -21,7 +21,7 @@ class DownloadPanel extends Component { downloads: this.props.downloads, physioFileID: this.props.physioFileID, annotationsAction: loris.BaseURL - + '/electrophysiology_browser/annotations', + + '/electrophysiology_browser/events', outputType: this.props.outputType, }; } @@ -54,27 +54,29 @@ class DownloadPanel extends Component { maxWidth: '250px', margin: '0 auto', } - }> + }> {Object.entries(panel.links).map(([type, download], j) => { const disabled = (download.file === ''); - return ( -
+ // Ignore physiological_coord_system_file + return type !== 'physiological_coord_system_file' + ? (
{download.label}
- {disabled - ? +
{download.label}
+ {disabled + ?
Not Available - : diff --git a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js index b7a6bb37cb3..0e35200b26c 100644 --- a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js +++ b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js @@ -139,10 +139,6 @@ class ElectrophysiologySessionView extends Component { type: 'physiological_task_event_file', file: '', }, - { - type: 'physiological_annotation_files', - file: '', - }, { type: 'all_files', file: '', @@ -152,8 +148,8 @@ class ElectrophysiologySessionView extends Component { chunksURL: null, epochsURL: null, electrodesURL: null, + coordSystemURL: null, events: null, - annotations: null, splitData: null, }, ], @@ -200,68 +196,79 @@ class ElectrophysiologySessionView extends Component { throw Error(resp.statusText); } return resp.json(); - }) - .then((data) => { - const database = data.database.map((dbEntry) => ({ - ...dbEntry, - // EEG Visualization urls - chunksURLs: - dbEntry - && dbEntry.file.chunks_urls.map( - (url) => - loris.BaseURL - + '/electrophysiology_browser/file_reader/?file=' - + url - ), - epochsURL: - dbEntry - && dbEntry.file?.epochsURL - && [loris.BaseURL + }).then((data) => { + const database = data.database.map((dbEntry) => ({ + ...dbEntry, + // EEG Visualization urls + chunksURLs: + dbEntry + && dbEntry.file.chunks_urls.map( + (url) => + loris.BaseURL + + '/electrophysiology_browser/file_reader/?file=' + + url + ), + epochsURL: + dbEntry + && dbEntry.file?.epochsURL + && [loris.BaseURL + + '/electrophysiology_browser/file_reader/?file=' + + dbEntry.file.epochsURL], + electrodesURL: + dbEntry + && dbEntry.file.downloads.map( + (group) => + group.links['physiological_electrode_file']?.file + && loris.BaseURL + '/electrophysiology_browser/file_reader/?file=' - + dbEntry.file.epochsURL], - electrodesURL: - dbEntry - && dbEntry.file.downloads.map( - (group) => - group.links['physiological_electrode_file']?.file - && loris.BaseURL - + '/electrophysiology_browser/file_reader/?file=' - + group.links['physiological_electrode_file'].file - ), - events: - dbEntry - && dbEntry.file.events, - annotations: - dbEntry - && dbEntry.file.annotations, - })); + + group.links['physiological_electrode_file'].file + ), + coordSystemURL: + dbEntry + && dbEntry.file.downloads.map( + (group) => + group.links['physiological_coord_system_file']?.file + && loris.BaseURL + + '/electrophysiology_browser/file_reader/?file=' + + group.links['physiological_coord_system_file'].file + ), + events: + dbEntry + && dbEntry.file.events, + hedSchema: + dbEntry + && dbEntry.file.hedSchema, + datasetTags: + dbEntry + && dbEntry.file.datasetTags, + })); - this.setState({ - setup: {data}, - isLoaded: true, - database: database, - patient: { - info: data.patient, - }, - }); + this.setState({ + setup: {data}, + isLoaded: true, + database: database, + patient: { + info: data.patient, + }, + }); - document.getElementById( - 'nav_next' - ).href = dataURL + data.nextSession + outputTypeArg; - document.getElementById( - 'nav_previous' - ).href = dataURL + data.prevSession + outputTypeArg; - if (data.prevSession !== '') { - document.getElementById('nav_previous').style.display = 'block'; - } - if (data.nextSession !== '') { - document.getElementById('nav_next').style.display = 'block'; - } - }) - .catch((error) => { - this.setState({error: true}); - console.error(error); - }); + document.getElementById( + 'nav_next' + ).href = dataURL + data.nextSession + outputTypeArg; + document.getElementById( + 'nav_previous' + ).href = dataURL + data.prevSession + outputTypeArg; + if (data.prevSession !== '') { + document.getElementById('nav_previous').style.display = 'block'; + } + if (data.nextSession !== '') { + document.getElementById('nav_next').style.display = 'block'; + } + }) + .catch((error) => { + this.setState({error: true}); + console.error(error); + }); } /** @@ -333,8 +340,10 @@ class ElectrophysiologySessionView extends Component { chunksURLs, epochsURL, events, - annotations, + hedSchema, + datasetTags, electrodesURL, + coordSystemURL, } = this.state.database[i]; const file = this.state.database[i].file; const splitPagination = []; @@ -365,9 +374,14 @@ class ElectrophysiologySessionView extends Component { } epochsURL={epochsURL} events={events} - annotations={annotations} electrodesURL={electrodesURL} + coordSystemURL={coordSystemURL} + hedSchema={hedSchema} + datasetTags={datasetTags} physioFileID={this.state.database[i].file.id} + samplingFrequency={ + this.state.database[i].file.summary[0].value + } >
diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx index 0771738c896..a429c910ffa 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx @@ -1,46 +1,55 @@ import React, {Component} from 'react'; import {tsvParse} from 'd3-dsv'; -import {createStore, applyMiddleware, Store} from 'redux'; +import {applyMiddleware, createStore, Store} from 'redux'; import {Provider} from 'react-redux'; import {createEpicMiddleware} from 'redux-observable'; import thunk from 'redux-thunk'; import {fetchJSON, fetchText} from '../ajax'; -import {rootReducer, rootEpic} from '../series/store'; +import {rootEpic, rootReducer} from '../series/store'; +import {emptyChannels, setChannels} from '../series/store/state/channels'; import {DEFAULT_MAX_CHANNELS, DEFAULT_TIME_INTERVAL} from '../vector'; import { - setChannels, - emptyChannels, -} from '../series/store/state/channels'; -import { - setEpochs, setDatasetMetadata, - setPhysioFileID, + setDatasetTags, + setEpochs, setFilteredEpochs, + setHedSchemaDocument, + setPhysioFileID, } from '../series/store/state/dataset'; import {setDomain, setInterval} from '../series/store/state/bounds'; -import {setElectrodes} from '../series/store/state/montage'; -import {AnnotationMetadata, EventMetadata} from '../series/store/types'; +import { + setCoordinateSystem, + setElectrodes, +} from '../series/store/state/montage'; +import {EventMetadata, HEDSchemaElement} from '../series/store/types'; +import TriggerableModal from 'jsx/TriggerableModal'; +import DatasetTagger from '../series/components/DatasetTagger'; +import {InfoIcon} from '../series/components/components'; declare global { interface Window { - EEGLabSeriesProviderStore: Store; + EEGLabSeriesProviderStore: Store[]; // Store reference per recording } } type CProps = { chunksURL: string, + epochsURL: string, electrodesURL: string, + coordSystemURL: string, + hedSchema: HEDSchemaElement[], + datasetTags: any, events: EventMetadata, - annotations: AnnotationMetadata, physioFileID: number, limit: number, + samplingFrequency: number, children: React.ReactNode, }; /** * EEGLabSeriesProvider component */ -class EEGLabSeriesProvider extends Component { +class EEGLabSeriesProvider extends Component { private store: Store; /** @@ -58,18 +67,51 @@ class EEGLabSeriesProvider extends Component { epicMiddleware.run(rootEpic); - window.EEGLabSeriesProviderStore = this.store; - const { chunksURL, + epochsURL, electrodesURL, + coordSystemURL, + hedSchema, + datasetTags, events, - annotations, physioFileID, limit, + samplingFrequency, } = props; + if (!window.EEGLabSeriesProviderStore) { + window.EEGLabSeriesProviderStore = []; + } + window.EEGLabSeriesProviderStore[chunksURL] = this.store; + /** + * + * @returns {void} - Confirmation dialog to prevent accidental page leave + */ + window.onbeforeunload = function() { + const dataset = + window.EEGLabSeriesProviderStore[chunksURL].getState().dataset; + if ([...dataset.addedTags, ...dataset.deletedTags].length > 0) { + return 'Are you sure you want to leave unsaved changes behind?'; + } + }; + + const formattedDatasetTags = {}; + Object.keys(datasetTags).forEach((column) => { + formattedDatasetTags[column] = {}; + Object.keys(datasetTags[column]).forEach((value) => { + formattedDatasetTags[column][value] = + datasetTags[column][value].map((tag) => { + return { + ...tag, + AdditionalMembers: parseInt(tag.AdditionalMembers), + }; + }); + }); + }); this.store.dispatch(setPhysioFileID(physioFileID)); + this.store.dispatch(setHedSchemaDocument(hedSchema)); + this.store.dispatch(setDatasetTags(formattedDatasetTags)); /** * @@ -81,14 +123,14 @@ class EEGLabSeriesProvider extends Component { const racers = (fetcher, url, route = '') => { if (url) { return [fetcher(`${url}${route}`) - .then((json) => ({json, url})) - // if request fails don't resolve - .catch((error) => { - console.error(error); - return Promise.resolve(); - })]; + .then((json) => ({json, url})) + // if request fails don't resolve + .catch((error) => { + console.error(error); + return new Promise(null); + })]; } else { - return [Promise.resolve()]; + return [new Promise(null)]; } }; @@ -111,65 +153,98 @@ class EEGLabSeriesProvider extends Component { timeInterval, seriesRange, limit, + samplingFrequency, }) ); this.store.dispatch(setChannels(emptyChannels( - Math.min(this.props.limit, channelMetadata.length), - 1 + Math.min(this.props.limit, channelMetadata.length), + 1 ))); this.store.dispatch(setDomain(timeInterval)); this.store.dispatch(setInterval(DEFAULT_TIME_INTERVAL)); } - }).then(() => { - return events.instances.map((instance) => { - const onset = parseFloat(instance.Onset); - const duration = parseFloat(instance.Duration); - const label = instance.TrialType && instance.TrialType !== 'n/a' ? - instance.TrialType : instance.EventValue; - const hed = instance.AssembledHED; + } + ).then(() => { + const epochs = []; + events.instances.map((instance) => { + const epochIndex = + epochs.findIndex( + (e) => e.physiologicalTaskEventID + === instance.PhysiologicalTaskEventID + ); + + const extraColumns = Array.from( + events.extraColumns + ).filter((column) => { + return column.PhysiologicalTaskEventID + === instance.PhysiologicalTaskEventID; + }); + + const hedTags = Array.from(events.hedTags).filter((column) => { + return column.PhysiologicalTaskEventID + === instance.PhysiologicalTaskEventID; + }).map((hedTag) => { + const foundTag = hedSchema.find((tag) => { + return tag.id === hedTag.HEDTagID; + }); + const additionalMembers = parseInt(hedTag.AdditionalMembers); + + // Currently only supporting schema-defined HED tags return { - onset: onset, - duration: duration, - type: 'Event', - label: label, - comment: null, - hed: hed, - channels: 'all', - annotationInstanceID: null, + schemaElement: foundTag ?? null, + HEDTagID: foundTag ? foundTag.id : null, + ID: hedTag.ID, + PropertyName: hedTag.PropertyName, + PropertyValue: hedTag.PropertyValue, + TagValue: hedTag.TagValue, + Description: hedTag.Description, + HasPairing: hedTag.HasPairing, + PairRelID: hedTag.PairRelID, + AdditionalMembers: isNaN(additionalMembers) ? 0 : additionalMembers, }; }); - }).then((events) => { - const epochs = events; - annotations.instances.map((instance) => { - const label = annotations.labels - .find((label) => - label.AnnotationLabelID == instance.AnnotationLabelID - ).LabelName; + + if (epochIndex === -1) { + const epochLabel = [null, 'n/a'].includes(instance.TrialType) + ? null + : instance.TrialType; epochs.push({ onset: parseFloat(instance.Onset), duration: parseFloat(instance.Duration), - type: 'Annotation', - label: label, - comment: instance.Description, - hed: null, + type: 'Event', + label: epochLabel ?? instance.EventValue, + value: instance.EventValue, + trialType: instance.TrialType, + properties: extraColumns, + hed: hedTags, channels: 'all', - annotationInstanceID: instance.AnnotationInstanceID, + physiologicalTaskEventID: instance.PhysiologicalTaskEventID, }); - }); + } else { + console.error('ERROR: EPOCH EXISTS'); + } + }); return epochs; - }).then((epochs) => { - this.store.dispatch( - setEpochs( - epochs - .flat() - .sort(function(a, b) { - return a.onset - b.onset; - }) - ) - ); - this.store.dispatch(setFilteredEpochs(epochs.map((_, index) => index))); - }) - ; + }).then((epochs) => { + const sortedEpochs = epochs + .flat() + .sort(function(a, b) { + return a.onset - b.onset; + }); + + const timeInterval = this.store.getState().dataset.timeInterval; + this.store.dispatch(setEpochs(sortedEpochs)); + this.store.dispatch(setFilteredEpochs({ + plotVisibility: sortedEpochs.reduce((indices, epoch, index) => { + if (!(epoch.onset < 1 && epoch.duration >= timeInterval[1])) { + // Full-recording events not visible by default + indices.push(index); + } + return indices; + }, []), + columnVisibility: [], + })); + }); Promise.race(racers(fetchText, electrodesURL)) .then((text) => { @@ -188,6 +263,27 @@ class EEGLabSeriesProvider extends Component { .catch((error) => { console.error(error); }); + + Promise.race(racers(fetchJSON, coordSystemURL)) + .then( ({json, _}) => { + if (json) { + const { + EEGCoordinateSystem, + EEGCoordinateUnits, + EEGCoordinateSystemDescription, + } = json; + this.store.dispatch( + setCoordinateSystem({ + name: EEGCoordinateSystem ?? 'Other', + units: EEGCoordinateUnits ?? 'm', + description: EEGCoordinateSystemDescription ?? 'n/a', + }) + ); + } + }) + .catch((error) => { + console.error(error); + }); } /** @@ -197,8 +293,45 @@ class EEGLabSeriesProvider extends Component { */ render() { const [signalViewer, ...rest] = React.Children.toArray(this.props.children); + return ( +
+ +
+ + + + + Dataset Tag Manager + +
+
+ More about HED + +
+
+ } + label="Open Dataset Tag Manager" + > + + +
{signalViewer} {rest} diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx index c07c0b12b25..ca7d93991d0 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx @@ -1,19 +1,28 @@ import React, {useEffect, useState} from 'react'; import { - AnnotationMetadata, Epoch as EpochType, RightPanel, + HEDTag, + HEDSchemaElement, } from '../store/types'; import {connect} from 'react-redux'; import {setTimeSelection} from '../store/state/timeSelection'; import {setRightPanel} from '../store/state/rightPanel'; import * as R from 'ramda'; -import {toggleEpoch, updateActiveEpoch} from '../store/logic/filterEpochs'; +import { + getNthMemberTrailingBadgeIndex, + getTagsForEpoch, + toggleEpoch, + updateActiveEpoch +} from '../store/logic/filterEpochs'; import {RootState} from '../store'; -import {setEpochs} from '../store/state/dataset'; +import {setActiveEpoch, setEpochs} from '../store/state/dataset'; import {setCurrentAnnotation} from '../store/state/currentAnnotation'; -import {NumericElement, SelectElement, TextareaElement} from './Form'; +import {NumericElement, SelectElement, TextboxElement} from './Form'; import swal from 'sweetalert2'; +import {InfoIcon} from "./components"; +import {colorOrder} from "../../color"; + type CProps = { timeSelection?: [number, number], @@ -25,11 +34,11 @@ type CProps = { currentAnnotation: EpochType, setCurrentAnnotation: (_: EpochType) => void, physioFileID: number, - annotationMetadata: AnnotationMetadata, toggleEpoch: (_: number) => void, updateActiveEpoch: (_: number) => void, interval: [number, number], - domain: [number, number], + hedSchema: HEDSchemaElement[], + datasetTags: any, }; /** @@ -43,13 +52,11 @@ type CProps = { * @param root0.currentAnnotation * @param root0.setCurrentAnnotation * @param root0.physioFileID - * @param root0.annotationMetadata * @param root0.toggleEpoch, * @param root0.updateActiveEpoch, * @param root0.interval - * @param root0.domain - * @param root0.toggleEpoch - * @param root0.updateActiveEpoch + * @param root0.hedSchema + * @param root0.datasetTags */ const AnnotationForm = ({ timeSelection, @@ -60,11 +67,11 @@ const AnnotationForm = ({ currentAnnotation, setCurrentAnnotation, physioFileID, - annotationMetadata, toggleEpoch, updateActiveEpoch, interval, - domain, + hedSchema, + datasetTags, }: CProps) => { const [startEvent = '', endEvent = ''] = timeSelection || []; const [event, setEvent] = useState<(number | string)[]>( @@ -75,17 +82,15 @@ const AnnotationForm = ({ ); const [label, setLabel] = useState( currentAnnotation ? - currentAnnotation.label : - null - ); - const [comment, setComment] = useState( - currentAnnotation ? - currentAnnotation.comment : - '' + currentAnnotation.label : + null ); + const [isSubmitted, setIsSubmitted] = useState(false); const [isDeleted, setIsDeleted] = useState(false); const [annoMessage, setAnnoMessage] = useState(''); + const [newTags, setNewTags] = useState([]); + const [deletedTagIDs, setDeletedTagIDs] = useState([]); // Time Selection useEffect(() => { @@ -97,13 +102,15 @@ const AnnotationForm = ({ * * @param event */ - const validate = (event) => ( - (event[0] || event[0] === 0) + const validate = (event) => { + return (event[0] || event[0] === 0) && (event[1] || event[1] === 0) && event[0] <= event[1] - && event[0] >= interval[0] && event[0] <= interval[1] - && event[1] >= interval[0] && event[1] <= interval[1] - ); + && ( + newTags.some((tag) => tag.value !== '') || + deletedTagIDs.length > 0 + ); + }; /** * @@ -155,20 +162,69 @@ const AnnotationForm = ({ /** * - * @param name - * @param value */ - const handleLabelChange = (name, value) => { - setLabel(value); + const handleAddTag = (tagType: string) => { + // Add tag if all are filled + if (newTags.find((tag) => { + return tag.value === ''; + })) { + setAnnoMessage('Fill other tags first'); + setTimeout(() => { + setAnnoMessage(''); + }, 2000); + } else { + setNewTags([ + ...newTags, + { + type: tagType, + value: '', + } + ]); + } }; + + /** * - * @param name + */ + const handleDeleteTag = (event) => { + const elementID = event.target.getAttribute('id'); + const tagRelID = elementID.split('-').pop(); + if (currentAnnotation.hed && + currentAnnotation.hed.map((tag) => { + return tag.ID + }).includes(tagRelID) + ) { + setDeletedTagIDs([...deletedTagIDs, tagRelID]); + } + }; + + /** + * + * @param tagIndex + */ + const handleRemoveAddedTag = (tagIndex: number) => { + setNewTags(newTags.filter((tag, index) => { + return index !== tagIndex; + })); + }; + + /** + * + * @param tagIndex * @param value */ - const handleCommentChange = (name, value) => { - setComment(value); + const handleTagChange = (tagIndex, value) => { + setNewTags([ + ...newTags.slice(0, tagIndex), + { + ...newTags[tagIndex], + value: value + }, + ...newTags.slice(tagIndex + 1), + ]) }; + /** * */ @@ -180,11 +236,8 @@ const AnnotationForm = ({ * */ const handleReset = () => { - // Clear all fields - setEvent(['', '']); - setTimeSelection([null, null]); - setLabel(''); - setComment(''); + setNewTags([]); + setDeletedTagIDs([]); }; /** @@ -212,10 +265,42 @@ const AnnotationForm = ({ return; } + const newTagIDs = newTags + .filter((tag) => { + return tag.value !== ''; + }) + .map((tag) => { + const node = getNodeByName(tag.value); + if (node) + return node.id; + }); + + const currentTagIDs = (currentAnnotation.hed ?? []).map((tag) => { + return tag.ID; + }).filter(currentTagID => { + return !deletedTagIDs.includes(currentTagID) + }); + + // Prevent duplicates + // const addingNewTagMultipleTimes = newTagIDs.length !== (new Set(newTagIDs)).size; + // const addingExistingTag = newTagIDs.filter((tagID) => { + // return currentTagIDs.includes(tagID) + // }).length > 0; + // + // if (addingNewTagMultipleTimes || addingExistingTag) { + // swal.fire( + // 'Warning', + // 'Duplicates are not allowed', + // 'warning' + // ); + // setIsSubmitted(false); + // return; + // } + const url = window.location.origin + - '/electrophysiology_browser/annotations/'; + '/electrophysiology_browser/events/'; - // get duration of annotation + // get duration of event let startTime = event[0]; let endTime = event[1]; if (typeof startTime === 'string') { @@ -227,11 +312,12 @@ const AnnotationForm = ({ const duration = endTime - startTime; // set body - // instance_id = null for new annotations + // instance_id = null for new events const body = { + request_type: 'event_update', physioFileID: physioFileID, instance_id: currentAnnotation ? - currentAnnotation.annotationInstanceID : + currentAnnotation.physiologicalTaskEventID : null, instance: { onset: startTime, @@ -239,22 +325,11 @@ const AnnotationForm = ({ label_name: label, label_description: label, channels: 'all', - description: comment, + added_hed: newTagIDs, + deleted_hed: deletedTagIDs, }, }; - const newAnnotation : EpochType = { - onset: startTime, - duration: duration, - type: 'Annotation', - label: label, - comment: comment, - channels: 'all', - annotationInstanceID: currentAnnotation ? - currentAnnotation.annotationInstanceID : - null, - }; - fetch(url, { method: 'POST', credentials: 'same-origin', @@ -263,47 +338,92 @@ const AnnotationForm = ({ if (response.ok) { return response.json(); } - }).then((data) => { + throw (response); + + }).then((response) => { setIsSubmitted(false); - // if in edit mode, remove old annotation instance + // if in edit mode, remove old event instance if (currentAnnotation !== null) { epochs.splice(epochs.indexOf(currentAnnotation), 1); - } else { - newAnnotation.annotationInstanceID = parseInt(data.instance_id); } + // } else { + // newAnnotation.physiologicalTaskEventID = parseInt(data.instance_id); + // } + + const data = response.instance; + + // TODO: Properly handle new event + const hedTags = Array.from(data.hedTags).map((hedTag : HEDTag) => { + const foundTag = hedSchema.find((tag) => { + return tag.id === hedTag.HEDTagID; + }); + // Currently only supporting schema-defined HED tags + return { + schemaElement: foundTag ?? null, + HEDTagID: hedTag.HEDTagID, + ID: hedTag.ID, + PropertyName: hedTag.PropertyName, + PropertyValue: hedTag.PropertyValue, + TagValue: hedTag.TagValue, + Description: hedTag.Description, + HasPairing: hedTag.HasPairing, + PairRelID: hedTag.PairRelID, + AdditionalMembers: hedTag.AdditionalMembers, + } + }); + + const epochLabel = [null, 'n/a'].includes(data.instance.TrialType) + ? null + : data.instance.TrialType; + const newAnnotation : EpochType = { + onset: parseFloat(data.instance.Onset), + duration: parseFloat(data.instance.Duration), + type: 'Event', + label: epochLabel ?? data.instance.EventValue, + value: data.instance.EventValue, + trialType: data.instance.TrialType, + properties: data.extraColumns, + hed: hedTags, + channels: 'all', + physiologicalTaskEventID: data.instance.PhysiologicalTaskEventID, + }; + epochs.push(newAnnotation); setEpochs( epochs - .sort(function(a, b) { - return a.onset - b.onset; - }) + .sort(function(a, b) { + return a.onset - b.onset; + }) ); // Reset Form handleReset(); + setCurrentAnnotation(newAnnotation); // Display success message setAnnoMessage(currentAnnotation ? - 'Annotation Updated!' : - 'Annotation Added!'); + 'Event Updated!' : + 'Event Added!'); setTimeout(() => { setAnnoMessage(''); // Empty string will cause success div to hide - - // If in edit mode, switch back to annotation panel - if (currentAnnotation !== null) { - setCurrentAnnotation(null); - setRightPanel('annotationList'); - } - }, 3000); + }, 2000); }).catch((error) => { console.error(error); // Display error message - swal.fire( - 'Error', - 'Something went wrong!', - 'error' - ); + if (error.status === 401) { + swal.fire( + 'Unauthorized', + 'This action is not permitted.', + 'error' + ); + } else { + swal.fire( + 'Error', + 'Something went wrong!', + 'error' + ); + } }); }, [isSubmitted]); @@ -311,11 +431,11 @@ const AnnotationForm = ({ useEffect(() => { if (isDeleted) { const url = window.location.origin - + '/electrophysiology_browser/annotations/'; + + '/electrophysiology_browser/events/'; const body = { physioFileID: physioFileID, instance_id: currentAnnotation ? - currentAnnotation.annotationInstanceID : + currentAnnotation.physiologicalTaskEventID : null, }; @@ -352,14 +472,14 @@ const AnnotationForm = ({ // Display success message swal.fire( 'Success', - 'Annotation Deleted!', + 'Event Deleted!', 'success' ); - // If in edit mode, switch back to annotation panel + // If in edit mode, switch back to EventManager panel if (currentAnnotation !== null) { setCurrentAnnotation(null); - setRightPanel('annotationList'); + setRightPanel('eventList'); } } }).catch((error) => { @@ -378,13 +498,226 @@ const AnnotationForm = ({ } }, [isDeleted]); - let labelOptions = {}; - annotationMetadata.labels.map((label) => { - labelOptions = { - ...labelOptions, - [label.LabelName]: label.LabelName, - }; - }); + const getNodeByName = (name: string) => { + return hedSchema.find((node) => { + return node.name === name + }); + } + + const addHedTagOptions = [ + { + type: 'SCORE', + value: 'SCORE Artifacts', + }, + { + type: 'DATASET', + value: 'Tags in current dataset', + }, + ] + + const buildPropertyOptions = (optgroup: string, parentHED: string) => { + return hedSchema.filter((node) => { + return node.longName.includes(parentHED) + }).map((tag) => { + return { + HEDTagID: tag.id, + label: tag.name, + longName: tag.longName, + value: tag.id, + optgroup: optgroup, + Description: tag.description, + } + }).sort((tagA, tagB) => { + return tagA.label.localeCompare(tagB.label); + }); + } + + const artifactTagOptions = [ + ...buildPropertyOptions( + 'Biological-artifact', + 'Artifact/Biological-artifact/' + ), + ...buildPropertyOptions( + 'Non-biological-artifact', + 'Artifact/Non-biological-artifact/' + ), + ]; + + const getUniqueDatasetTags = () => { + const idSet = new Set(); + const tagList = []; + Object.keys(datasetTags).forEach((columnName) => { + Object.keys(datasetTags[columnName]).forEach((fieldValue) => { + const hedTags = datasetTags[columnName][fieldValue].map((hedTag) => { + if (hedTag && hedTag.HEDTagID !== null) { + const schemaElement = hedSchema.find((schemaTag) => { + return schemaTag.id === hedTag.HEDTagID; + }) + if (schemaElement && !idSet.has(schemaElement.id)) { + idSet.add(schemaElement.id); + const optGroup = schemaElement.longName.substring(0, schemaElement.longName.lastIndexOf('/')); + return { + HEDTagID: schemaElement.id, + label: schemaElement.name, + longName: schemaElement.longName, + value: schemaElement.id, + optgroup: optGroup.length > 0 ? optGroup : schemaElement.name, + Description: schemaElement.description, + } + } + } + }); + tagList.push(...hedTags.filter((tag) => { + return tag !== undefined; + })); + }); + }); + return tagList.sort((tagA, tagB) => { + return tagA.label.localeCompare(tagB.label); + }); + } + + const getOptions = (optionType: string) => { + switch (optionType) { + case 'SCORE': + return artifactTagOptions; + case 'DATASET': + return getUniqueDatasetTags(); + default: + return []; + } + } + + const buildGroupSpan = (char: string, colorIndex: number) => { + return ( + + {char} + + ); + } + + const buildHEDBadge = (hedTag: HEDTag, belongsToEvent: boolean) => { + return ( +
+
+ + {hedTag.schemaElement.name} + + +
+
+
+ {hedTag.schemaElement.longName} +
+
+
+ {hedTag.schemaElement.description} +
+
+
+ ); + } + + const buildHEDBadges = (hedTags: HEDTag[], belongsToEvent: boolean = false) => { + const rootTags = hedTags.filter((tag) => { + return !hedTags.some((t) => { + return tag.ID === t.PairRelID + }) + }); + + const tagBadges = []; + + rootTags.forEach((tag) => { + if (deletedTagIDs.includes(tag.ID)) { + return; + } + let groupColorIndex = 0; + if (tag.PairRelID === null) { + tagBadges.push(buildHEDBadge(tag, belongsToEvent)); + groupColorIndex++; + } else { + const tagGroup = []; + let groupMember = tag; + while (groupMember) { + tagGroup.push(groupMember); + groupMember = hedTags.find((hedTag) => { + return hedTag.ID === groupMember.PairRelID; + }); + } + + const tagBadgeGroup = []; + const tagBadgeSubgroup = []; + tagGroup.reverse().map((groupTag) => { + if (groupTag.PairRelID === null) { + tagBadgeGroup.push(buildHEDBadge(groupTag, belongsToEvent)); + } else { + if (groupTag.HasPairing === '1') { + if (groupTag.AdditionalMembers > 0 || tagBadgeSubgroup.length === 0) { + let commaIndex = getNthMemberTrailingBadgeIndex( + tagBadgeGroup, + groupTag.AdditionalMembers + ( + tagBadgeSubgroup.length > 0 ? 0 : 1 + ) + ); + + tagBadgeGroup.splice(commaIndex, 0, buildGroupSpan(')', groupColorIndex)); + if (tagBadgeSubgroup.length > 0) { + tagBadgeGroup.splice(0, 0, ...tagBadgeSubgroup); + } + if (groupTag.HEDTagID !== null) { + tagBadgeGroup.splice(0, 0, buildHEDBadge(groupTag, belongsToEvent)); + } + tagBadgeGroup.splice(0, 0, buildGroupSpan('(', groupColorIndex)); + tagBadgeSubgroup.length = 0; + } else { + if (groupTag.HEDTagID === null) { + if (tagBadgeSubgroup.length > 0) { + tagBadgeSubgroup.splice(0, 0, buildGroupSpan('(', groupColorIndex)); + tagBadgeSubgroup.push(buildGroupSpan(')', groupColorIndex)); + } else { + console.error('UNEXPECTED STATE'); + } + } else { + if (tagBadgeSubgroup.length > 0) { + tagBadgeSubgroup.splice(0, 0, buildHEDBadge(groupTag, belongsToEvent)); + tagBadgeSubgroup.splice(0, 0, buildGroupSpan('(', groupColorIndex)); + tagBadgeSubgroup.push(buildGroupSpan(')', groupColorIndex)); + } else { + tagBadgeGroup.splice(0, 0, buildHEDBadge(groupTag, belongsToEvent)); + tagBadgeGroup.splice(0, 0, buildGroupSpan('(', groupColorIndex)); + tagBadgeGroup.push(buildGroupSpan(')', groupColorIndex)); + } + } + } + groupColorIndex++; + } else { + if (tagBadgeSubgroup.length > 0) { + tagBadgeGroup.splice(0, 0, ...tagBadgeSubgroup); + } + tagBadgeSubgroup.splice(0, tagBadgeSubgroup.length, buildHEDBadge(groupTag, belongsToEvent)); + } + } + }); + tagBadges.push(...tagBadgeGroup); + } + }); + return tagBadges; + } return (
- {currentAnnotation ? 'Edit' : 'Add'} Annotation + {currentAnnotation ? 'Edit' : 'Add'} Event { - setRightPanel('annotationList'); + if (deletedTagIDs.length > 0 || + (newTags.length > 0 && newTags.find((tag) => tag.value !== '') + )) { + if (!confirm('Are you sure you want to discard your changes? ' + + ' Otherwise, press cancel and "Submit" your changes')) { + return; + } + } + setRightPanel('eventList'); setCurrentAnnotation(null); setTimeSelection(null); + updateActiveEpoch(null); }} >
+ Event Name + + { + currentAnnotation.label === currentAnnotation.trialType + ? 'trial_type' + : 'value' + } + + + } + value={currentAnnotation ? currentAnnotation.label : ""} + required={true} + readonly={true} + /> - - +
+ { + currentAnnotation && currentAnnotation.properties.length > 0 && ( + <> + +
+ { + currentAnnotation.properties.map((property) => { + return ( + + ); + }) + } +
+ + ) + } +
+
+ +
+ { + currentAnnotation && currentAnnotation.hed && + getTagsForEpoch(currentAnnotation, datasetTags, hedSchema) + .length > 0 && ( + <> +
Dataset
+ { + buildHEDBadges( + getTagsForEpoch(currentAnnotation, datasetTags, hedSchema), + false, + ).map((badge) => { + return badge; + }) + } + + ) + } + { + ( + ( + currentAnnotation + && currentAnnotation.hed + && currentAnnotation.hed.length > 0 + ) || newTags.length > 0 + ) && ( +
Instance
+ ) + } + { + currentAnnotation && currentAnnotation.hed && + buildHEDBadges( + currentAnnotation.hed, + true, + ).map((badge) => { + return badge; + }) + } +
+ { + newTags.map((tag, tagIndex) => { + return ( + <> + { + handleTagChange(tagIndex, value); + }} + useOptionGroups={true} + /> +
handleRemoveAddedTag(tagIndex)} + style={{ + position: 'relative', + left: '100%', + bottom: '30px', + height: '0', + width: '10%', + textAlign: 'center', + marginLeft: '2px', + cursor: 'pointer', + fontWeight: 'bold', + }} + > + x +
+ + ); + }) + } +
+
+
+ Select tag from: +
+ { + const addOption = addHedTagOptions.find((option) => { + return option.value === value; + }) + handleAddTag(addOption.type) + }} + /> + {/**/} + {/* Add Tag*/} + {/**/} +
+
+
+ - - {currentAnnotation && - - } {annoMessage && (
({ + physioFileID: state.dataset.physioFileID, timeSelection: state.timeSelection, epochs: state.dataset.epochs, - filteredEpochs: state.dataset.filteredEpochs, + filteredEpochs: state.dataset.filteredEpochs.plotVisibility, currentAnnotation: state.currentAnnotation, interval: state.bounds.interval, - domain: state.bounds.domain, + hedSchema: state.dataset.hedSchema, + datasetTags: state.dataset.datasetTags, }), (dispatch: (any) => void) => ({ setTimeSelection: R.compose( diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/DatasetTagger.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/DatasetTagger.tsx new file mode 100644 index 00000000000..801f6a93771 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/DatasetTagger.tsx @@ -0,0 +1,1392 @@ +import React, {useEffect, useRef, useState} from 'react'; +import * as R from 'ramda'; +import {connect} from "react-redux"; +import {RootState} from "../store"; +import {SelectDropdown} from 'jsx/MultiSelectDropdown'; +import {CheckboxElement} from 'jsx/Form'; +import {HEDTag, HEDSchemaElement} from "../store/types"; +import {setAddedTags, setDatasetTags, setDeletedTags, setRelOverrides} from "../store/state/dataset"; +import swal from "sweetalert2"; +import {buildHEDString, getNthMemberTrailingBadgeIndex} from "../store/logic/filterEpochs"; +import {colorOrder} from "../../color"; + +type CProps = { + physioFileID: number, + hedSchema: HEDSchemaElement[], + datasetTags: HEDTag, + relOverrides: HEDTag[], + addedTags: HEDTag[], + deletedTags: HEDTag[], + setAddedTags: (_: HEDTag[]) => void, + setDeletedTags: (_: HEDTag[]) => void, + setRelOverrides: (_: HEDTag[]) => void, + setDatasetTags: (_: any) => void, +}; + +/** + * + * @param root0 + * @param root0.physioFileID + * @param root0.datasetTags + * @param root0.relOverrides + * @param root0.hedSchema + * @param root0.addedTags + * @param root0.deletedTags + * @param root0.setAddedTags + * @param root0.setDeletedTags + * @param root0.setDatasetTags + * @param root0.setRelOverrides + */ +const DatasetTagger = ({ + physioFileID, + datasetTags, + relOverrides, + hedSchema, + addedTags, + deletedTags, + setAddedTags, + setDeletedTags, + setDatasetTags, + setRelOverrides, +}: CProps) => { + const tagListID = 'searchable-hed-tags'; + const [searchText, setSearchText] = useState(''); + const [searchTextValid, setSearchTextValid] = useState(false); + const [showLongFormHED, setShowLongFormHED] = useState(false); + const [groupMode, setGroupMode] = useState(false); + const [groupedTags, setGroupedTags] = useState([]); + const [activeColumnName, setActiveColumnName] = useState(''); + const [activeFieldValue, setActiveFieldValue] = useState(''); + const [submittingChanges, setSubmittingChanges] = useState(false); + const [datasetTooltip, setDatasetTooltip] = useState({ + title: '', + description: '', + }); + const [activeHEDSchemas, setActiveHEDSchemas] = useState({}); + + useEffect(() => { + // Initialize active HED schemas + const activeSchemas = {}; + hedSchema.forEach((tag) => { + if (!activeSchemas.hasOwnProperty(tag.schemaName.toUpperCase())) { + activeSchemas[tag.schemaName.toUpperCase()] = true; + } + }); + setActiveHEDSchemas(activeSchemas); + }, []); + + const generateTagID = (index: number) => { + return `add_${Date.now()}${index}`; + } + + const schemaTags : HEDSchemaElement[] = hedSchema.map((hedTag: HEDSchemaElement) => { + return { + ...hedTag, + longName: hedTag.longName + ( + hedTag.parentID === null + ? ' ' // Need to add space otherwise doesn't display + : '' + ), + }; + }); + const parentNodes = schemaTags.filter((hedTag: HEDSchemaElement) => { + return hedTag.parentID === null; + }); + const inputFieldRef = useRef(null); + + const handleResetAllChanges = () => { + swal.fire({ + title: 'Are you sure?', + text: "This will undo all changes since your latest submission. You won't be able to revert this!", + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#3085d6', + cancelButtonColor: '#d33', + confirmButtonText: 'Yes, reset all changes!' + }).then((result) => { + if (result.value) { + setAddedTags([]); + setDeletedTags([]); + setRelOverrides([]); + setGroupedTags([]); +; } + }); + } + + const handleResetFieldChanges = () => { + setAddedTags(addedTags.filter((tag) => { + return !( + tag.PropertyName === activeColumnName && + tag.PropertyValue === activeFieldValue + ); + })); + setDeletedTags(deletedTags.filter((tag) => { + return !( + tag.PropertyName === activeColumnName && + tag.PropertyValue === activeFieldValue + ); + })); + + const relIDs = datasetTags[activeColumnName][activeFieldValue].map((tag) => { + return tag.ID; + }) + setRelOverrides(relOverrides.filter((tag) => { + return !relIDs.includes(tag.ID); + })); + + setGroupedTags([]); + } + + const getFirstGroupTag = (tag: HEDTag) => { + const tagsToSearch = applyOverrides([...addedTags, ...datasetTags[activeColumnName][activeFieldValue]]); + + let rootTag = tag; + let tagFound = true; + while (tagFound) { + const previousTag = tagsToSearch.find((t) => { + return t.PairRelID === rootTag.ID; + }); + if (!previousTag) { + tagFound = false; // redundant + break; + } + rootTag = previousTag; + } + return rootTag; + } + + const sortTagsByPlaceholderIDs = (tags: HEDTag[]) => { + const sortedIDs = []; + tags.forEach((tag) => { + if (sortedIDs.includes(tag.ID)) + return; // continue; + const rootTag = getFirstGroupTag(tag); + const tagPairings = getGroupedTagPairings([rootTag]); + sortedIDs.push( + ...[rootTag, ...tagPairings].map((t) => { + return t.ID; + }) + ); + }); + return tags.sort((a, b) => { + return sortedIDs.indexOf(b.ID) - sortedIDs.indexOf(a.ID); + }); + } + + const handleSubmitChanges = () => { + setSubmittingChanges(true); + const url = window.location.origin + + '/electrophysiology_browser/events/'; + + const updatedAddedTags = sortTagsByPlaceholderIDs(applyOverrides(addedTags)); + + const editedTags = relOverrides.filter((tag) => { + return !updatedAddedTags.some((t) => { + return t.ID === tag.ID; + }) && !deletedTags.some((t) => { + return t.ID === tag.ID; + }); + }); + + const body = { + request_type: 'dataset_tags', + physioFileID: physioFileID, + added_tags: updatedAddedTags, // So that new PairRelIDs can be referenced + deleted_tags: deletedTags, + edited_tags: editedTags, + }; + + fetch(url, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify(body), + }).then((response) => { + setSubmittingChanges(false); + if (response.ok) { + return response.json(); + } + throw (response); + }).then((response) => { + const updatedDatasetTags = datasetTags; + + deletedTags.forEach((deletedTag) => { + const tagList = updatedDatasetTags[deletedTag.PropertyName][deletedTag.PropertyValue]; + updatedDatasetTags[deletedTag.PropertyName][deletedTag.PropertyValue] = + tagList.filter((tag) => { + return tag.ID !== deletedTag.ID + }); + }) + + applyOverrides(addedTags).forEach((addedTag) => { + const pairRelTagMapping = response['mapping'].find((mapping) => { + return mapping.AddID === addedTag.PairRelID + }); + const addedTagID = addedTag.ID + const tagMapping = response['mapping'].find((mapping) => { + return mapping.AddID === addedTagID; + }); + if (tagMapping) { + addedTag.ID = tagMapping.RelID + if (pairRelTagMapping) { + addedTag.PairRelID = pairRelTagMapping.RelID; + } + updatedDatasetTags[addedTag.PropertyName][addedTag.PropertyValue].push( + addedTag + ); + + const tagInGrouped = groupedTags.find((tag: HEDTag) => { + return tag.ID === addedTagID + }); + if (tagInGrouped) { + setGroupedTags([ + ...applyOverrides(groupedTags).filter((tag) => { + return tag.ID !== addedTagID; + }), + addedTag + ]); + } + } else { + swal.fire( + 'Error', + 'There was an error with the response. Please report this incident.', + 'error' + ); + return; + } + }); + + editedTags.forEach((editedTag) => { + const tagInDataset = + updatedDatasetTags[editedTag.PropertyName][editedTag.PropertyValue] + .find((tag) => { + return tag.ID === editedTag.ID + }) + ; + if (!tagInDataset) { + console.error('Unable to find an edited tag!'); + return; + } + const pairRelTagMapping = response['mapping'].find((mapping) => { + return mapping.AddID === editedTag.PairRelID + }); + + updatedDatasetTags[editedTag.PropertyName][editedTag.PropertyValue] = [ + ...updatedDatasetTags[editedTag.PropertyName][editedTag.PropertyValue].filter((tag) => { + return tag.ID !== editedTag.ID; + }), + { + ...editedTag, + PairRelID: pairRelTagMapping ? pairRelTagMapping.RelID : editedTag.PairRelID, + }, + ]; + }); + + // Filter invalidated tags + Object.keys(updatedDatasetTags).forEach((columnName) => { + Object.keys(updatedDatasetTags[columnName]).forEach((columnValue) => { + updatedDatasetTags[columnName][columnValue] = + updatedDatasetTags[columnName][columnValue].filter((tag) => { + return !(tag.HEDTagID === null && tag.PairRelID === null); + }); + }); + }); + + setDatasetTags(updatedDatasetTags); + setAddedTags([]); + setDeletedTags([]); + setRelOverrides([]); + + swal.fire( + 'Success', + 'Tags updated successfully', + 'success' + ); + }).catch((error) => { + console.error(error); + if (error.status === 401) { + swal.fire( + 'Unauthorized', + 'This action is not permitted.', + 'error' + ); + } else { + swal.fire( + 'Error', + 'There was an error with the request! Please report this incident.', + 'error' + ); + } + }); + } + + const isDirty = (columnName: string, columnValue: string) => { + return [...deletedTags, ...addedTags].some((tag) => { + return tag.PropertyName === columnName + && ( + columnValue.length > 0 + ? tag.PropertyValue === columnValue + : true + ); + }) || ( + columnValue.length === 0 && + Object.keys(datasetTags[columnName]).some((colVal) => { + return datasetTags[columnName][colVal].some((tag) => { + return relOverrides.map((relOverride) => { + return relOverride.ID; + }).includes(tag.ID); + }) + }) + ) || ( + columnValue.length > 0 && + datasetTags[columnName][columnValue].some((tag) => { + return relOverrides.map((relOverride) => { + return relOverride.ID; + }).includes(tag.ID); + }) + ); + } + + useEffect(() => { + const openTagViewerClasses = document + .querySelector('#tag-modal-container > div > button') + .classList; + + if ([...addedTags, ...deletedTags].length > 0) { + openTagViewerClasses.add('tag-modal-container-dirty'); + } else { + openTagViewerClasses.remove('tag-modal-container-dirty'); + } + }, [addedTags, deletedTags]) + + const validateSearchTag = (longName: string) => { + const hedTag = schemaTags.find((tag: HEDSchemaElement) => { + return tag.longName === longName; + }) + return hedTag ? hedTag.id : false; + } + + const handleAddTag = () => { + const tagText = inputFieldRef.current.value.trim(); + + // Validate tag exists + const hedTagID = validateSearchTag(tagText); + const newTagID = generateTagID(0); + + if (hedTagID) { + const hedTag = schemaTags.find((tag) => { + return tag.id === hedTagID; + }) + + setAddedTags([ + ...addedTags, + { + schemaElement: hedTag, + HEDTagID: hedTag.id, + ID: newTagID, + PropertyName: activeColumnName, + PropertyValue: activeFieldValue, + TagValue: null, + Description: + datasetTags[activeColumnName][activeFieldValue].length > 0 + ? datasetTags[activeColumnName][activeFieldValue][0].Description ?? '' + : '', + HasPairing: '0', + PairRelID: null, + AdditionalMembers: 0, + } + ]); + setSearchText(''); + setSearchTextValid(false); + } else { + console.error('Failed to add tag. TODO: report') + } + } + + const getRootTags = (tags: HEDTag[]) => { + return tags.filter((tag) => { + return tag.ID && + !tags.some((t) => { + return tag.ID === t.PairRelID; + }) + }); + } + + const handleRemoveTag = (tagRelID: any) => { + if (groupMode) { + console.warn('If you want to delete a tag, you cannot be in "Group Mode". Press the "Tag Mode" button'); + return; + } + + const tagsToSearch = applyOverrides(datasetTags[activeColumnName][activeFieldValue]); + const tagFromDataset = tagsToSearch.find((tag) => { + return tag.ID === tagRelID; + }) + + if (tagFromDataset) { + // Only allow deletion if not grouped + const rootTags = getRootTags(tagsToSearch); + + const tagInRootTags = rootTags.find((tag) => { + return tag.ID === tagFromDataset.ID + }); + + const tagInGroupedTags = applyOverrides(groupedTags).find((tag) => { + return tag.ID === tagFromDataset.ID; + }) + + if (!tagInGroupedTags && tagInRootTags && tagInRootTags.PairRelID === null) { + setDeletedTags([ + ...deletedTags, + tagFromDataset + ]); + } else { + console.error('You may not delete a grouped tag. Undo group first.'); + } + } else { + const updatedAddedTags = applyOverrides(addedTags); + const updatedGroupedTags = applyOverrides(groupedTags); + // Remove tag from addedTags + if (tagRelID.startsWith('add_')) { + const tagFromAdded = updatedAddedTags.find((tag) => { + return tag.ID === tagRelID; + }); + const tagInGroupedTags = tagFromAdded + ? updatedGroupedTags.find((tag) => { + return tag.ID === tagFromDataset.ID; + }) + : false; + if (!tagInGroupedTags && tagFromAdded) { + setAddedTags(updatedAddedTags.filter((tag) => { + return tag !== tagFromAdded; + })); + } + } + } + + // Force tooltip removal + handleHEDMouseLeave(); + } + + const handleTextChange = (e) => { + const newText = e.target.value; + let fullHEDString = newText; + const hedTag = schemaTags.find((tag) => { + return tag.name === newText; + }) + if (hedTag && hedTag.longName.trim().length > fullHEDString.length) + fullHEDString = hedTag.longName.trim(); + setSearchText(fullHEDString); + setSearchTextValid(!!validateSearchTag(fullHEDString)); + } + + const handleFieldValueChange = (e) => { + setActiveFieldValue(e.target.value); + setGroupedTags([]); + } + + const handleColumnValueChange = (e) => { + setActiveColumnName(e.target.value); + setActiveFieldValue(null); + setSearchText(''); + } + + const getNumberOfDirectChildren = (tagName: string) => { + return schemaTags.filter((hedTag) => { + const tagSplit = hedTag.longName.split('/'); + return tagSplit.length === 2 && tagSplit[0] === tagName.trim(); + }).length; + } + + const buildDataList = (onlyParentNodes: boolean) => { + return ( + + { + (onlyParentNodes + ? parentNodes + : schemaTags + ).map((hedTag) => { + if (!activeHEDSchemas[hedTag.schemaName.toUpperCase()]) + return null; + + return ( + + ); + } + + const buildColumnNames = () => { + return columnOptions.filter((column) => { + return column.value !== 'channels' // Removed until recognized + && column.field_values.filter((value) => { + return !['', 'n/a'].includes(value); // Column has real values + }).length > 0 + }).map((column) => { + const columnName = column.value; + const columnIsDirty = isDirty(columnName, ''); + + return ( + + ) + }); + } + + const buildFieldValues = (columnName: string) => { + const column = columnOptions.find((opt) => { + return opt.value === columnName + }); + + if (!column) + return null; + + return column.field_values.map((fieldValue) => { + const valueIsDirty = isDirty(columnName, fieldValue); + return ( + + ) + }) + } + + const handleHEDMouseEnter = (hedSchemaElement: HEDSchemaElement) => { + if (hedSchemaElement) { + setDatasetTooltip({ + title: hedSchemaElement.longName, + description: hedSchemaElement.description, + }); + } + } + + const handleHEDMouseLeave = () => { + clearDatasetTooltip(); + } + + const clearDatasetTooltip = () => { + setDatasetTooltip({ + title: '', + description: '', + }); + } + + const getGroupedTagPairings = (hedTags: HEDTag[]) => { + const tagPairings = []; + if (activeFieldValue) { // null between transitions + hedTags.forEach((hedTag) => { + let pairRelID = hedTag.PairRelID; + while (pairRelID !== null) { + const pairRelTag = applyOverrides([...addedTags, ...datasetTags[activeColumnName][activeFieldValue]]) + .find((t) => { + return t.ID === pairRelID; + }); + if (pairRelTag) { + tagPairings.push(pairRelTag); + pairRelID = pairRelTag.PairRelID; + } else { + console.error(`Something went wrong. Tag with ID ${pairRelID} not found. Should not proceed.`); + pairRelID = null; + } + } + }); + } + return tagPairings; + } + + const buildGroupSpan = (char: string, colorIndex: number) => { + return ( + + {char} + + ); + } + + const buildHEDBadges = (tags: HEDTag[]) => { + const hedTags = applyOverrides(tags).filter((tag) => { + // Filter out invalidated addedTags that were added for structure (HEDTagID === null) and DB garbage + return tag.HEDTagID !== null || tag.PairRelID !== null; + }); + + const rootTags = getRootTags(hedTags); + const tagBadges = []; + + rootTags.forEach((tag) => { + let groupColorIndex = 0; + if (tag.PairRelID === null) { + tagBadges.push(buildHEDBadge(tag.schemaElement.longName, tag.ID.toString(), true)); + groupColorIndex++; + } else { + const tagGroup = []; + let groupMember = tag; + while (groupMember) { + tagGroup.push(groupMember); + groupMember = hedTags.find((hedTag) => { + return hedTag.ID === groupMember.PairRelID; + }); + } + + const tagBadgeGroup = []; + const tagBadgeSubgroup = []; + tagGroup.reverse().map((groupTag: HEDTag) => { + if (groupTag.PairRelID === null) { + tagBadgeGroup.push(buildHEDBadge(groupTag.schemaElement.longName, groupTag.ID, true)); + } else { + if (groupTag.HasPairing === '1') { + if (groupTag.AdditionalMembers > 0 || tagBadgeSubgroup.length === 0) { + let commaIndex = getNthMemberTrailingBadgeIndex( + tagBadgeGroup, + groupTag.AdditionalMembers + ( + tagBadgeSubgroup.length > 0 ? 0 : 1 + ) + ); + + tagBadgeGroup.splice(commaIndex, 0, buildGroupSpan(')', groupColorIndex)); + if (tagBadgeSubgroup.length > 0) { + tagBadgeGroup.splice(0, 0, ...tagBadgeSubgroup); + } + if (groupTag.HEDTagID !== null) { + tagBadgeGroup.splice(0, 0, buildHEDBadge(groupTag.schemaElement.longName, groupTag.ID, true)); + } + tagBadgeGroup.splice(0, 0, buildGroupSpan('(', groupColorIndex)); + tagBadgeSubgroup.length = 0; + } else { + if (groupTag.HEDTagID === null) { + if (tagBadgeSubgroup.length > 0) { + tagBadgeSubgroup.splice(0, 0, buildGroupSpan('(', groupColorIndex)); + tagBadgeSubgroup.push(buildGroupSpan(')', groupColorIndex)); + } else { + console.error('UNEXPECTED STATE'); + } + } else { + if (tagBadgeSubgroup.length > 0) { + tagBadgeSubgroup.splice(0, 0, buildHEDBadge(groupTag.schemaElement.longName, groupTag.ID, true)); + tagBadgeSubgroup.splice(0, 0, buildGroupSpan('(', groupColorIndex)); + tagBadgeSubgroup.push(buildGroupSpan(')', groupColorIndex)); + } else { + tagBadgeGroup.splice(0, 0, buildHEDBadge(groupTag.schemaElement.longName, groupTag.ID, true)); + tagBadgeGroup.splice(0, 0, buildGroupSpan('(', groupColorIndex)); + tagBadgeGroup.push(buildGroupSpan(')', groupColorIndex)); + } + } + } + groupColorIndex++; + } else { + if (tagBadgeSubgroup.length > 0) { + tagBadgeGroup.splice(0, 0, ...tagBadgeSubgroup); + } + tagBadgeSubgroup.splice(0, tagBadgeSubgroup.length, buildHEDBadge(groupTag.schemaElement.longName, groupTag.ID, true)); + } + } + }); + tagBadges.push(...tagBadgeGroup); + } + }); + return tagBadges; + } + + const buildHEDBadge = (text: string, relID: string, isSubmitted: boolean = true) => { + const hedTag = schemaTags.find((tag) => { + return tag.longName.trim() === text; + }); + + const tagsToSearch = applyOverrides([...addedTags, ...datasetTags[activeColumnName][activeFieldValue]]); + + let hedTagObj = isSubmitted && tagsToSearch.find((tag) => { + return tag.ID === relID; + }); + + const tagIsGrouped = hedTagObj && hedTagObj.HasPairing === '1' || ( + tagsToSearch.some((tag) => { + return tag.PairRelID ? tag.PairRelID.toString() : undefined === relID + }) + ); + + let tagIsSelected = false; + + if (hedTagObj) { + tagIsSelected = applyOverrides(groupedTags).some((tag) => { + if (tag.ID === relID) { + return true; + } + if (tag.PairRelID) { + let tagObject = tagsToSearch.find((t) => { + return t.ID === tag.ID; + }); + while (tagObject && (tagObject.PairRelID !== null || tagObject.HasPairing === '1')) { + tagObject = tagsToSearch.find((t) => { + return t.ID === tagObject.PairRelID; + }); + if (tagObject && tagObject.ID === relID) { + return true; + } + } + } + }); + } + + let tagClassName = 'selection-filter-tag selection-filter-dataset-tag dataset-filter-tag'; + tagClassName += isSubmitted ? '' : ' dataset-tag-dirty'; + tagClassName += tagIsSelected ? ' dataset-tag-selected' : ''; + + return ( +
{ + handleHEDMouseEnter(hedTag); + }} + onMouseLeave={handleHEDMouseLeave} + onClick={(e) => { + if (!isSubmitted || (!groupMode && !e.shiftKey)) + return; + + if (hedTagObj) { + let tagRelID = relID; + if (tagIsGrouped) { + // Find leaf tag + let pairRelObj = hedTagObj; + while (pairRelObj !== undefined) { + tagRelID = pairRelObj.ID; + hedTagObj = pairRelObj; + pairRelObj = tagsToSearch.find((tag) => { + return tag.PairRelID === pairRelObj.ID; + }); + } + } + if (tagIsSelected) { + // Remove leaf + setGroupedTags(applyOverrides(groupedTags).filter((tag) => { + return tag.ID !== tagRelID; + })); + } else { + // Add leaf + setGroupedTags([...groupedTags, hedTagObj]); + } + } else { + console.error(`Tag not found: ${relID}`); + } + }} + > +
+ + { + hedTag + ? showLongFormHED + ? hedTag.longName + : hedTag.name + : text + } + + { + if (isSubmitted) { + handleRemoveTag(relID); + } else { + setSearchText(''); + clearDatasetTooltip(); + } + }} + > + x + +
+
+ ); + } + + const applyOverrides = (hedTags: HEDTag[]): HEDTag[] => { + return hedTags.map((hedTag) => { + let schemaElement = hedTag.schemaElement; + if (hedTag.HEDTagID !== null && !schemaElement) { + schemaElement = schemaTags.find((tag) => { + return tag.id === hedTag.HEDTagID; + }) + } + const overriddenTag = relOverrides.find((tag) => { + return tag.ID === hedTag.ID; + }); + + if (overriddenTag) { + hedTag = { + ...hedTag, + HasPairing: overriddenTag.HasPairing, + PairRelID: overriddenTag.PairRelID, + AdditionalMembers: overriddenTag.AdditionalMembers, + } + } + return { + ...hedTag, + schemaElement: schemaElement, + }; + }) + } + + + const columnOptions = Object.keys(datasetTags).map((column) => { + return { + label: column, + value: column, + description: '', + field_values: Object.keys(datasetTags[column]).map((columnValue) => { + return columnValue; + }) + } + }); + + const handleConfirmGroup = () => { + const updatedGroupTags = applyOverrides(groupedTags); + const newTags = []; + const tagOverrides = []; + + // Tag created before first + const newTagID = generateTagID(0); + const newTag: HEDTag = { + schemaElement: null, + HEDTagID: null, + ID: newTagID, + PropertyName: activeColumnName, + PropertyValue: activeFieldValue, + TagValue: null, + Description: updatedGroupTags[0].Description, + HasPairing: '1', + PairRelID: updatedGroupTags[0].ID, + AdditionalMembers: updatedGroupTags.length - 1, + } + newTags.push(newTag); + + updatedGroupTags.forEach((rootTag, groupTagIndex) => { + const tagPairings = applyOverrides(getGroupedTagPairings([rootTag])); + const leafTag = tagPairings.length > 0 ? tagPairings.slice(-1)[0] : rootTag; + const leafHadPairing = leafTag.HasPairing === '1'; + + if ((groupTagIndex + 1) < updatedGroupTags.length) { + tagOverrides.push({ + ...leafTag, + HasPairing: '0', + PairRelID: updatedGroupTags[groupTagIndex + 1].ID, + }); + } + + if (leafHadPairing) { + console.error('Something went wrong'); + } + }); + + setAddedTags([...addedTags, ...newTags]); + setRelOverrides([ + ...relOverrides.filter((t) => { + return !tagOverrides.some((relTag) => { + return relTag.ID === t.ID; + }) + }), + ...tagOverrides + ]); + + setGroupedTags([]); + } + + const handleUndoGroup = () => { + let firstTag = applyOverrides(groupedTags)[0]; + const tagPairings = getGroupedTagPairings([firstTag]); + const tagOverrides = []; + // Remove pairings + [firstTag, ...tagPairings].forEach((tag) => { + tagOverrides.push({ + ...tag, + HasPairing: '0', + PairRelID: null, + AdditionalMembers: 0, + }); + }); + + setRelOverrides([ + ...relOverrides.filter((t) => { + return !tagOverrides.some((relTag) => { + return relTag.ID === t.ID; + }) + }), + ...tagOverrides + ]); + + setGroupedTags([]); + // TODO: IF REL SAME AS OG, DELETE FROM REL + } + + const handleSchemaFieldClick = (key, action) => { + setActiveHEDSchemas(previouslyActive => ({ + ...previouslyActive, + [key]: action === 'check' + })); + } + + const handleToggleAllSchemas = (action) => { + const active = action === 'check'; + setActiveHEDSchemas( + Object.keys(activeHEDSchemas).reduce((schemas, schema) => { + return { + ...schemas, + [schema]: active + }; + }, {}) + ); + } + + return ( +
+
+
+
+ +
+
+ +
+
Column Values
+ +
0 ? 'block' : 'none'}`, + position: 'absolute', + width: '22.5%', + height: '52.5vh', + backgroundColor: '#555555', + color: '#fff', + padding: '10px', + borderRadius: '6px', + textAlign: 'left', + }} + > +
+ {datasetTooltip.title} +
+
+ {datasetTooltip.description} +
+
+
+
+ { + (activeFieldValue === null || activeFieldValue === '') && ( +
+ Select a Column Name and Value to Add or View HED Tags +
+ ) + } + { + (activeFieldValue !== null && activeFieldValue !== '') && ( +
+
+ { + setShowLongFormHED(!showLongFormHED); + }} + /> +
+
+ Currently active schemas: +
+
+ +
+
+
+ +
+ {e.preventDefault()}}> + + + {buildDataList(searchText.length === 0)} + +
+ + +
+
+ Description: +
+
+
+
+