diff --git a/SQL/0000-00-05-ElectrophysiologyTables.sql b/SQL/0000-00-05-ElectrophysiologyTables.sql index 8a2d0810464..1d44150b089 100644 --- a/SQL/0000-00-05-ElectrophysiologyTables.sql +++ b/SQL/0000-00-05-ElectrophysiologyTables.sql @@ -299,6 +299,7 @@ CREATE TABLE `physiological_task_event` ( `InsertTime` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `Onset` DECIMAL(11,6) NOT NULL, `Duration` DECIMAL(11,6) NOT NULL, + `Channel` TEXT DEFAULT NULL, `EventCode` INT(10) DEFAULT NULL, `EventValue` varchar(255) DEFAULT NULL, `EventSample` decimal(11,6) DEFAULT NULL, @@ -577,6 +578,7 @@ CREATE TABLE `physiological_task_event_hed_rel` ( `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 + `TaggedBy` int(10) unsigned DEFAULT NULL, 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, @@ -584,7 +586,8 @@ CREATE TABLE `physiological_task_event_hed_rel` ( 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 + REFERENCES `physiological_task_event` (`PhysiologicalTaskEventID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_pte_tagged_by_user` FOREIGN KEY (`TaggedBy`) REFERENCES `users` (`ID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- Create `bids_event_dataset_mapping` table @@ -599,10 +602,12 @@ CREATE TABLE `bids_event_dataset_mapping` ( `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 + `TaggedBy` int(10) unsigned DEFAULT NULL, 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 + CONSTRAINT `FK_dataset_hed_tag_id` FOREIGN KEY (`HEDTagID`) REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_bed_tagged_by_user` FOREIGN KEY (`TaggedBy`) REFERENCES `users` (`ID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; @@ -618,8 +623,143 @@ CREATE TABLE `bids_event_file_mapping` ( `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 + `TaggedBy` int(10) unsigned DEFAULT NULL, 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 + CONSTRAINT `FK_file_hed_tag_id` FOREIGN KEY (`HEDTagID`) REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_bef_tagged_by_user` FOREIGN KEY (`TaggedBy`) REFERENCES `users` (`ID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + +-- Create parameter_project table to track channel delimiter +CREATE TABLE `parameter_project` ( + `ParameterProjectID` int(10) unsigned NOT NULL auto_increment, + `ProjectID` int(10) unsigned NOT NULL default '0', + `ParameterTypeID` int(10) unsigned NOT NULL default '0', + `Value` varchar(255) default NULL, + `InsertTime` int(10) unsigned NOT NULL default '0', + PRIMARY KEY (`ParameterProjectID`), + UNIQUE KEY `project_type` (`ProjectID`,`ParameterTypeID`), + KEY `parameter_value` (`ParameterTypeID`,`Value`(64)), + CONSTRAINT `FK_parameter_project_2` FOREIGN KEY (`ParameterTypeID`) REFERENCES `parameter_type` (`ParameterTypeID`), + CONSTRAINT `FK_parameter_project_1` FOREIGN KEY (`ProjectID`) REFERENCES `Project` (`ProjectID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +INSERT IGNORE INTO `parameter_type_category` (Name, Type) +VALUES ('Project Parameters', 'Metavars'); + +-- Add channel delimiter, taken from events.json to DB +INSERT IGNORE INTO parameter_type (Name, Type, Description, SourceFrom, Queryable, IsFile) VALUES + ('ChannelDelimiter', 'text', 'Channel name separator', 'parameter_project', 1, 0); + +INSERT INTO parameter_type_category_rel (ParameterTypeID, ParameterTypeCategoryID) +SELECT pt.ParameterTypeID, ptc.ParameterTypeCategoryID +FROM parameter_type pt, parameter_type_category ptc +WHERE ptc.Name='Project Parameters' AND pt.Name IN ('ChannelDelimiter'); + + + +-- Create `hed_tag_endorsement` table +CREATE TABLE `hed_tag_endorsement` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `HEDRelID` int(10) unsigned NOT NULL, -- TODO: Manually handle ON DELETE CASCADE + `HEDTable` enum( + 'physiological_task_event_hed_rel', + 'bids_event_dataset_mapping', + 'bids_event_file_mapping' + ) NOT NULL, + `EndorsedBy` int(10) unsigned NOT NULL, + `EndorsementStatus` enum( + 'Endorsed', + 'Caveat', + 'Comment' + ) NOT NULL, + `EndorsementComment` TEXT DEFAULT NULL, + `EndorsedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `LastUpdate` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`ID`), + CONSTRAINT `FK_endorsed_by_user` + FOREIGN KEY (`EndorsedBy`) REFERENCES `users` (`ID`) ON UPDATE CASCADE ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Create `hed_tag_endorsement_history` table +CREATE TABLE `hed_tag_endorsement_history` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `EndorsementID` int(10) unsigned NOT NULL, + `Action` enum( + 'INSERT', + 'UPDATE', + 'DELETE' + ) NOT NULL, + `HEDRelID` int(10) unsigned NOT NULL, + `HEDTable` enum( + 'physiological_task_event_hed_rel', + 'bids_event_dataset_mapping', + 'bids_event_file_mapping' + ) NOT NULL, + `EndorsedBy` int(10) unsigned NOT NULL, + `EndorsementStatus` enum( + 'Endorsed', + 'Caveat', + 'Comment' + ) NOT NULL, + `EndorsementComment` TEXT DEFAULT NULL, + `EndorsedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`ID`), + CONSTRAINT `FK_endorsement_id` + FOREIGN KEY (`EndorsementID`) REFERENCES `hed_tag_endorsement` (`ID`) ON UPDATE CASCADE, + CONSTRAINT `FK_endorsed_by_user_history` + FOREIGN KEY (`EndorsedBy`) REFERENCES `users` (`ID`) ON UPDATE CASCADE ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Create `hed_tag_history` table +CREATE TABLE `hed_tag_history` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `HEDTableID` int(10) unsigned NOT NULL, + `HEDTable` enum( + 'physiological_task_event_hed_rel', + 'bids_event_dataset_mapping', + 'bids_event_file_mapping' + ) NOT NULL, + `HEDReferenceID` int(10) unsigned NOT NULL, -- PhysiologicalTaskEventID, ProjectID, EventFileID + `TaggedBy` int(10) unsigned DEFAULT NULL, + `PropertyName` varchar(50) DEFAULT NULL, + `PropertyValue` varchar(255) DEFAULT NULL, + `HEDTagID` int(10) unsigned DEFAULT NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` TEXT NULL, + `Description` TEXT NULL, + `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, + `ModificationType` enum('insert', 'update', 'delete') NOT NULL, + `ModifiedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`ID`), + CONSTRAINT `FK_hed_tagged_by_history` + FOREIGN KEY (`TaggedBy`) REFERENCES `users` (`ID`) ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Create `physiological_task_event_history` table +CREATE TABLE `physiological_task_event_history` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `PhysiologicalTaskEventID` int(10) unsigned NOT NULL, + `PhysiologicalFileID` int(10) unsigned NOT NULL, + `EventFileID` int(10) unsigned NOT NULL, + `InsertTime` timestamp NOT NULL, + `Onset` decimal(11,6) DEFAULT NULL, + `Duration` decimal(11,6) DEFAULT NULL, + `Channel` TEXT DEFAULT NULL, + `EventCode` int(10) DEFAULT NULL, + `EventValue` varchar(255) DEFAULT NULL, + `EventSample` decimal(11,6) DEFAULT NULL, + `EventType` varchar(50) DEFAULT NULL, + `TrialType` varchar(255) DEFAULT NULL, + `ResponseTime` time DEFAULT NULL, + `ModifiedBy` int(10) unsigned DEFAULT NULL, + `ModificationType` enum('insert', 'update', 'delete') NOT NULL, + `ModifiedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`ID`), + CONSTRAINT `FK_physiological_task_event_modified_by_history` + FOREIGN KEY (`ModifiedBy`) REFERENCES `users` (`ID`) ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + diff --git a/SQL/New_patches/2025-09-29_channel_delimiter.sql b/SQL/New_patches/2025-09-29_channel_delimiter.sql new file mode 100644 index 00000000000..42fb5b71070 --- /dev/null +++ b/SQL/New_patches/2025-09-29_channel_delimiter.sql @@ -0,0 +1,31 @@ +-- Add column to track associated channel names +ALTER TABLE physiological_task_event ADD COLUMN `Channel` TEXT DEFAULT NULL; + +-- Create parameter_project table to track parameters and channel delimiter +CREATE TABLE `parameter_project` ( + `ParameterProjectID` int(10) unsigned NOT NULL auto_increment, + `ProjectID` int(10) unsigned NOT NULL default '0', + `ParameterTypeID` int(10) unsigned NOT NULL default '0', + `Value` text default NULL, + `InsertTime` int(10) unsigned NOT NULL default '0', + PRIMARY KEY (`ParameterProjectID`), + UNIQUE KEY `project_type` (`ProjectID`,`ParameterTypeID`), + KEY `parameter_value` (`ParameterTypeID`,`Value`(64)), + CONSTRAINT `FK_parameter_project_2` FOREIGN KEY (`ParameterTypeID`) REFERENCES `parameter_type` (`ParameterTypeID`), + CONSTRAINT `FK_parameter_project_1` FOREIGN KEY (`ProjectID`) REFERENCES `Project` (`ProjectID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + +INSERT IGNORE INTO `parameter_type_category` (Name, Type) +VALUES ('Project Parameters', 'Metavars'); + +-- Add channel delimiter, taken from events.json to DB +INSERT IGNORE INTO parameter_type (Name, Type, Description, SourceFrom, Queryable, IsFile) VALUES + ('ChannelDelimiter', 'text', 'Channel name separator', 'parameter_project', 1, 0); + +INSERT INTO parameter_type_category_rel (ParameterTypeID, ParameterTypeCategoryID) +SELECT pt.ParameterTypeID, ptc.ParameterTypeCategoryID +FROM parameter_type pt, parameter_type_category ptc +WHERE ptc.Name='Project Parameters' AND pt.Name IN ('ChannelDelimiter'); + + diff --git a/SQL/New_patches/2025-09-29_hed_endorsement.sql b/SQL/New_patches/2025-09-29_hed_endorsement.sql new file mode 100644 index 00000000000..1c70db86aa8 --- /dev/null +++ b/SQL/New_patches/2025-09-29_hed_endorsement.sql @@ -0,0 +1,121 @@ +-- Add TaggedBy column (user FK). NULL signifies it came with the dataset +ALTER TABLE physiological_task_event_hed_rel + ADD COLUMN TaggedBy int(10) unsigned DEFAULT NULL; +ALTER TABLE bids_event_dataset_mapping + ADD COLUMN TaggedBy int(10) unsigned DEFAULT NULL; +ALTER TABLE bids_event_file_mapping + ADD COLUMN TaggedBy int(10) unsigned DEFAULT NULL; +-- ADD FK constraint +ALTER TABLE physiological_task_event_hed_rel + ADD CONSTRAINT `FK_pte_tagged_by_user` + FOREIGN KEY (`TaggedBy`) REFERENCES `users` (`ID`); +ALTER TABLE bids_event_dataset_mapping + ADD CONSTRAINT `FK_bed_tagged_by_user` + FOREIGN KEY (`TaggedBy`) REFERENCES `users` (`ID`); +ALTER TABLE bids_event_file_mapping + ADD CONSTRAINT `FK_bef_tagged_by_user` + FOREIGN KEY (`TaggedBy`) REFERENCES `users` (`ID`); + +-- Create `hed_tag_endorsement` table +CREATE TABLE `hed_tag_endorsement` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `HEDRelID` int(10) unsigned NOT NULL, -- TODO: Manually handle ON DELETE CASCADE + `HEDTable` enum( + 'physiological_task_event_hed_rel', + 'bids_event_dataset_mapping', + 'bids_event_file_mapping' + ) NOT NULL, + `EndorsedBy` int(10) unsigned NOT NULL, + `EndorsementStatus` enum( + 'Endorsed', + 'Caveat', + 'Comment' + ) NOT NULL, + `EndorsementComment` TEXT DEFAULT NULL, + `EndorsedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `LastUpdate` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`ID`), + CONSTRAINT `FK_endorsed_by_user` + FOREIGN KEY (`EndorsedBy`) REFERENCES `users` (`ID`) ON UPDATE CASCADE ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Create `hed_tag_endorsement_history` table +CREATE TABLE `hed_tag_endorsement_history` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `EndorsementID` int(10) unsigned NOT NULL, + `Action` enum( + 'INSERT', + 'UPDATE', + 'DELETE' + ) NOT NULL, + `HEDRelID` int(10) unsigned NOT NULL, + `HEDTable` enum( + 'physiological_task_event_hed_rel', + 'bids_event_dataset_mapping', + 'bids_event_file_mapping' + ) NOT NULL, + `EndorsedBy` int(10) unsigned NOT NULL, + `EndorsementStatus` enum( + 'Endorsed', + 'Caveat', + 'Comment' + ) NOT NULL, + `EndorsementComment` TEXT DEFAULT NULL, + `EndorsedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`ID`), + CONSTRAINT `FK_endorsement_id` + FOREIGN KEY (`EndorsementID`) REFERENCES `hed_tag_endorsement` (`ID`) ON UPDATE CASCADE, + CONSTRAINT `FK_endorsed_by_user_history` + FOREIGN KEY (`EndorsedBy`) REFERENCES `users` (`ID`) ON UPDATE CASCADE ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Create `hed_tag_history` table +CREATE TABLE `hed_tag_history` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `HEDTableID` int(10) unsigned NOT NULL, + `HEDTable` enum( + 'physiological_task_event_hed_rel', + 'bids_event_dataset_mapping', + 'bids_event_file_mapping' + ) NOT NULL, + `HEDReferenceID` int(10) unsigned NOT NULL, -- PhysiologicalTaskEventID, ProjectID, EventFileID + `TaggedBy` int(10) unsigned DEFAULT NULL, + `PropertyName` varchar(50) DEFAULT NULL, + `PropertyValue` varchar(255) DEFAULT NULL, + `HEDTagID` int(10) unsigned DEFAULT NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` TEXT NULL, + `Description` TEXT NULL, + `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, + `ModificationType` enum('insert', 'update', 'delete') NOT NULL, + `ModifiedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`ID`), + CONSTRAINT `FK_hed_tagged_by_history` + FOREIGN KEY (`TaggedBy`) REFERENCES `users` (`ID`) ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Create `physiological_task_event_history` table +CREATE TABLE `physiological_task_event_history` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `PhysiologicalTaskEventID` int(10) unsigned NOT NULL, + `PhysiologicalFileID` int(10) unsigned NOT NULL, + `EventFileID` int(10) unsigned NOT NULL, + `InsertTime` timestamp NOT NULL, + `Onset` decimal(11,6) DEFAULT NULL, + `Duration` decimal(11,6) DEFAULT NULL, + `Channel` TEXT DEFAULT NULL, + `EventCode` int(10) DEFAULT NULL, + `EventValue` varchar(255) DEFAULT NULL, + `EventSample` decimal(11,6) DEFAULT NULL, + `EventType` varchar(50) DEFAULT NULL, + `TrialType` varchar(255) DEFAULT NULL, + `ResponseTime` time DEFAULT NULL, + `ModifiedBy` int(10) unsigned DEFAULT NULL, + `ModificationType` enum('insert', 'update', 'delete') NOT NULL, + `ModifiedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`ID`), + CONSTRAINT `FK_physiological_task_event_modified_by_history` + FOREIGN KEY (`ModifiedBy`) REFERENCES `users` (`ID`) ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + diff --git a/jsx/DataTable.js b/jsx/DataTable.js index 2826e82cb1c..e0d8c4cdee5 100644 --- a/jsx/DataTable.js +++ b/jsx/DataTable.js @@ -471,7 +471,11 @@ class DataTable extends Component { { this.setSortColumn(i); }}> - {this.props.fields[i].label} + { + this.props.fields[i].custom_label + ? this.props.fields[i].custom_label + : this.props.fields[i].label + } ); } diff --git a/jsx/Filter.js b/jsx/Filter.js index 7e018361e5e..d45058eb2c2 100644 --- a/jsx/Filter.js +++ b/jsx/Filter.js @@ -46,7 +46,7 @@ function Filter(props) { * @param {string} value - the name of the form element */ const onFieldUpdate = (name, value) => { - const {fields} = JSON.parse(JSON.stringify(props)); + const {fields} = props; const type = fields .find((field) => (field.filter||{}).name == name).filter.type; const exactMatch = (!(type === 'text' || type === 'date' diff --git a/jsx/Panel.d.ts b/jsx/Panel.d.ts index 5688f325ed9..7ad88c2cddf 100644 --- a/jsx/Panel.d.ts +++ b/jsx/Panel.d.ts @@ -1,4 +1,4 @@ -import {ReactNode} from 'react'; +import {ReactInstance, ReactNode} from 'react'; type PanelProps = { initCollapsed?: boolean @@ -6,7 +6,7 @@ type PanelProps = { parentId?: string id?: string height?: string - title?: string + title?: ReactNode | object class?: string children: ReactNode views?: object diff --git a/jsx/Panel.js b/jsx/Panel.js index 0fca9987bba..446cc91afd3 100644 --- a/jsx/Panel.js +++ b/jsx/Panel.js @@ -144,7 +144,7 @@ Panel.propTypes = { parentId: PropTypes.string, id: PropTypes.string, height: PropTypes.string, - title: PropTypes.string, + title: PropTypes.object, class: PropTypes.string, children: PropTypes.node, views: PropTypes.array, @@ -153,7 +153,6 @@ Panel.propTypes = { bold: PropTypes.bool, panelSize: PropTypes.string, style: PropTypes.object, - children: PropTypes.node, }; Panel.defaultProps = { initCollapsed: false, diff --git a/modules/electrophysiology_browser/css/electrophysiology_browser.css b/modules/electrophysiology_browser/css/electrophysiology_browser.css index 5481f7ec617..b063078a535 100644 --- a/modules/electrophysiology_browser/css/electrophysiology_browser.css +++ b/modules/electrophysiology_browser/css/electrophysiology_browser.css @@ -14,15 +14,23 @@ width: 100%; } +.react-series-data-viewer-scoped > panel-primary { + padding-bottom: 35px; +} + +#channel-viewer > .panel-body { + margin-bottom: 30px; +} + .checkbox-flex-label > div > input[type="checkbox"] { vertical-align: top; } .checkbox-flex-label { display: flex; - align-items: center; + align-items: flex-start; margin-bottom: 0; - justify-content: flex-end; + justify-content: center; } .btn-dropdown-toggle { @@ -45,6 +53,10 @@ padding: 0; } +.dropdown-menu > .active { + background-color: #e4ebf2; +} + .btn.btn-xs { font-size: 12px; @@ -66,6 +78,12 @@ outline: 0; } +.btn-blue:not(.active) { + color: white; + background-color: #246EB6; + border-color: #246EB6; +} + .no-gutters > div { padding:0; } @@ -78,7 +96,9 @@ svg:not(:root) { overflow: clip; } -.annotation.list-group-item { +.annotation.list-group-item, +.panel-event.list-group-item, +.panel-event-channel-based.list-group-item { position: relative; display: flex; flex-direction: column; @@ -88,24 +108,145 @@ svg:not(:root) { width: 100%; } -.annotation { - background: #fffae6; - border-left: 5px solid #8eecfa; +.annotation, +.panel-event { + background: #fff; + border-left: 5px solid #a6d5f2; } -.epoch-details { +.panel-event-channel-based { + background: #fff; + border-left: 5px solid #7ef1de; +} + +#right-panel-controls { + display: flex; + justify-content: flex-end; + padding-right: 5px; +} + +.list-group-item:hover { + background: #fff9d6; +} + +#event-name { + width: 100%; +} + +/* THIS REMOVED BORDERS */ +.form-control.form-edit:not([name="select-add-hed"]) { + background-color: unset !important; + border: none !important; + box-shadow: none !important; +} + +.event-label { + display: inline-block; + background-color: #eff1f2; + color: #1f2329; + font-weight: normal; +} + +.control-label { + margin: 0; +} + +.additional-columns-outer { display: flex; width: 100%; - padding: 10px 0; + margin-top: 8px; + align-items: center; } -.epoch-action { +.additional-columns-inner { + flex: 1; +} + +.code-mimic { + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; + padding: 2px 4px; + font-size: 90%; + border-radius: 4px; +} + +.word-break-word { + word-break: break-word; +} + +.form-element-row { display: flex; - flex-direction: row; - justify-content: end; align-items: center; } +.select-tag-text, .select-placeholder-text { + font-style: italic; + color: #aaa; +} + +.select-tag-text > *, +.select-placeholder-text > * { + font-style: normal; + color: #555; +} + +.select-element-row > label { + flex-basis: 40%; +} + +.select-element-row > div { + flex: 1; +} + +.numeric-element-row { + margin: 8px 0; +} +.numeric-element-row > label { + flex-basis: 40%; +} + +.numeric-element-row > label::after { + font-weight: normal; + content: ' [s]'; +} + +.numeric-element-row > div { + flex-basis: 60%; +} + +.epoch-details { + display: flex; + width: calc(100% - 15px); + padding: 10px 0; +} + +.epoch-action { + display: grid; + grid-template-columns: 50% 50%; + row-gap: 5px; + align-content: center; + padding-right: 15px; + scale: 1.25; +} + +.epoch-action > .btn-xs { + padding: 0; + font-size: 13px; +} + +.epoch-details > .epoch-label { + flex-grow: 1; + padding-left: 15px; + padding-right: 10px; + margin-right: 5px; + text-overflow: ellipsis; + overflow: hidden; +} + +.epoch-action button { + width: 24px; + height: 22px; +} + .epoch-tag { padding: 10px; background: #e7e4e4; @@ -113,6 +254,11 @@ svg:not(:root) { width: 100%; } +.epoch-tag > div, +.epoch-tag > div > div { + margin: 5px 0; +} + .badge-pill { max-width: 100%; } @@ -140,15 +286,7 @@ svg:not(:root) { display: inline-block; } -.toggle-long-hed label > div { - padding-right: 10px !important; -} - -.toggle-long-hed input[type='checkbox'] { - vertical-align: top; -} - -.tag-hed-score { +.tag-hed-artifact { cursor: pointer; } @@ -161,8 +299,8 @@ svg:not(:root) { border-radius: 6px; position: absolute; z-index: 1; - top: -50px; - right: 105%; + top: 50px; + right: unset; width: 500px; max-height: 350px; overflow-y: scroll; @@ -179,12 +317,54 @@ svg:not(:root) { font-size: 16px; } +#dataset-hed-tooltip { + position: absolute; + width: 22.5%; + height: 52.5vh; + background-color: #555555; + color: whitesmoke; + padding: 10px; + border-radius: 6px; + text-align: left; + z-index: 5; +} + .tooltip-description { font-weight: normal; font-style: italic; + font-size: 12px; + margin-top: 15px; +} + +.tooltip-footer { + font-size: 12px; + color: black; + background: whitesmoke; + border-radius: 5px; + padding: 5px; +} + +.tooltip-footer-viewer { + position: absolute; + bottom: 20%; + width: 93%; +} + +.tooltip-footer-panel { + width: 50%; + margin: auto; +} + +.hed-badge-not-origin:after { + content: '※'; + vertical-align: top; + font-size: 11px; } .badge-hed-add { + /*color: #4b9b29 !important;*/ + /*border: solid 1px #4b9b29; + #246EB6*/ color: #246EB6 !important; border: solid 1px #246EB6; background-color: #fff !important; @@ -209,6 +389,14 @@ svg:not(:root) { border-left: #999 solid 1px; } +#select-schema-dropdown button { + padding: 2px !important; +} + +#hed-tag-input::-webkit-calendar-picker-indicator { + opacity: 100; +} + .selection-filter-tag { display: inline-block; margin: 2px 5px; @@ -220,15 +408,21 @@ svg:not(:root) { max-width: 100vw; } + .selection-filter-dataset-tag { color: #fff; background-color: #A9A9A9; } .dataset-tag-selected { + /*background-color: #4B9B29;*/ background-color: rgb(24, 99, 0); } +#tag-modal-container > button { + z-index: 9; +} + .filter-tag-name { white-space: nowrap; text-overflow: ellipsis; @@ -237,12 +431,193 @@ svg:not(:root) { max-width: 12vw; } -.dataset-tag-dirty { - opacity: 0.6; +#filter-dropdown > .dropdown-menu, +#filter-dropdown > .dropdown-menu > li > .dropdown-menu +{ + min-width: unset; + width: max-content; +} + +#filter-dropdown > .dropdown-menu > li > i { + width: 14px; +} + +#filter-dropdown > button > i { + width: 12px; +} + +.filter-ban-circle::after { + content: "\e090"; + position: relative; + right: 100%; + color: black; + opacity: 0.5; + font-size: 13px; +} + +input[type="search"]#hed-search, input[type="search"]#label-search { + -webkit-appearance: searchfield; +} + +input[type="search"]#hed-search::-webkit-search-cancel-button, +input[type="search"]#label-search::-webkit-search-cancel-button { + -webkit-appearance: searchfield-cancel-button; +} + +label[for=toggle-ignore_na], label[for=toggle-invert-search] { + font-weight: normal; + font-size: 12px; +} + +.label-ellipsis { + text-overflow: ellipsis; + overflow: hidden; + padding-left: 5px; + width: 64px; + font-style: italic; +} + +@media only screen and (min-width : 1441px) { + .label-ellipsis { + width: 80px; + } +} + +@media only screen and (min-width : 1550px) { + .label-ellipsis { + width: 105px; + } +} + +@media only screen and (min-width : 1660px) { + .label-ellipsis { + width: 8vw; + } +} + +li.hed-endorsement { + display: block; + cursor: default; +} + +li.hed-endorsement::before { + content: '\e034'; + font-family: 'Glyphicons Halflings'; + font-size: 11px; + float: left; + margin-top: 3px; + margin-left: -20px; + font-style: normal; +} + +option.hed-endorsed::after { + content: '\e034'; + font-family: 'Glyphicons Halflings'; + font-size: 12px; + float: right; + margin-top: 3px; + margin-right: 10px; + color: green; + font-style: normal; +} + +li.hed-endorsed::before { + color: green; +} + +li.hed-caveat::before { + color: red; +} + +li.hed-comment::before { + content: '\e111'; + color: #256eb6; +} + +.hed-endorsement-list:hover, .hed-endorsement-list-selected { + background-color: #fff9d6; +} + +.hed-endorsement-radio-label { + padding: 0 4px; + font-weight: normal; +} + +.caret-right { + border-bottom: 4px solid transparent; + border-top: 4px solid transparent; + border-left: 4px solid; + float: right; + position: relative; + top: 7px; +} + +.tagged-by-panel, .additional-columns-panel { + max-width: calc(100% - 5px); + margin: 5px 0; +} + +.tagged-by-panel > .panel-heading > .panel-title, +.dataset-tagged-by-panel > .panel-heading > .panel-title, +.additional-columns-panel > .panel-heading > .panel-title { + font-size: 14px; + font-weight: normal; } -.tag-modal-container-dirty:before { - content: '*'; +.tagged-by-panel > .panel-collapse { + background-color: #fff; +} + +.list-group-item:hover .tagged-by-panel > .panel-collapse { + background-color: #fff9d6; +} + +.dataset-tagged-by-panel { + width: 100%; +} + +.dataset-tagged-by-panel > .panel-collapse, +.additional-columns-panel > .panel-collapse, +.tagged-by-panel.tagged-by-active > .panel-collapse { + background-color: white; +} + +.hed-endorsement-tooltip { + padding: 1px 6px; + border-radius: 5px; + font-weight: normal; + font-size: 12px; + color: #034785; + background-color: #E4EBF2; + border: 1px solid #034785; + display: inline-block; + position: absolute; + z-index: 4; + word-break: normal; + margin-left: 5px; + width: max-content; +} + +.hed-endorsement-tooltip-line { + display: inline-block; + position: absolute; + background-color: #034785; + margin: 0; +} + +.glyphicon-endorsement-panel { + color: #256eb6; + padding: 0 2px; + float: right; +} + +.glyphicon-greyed { + color: grey !important; + cursor: not-allowed !important; +} + +.dataset-tag-dirty { + opacity: 0.6; } #select_column { @@ -271,12 +646,31 @@ svg:not(:root) { width: unset; } - #tag-modal-container > div > div { width: 75vw !important; height: 75vh; } +#tag-modal-container > div > div > div > div:last-child { + display: none; +} + +.cursor-pointer { + cursor: pointer; +} + +.cursor-grab { + cursor: grab; +} + +.cursor-grabbing { + cursor: grabbing; +} + +.cursor-default { + cursor: default; +} + .line-height-14 { line-height: 14px; } @@ -285,8 +679,32 @@ svg:not(:root) { margin-top: 10px; } +.flex-basis-30 { + flex-basis: 30%; +} + +.flex-basis-40 { + flex-basis: 40%; +} + .flex-basis-45 { - flex-basis: 45% + flex-basis: 45%; +} + +.flex-basis-50 { + flex-basis: 50%; +} + +.width-100 { + width: 100%; +} + +.scale-1_5 { + scale: 1.5; +} + +.float-right { + float: right; } .event-list .btn.btn-primary { @@ -306,6 +724,15 @@ svg:not(:root) { border: 1px solid #333; } +.event-panel-message { + padding: 10px 5px; + background: rgba(238, 238, 238, 0.8); + text-align: center; + position: sticky; + top: 0; + z-index: 1; +} + .input-interval-bound { width: 60px; height: 22px; @@ -342,15 +769,28 @@ svg:not(:root) { border-bottom: none; } -.electrode:hover circle { - stroke: #064785; - cursor: pointer; - fill: #E4EBF2; +#channel-selector-montage > div { + padding-top: 3% !important; } -.electrode:hover text { - fill: #064785; - cursor: pointer; +#channel-selector-montage svg { + margin-top: 5vh; +} + +#channel-selector-montage > div > div > div:nth-child(2) { + max-height: 80vh !important; +} + +#channel-selector-montage > div > div > div > div:last-child { + display: none !important; /* Disappear modal footer */ +} + +.epoch-action .glyphicon { + padding-top: 2px !important; +} + +.glyphicon-option-show:before { + content: '•••'; } #eegSessionView .table-scroll { @@ -387,8 +827,44 @@ svg:not(:root) { width: auto; } -.cursor-default { - cursor: default; +.close-tab { + background-color: rgb(228, 235, 242); + border: 1px solid #C3D5DB !important; + border-bottom-color: rgb(228, 235, 242) !important; + font-weight: normal; + font-size: 20px !important; + border-radius: 4px 4px 0 0; + align-content: center; + text-align: center; + flex-grow: 1; + padding: 5px 10px !important; + margin-right: 0 !important; +} + +.event-tab { + flex: 1; + margin-right: 3px; +} + +.close-tab:hover { + color: #246EB6; + text-decoration: none; +} + +.nav-tabs > li > a { + line-height: 0.8; + font-size: 14px; + align-content: center; + height: 42px; +} + +.nav-tabs > li { + margin-bottom: -1px !important; +} + +.nav-tabs > li:not(:last-child) > a { + font-size: 14px; + margin-right: 3px; } /* Custom, iPhone Retina */ @@ -426,12 +902,33 @@ svg:not(:root) { } } +/* Medium Devices, Desktops */ +@media only screen and (max-width : 991px) { + .row:has(#right-panel-controls) { + width: 95% !important; + } +} + /* Medium Devices, Desktops */ @media only screen and (min-width : 992px) { .event-list { - margin-top: 40px; margin-bottom: 0; } + + .badge-hed-tooltip { + top: -50px !important; + right: 105% !important; + } + + .nav-tabs > li:not(:last-child) > a { + font-size: 9px !important; + } +} + +@media only screen and (min-width : 1080px) { + .nav-tabs > li:not(:last-child) > a { + font-size: 11px !important; + } } /* Large Devices, Wide Screens */ @@ -443,4 +940,15 @@ svg:not(:root) { .pagination-nav { padding-top: 0; } + + .nav-tabs > li:not(:last-child) > a { + font-size: 12px !important; + } +} + +/* Extra Large Devices, Extra Wide Screens */ +@media only screen and (min-width : 1400px) { + .nav-tabs > li:not(:last-child) > a { + font-size: 14px !important; + } } diff --git a/modules/electrophysiology_browser/css/electrophysiology_browser_index.css b/modules/electrophysiology_browser/css/electrophysiology_browser_index.css new file mode 100644 index 00000000000..7df1d5415a4 --- /dev/null +++ b/modules/electrophysiology_browser/css/electrophysiology_browser_index.css @@ -0,0 +1,44 @@ +.browser-index-css-tooltip { + position: relative; +} + +.browser-index-css-tooltip .browser-index-tooltip-text { + width: 220px; + bottom: 140%; + left: 50%; + margin-left: -110px; + margin-top: 8px; + visibility: hidden; + background-color: #E4EBF2; + color: #064785; + text-align: center; + padding: 5px; + position: absolute; + font-size: 12px; + border-radius: 4px; + font-weight: normal; + border: 1px solid #C3D5DB; + + opacity: 0; + /*transition: opacity 0.5s;*/ +} + +.browser-index-css-tooltip:hover .browser-index-tooltip-text { + visibility: visible; + opacity: 1; +} + +.browser-index-css-tooltip .browser-index-tooltip-text::after { + content: " "; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #C3D5DB transparent transparent transparent; +} + +.dynamicContentWrapper.table-scroll { + overflow-x: unset !important; +} diff --git a/modules/electrophysiology_browser/help/sessions.md b/modules/electrophysiology_browser/help/sessions.md index 7aa591e5f2d..81c00b31383 100644 --- a/modules/electrophysiology_browser/help/sessions.md +++ b/modules/electrophysiology_browser/help/sessions.md @@ -12,3 +12,38 @@ Files can be downloaded containing only the recording signal, the events, or oth - Channels info (tsv): channel status and filter settings. - Events (tsv): events (both stimuli and responses) recorded during the session. +___ + +#### Keyboard Shortcuts Legend + +##### ***When cursor is inside signal plot:*** + +(↑, ←): Arrow keys + +← / → : Go backwards/forwards by the value of the time interval +↑ / ↓ : Page channels up/down by number of displayed channels +Use the Shift key ⇧ in order to : +(⇧ +) **+** : Zoom in +(⇧ +) **–** : Zoom out +(⇧ +) **Z**: Zoom to selected time region +(⇧ +) **X**: Reset zoom interval to default +(⇧ +) **M**: Increase amplitude +(⇧ +) **N**: Decrease amplitude +(⇧ +) **E**: Open 'Event Panel' +(⇧ +) **A**: Open 'Add Event' panel +(⇧ +) **H**: Open 'HED Endorsement' panel +(⇧ +) **V**: Show values at cursor (on/off) +(⇧ +) **B**: Plot all signals on same axis ('Stacked' on/off) +(⇧ +) **S**: 'Isolate' mode - Show one channel at a time (in 'Stacked' view) by hovering on channel name + +##### ***HED Endorsement Panel shortcuts*** + +(⇧ +) ↑ / ↓ : Select previous/next HED tag +(⇧ +) ← / → : Select and jump to previous/next HED tag +(⇧ +) **I**: Scroll to current selection +(⇧ +) **J**: Jump to current selection +(⇧ +) **K**: Edit current selection's event +(^ +) **E** : Select 'Endorse' action +(^ +) **C** : Select 'Caveat' action +(^ +) **M** : Select 'Comment' action +(^ +) **Enter** : Submit selected action diff --git a/modules/electrophysiology_browser/jsx/components/DownloadPanel.js b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js index a254e1c8cbf..e9f280b6250 100644 --- a/modules/electrophysiology_browser/jsx/components/DownloadPanel.js +++ b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js @@ -36,7 +36,7 @@ class DownloadPanel extends Component { return ( File Download} >
+ HED + + + + HED tags in recording metadata (click for info) + + + ; + } +} + +export default HasHEDIcon; diff --git a/modules/electrophysiology_browser/jsx/components/HasHedIcon.js b/modules/electrophysiology_browser/jsx/components/HasHedIcon.js new file mode 100644 index 00000000000..51ca0527991 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/components/HasHedIcon.js @@ -0,0 +1,48 @@ +import {Component} from 'react'; + +/** + * HED Icon for data table column + * + */ +class HasHEDIcon extends Component { + /** + * @constructor + * @param {object} props - React Component properties + */ + constructor(props) { + super(props); + } + + /** + * Renders the React component. + * + * @return {React.ReactNode} - React markup for the component + */ + render() { + return <> + HED + + + + HED tags in recording metadata (click for info) + + + ; + } +} + +export default HasHEDIcon; diff --git a/modules/electrophysiology_browser/jsx/components/electrophysiology_session_panels.js b/modules/electrophysiology_browser/jsx/components/electrophysiology_session_panels.js index afba3e7e763..d96b817f1e2 100644 --- a/modules/electrophysiology_browser/jsx/components/electrophysiology_session_panels.js +++ b/modules/electrophysiology_browser/jsx/components/electrophysiology_session_panels.js @@ -41,9 +41,7 @@ class FilePanel extends Component {
{'Acquisition Details for Recording'}} >
@@ -99,7 +97,7 @@ class FilePanel extends Component { FilePanel.propTypes = { id: PropTypes.string, - title: PropTypes.string, + title: PropTypes.object, data: PropTypes.array, children: PropTypes.node, }; diff --git a/modules/electrophysiology_browser/jsx/components/electrophysiology_session_summary.js b/modules/electrophysiology_browser/jsx/components/electrophysiology_session_summary.js index 578f83b65be..8c30fdb1d43 100644 --- a/modules/electrophysiology_browser/jsx/components/electrophysiology_session_summary.js +++ b/modules/electrophysiology_browser/jsx/components/electrophysiology_session_summary.js @@ -31,7 +31,7 @@ class SummaryPanel extends Component {
Summary} >
, + }, {label: 'Acquisition Time', show: true}, {label: 'Insertion Time', show: true}, {label: 'Links', show: true}, diff --git a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js index 59785e3f58d..88f7dabf403 100644 --- a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js +++ b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js @@ -18,7 +18,6 @@ import {SummaryPanel} from './components/electrophysiology_session_summary'; import {DownloadPanel} from './components/DownloadPanel'; import Sidebar from './components/Sidebar'; import SidebarContent from './components/SidebarContent'; - let EEGLabSeriesProvider; let SeriesRenderer; let EEGMontage; @@ -241,6 +240,12 @@ class ElectrophysiologySessionView extends Component { datasetTags: dbEntry && dbEntry.file.datasetTags, + datasetTagEndorsements: + dbEntry + && dbEntry.file.datasetTagEndorsements, + eegMontage: + dbEntry + && dbEntry.file.eegMontage, })); this.setState({ @@ -342,8 +347,10 @@ class ElectrophysiologySessionView extends Component { events, hedSchema, datasetTags, + datasetTagEndorsements, electrodesURL, coordSystemURL, + eegMontage, } = this.state.database[i]; const file = this.state.database[i].file; const splitPagination = []; @@ -359,11 +366,32 @@ class ElectrophysiologySessionView extends Component { >{j+1} ); } + const recordingHasHED = events.hed_tags.length > 0 || + Object.keys(datasetTags).some((column) => { + return Object.keys(datasetTags[column]).filter((columnValue) => { + return datasetTags[column][columnValue].length > 0; + }).some((columnValue) => { + if (column === 'trial_type') { + return events.instances.some((event) => { + return event['TrialType'] === columnValue; + }); + } else if (column === 'value') { + return events.instances.some((event) => { + return event['EventValue'] === columnValue; + }); + } + + return events.extra_columns.some((prop) => { + return prop.PropertyName === column && + prop.PropertyValue === columnValue; + }); + }); + }); database.push(
{this.state.database[i].file.name}} data={this.state.database[i].file.details} > {EEG_VIS_ENABLED && @@ -378,18 +406,23 @@ class ElectrophysiologySessionView extends Component { coordSystemURL={coordSystemURL} hedSchema={hedSchema} datasetTags={datasetTags} + datasetTagEndorsements={datasetTagEndorsements} physioFileID={this.state.database[i].file.id} samplingFrequency={ this.state.database[i].file.summary[0].value } + eegMontageName={eegMontage} + recordingHasHED={recordingHasHED} > + {'Signal Viewer' + (file.splitData + ? ' [split ' + (file.splitData?.splitIndex + 1) + ']' + : '' + )} + } > {file.splitData && 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 f107fb46ecf..d9afe61891d 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,4 +1,4 @@ -import React, {Component} from 'react'; +import React, {Component, createRef} from 'react'; import {tsvParse} from 'd3-dsv'; import {applyMiddleware, createStore, Store} from 'redux'; import {Provider} from 'react-redux'; @@ -7,7 +7,9 @@ import thunk from 'redux-thunk'; import {fetchJSON, fetchText} from '../ajax'; import {rootEpic, rootReducer} from '../series/store'; import {emptyChannels, setChannels} from '../series/store/state/channels'; -import {DEFAULT_MAX_CHANNELS, DEFAULT_TIME_INTERVAL} from '../vector'; +import { + DEFAULT_CHANNEL_DELIMITER, DEFAULT_MAX_CHANNELS, DEFAULT_TIME_INTERVAL, +} from '../vector'; import { setDatasetMetadata, setDatasetTags, @@ -18,8 +20,7 @@ import { } from '../series/store/state/dataset'; import {setDomain, setInterval} from '../series/store/state/bounds'; import { - setCoordinateSystem, - setElectrodes, + setCoordinateSystem, setElectrodes, } from '../series/store/state/montage'; import {EventMetadata, HEDSchemaElement} from '../series/store/types'; import TriggerableModal from 'jsx/TriggerableModal'; @@ -39,13 +40,22 @@ type CProps = { coordSystemURL: string, hedSchema: HEDSchemaElement[], datasetTags: any, + datasetTagEndorsements: any, events: EventMetadata, physioFileID: number, limit: number, - samplingFrequency: number, + samplingFrequency: string, + eegMontageName: string, + recordingHasHED: boolean, children: React.ReactNode, }; +const MenuOption = { + 'TAG_MODE': 'View/Edit Tags', + 'ENDORSEMENT_MODE': 'Endorse Tags', + 'JSON_MODE': 'View JSON', +}; + /** * EEGLabSeriesProvider component */ @@ -65,6 +75,11 @@ class EEGLabSeriesProvider extends Component { applyMiddleware(thunk, epicMiddleware) ); + this.state = { + activeMenuOption: 'TAG_MODE', + datasetTaggerTabsRef: createRef(), + }; + epicMiddleware.run(rootEpic); const { @@ -74,22 +89,26 @@ class EEGLabSeriesProvider extends Component { coordSystemURL, hedSchema, datasetTags, + datasetTagEndorsements, events, physioFileID, limit, samplingFrequency, + eegMontageName, + recordingHasHED, } = 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; + 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?'; } @@ -99,16 +118,34 @@ class EEGLabSeriesProvider extends Component { Object.keys(datasetTags).forEach((column) => { formattedDatasetTags[column] = {}; Object.keys(datasetTags[column]).forEach((value) => { - formattedDatasetTags[column][value] = - datasetTags[column][value].map((tag) => { + formattedDatasetTags[column][value] + = datasetTags[column][value].map((tag) => { + const hedEndorsements = datasetTagEndorsements + .filter((endorsement) => { + return endorsement.HEDRelID === tag.ID; + }).map((endorsement) => { + const endorserName = + `${endorsement.FirstName.substring(0, 1)}.` + + endorsement.LastName; + return { + EndorsedBy: endorserName, + EndorsedByID: endorsement.EndorsedByID, + EndorsementComment: endorsement.EndorsementComment, + EndorsementStatus: endorsement.EndorsementStatus, + EndorsementTime: endorsement.LastUpdate, + }; + }); return { ...tag, AdditionalMembers: parseInt(tag.AdditionalMembers), + TaggerName: tag.TaggerName === 'Origin' + ? 'Data Authors' : tag.TaggerName, + TaggedBy: tag.TaggedBy, + Endorsements: hedEndorsements, }; }); }); }); - this.store.dispatch(setPhysioFileID(physioFileID)); this.store.dispatch(setHedSchemaDocument(hedSchema)); this.store.dispatch(setDatasetTags(formattedDatasetTags)); @@ -127,10 +164,10 @@ class EEGLabSeriesProvider extends Component { // if request fails don't resolve .catch((error) => { console.error(error); - return new Promise(null); + return Promise.resolve(); })]; } else { - return [new Promise(null)]; + return [Promise.resolve()]; } }; @@ -138,11 +175,7 @@ class EEGLabSeriesProvider extends Component { ({json, url}) => { if (json) { const { - channelMetadata, - shapes, - timeInterval, - seriesRange, - validSamples, + channelMetadata, shapes, timeInterval, seriesRange, validSamples, } = json; this.store.dispatch( setDatasetMetadata({ @@ -154,6 +187,8 @@ class EEGLabSeriesProvider extends Component { seriesRange, limit, samplingFrequency, + eegMontageName, + recordingHasHED, }) ); this.store.dispatch(setChannels(emptyChannels( @@ -166,41 +201,70 @@ class EEGLabSeriesProvider extends Component { } ).then(() => { const epochs = []; + const channelDelimiter = events['channel_delimiter'].length > 0 + ? events['channel_delimiter'] + : DEFAULT_CHANNEL_DELIMITER; + this.store.dispatch( + setDatasetMetadata({channelDelimiter: channelDelimiter}) + ); + events.instances.map((instance) => { - const epochIndex = - epochs.findIndex( - (e) => e.physiologicalTaskEventID - === instance.PhysiologicalTaskEventID - ); + const epochIndex = epochs.findIndex( + (e) => e.physiologicalTaskEventID + === instance.PhysiologicalTaskEventID + ); - const extraColumns = Array.from( - events.extraColumns - ).filter((column) => { - return column.PhysiologicalTaskEventID + const extraColumns = Array.from(events['extra_columns']) + .filter((column) => { + return column['PhysiologicalTaskEventID'] === instance.PhysiologicalTaskEventID; - }); + }); - const hedTags = Array.from(events.hedTags).filter((column) => { - return column.PhysiologicalTaskEventID + const hedTags = Array.from(events['hed_tags']).filter((column) => { + return column['PhysiologicalTaskEventID'] === instance.PhysiologicalTaskEventID; }).map((hedTag) => { const foundTag = hedSchema.find((tag) => { - return tag.id === hedTag.HEDTagID; + return tag.id === hedTag['HEDTagID']; }); - const additionalMembers = parseInt(hedTag.AdditionalMembers); + + const additionalMembers = parseInt( + hedTag['AdditionalMembers'] as string + ); + + const hedEndorsements = events['hed_endorsements'] + .filter((endorsement) => { + return endorsement['HEDRelID'] === hedTag['ID']; + }).map((endorsement) => { + const endorserName = + `${endorsement['FirstName'].substring(0, 1)}.` + + endorsement['LastName']; + return { + EndorsedBy: endorserName, + EndorsedByID: endorsement['EndorsedByID'], + EndorsementComment: endorsement['EndorsementComment'], + EndorsementStatus: endorsement['EndorsementStatus'], + EndorsementTime: endorsement['LastUpdate'], + }; + }); // Currently only supporting schema-defined HED tags return { 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, + 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, + TaggerName: hedTag['TaggerName'] === 'Origin' + ? 'Data Authors' : hedTag['TaggerName'], + TaggedBy: hedTag['TaggedBy'], + Endorsements: hedEndorsements, }; }); @@ -217,7 +281,11 @@ class EEGLabSeriesProvider extends Component { trialType: instance.TrialType, properties: extraColumns, hed: hedTags, - channels: 'all', + channels: ['n/a', null].includes(instance.Channel) + ? [] + : channelDelimiter.length > 0 + ? instance.Channel.split(channelDelimiter) + : [instance.Channel], physiologicalTaskEventID: instance.PhysiologicalTaskEventID, }); } else { @@ -237,15 +305,16 @@ class EEGLabSeriesProvider extends Component { 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); + indices.push(index); // Full-recording events not visible by default } return indices; }, []), columnVisibility: [], + searchVisibility: [], })); }); + Promise.race(racers(fetchText, electrodesURL)) .then((text) => { if (!(typeof text.json === 'string' @@ -265,7 +334,7 @@ class EEGLabSeriesProvider extends Component { }); Promise.race(racers(fetchJSON, coordSystemURL)) - .then( ({json, _}) => { + .then( ({json}) => { if (json) { const { EEGCoordinateSystem, @@ -294,42 +363,99 @@ class EEGLabSeriesProvider extends Component { render() { const [signalViewer, ...rest] = React.Children.toArray(this.props.children); + const hedTagLogo = ( + + ); + return (
-
- - - - + <> +
+
+ + {hedTagLogo} + + Dataset Tag Manager - + +
+
+ More about HED + +
-
- More about HED - +
+
-
+ } label="Open Dataset Tag Manager" > - + { + this.setState({activeMenuOption: menuOption}); + }} + filenamePrefix={this.props.chunksURL[0] + .split('/').at(-1) // filename + .split('_').slice(0, -1) // prefix + .join('_') + } + />
{signalViewer} 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 ca7d93991d0..081b5dd37fb 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,10 +1,5 @@ import React, {useEffect, useState} from 'react'; -import { - Epoch as EpochType, - RightPanel, - HEDTag, - HEDSchemaElement, -} from '../store/types'; +import {ChannelMetadata, Epoch as EpochType, HEDSchemaElement, HEDTag, RightPanel,} from '../store/types'; import {connect} from 'react-redux'; import {setTimeSelection} from '../store/state/timeSelection'; import {setRightPanel} from '../store/state/rightPanel'; @@ -16,9 +11,12 @@ import { updateActiveEpoch } from '../store/logic/filterEpochs'; import {RootState} from '../store'; -import {setActiveEpoch, setEpochs} from '../store/state/dataset'; +import {setEpochs} from '../store/state/dataset'; import {setCurrentAnnotation} from '../store/state/currentAnnotation'; import {NumericElement, SelectElement, TextboxElement} from './Form'; +import Panel from './Panel'; +import Modal from 'jsx/Modal'; +import EEGMontage from "./EEGMontage"; import swal from 'sweetalert2'; import {InfoIcon} from "./components"; import {colorOrder} from "../../color"; @@ -27,6 +25,7 @@ import {colorOrder} from "../../color"; type CProps = { timeSelection?: [number, number], epochs: EpochType[], + domain: [number, number], filteredEpochs: number[], setTimeSelection: (_: [number, number]) => void, setRightPanel: (_: RightPanel) => void, @@ -39,6 +38,12 @@ type CProps = { interval: [number, number], hedSchema: HEDSchemaElement[], datasetTags: any, + channelDelimiter: string, + channelMetadata: ChannelMetadata[], + panelIsDirty: boolean, + setPanelIsDirty: (_: boolean) => void, + eventChannels: string[], + setEventChannels: (_: string[] ) => void, }; /** @@ -46,6 +51,7 @@ type CProps = { * @param root0 * @param root0.timeSelection * @param root0.epochs + * @param root0.domain * @param root0.setTimeSelection * @param root0.setRightPanel * @param root0.setEpochs @@ -57,10 +63,17 @@ type CProps = { * @param root0.interval * @param root0.hedSchema * @param root0.datasetTags + * @param root0.channelDelimiter + * @param root0.channelMetadata + * @param root0.panelIsDirty + * @param root0.setPanelIsDirty + * @param root0.eventChannels + * @param root0.setEventChannels */ const AnnotationForm = ({ timeSelection, epochs, + domain, setTimeSelection, setRightPanel, setEpochs, @@ -72,45 +85,109 @@ const AnnotationForm = ({ interval, hedSchema, datasetTags, + channelDelimiter, + channelMetadata, + panelIsDirty, + setPanelIsDirty, + eventChannels, + setEventChannels, }: CProps) => { - const [startEvent = '', endEvent = ''] = timeSelection || []; - const [event, setEvent] = useState<(number | string)[]>( - [ - startEvent, - endEvent, - ] + const [eventInterval, setEventInterval] = useState<(number | string)[]>( + timeSelection ?? ['', ''] ); const [label, setLabel] = useState( currentAnnotation ? - currentAnnotation.label : - null + currentAnnotation.label : + null ); + const [eventProperties, setEventProperties] = useState({}); const [isSubmitted, setIsSubmitted] = useState(false); const [isDeleted, setIsDeleted] = useState(false); const [annoMessage, setAnnoMessage] = useState(''); const [newTags, setNewTags] = useState([]); const [deletedTagIDs, setDeletedTagIDs] = useState([]); + const [throwChannelEditWarning, setThrowChannelEditWarning] = useState(false); + const [channelSelectorVisible, setChannelSelectorVisible] = useState(false); + + useEffect(() => { + setEventProperties( + Object.keys(datasetTags) + .filter(column => column !== 'trial_type') + .reduce((properties, column) => { + const property = currentAnnotation?.properties.find(prop => prop.PropertyName === column); + properties[column] = property ? property.PropertyValue : ''; + return properties; + }, {}) + ); + }, []); + + // Time Selection useEffect(() => { - const [startEvent = '', endEvent = ''] = timeSelection || []; - setEvent([startEvent, endEvent]); + if (!currentAnnotation) { + // Only if being created. Time edit not currently allowed + const [startEvent, endEvent] = timeSelection ?? [0, 0]; + setEventInterval([startEvent, endEvent]); + } }, [timeSelection]); + useEffect(() => { + setPanelIsDirty( + (deletedTagIDs.length > 0 || + (newTags.length > 0 && newTags.find((tag) => tag.value !== '')) || + JSON.stringify(eventChannels) !== + JSON.stringify(currentAnnotation ? currentAnnotation.channels : []) + ) || + (!currentAnnotation && ( + (label && label.length > 0) || + Object.values(eventProperties).some(((prop: string) => { + return prop.length > 0; + })) + )) + ) + }, [label, eventProperties, deletedTagIDs, newTags, eventChannels, currentAnnotation?.channels]); + + const validateTimeRange = (timeRange) => { + return (timeRange[0] || timeRange[0] === 0) + && (timeRange[1] || timeRange[1] === 0) + && !( + timeRange[0] === 0 && + timeRange[1] === 0 + ); + } + + // Initiate on load + useEffect(() => { + setEventChannels(currentAnnotation ? currentAnnotation.channels : []); + }, []); + + /** * * @param event */ const validate = (event) => { - return (event[0] || event[0] === 0) - && (event[1] || event[1] === 0) - && event[0] <= event[1] - && ( - newTags.some((tag) => tag.value !== '') || - deletedTagIDs.length > 0 + return validateTimeRange(event) && + event[0] <= event[1] && ( + ( + currentAnnotation && ( + newTags.some((tag) => tag.value !== '') || + deletedTagIDs.length > 0 || + JSON.stringify(eventChannels) !== JSON.stringify(currentAnnotation?.channels) + ) + ) || ( + !currentAnnotation && ( + (label && label.length > 0) || + Object.values(eventProperties).some(((prop: string) => { + return prop.length > 0; + })) || + newTags.some((tag) => tag.value !== '') + ) + ) ); - }; + } /** * @@ -118,19 +195,20 @@ const AnnotationForm = ({ * @param val */ const handleStartTimeChange = (id, val) => { - const value = parseFloat(val); - setEvent([value, event[1]]); + const value = Math.min(Math.max(parseFloat(val), domain[0]), domain[1]); + setEventInterval([value, eventInterval[1]]); - if (validate([value, event[1]])) { - let endTime = event[1]; + if (validateTimeRange([value, eventInterval[1]])) { + let endTime = eventInterval[1]; if (typeof endTime === 'string') { endTime = parseFloat(endTime); } + setTimeSelection( [ - value || null, - endTime || null, + value, + endTime, ] ); } @@ -142,24 +220,50 @@ const AnnotationForm = ({ * @param val */ const handleEndTimeChange = (name, val) => { - const value = parseFloat(val); - setEvent([event[0], value]); + const value = Math.min(Math.max(parseFloat(val), domain[0]), domain[1]); + setEventInterval([eventInterval[0], value]); - if (validate([event[0], value])) { - let startTime = event[0]; + if (validateTimeRange([eventInterval[0], value])) { + let startTime = eventInterval[0]; if (typeof startTime === 'string') { startTime = parseFloat(startTime); } setTimeSelection( [ - startTime || null, + startTime, value, ] ); } }; + /** + * + * @param name + * @param val + */ + const handleDurationChange = (name, val) => { + const value = Math.min(Math.max(parseFloat(val), domain[0]), domain[1]); + const endTime = parseFloat(eventInterval[0].toString()) + value; + setEventInterval([eventInterval[0], endTime]); + + if (validateTimeRange([eventInterval[0], endTime])) { + let startTime = eventInterval[0]; + + if (typeof startTime === 'string') { + startTime = parseFloat(startTime); + } + setTimeSelection( + [ + startTime, + endTime, + ] + ); + } + }; + + /** * */ @@ -174,11 +278,11 @@ const AnnotationForm = ({ }, 2000); } else { setNewTags([ - ...newTags, { type: tagType, value: '', - } + }, + ...newTags, ]); } }; @@ -192,7 +296,7 @@ const AnnotationForm = ({ const tagRelID = elementID.split('-').pop(); if (currentAnnotation.hed && currentAnnotation.hed.map((tag) => { - return tag.ID + return tag.ID.toString(); }).includes(tagRelID) ) { setDeletedTagIDs([...deletedTagIDs, tagRelID]); @@ -236,15 +340,35 @@ const AnnotationForm = ({ * */ const handleReset = () => { + if (!currentAnnotation) { + // Clear all fields + setLabel(''); + setTimeSelection([0, 0]); + setEventInterval([0, 0]); + setEventProperties( + Object.keys(eventProperties) + .reduce((props, prop) => { + return { + ...props, + [prop]: '', + }; + }, {}) + ); + } setNewTags([]); setDeletedTagIDs([]); + + setEventChannels(currentAnnotation + ? currentAnnotation.channels + : [] + ); }; /** * */ const handleDelete = () => { - setIsDeleted(true); + // setIsDeleted(true); }; // Submit @@ -255,7 +379,7 @@ const AnnotationForm = ({ } // Validate inputs - if (!label || !event[0] || !event[1]) { + if (!label || !validateTimeRange(eventInterval)) { swal.fire( 'Warning', 'Please fill out all required fields', @@ -275,10 +399,14 @@ const AnnotationForm = ({ return node.id; }); - const currentTagIDs = (currentAnnotation.hed ?? []).map((tag) => { + const currentTagIDs = ( + currentAnnotation + ? currentAnnotation.hed ?? [] + : [] + ).map((tag) => { return tag.ID; }).filter(currentTagID => { - return !deletedTagIDs.includes(currentTagID) + return !deletedTagIDs.includes(currentTagID.toString()) }); // Prevent duplicates @@ -301,15 +429,21 @@ const AnnotationForm = ({ '/electrophysiology_browser/events/'; // get duration of event - let startTime = event[0]; - let endTime = event[1]; + let startTime = eventInterval[0]; + let endTime = eventInterval[1]; if (typeof startTime === 'string') { startTime = parseFloat(startTime); } if (typeof endTime === 'string') { endTime = parseFloat(endTime); } - const duration = endTime - startTime; + const duration = currentAnnotation // Edit not currently allowed + ? currentAnnotation.duration + : Math.abs(endTime - startTime); + + const onset = currentAnnotation // Edit not currently allowed + ? currentAnnotation.onset + : Math.min(startTime, endTime); // set body // instance_id = null for new events @@ -320,13 +454,24 @@ const AnnotationForm = ({ currentAnnotation.physiologicalTaskEventID : null, instance: { - onset: startTime, + onset: onset, duration: duration, label_name: label, label_description: label, - channels: 'all', + channels: eventChannels.length > 0 + ? eventChannels + : ['n/a'], added_hed: newTagIDs, deleted_hed: deletedTagIDs, + event_type: 'trial_type', + properties: Object.keys(eventProperties) + .reduce((properties, propertyName) => { + properties[propertyName] = + eventProperties[propertyName].length > 0 + ? eventProperties[propertyName] + : 'n/a'; + return properties; + }, {}), }, }; @@ -339,25 +484,17 @@ const AnnotationForm = ({ return response.json(); } throw (response); - }).then((response) => { setIsSubmitted(false); - // if in edit mode, remove old event instance - if (currentAnnotation !== null) { - epochs.splice(epochs.indexOf(currentAnnotation), 1); - } - // } 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) => { + // TODO: Properly handle new event -- below line strange + const hedTags = Array.from(data.hed_tags).map((hedTag : HEDTag) => { const foundTag = hedSchema.find((tag) => { return tag.id === hedTag.HEDTagID; }); + // Currently only supporting schema-defined HED tags return { schemaElement: foundTag ?? null, @@ -370,12 +507,21 @@ const AnnotationForm = ({ HasPairing: hedTag.HasPairing, PairRelID: hedTag.PairRelID, AdditionalMembers: hedTag.AdditionalMembers, + TaggedBy: hedTag.TaggedBy, + TaggerName: hedTag.TaggerName === 'Origin' + ? 'Data Authors' + : hedTag.TaggerName, + Endorsements: data.hed_endorsements + .filter((endorsement) => { + return endorsement.HEDRelID === hedTag.ID; + }), } }); const epochLabel = [null, 'n/a'].includes(data.instance.TrialType) - ? null - : data.instance.TrialType; + ? null + : data.instance.TrialType; + const newAnnotation : EpochType = { onset: parseFloat(data.instance.Onset), duration: parseFloat(data.instance.Duration), @@ -383,30 +529,46 @@ const AnnotationForm = ({ label: epochLabel ?? data.instance.EventValue, value: data.instance.EventValue, trialType: data.instance.TrialType, - properties: data.extraColumns, + properties: data.extra_columns, hed: hedTags, - channels: 'all', + channels: data.instance.Channel === 'n/a' + ? [] + : data.instance.Channel.split(channelDelimiter), physiologicalTaskEventID: data.instance.PhysiologicalTaskEventID, }; - epochs.push(newAnnotation); - setEpochs( - epochs - .sort(function(a, b) { - return a.onset - b.onset; - }) - ); - // Reset Form - handleReset(); - setCurrentAnnotation(newAnnotation); + // Maintain index + if (currentAnnotation !== null) { + const eventIndex = epochs.indexOf(currentAnnotation); + setEpochs([ + ...epochs.slice(0, eventIndex), + newAnnotation, + ...epochs.slice(eventIndex + 1), + ]); + } else { + epochs.push(newAnnotation); + setEpochs( + epochs + .sort(function(a, b) { + return a.onset - b.onset; + }) + ); + } // Display success message setAnnoMessage(currentAnnotation ? 'Event Updated!' : 'Event Added!'); + setCurrentAnnotation(newAnnotation); + + // handleReset(); + setNewTags([]); + setDeletedTagIDs([]); + setTimeout(() => { setAnnoMessage(''); // Empty string will cause success div to hide + updateActiveEpoch(epochs.indexOf(currentAnnotation ? currentAnnotation : newAnnotation)); }, 2000); }).catch((error) => { console.error(error); @@ -431,7 +593,7 @@ const AnnotationForm = ({ useEffect(() => { if (isDeleted) { const url = window.location.origin - + '/electrophysiology_browser/events/'; + + '/electrophysiology_browser/events/'; const body = { physioFileID: physioFileID, instance_id: currentAnnotation ? @@ -505,14 +667,14 @@ const AnnotationForm = ({ } const addHedTagOptions = [ - { - type: 'SCORE', - value: 'SCORE Artifacts', - }, { type: 'DATASET', value: 'Tags in current dataset', }, + { + type: 'ARTIFACTS', + value: 'HED 8.3.0 Artifacts', + }, ] const buildPropertyOptions = (optgroup: string, parentHED: string) => { @@ -524,7 +686,7 @@ const AnnotationForm = ({ label: tag.name, longName: tag.longName, value: tag.id, - optgroup: optgroup, + optgroup: `${optgroup} [${tag.schemaName}]`, Description: tag.description, } }).sort((tagA, tagB) => { @@ -535,11 +697,11 @@ const AnnotationForm = ({ const artifactTagOptions = [ ...buildPropertyOptions( 'Biological-artifact', - 'Artifact/Biological-artifact/' + 'Property/Data-property/Data-artifact/Biological-artifact/' ), ...buildPropertyOptions( - 'Non-biological-artifact', - 'Artifact/Non-biological-artifact/' + 'Nonbiological-artifact', + 'Property/Data-property/Data-artifact/Nonbiological-artifact/' ), ]; @@ -561,7 +723,9 @@ const AnnotationForm = ({ label: schemaElement.name, longName: schemaElement.longName, value: schemaElement.id, - optgroup: optGroup.length > 0 ? optGroup : schemaElement.name, + optgroup: optGroup.length > 0 + ? `${optGroup} [${schemaElement.schemaName}]` + : schemaElement.name, Description: schemaElement.description, } } @@ -579,7 +743,7 @@ const AnnotationForm = ({ const getOptions = (optionType: string) => { switch (optionType) { - case 'SCORE': + case 'ARTIFACTS': return artifactTagOptions; case 'DATASET': return getUniqueDatasetTags(); @@ -605,12 +769,21 @@ const AnnotationForm = ({ return (
- - {hedTag.schemaElement.name} - + + {hedTag.schemaElement.name} + {hedTag.schemaElement.description}
+
+
+
+
+ Tagged By: +
+
+ {hedTag.TaggerName} +
+
+
+
+ Endorsed By: +
+
+
+ { + hedTag.Endorsements.length > 0 + ? hedTag.Endorsements + .filter((endorsement) => { + return ['Endorsed', 'Caveat'] + .includes(endorsement.EndorsementStatus); + }) + .map((endorsement, i) => { + return + {endorsement.EndorsedBy} + + + }) + : 'n/a' + } +
+
+
+
); } + const strikethroughText = (text) => { + const combiner = '\u0336'; + return text + .split('') + .map(char => char + combiner) + .join(''); + } + const buildHEDBadges = (hedTags: HEDTag[], belongsToEvent: boolean = false) => { const rootTags = hedTags.filter((tag) => { return !hedTags.some((t) => { @@ -643,7 +892,7 @@ const AnnotationForm = ({ const tagBadges = []; rootTags.forEach((tag) => { - if (deletedTagIDs.includes(tag.ID)) { + if (deletedTagIDs.includes(tag.ID.toString())) { return; } let groupColorIndex = 0; @@ -666,7 +915,7 @@ const AnnotationForm = ({ if (groupTag.PairRelID === null) { tagBadgeGroup.push(buildHEDBadge(groupTag, belongsToEvent)); } else { - if (groupTag.HasPairing === '1') { + if (groupTag.HasPairing == '1') { if (groupTag.AdditionalMembers > 0 || tagBadgeSubgroup.length === 0) { let commaIndex = getNthMemberTrailingBadgeIndex( tagBadgeGroup, @@ -719,10 +968,20 @@ const AnnotationForm = ({ return tagBadges; } + const closePanel = () => { + setRightPanel('eventList'); + handleReset(); + setCurrentAnnotation(null); + setTimeSelection(null); + updateActiveEpoch(null); + setPanelIsDirty(false); + } + return (
- {currentAnnotation ? 'Edit' : 'Add'} Event - { - 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); + borderTopLeftRadius: 0, }} - > + > + {currentAnnotation ? 'Edit Event' : 'Add Event'}
-
+ +
Event Name - - { - currentAnnotation.label === currentAnnotation.trialType - ? 'trial_type' - : 'value' - } - - - } - value={currentAnnotation ? currentAnnotation.label : ""} - required={true} - readonly={true} - /> - - setLabel(value)} + required={false/*currentAnnotation === null*/} + disabled={currentAnnotation !== null} + labelOuterClass={'flex-basis-40'} + labelClass={'control-label'} + elementClass={'additional-columns-outer'} + inputClass={'additional-columns-inner'} />
+
+ + eventInterval[0]) + ? ( + Math.round(( + // Math.abs( + parseFloat(eventInterval[1].toString()) - parseFloat(eventInterval[0].toString()) + // ) + + Number.EPSILON) * 1000) / 1000 + ).toString() + : '0' + } + required={currentAnnotation === null} + onUserInput={handleDurationChange} + /> + +
+
+ +
+
+ + { setChannelSelectorVisible(false); }} + show={channelSelectorVisible} + > + { + return channelMetadata.findIndex((channel) => { + return channel.name === channelName; + }) + }).filter(index => index !== -1), + }} + contentHeight='75vh' + cssClass={'scale-1_5'} + editChannels={true} + setCancelWarning={setThrowChannelEditWarning} + setEventChannels={setEventChannels} + eventChannels={eventChannels} + setChannelSelectorVisible={setChannelSelectorVisible} + /> + +
+
+
+ { + [...eventChannels, ...(currentAnnotation?.channels ?? [])].length < 5 && +
+ } +
+ { + [...eventChannels, ...(currentAnnotation?.channels ?? [])].length > 0 + ? Array.from((new Set([...eventChannels, ...(currentAnnotation?.channels ?? [])]))) + .sort((channelA, channelB) => { + return channelMetadata.findIndex(channel => channel.name === channelA) + - channelMetadata.findIndex(channel => channel.name === channelB); + }) + .map((channel, i) => { + return + {i > 0 && <>{channelDelimiter} } + + {channel} + {/*!currentAnnotation?.channels.includes(channel) ? '*' : ''*/} + + + }) + : 'n/a' + } +
+ +
+
+
{ - currentAnnotation && currentAnnotation.properties.length > 0 && ( + Object.keys(datasetTags) + .filter(column => column !== 'trial_type') + .length > 0 && ( <> - -
- { - currentAnnotation.properties.map((property) => { - return ( - - ); - }) + + Additional Columns + } -
+ initCollapsed={true} + collapsed={true} + style={{ + padding: '0 15px', + margin: '5px 15px 0 15px', + }} + > +
+
+ { + Object.keys(datasetTags) + .filter(column => column !== 'trial_type') + .map((property, i) => { + return ( + prop.PropertyName === property) + ? currentAnnotation.properties.find(prop => prop.PropertyName === property).PropertyValue + : 'n/a' + : eventProperties[property] + } + onUserInput={ + (_, value) => setEventProperties({ + ...eventProperties, + [property]: value, + }) + } + required={false} + disabled={currentAnnotation !== null} + labelOuterClass={'flex-basis-40'} + labelClass={'event-label code-mimic word-break-word'} + elementClass={'additional-columns-outer'} + inputClass={'additional-columns-inner'} + /> + ); + }) + } +
+
+ ) } -
-
+
+ { + currentAnnotation && currentAnnotation.hed && ( + currentAnnotation.hed.some(hedTag => hedTag.TaggerName !== 'Data Authors') || + getTagsForEpoch(currentAnnotation, datasetTags, hedSchema) + .some(hedTag => hedTag.TaggerName !== 'Data Authors') + ) && ( + <> + ※ = Not tagged by Data Authors + + ) + } +
+
{ currentAnnotation && currentAnnotation.hed && - getTagsForEpoch(currentAnnotation, datasetTags, hedSchema) - .length > 0 && ( + getTagsForEpoch(currentAnnotation, datasetTags, hedSchema).length > 0 && ( <>
Dataset
{ @@ -882,7 +1328,7 @@ const AnnotationForm = ({ && currentAnnotation.hed.length > 0 ) || newTags.length > 0 ) && ( -
Instance
+
Instance
) } { @@ -894,28 +1340,66 @@ const AnnotationForm = ({ return badge; }) } +
+
+ Add tag from: +
+ { + const addOption = addHedTagOptions.find((option) => { + return option.value === value; + }) + handleAddTag(addOption.type) + }} + /> +
{ newTags.map((tag, tagIndex) => { + const emptyText = `Select ${ + addHedTagOptions.find((option) => { + return option.type === newTags[tagIndex].type; + }) + .value // Plural -- make singular + .split(' ') + .map((s, i) => s.slice(-1) === 's' ? s.slice(0, -1) : s) + .join(' ') + }`; + return ( - <> + { - handleTagChange(tagIndex, value); + handleTagChange( + tagIndex, + value === emptyText ? '' : value + ); }} useOptionGroups={true} />
handleRemoveAddedTag(tagIndex)} style={{ position: 'relative', @@ -931,59 +1415,87 @@ const AnnotationForm = ({ > x
- +
); }) }
-
-
- Select tag from: -
- { - const addOption = addHedTagOptions.find((option) => { - return option.value === value; - }) - handleAddTag(addOption.type) - }} - /> - {/**/} - {/* Add Tag*/} - {/**/} -
- - - +
+ + +
+ + +
+ + {/*{currentAnnotation &&*/} + {/* */} + {/* Delete*/} + {/* */} + {/*}*/} {annoMessage && (
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 index f3e3e2ef9d0..3280ab0350b 100644 --- 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 @@ -1,15 +1,38 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {MutableRefObject, 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 {CheckboxElement, SelectDropdown} from './Form'; +import Panel from './Panel'; // Different from ./Panel +import {HEDSchemaElement, HEDTag} from "../store/types"; +import {setAddedTags, setDatasetTags, setDeletedTags, setRelOverrides, setDatasetMetadata} from "../store/state/dataset"; import swal from "sweetalert2"; -import {buildHEDString, getNthMemberTrailingBadgeIndex} from "../store/logic/filterEpochs"; +import {buildHEDString, getNthMemberTrailingBadgeIndex, getRootTags} from "../store/logic/filterEpochs"; import {colorOrder} from "../../color"; +const TagAction = { + 'Select': { + text: 'Select Action', + icon: undefined, + color: 'white', + }, + 'Endorsed': { + text: 'Endorse', + icon: 'flag', + color: 'green', + }, + 'Caveat': { + text: 'Caveat', + icon: 'flag', + color: 'red', + }, + 'Comment': { + text: 'Comment', + icon: 'comment', + color: '#256eb6', + }, +} + type CProps = { physioFileID: number, hedSchema: HEDSchemaElement[], @@ -19,8 +42,15 @@ type CProps = { deletedTags: HEDTag[], setAddedTags: (_: HEDTag[]) => void, setDeletedTags: (_: HEDTag[]) => void, + channelDelimiter: string, setRelOverrides: (_: HEDTag[]) => void, setDatasetTags: (_: any) => void, + activeMenuTab: string, + setActiveMenuTab: (_: string) => void, + tabsRef: MutableRefObject, + tagsHaveChanges: boolean, + setDatasetMetadata: (_: any) => void, + filenamePrefix: string, }; /** @@ -32,10 +62,17 @@ type CProps = { * @param root0.hedSchema * @param root0.addedTags * @param root0.deletedTags + * @param root0.channelDelimiter * @param root0.setAddedTags * @param root0.setDeletedTags * @param root0.setDatasetTags * @param root0.setRelOverrides + * @param root0.activeMenuTab + * @param root0.setActiveMenuTab + * @param root0.tabsRef + * @param root0.tagsHaveChanges + * @param root0.setDatasetMetadata + * @param root0.filenamePrefix */ const DatasetTagger = ({ physioFileID, @@ -44,10 +81,17 @@ const DatasetTagger = ({ hedSchema, addedTags, deletedTags, + channelDelimiter, setAddedTags, setDeletedTags, setDatasetTags, setRelOverrides, + activeMenuTab, + setActiveMenuTab, + tabsRef, + tagsHaveChanges, + setDatasetMetadata, + filenamePrefix, }: CProps) => { const tagListID = 'searchable-hed-tags'; const [searchText, setSearchText] = useState(''); @@ -61,8 +105,53 @@ const DatasetTagger = ({ const [datasetTooltip, setDatasetTooltip] = useState({ title: '', description: '', + taggedBy: 0, + taggerName: '', + endorsements: [], }); const [activeHEDSchemas, setActiveHEDSchemas] = useState({}); + const [numJSONSpaces, setNumJSONSpaces] = useState(2); + const [showUnpublishedTags, setShowUnpublishedTags] = useState(false); + + const [activeEndorsementMenuItem, setActiveEndorsementMenuItem] = useState({ + action: 'Select', + commentText: '', + }); + const endorsementMenuRef = useRef(null); + + + const handleOutsideClick = (event) => { + // Currently the best way to distinguish outer box + if (event.target.style.zIndex === '9999') { + event.stopPropagation(); + } + } + + const resetAllChanges = () => { + setAddedTags([]); + setDeletedTags([]); + setRelOverrides([]); + setGroupedTags([]); + } + + const onModalClose = (event) => { + if ([...addedTags, ...deletedTags].length > 0) { + event.stopPropagation(); + swal.fire({ + title: 'Are you sure?', + text: 'Leaving this window will result in the loss of any information entered.', + type: 'warning', + showCancelButton: true, + confirmButtonText: 'Proceed', + cancelButtonText: 'Cancel', + }).then((result) => { + if (result.value) { + resetAllChanges(); + event.target.dispatchEvent((new Event('click', { bubbles: true, cancelable: true }))); + } + }); + } + } useEffect(() => { // Initialize active HED schemas @@ -73,8 +162,32 @@ const DatasetTagger = ({ } }); setActiveHEDSchemas(activeSchemas); + + // ff only one column, preselect it + if (Object.keys(datasetTags).length === 1) { + setColumnTo(Object.keys(datasetTags)[0]); + } + + // Prevent default behaviour + document.querySelector('#tag-modal-container > div') + .addEventListener('click', handleOutsideClick, true); + + return () => { + document.querySelector('#tag-modal-container > div') + ?.removeEventListener('click', handleOutsideClick, true); + + }; + + + // Tabs initially invisible + // tabsRef.current.style.display = 'none'; + // setActiveMenuTab('TAG_MODE'); }, []); + useEffect(() => { + setDatasetMetadata({ tagsHaveChanges: false, }); + }, [tagsHaveChanges]); + const generateTagID = (index: number) => { return `add_${Date.now()}${index}`; } @@ -105,11 +218,8 @@ const DatasetTagger = ({ confirmButtonText: 'Yes, reset all changes!' }).then((result) => { if (result.value) { - setAddedTags([]); - setDeletedTags([]); - setRelOverrides([]); - setGroupedTags([]); -; } + resetAllChanges(); + } }); } @@ -226,7 +336,9 @@ const DatasetTagger = ({ return mapping.AddID === addedTagID; }); if (tagMapping) { - addedTag.ID = tagMapping.RelID + addedTag.ID = tagMapping.RelID; + addedTag.TaggerName = tagMapping.TaggerName; + addedTag.TaggedBy = tagMapping.TaggedBy; if (pairRelTagMapping) { addedTag.PairRelID = pairRelTagMapping.RelID; } @@ -292,6 +404,7 @@ const DatasetTagger = ({ }); setDatasetTags(updatedDatasetTags); + setDatasetMetadata({ tagsHaveChanges: true }); setAddedTags([]); setDeletedTags([]); setRelOverrides([]); @@ -347,14 +460,16 @@ const DatasetTagger = ({ } useEffect(() => { - const openTagViewerClasses = document - .querySelector('#tag-modal-container > button') - .classList; - - if ([...addedTags, ...deletedTags].length > 0) { - openTagViewerClasses.add('tag-modal-container-dirty'); - } else { - openTagViewerClasses.remove('tag-modal-container-dirty'); + // Override onClose() + document + .querySelector( + '#tag-modal-container > div > div > div > span' + ).addEventListener('click', onModalClose, true); + + return () => { + document.querySelector( + '#tag-modal-container > div > div > div > span') + ?.removeEventListener('click', onModalClose, true); } }, [addedTags, deletedTags]) @@ -393,33 +508,27 @@ const DatasetTagger = ({ HasPairing: '0', PairRelID: null, AdditionalMembers: 0, + TaggedBy: 0, + TaggerName: 'You', + Endorsements: [], } ]); setSearchText(''); setSearchTextValid(false); } else { - console.error('Failed to add tag. TODO: report') + console.log('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'); + console.log('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; + return tag.ID == tagRelID; }) if (tagFromDataset) { @@ -427,11 +536,11 @@ const DatasetTagger = ({ const rootTags = getRootTags(tagsToSearch); const tagInRootTags = rootTags.find((tag) => { - return tag.ID === tagFromDataset.ID + return tag.ID == tagFromDataset.ID }); const tagInGroupedTags = applyOverrides(groupedTags).find((tag) => { - return tag.ID === tagFromDataset.ID; + return tag.ID == tagFromDataset.ID; }) if (!tagInGroupedTags && tagInRootTags && tagInRootTags.PairRelID === null) { @@ -448,16 +557,16 @@ const DatasetTagger = ({ // Remove tag from addedTags if (tagRelID.startsWith('add_')) { const tagFromAdded = updatedAddedTags.find((tag) => { - return tag.ID === tagRelID; + return tag.ID == tagRelID; }); const tagInGroupedTags = tagFromAdded ? updatedGroupedTags.find((tag) => { - return tag.ID === tagFromDataset.ID; + return tag.ID == tagFromDataset.ID; }) : false; if (!tagInGroupedTags && tagFromAdded) { setAddedTags(updatedAddedTags.filter((tag) => { - return tag !== tagFromAdded; + return tag != tagFromAdded; })); } } @@ -482,6 +591,13 @@ const DatasetTagger = ({ const handleFieldValueChange = (e) => { setActiveFieldValue(e.target.value); setGroupedTags([]); + tabsRef.current.style.display = 'block'; // overkill - revise + } + + const setColumnTo = (columnName: string) => { + setActiveColumnName(columnName); + setActiveFieldValue(null); + setSearchText(''); } const handleColumnValueChange = (e) => { @@ -497,6 +613,24 @@ const DatasetTagger = ({ }).length; } + // TODO: Revise this -- Breaks when a comment is present (different from HEDEndorsement) + const tagInGroupIsEndorsed = (tag: HEDTag, tagList: HEDTag[]) => { + return tag.Endorsements.length === 0 || + tag.Endorsements.every((endorsement) => { + return endorsement.EndorsementStatus !== 'Endorsed' + }) || ( + tag.PairRelID !== null && + tagInGroupIsEndorsed(tagList.find(t => t.ID === tag.PairRelID), tagList) + ); + } + + const allTagsAreEndorsed = (hedTags: HEDTag[]) => { + return hedTags.length > 0 && + !getRootTags(hedTags).some((rootTag) => { + return tagInGroupIsEndorsed(rootTag, hedTags); + }); + } + const buildDataList = (onlyParentNodes: boolean) => { return ( @@ -556,11 +690,15 @@ const DatasetTagger = ({ if (!column) return null; - return column.field_values.map((fieldValue) => { + return column.field_values + .filter((value) => value !== '') + .map((fieldValue) => { const valueIsDirty = isDirty(columnName, fieldValue); + const tagsAreEndorsed = allTagsAreEndorsed(datasetTags[columnName][fieldValue]); return (