Work on refactoring measurements out of Lesion Tracker package

- Removed AssociatedStudies Collection
- Measurement API is generated from configuration files
- Data exchange methods are defined in configuration
This commit is contained in:
Erik Ziegler committed 2016-11-15 08:21:46 +01:00
1 parent c511edd419
commit ab4d4c9008
537 files changed
+5122 -3545

No files matched your search

@@ -0,0 +1,19 @@
<template name="progressDialog">
<div id="progressDialog" tabindex="0">
<div class="dialogHeader">
<h5>{{progressDialogTitle}}</h5>
</div>
<div class="dialogContent">
<div class="progress">
<div class="progress-bar progress-bar-striped active"
role="progressbar"
style="width:{{progressStatus}}">
{{formatNumberPrecision progressStatus 0}}%
</div>
</div>
<div id="message">
{{{progressMessage}}}
</div>
</div>
</div>
</template>
@@ -0,0 +1,69 @@
progressDialog = {
show: function(title, numberOfTotal) {
Session.set('progressDialogSettings', {
title: title,
numberOfCompleted: 0,
numberOfTotal: numberOfTotal
});
$('#progressDialog').css('display', 'block');
},
update: function(numberOfCompleted) {
var progressDialogSettings = Session.get('progressDialogSettings');
progressDialogSettings.numberOfCompleted = numberOfCompleted;
Session.set('progressDialogSettings', progressDialogSettings);
if (progressDialogSettings.numberOfCompleted === progressDialogSettings.numberOfTotal) {
progressDialog.close();
}
},
close: function() {
Session.set('progressDialogSettings', {
title: '',
numberOfCompleted: 0,
numberOfTotal: 1
});
$('#progressDialog').css('display', 'none');
},
setMessage: function(message) {
let progressDialogSettings = Session.get('progressDialogSettings');
progressDialogSettings.message = message;
Session.set('progressDialogSettings', progressDialogSettings);
}
};
Template.progressDialog.helpers({
progressDialogTitle() {
var settings = Session.get('progressDialogSettings');
if (!settings) {
return;
}
return settings.title;
},
progressStatus() {
const settings = Session.get('progressDialogSettings');
if (!settings) {
return;
}
if (settings.numberOfCompleted === undefined ||
settings.numberOfTotal === undefined) {
return;
}
return settings.numberOfCompleted / settings.numberOfTotal;
},
progressMessage() {
var settings = Session.get('progressDialogSettings');
if (!settings) {
return;
}
return settings.message;
}
});
@@ -0,0 +1,25 @@
#progressDialog
display: none
position: absolute
top: 0
bottom: 0
left: 0
right: 0
margin: auto
width: 20%
height: 20%
z-index: 100
border-radius: 5px
padding: 10px 20px 10px 20px
background-color: rgba(255,255,255,1)
outline: none
.dialogContent
margin-bottom: 20px
#message
width: 100%
span
display: inline-block;
width: 100%
@@ -0,0 +1,104 @@
<template name="seriesDetailsTable">
{{#unless selectedStudies.length}}
<p align="center">Series details not found</p>
{{/unless}}
{{#each selectedStudies}}
<div class="selectedStudyContainer">
<table class="table table-responsive tblStudyDetails">
<tbody>
<tr>
<td class="center patientID">
<div>
<span>Patient ID</span>
</div>
<input type="text"
class="form-control"
id="patientID"
placeholder="Patient ID"
disabled="disabled"
value="{{ patientId }}">
</td>
<td class="patientName">
<div>
<span>Patient Name</span>
</div>
<input type="text"
class="form-control"
id="patientName"
placeholder="Patient Name"
disabled="disabled"
value="{{ formatPN patientName }}">
</td>
<td class="accessionNumber">
<div>
<span>Accession Number</span>
</div>
<input type="text"
class="form-control"
id="accessionNumber"
placeholder="Accession Number"
disabled="disabled"
value="{{ accessionNumber }}">
</td>
</tr>
<tr>
<td class="studyDescription">
<div>
<span>Study Description</span>
</div>
<input type="text"
class="form-control"
id="studyDescription"
placeholder="Study Description"
disabled="disabled"
value="{{ studyDescription }}">
</td>
<td class="studyDate">
<div>
<span>Study Date</span>
</div>
<input type="text"
class="form-control"
id="studyDate"
placeholder="Study Date"
disabled="disabled"
value="{{ formatDA studyDate }}">
</td>
<td></td>
</tr>
</tbody>
</table>
<table class="table table-responsive tblSeriesDetails">
<thead>
<tr>
<th class="header">Number</th>
<th class="header">Modality</th>
<th class="header">Description</th>
<th class="header">Instances</th>
</tr>
</thead>
<tbody>
{{#if displaySeriesLoadingText}}
<tr>
<td colspan="4" class="center">
{{> loadingText }}
</td>
</tr>
{{/if}}
{{#each seriesList}}
<tr>
<td class="center">{{ seriesNumber }}</td>
<td class="center">{{ modality }}</td>
<td class="center">{{ seriesDescription }}</td>
<td class="center">{{ instances.length }}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/each}}
</template>
@@ -0,0 +1,51 @@
Template.seriesDetailsTable.onCreated(() => {
const instance = Template.instance();
// Create selected studies
instance.selectedStudies = new ReactiveDict();
let selectedStudiesData = instance.data.selectedStudies;
if (!selectedStudiesData) {
return;
}
// Display loading text while getting series
_.each(selectedStudiesData, selectedStudy => {
selectedStudy.displaySeriesLoadingText = true;
});
// Set reactive selected studies
instance.selectedStudies.set('studies', selectedStudiesData);
});
Template.seriesDetailsTable.onRendered(() => {
const instance = Template.instance();
const studies = instance.selectedStudies.get('studies');
if (!studies) {
return;
}
// Get series list for the study
_.map(studies, (selectedStudy, index) => {
studies[index].seriesList = [];
getStudyMetadata(selectedStudy.studyInstanceUid, study => {
// Set series list
studies[index].seriesList = study.seriesList;
studies[index].displaySeriesLoadingText = false;
// Update selected studies
instance.selectedStudies.set('studies', studies);
});
});
});
Template.seriesDetailsTable.helpers({
selectedStudies() {
const instance = Template.instance();
return instance.selectedStudies.get('studies');
}
});
@@ -0,0 +1,30 @@
@import "{design}/app"
.selectedStudyContainer
margin-bottom: 15px;
.tblStudyDetails
tbody > tr > td > label
color: $textPrimaryColor
tbody > tr > td > input[type="text"]
height: 28px
background-color: $primaryBackgroundColor
color: $textPrimaryColor
width: calc(100% - 10px)
border-radius: 4px
box-sizing: border-box
.tblSeriesDetails
thead > tr > th
text-align: center
tbody > tr
td
text-align: center
color: $textPrimaryColor
.loadingTextDiv
font-size: 13px
color: $textSecondaryColor
@@ -0,0 +1,50 @@
<template name="serverInformationDicomWeb">
<div class="row">
<div class="col-lg-6">
{{>inputText labelClass='form-group' key='wadoUriRoot'}}
</div>
<div class="col-lg-6">
{{>inputText labelClass='form-group' key='wadoRoot'}}
</div>
<div class="col-lg-6">
{{>inputSelect labelClass='form-group' key='imageRendering' hideSearch=true
options=(clone placeholder='Select an option')}}
</div>
<div class="col-lg-6">
{{>inputText labelClass='form-group' key='qidoRoot'}}
</div>
<div class="col-lg-6">
<strong>QIDO options</strong>
<div class="form-group">
<div class="form-control">
<div class="checkbox">
{{>inputCheckbox key='qidoSupportsIncludeField'}}
</div>
</div>
</div>
</div>
</div>
<hr>
<h4>Request options</h4>
{{#group class='row' key='requestOptions'}}
<div class="col-lg-6">
{{>inputText labelClass='form-group' key='auth'}}
</div>
<div class="col-lg-6">
<strong>Logging options</strong>
<div class="form-group">
<div class="form-control clearfix">
<div class="checkbox pull-left">
{{>inputCheckbox key='logRequests'}}
</div>
<div class="checkbox pull-left">
{{>inputCheckbox key='logResponses'}}
</div>
<div class="checkbox pull-left">
{{>inputCheckbox key='logTiming'}}
</div>
</div>
</div>
</div>
{{/group}}
</template>
@@ -0,0 +1,10 @@
Template.serverInformationDicomWeb.onRendered(() => {
const instance = Template.instance();
instance.autorun(function() {
const mode = instance.data.mode.get();
if (mode === 'edit') {
const data = instance.data.currentItem.get();
instance.data.form.value(data);
}
});
});
@@ -0,0 +1,60 @@
<template name="serverInformationDimse">
<div class="row">
<div class="col-lg-6">
{{>inputText labelClass='form-group' key='wadoUriRoot'}}
</div>
</div>
<div class="panel panel-default panel-body">
<div class="clearfix">
<h4 class="pull-left">Peer List</h4>
{{#button class='btn btn-primary pull-right'
action='newPeer' params=peer
}}New peer{{/button}}
</div>
<hr>
{{#group class='row' key='peers' arrayValues=true}}
{{#each peer in instance.peers.get}}
{{#group class='col-lg-6'}}
<div class="panel panel-default panel-body">
<div class="row">
<div class="col-lg-6">
{{>inputText class='form-control' key='aeTitle'}}
</div>
<div class="col-lg-6">
{{>inputText class='form-control' key='hostAE'}}
</div>
<div class="col-lg-6">
{{>inputText class='form-control' key='host'}}
</div>
<div class="col-lg-6">
{{>inputText class='form-control' key='port'}}
</div>
<div class="col-lg-12">
<strong>Settings</strong>
<div class="form-group">
<div class="form-control height-auto clearfix">
<div class="checkbox pull-left">
{{>inputCheckbox key='default'}}
</div>
<div class="checkbox pull-left">
{{>inputCheckbox key='server'}}
</div>
<div class="checkbox pull-left">
{{>inputCheckbox key='supportsInstanceRetrievalByStudyUid'}}
</div>
</div>
</div>
</div>
<div class="col-lg-12 text-right">
{{#button class='btn btn-sm btn-danger'
action='removePeer' params=@index
}}Remove{{/button}}
</div>
</div>
</div>
{{/group}}
{{/each}}
{{/group}}
</div>
<hr>
</template>
@@ -0,0 +1,38 @@
Template.serverInformationDimse.onCreated(() => {
const instance = Template.instance();
instance.peers = new ReactiveVar([]);
instance.autorun(() => {
const currentItem = instance.data.currentItem.get();
if (currentItem) {
instance.peers.set(currentItem.peers || []);
}
});
const api = instance.data.api;
_.extend(api, {
newPeer() {
const peers = instance.peers.get();
peers.push({});
instance.peers.set(peers);
},
removePeer(peerIndex) {
const peers = instance.peers.get();
peers.splice(peerIndex, 1);
instance.peers.set(peers);
const data = instance.data.currentItem.get();
data.peers = peers;
Tracker.afterFlush(() => instance.data.form.value(data));
}
});
});
Template.serverInformationDimse.onRendered(() => {
const instance = Template.instance();
instance.autorun(() => {
const mode = instance.data.mode.get();
if (mode === 'edit') {
const data = instance.data.currentItem.get();
instance.data.form.value(data);
}
});
});
@@ -0,0 +1,27 @@
<template name="serverInformationForm">
{{#form class='server-information-form' schema=instance.currentSchema api=this.api}}
{{>inputHidden key='_id'}}
<div class="row">
<div class="col-lg-6">
{{>inputText labelClass='form-group' key='name'}}
</div>
<div class="col-lg-6">
{{>inputSelect labelClass='form-group' key='type'
hideSearch=true disabled=(eq this.mode.get 'edit')
options=(clone placeholder='Choose a server type')}}
</div>
</div>
<hr>
{{#if eq this.serverType.get 'dicomWeb'}}
{{>serverInformationDicomWeb this}}
{{/if}}
{{#if eq this.serverType.get 'dimse'}}
{{>serverInformationDimse this}}
{{/if}}
{{#if this.serverType.get}}
<div>
{{#button class='btn-primary' action='save'}}Save{{/button}}
</div>
{{/if}}
{{/form}}
</template>
@@ -0,0 +1,72 @@
import { Template } from 'meteor/templating';
import { ReactiveVar } from 'meteor/reactive-var';
import { Tracker } from 'meteor/tracker';
import { DICOMWebServer as dicomSchema } from 'meteor/ohif:study-list/both/schema/servers.js';
import { DIMSEServer as dimseSchema } from 'meteor/ohif:study-list/both/schema/servers.js';
Template.serverInformationForm.onCreated(() => {
const instance = Template.instance();
instance.data.api = {
save() {
// Stop here if the form validation fails
if (!instance.data.form.validate()) {
return;
}
// Get the current form data
const formData = instance.data.form.value();
// Call the save method
Meteor.call('serverSave', formData, function(error) {
if (error) {
// TODO: check for errors: not-authorized, data-write
}
instance.data.resetState();
});
}
};
instance.currentSchema = new ReactiveVar(dicomSchema);
});
Template.serverInformationForm.onRendered(() => {
const instance = Template.instance();
instance.data.$form = instance.$('form:first');
instance.data.form = instance.data.$form.data('component');
// Handle the server type
instance.autorun(() => {
// Get the server type component
const typeComponent = instance.$('[data-key=type] :input').data('component');
// Run this computation every time the user change the server type
typeComponent.depend();
// Get the current server type value
const type = typeComponent.value();
// Set the serverType reactive value
instance.data.serverType.set(type);
// Change the schema based on the selected server type
if (type === 'dimse') {
instance.currentSchema.set(dimseSchema);
} else {
instance.currentSchema.set(dicomSchema);
}
});
// Handle the form mode (edit or add)
instance.autorun(() => {
const mode = instance.data.mode.get();
// Check if it is on edit mode and load the saved data
if (mode === 'edit') {
const data = instance.data.currentItem.get();
instance.data.form.value(data);
}
});
});
@@ -0,0 +1,10 @@
<template name="serverInformationFormField">
<div class="col-lg-6">
<div class="form-group">
<label class="wrapper">
<span>{{this.label}}</span>
<input type="text" name="{{this.name}}" class="form-control">
</label>
</div>
</div>
</template>
@@ -0,0 +1,45 @@
<template name="serverInformationList">
{{#form api=instance.api}}
<table id="tblServer" class="table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
{{#each server in servers}}
<tr>
<td>{{server.name}}</td>
<td>{{server.type}}</td>
<td class="text-right">
<div class="btn-group" role="group" aria-label="Actions">
{{#button class='btn-sm btn-default' action='use' params=server title='Use this server' disabled=(isActive server)}}
{{#if isActive server}}
<i class="fa fa-check-square-o"></i>
{{else}}
<i class="fa fa-square-o"></i>
{{/if}}
{{/button}}
{{#button class='btn-sm btn-default' action='edit' params=server title='Edit' disabled=(eq server.origin 'json')}}
<i class="fa fa-pencil"></i>
{{/button}}
{{#button class='btn-sm btn-danger' action='delete' params=server title='Remove' disabled=(eq server.origin 'json')}}
<i class="fa fa-trash"></i>
{{/button}}
</div>
</td>
</tr>
{{else}}
<tr>
<td class="text-center" colspan="3">No servers found</td>
</tr>
{{/each}}
</tbody>
</table>
{{#button class='btn-sm btn-default' action='add'}}
<i class="fa fa-plus"></i> Add a new server
{{/button}}
{{/form}}
</template>
@@ -0,0 +1,47 @@
Template.serverInformationList.onCreated(() => {
const instance = Template.instance();
instance.api = {
add: () => instance.data.mode.set('create'),
edit(server) {
instance.data.currentItem.set(server);
instance.data.mode.set('edit');
},
delete(server) {
// TODO: Replace this for confirmation dialog after LT-refactor is merged back to master
if (!confirm('Are you sure you want to remove this peer?')) {
return;
}
Meteor.call('serverRemove', server._id, error => {
// TODO: check for errors: data-write
});
},
use(server) {
Meteor.call('serverSetActive', server._id, error => {
// TODO: check for errors: data-write
});
}
};
});
Template.serverInformationList.helpers({
isActive: function(server) {
const currentServer = CurrentServer.findOne();
if (!currentServer) {
return;
}
return server._id === currentServer.serverId;
},
servers: function() {
const options = {
sort: {
origin: -1,
type: 1,
name: 1
}
};
return Servers.find({}, options).fetch();
}
});
@@ -0,0 +1,27 @@
<template name="serverInformationModal">
<div class="modal" id="serverInformationModal" tabindex="-1" role="dialog" aria-labelledby="serverInformationModalLabel">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
{{#let isList=(eq instance.container.mode.get "list")}}
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="serverInformationModalLabel">Server Information</h4>
</div>
<div class="modal-body">
{{#if isList}}
{{>serverInformationList instance.container}}
{{else}}
{{>serverInformationForm instance.container}}
{{/if}}
</div>
<div class="modal-footer">
{{#unless isList}}
<button type="button" class="btn btn-default js-back">Back</button>
{{/unless}}
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
{{/let}}
</div>
</div>
</div>
</template>
@@ -0,0 +1,20 @@
Template.serverInformationModal.onCreated(function() {
var instance = Template.instance();
instance.container = {
mode: new ReactiveVar('list'),
serverType: new ReactiveVar(null),
currentItem: new ReactiveVar(null),
$form: null,
resetState: function() {
instance.container.mode.set('list');
instance.container.serverType.set(null);
instance.container.currentItem.set(null);
}
};
});
Template.serverInformationModal.events({
'click .js-back, click [data-dismiss=modal]': function(event, instance) {
instance.container.resetState();
}
});
@@ -0,0 +1,5 @@
.server-information-form
label.wrapper
display: block
.checkbox
margin: 0 8px 0 0
@@ -0,0 +1,25 @@
<template name="studyContextMenu">
{{#if uiSettings.studyListFunctionsEnabled}}
<div id="studyContextMenu" class="studyContextMenu noselect"
oncontextmenu='return false;'
unselectable='on'
onselectstart='return false;'>
<ul>
<li>
<a id="viewSeriesDetails" type="button"
title="View Series Details">
View Series Details
</a>
<a class="disabled">Anonymize</a>
<a class="disabled">Send</a>
<hr/>
<a class="disabled">Delete</a>
<a id="exportSelectedStudies"
title="Export Selected Studies">
Export
</a>
</li>
</ul>
</div>
{{/if}}
</template>
@@ -0,0 +1,105 @@
function closeHandler(dialog) {
// Hide the dialog
$(dialog).css('display', 'none');
// Remove the backdrop
$('.removableBackdrop').remove();
}
/**
* This function is used inside the StudyList package to define a right click callback
*
* @param e
*/
openStudyContextMenu = function(e) {
StudyList.functions['exportSelectedStudies'] = exportSelectedStudies;
StudyList.functions['viewSeriesDetails'] = viewSeriesDetails;
Template.studyContextMenu.study = $(e.currentTarget);
var dialog = $('#studyContextMenu');
// Show the nonTargetLesion dialog above
var dialogProperty = {
display: 'block'
};
var pageHeight = $(document).height();
dialogProperty.top = Math.max(e.pageY, 0);
dialogProperty.top = Math.min(dialogProperty.top, pageHeight - dialog.outerHeight());
var pageWidth = $(document).width();
dialogProperty.left = Math.max(e.pageX, 0);
dialogProperty.left = Math.min(dialogProperty.left, pageWidth - dialog.outerWidth());
// Device is touch device or not
// If device is touch device, set position center of screen vertically and horizontally
if (isTouchDevice()) {
// add dialogMobile class to provide a black, transparent background
dialog.addClass('dialogMobile');
dialogProperty.top = 0;
dialogProperty.left = 0;
dialogProperty.right = 0;
dialogProperty.bottom = 0;
dialogProperty.margin = 'auto';
}
dialog.css(dialogProperty);
dialog.focus();
// Show the backdrop
UI.render(Template.removableBackdrop, document.body);
// Make sure the context menu is closed when the user clicks away
$('.removableBackdrop').one('mousedown touchstart', function() {
closeHandler(dialog);
});
};
/**
* Exports all selected studies on the studylist
*/
function exportSelectedStudies() {
var selectedStudies = StudyListSelectedStudies.find({}, {
sort: {
studyDate: 1
}
}).fetch() || [];
exportStudies(selectedStudies);
}
/**
* Display series details of study in modal
*/
function viewSeriesDetails() {
var selectedStudies = StudyListSelectedStudies.find({}, {
sort: {
studyDate: 1
}
}).fetch();
if (!selectedStudies) {
return;
}
Modal.show('viewSeriesDetailsModal', {
selectedStudies: selectedStudies
});
}
Template.studyContextMenu.events({
'click a': function(e) {
var study = Template.studyContextMenu.study;
var id = $(e.currentTarget).attr('id');
var fn = StudyList.functions[id];
if (fn && typeof(fn) === 'function') {
fn(study);
}
var dialog = $('#studyContextMenu');
closeHandler(dialog);
}
});
@@ -0,0 +1,63 @@
@import "{design}/app"
.studyContextMenu
z-index: 10000
display: none
position: absolute
color: #2f2f2f
background-color: #f2f2f2
width: -moz-fit-content
width: -webkit-fit-content
width: fit-content
width: 207px
height: -moz-fit-content
height: -webkit-fit-content
height: fit-content
min-width: 100px
font-size: 14px
text-align: left
-webkit-background-clip: padding-box
background-clip: padding-box
border: 1px solid rgba(0, 0, 0, .15)
border-radius: 3px
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175)
box-shadow: 0 6px 12px rgba(0, 0, 0, .175)
hr
border-top: solid 1px #d1d1d1
margin: 2px
ul
width: 100%
margin: 5px 0
list-style: none
padding: 0
li
clear: both
white-space: nowrap
a
padding: 3px 15px
display: block
text-decoration: none
cursor: pointer
font-size: 15px
line-height: 1.7
color: black
&:not(.disabled):hover
theme('color', '$activeColor')
text-decoration: none
&.disabled
opacity: 0.4
pointer-events: none
cursor: default
.fa-lg
width: 40px
text-align: center
@@ -0,0 +1,19 @@
<template name="studylist">
<div id="studylistTabs" class="tab-content">
<div role="tabpanel" class="tab-pane active" id="studylistTab">
<div class="studylistContainer">
{{> studylistResult (clone this)}}
</div>
</div>
<div role="tabpanel" class="tab-pane" id="viewerTab">
<div class="viewerContainer">
</div>
</div>
{{>loadingText}}
</div>
{{#each templateName in additionalTemplates}}
{{> UI.dynamic template=templateName}}
{{/each}}
{{>studyContextMenu }}
{{>progressDialog}}
</template>
@@ -0,0 +1,61 @@
/**
* Template: StudyList
*
* This is the main component of the StudyList package
*/
// Define the ViewerData global object
// If there is currently any Session data for this object,
// use this to repopulate the variable
ViewerData = Session.get('ViewerData') || {};
// Define the StudyListStudies Collection
// This is a client-side only Collection which
// Stores the list of studies in the StudyList
StudyListStudies = new Meteor.Collection(null);
StudyListStudies._debugName = 'StudyListStudies';
Session.setDefault('activeContentId', 'studylistTab');
Template.studylist.onCreated(() => {
const instance = Template.instance();
if (StudyList.subscriptions) {
StudyList.subscriptions.forEach(subscriptionName => {
instance.subscribe(subscriptionName);
});
}
});
Template.studylist.onRendered(() => {
const instance = Template.instance();
if (instance.data && instance.data.studyInstanceUid) {
const studyInstanceUid = instance.data.studyInstanceUid;
openNewTab(studyInstanceUid, studyInstanceUid);
} else {
// If there is a tab set as active in the Session,
// switch to that now.
console.log('studylist onRendered');
const contentId = Session.get('activeContentId');
if (contentId !== 'studylistTab' && ViewerData && ViewerData[contentId]) {
switchToTab(contentId);
}
}
Meteor.subscribe('hangingprotocols');
});
Template.studylist.events({
'click #tablist a[data-toggle="tab"]': function(e) {
// If this tab is already active, do nothing
const tabButton = $(e.currentTarget);
const tabTitle = tabButton.parents('.tabTitle');
if (tabTitle.hasClass('active')) {
return;
}
// Otherwise, switch to the tab
const contentId = tabButton.data('target').replace('#', '');
switchToTab(contentId);
}
});
@@ -0,0 +1,34 @@
@import "{design}/app"
body
theme('background-color', '$primaryBackgroundColor')
#tblStudyList
tr
height: 20px
#studylistTab
theme('background-color', '$primaryBackgroundColor')
.studylistContainer
theme('background-color', '$primaryBackgroundColor')
margin: 0 auto
color: white
float: left;
width: 100%;
.tab-content
width: 100%
height: calc(100% - 91px)
.loadingTextDiv
theme('color', '$textSecondaryColor')
font-size: 30px
.tab-pane
width: 100%
height: 100%
.viewerContainer
height: 100%
width: 100%
@@ -0,0 +1,29 @@
<template name="studylistPagination">
<div class="studylistPagination">
<div class="row">
<div class="col-xs-4 col-sm-3 col-md-3">
{{#with recordCount}}
<div class="form-inline form-group rows-per-page">
<label>
<span>Show</span>
<select class="js-select-rows-per-page">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<span class="rows-per-page-label">rows per page</span>
</label>
</div>
{{/with}}
</div>
<div class="col-xs-8 col-sm-9 col-md-9">
<div class="form-inline form-group page-number">
<label>
<ul id="pagination" class="no-margins"></ul>
</label>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,58 @@
import { Template } from 'meteor/templating';
import './studylistPagination.html';
const visiblePages = 10;
Template.studylistPagination.onCreated(function() {
let instance = Template.instance();
// replace parentVariable with the name of the instance variable
instance.parent = this.parent(1);
});
Template.studylistPagination.onRendered(function() {
const instance = Template.instance();
const $paginationControl = instance.$('#pagination');
// Track changes on recordCount and rowsPerPage
instance.autorun(function() {
const recordCount = instance.parent.recordCount.get();
const rowsPerPage = instance.parent.rowsPerPage.get();
// Destroy plugin if exists
if ($paginationControl.data().twbsPagination) {
$paginationControl.twbsPagination('destroy');
}
if (recordCount && rowsPerPage) {
const totalPageNumber = Math.ceil(recordCount / rowsPerPage);
// Initialize plugin
$paginationControl.twbsPagination({
totalPages: totalPageNumber,
visiblePages: visiblePages,
onPageClick: function (event, page) {
// Update currentPage
// Decrease page by 1 to set currentPage
// Since reactive table current page index starts by 0
instance.parent.currentPage.set(page - 1);
}
});
}
});
});
Template.studylistPagination.helpers({
recordCount() {
const instance = Template.instance();
return instance.parent.recordCount.get();
}
});
Template.studylistPagination.events({
'change .js-select-rows-per-page'(event, instance) {
const rowsPerPage = $(event.currentTarget).val();
// Update rowsPerPage of parent
instance.parent.rowsPerPage.set(parseInt(rowsPerPage, 10));
}
});
@@ -0,0 +1,43 @@
@import "{design}/app"
.studylistPagination
font-size: 13px
font-weight: normal !important
.rows-per-page
padding-top: 20px
label
font-weight: normal
select
theme('background-color', '$primaryBackgroundColor')
color: white
.page-number
text-align: right
label
font-weight: normal
ul#pagination
li
a
padding: 4px 8px
theme('background-color', '$primaryBackgroundColor')
color: white
&:hover
theme('color', '$activeColor')
.active
a
theme('background-color', '$uiGray')
border-color: #ddd
color: white
.disabled
a:hover
color: inherit
@@ -0,0 +1,107 @@
<template name="studylistResult">
<div class="studyListToolbar">
<div class="header">
Study list
</div>
<div class="studyCount pull-right">
{{numberOfStudies}}
</div>
<div class="pull-right">
{{>studylistToolbar}}
</div>
</div>
<div class="theadBackground">
</div>
<div id="studyListContainer">
<table id="tblStudyList" class="studylistResult table noselect">
<thead>
<tr>
<th class="patientName">
<div id="_patientName" class="sortingCell">
<span>Patient Name</span>
<i class="{{sortingColumnsIcons.patientName}}">&nbsp;</i>
</div>
<input type="text"
class="form-control studylist-search"
id="patientName"
placeholder="Patient Name">
</th>
<th class="patientId">
<div id="_patientId" class="sortingCell">
<span>MRN</span>
<i class="{{sortingColumnsIcons.patientId}}">&nbsp;</i>
</div>
<input type="text"
class="form-control studylist-search"
id="patientId"
placeholder="MRN">
</th>
<th class="accessionNumber">
<div id="_accessionNumber" class="sortingCell ">
<span>Accession #</span>
<i class="{{sortingColumnsIcons.accessionNumber}}">&nbsp;</i>
</div>
<input type="text"
class="form-control studylist-search"
id="accessionNumber"
placeholder="Accession #">
</th>
<th class="studyDate">
<div id="_studyDate" class="sortingCell">
<span>Study Date</span>
<i class="{{sortingColumnsIcons.studyDate}}">&nbsp;</i>
</div>
<input type="text"
class="form-control studylist-search"
name="daterange"
id="studyDate"
placeholder="Study Date">
</th>
<th class="modalities">
<div id="_modalities" class="sortingCell">
<span>Modality</span>
<i class="{{sortingColumnsIcons.modalities}}">&nbsp;</i>
</div>
<input type="text"
class="form-control studylist-search"
id="modality"
placeholder="Modality">
</th>
<th class="studyDescription">
<div id="_studyDescription" class="sortingCell">
<span>Study Description</span>
<i class="{{sortingColumnsIcons.studyDescription}}">&nbsp;</i>
</div>
<input type="text"
class="form-control studylist-search"
id="studyDescription"
placeholder="Study Description">
</th>
<th class="numberOfStudyRelatedInstances">
<div id="_numberOfStudyRelatedInstances" class="sortingCell">
<span># Images</span>
<i class="{{sortingColumnsIcons.numberOfStudyRelatedInstances}}">&nbsp;</i>
</div>
<input type="text" class="form-control studylist-search invisible"/>
</th>
</tr>
</thead>
<tbody id="studyListData">
{{#each study in studies}}
{{>studylistStudy (clone study this)}}
{{/each}}
</tbody>
</table>
<!-- Pagination -->
{{> studylistPagination }}
{{#if session "showLoadingText"}}
{{>loadingText}}
{{ else }}
{{#unless numberOfStudies}}
<div class="notFound">No matching results</div>
{{/unless}}
{{/if}}
</div>
</template>
@@ -0,0 +1,288 @@
Session.setDefault('showLoadingText', true);
Template.studylistResult.helpers({
/**
* Returns a sorted instance of the StudyListStudies Collection
* by Patient name and Study Date in Ascending order.
*/
studies() {
const instance = Template.instance();
let studies;
let sortOption = {
patientName: 1,
studyDate: 1
};
// Update sort option if session is defined
if (Session.get('sortOption')) {
sortOption = Session.get('sortOption');
}
// Pagination parameters
const rowsPerPage = instance.rowsPerPage.get();
const currentPage = instance.currentPage.get();
const offset = rowsPerPage * currentPage;
const limit = offset + rowsPerPage;
studies = StudyListStudies.find({}, {
sort: sortOption
}).fetch();
if (!studies) {
return;
}
// Update record count
instance.recordCount.set(studies.length);
// Limit studies
return studies.slice(offset, limit);
},
numberOfStudies() {
return StudyListStudies.find().count();
},
sortingColumnsIcons() {
const instance = Template.instance();
let sortingColumnsIcons = {};
Object.keys(instance.sortingColumns.keys).forEach(key => {
const value = instance.sortingColumns.get(key);
if (value === 1) {
sortingColumnsIcons[key] = 'fa fa-fw fa-sort-up';
} else if (value === -1) {
sortingColumnsIcons[key] = 'fa fa-fw fa-sort-down';
} else {
// fa-fw is blank
sortingColumnsIcons[key] = 'fa fa-fw';
}
});
return sortingColumnsIcons;
}
});
var studyDateFrom;
var studyDateTo;
var filter;
/**
* Transforms an input string into a search filter for
* the StudyList Search call
*
* @param filter The input string to be searched for
* @returns {*}
*/
function getFilter(filter) {
if (filter && filter.length && filter.substr(filter.length - 1) !== '*') {
filter += '*';
}
return filter;
}
/**
* Search for a value in a string
*/
function isIndexOf(mainVal, searchVal) {
if (mainVal === undefined || mainVal === '' || mainVal.indexOf(searchVal) > -1){
return true;
}
return false;
}
/**
* Replace object if undefined
*/
function replaceUndefinedColumnValue(text) {
if (text === undefined || text === 'undefined') {
return '';
} else {
return text.toUpperCase();
}
}
/**
/**
* Convert string to study date
*/
function convertStringToStudyDate(dateStr) {
const y = dateStr.substring(0, 4);
const m = dateStr.substring(4, 6);
const d = dateStr.substring(6, 8);
const newDateStr = m + '/' + d + '/' + y;
return new Date(newDateStr);
}
/**
* Runs a search for studies matching the studylist query parameters
* Inserts the identified studies into the StudyListStudies Collection
*/
function search() {
console.log('search()');
// Show loading message
Session.set('showLoadingText', true);
// Create the filters to be used for the StudyList Search
filter = {
patientName: getFilter($('input#patientName').val()),
patientId: getFilter($('input#patientId').val()),
accessionNumber: getFilter($('input#accessionNumber').val()),
studyDescription: getFilter($('input#studyDescription').val()),
studyDateFrom: studyDateFrom,
studyDateTo: studyDateTo,
modalitiesInStudy: $('input#modality').val() ? $('input#modality').val() : ""
};
// Make sure that modality has a reasonable value, since it is occasionally
// returned as 'undefined'
const modality = replaceUndefinedColumnValue($('input#modality').val());
// Clear all current studies
StudyListStudies.remove({});
Meteor.call('StudyListSearch', filter, (error, studies) => {
console.log('StudyListSearch');
if (error) {
log.warn(error);
return;
}
// Hide loading text
Session.set('showLoadingText', false);
if (!studies) {
return;
}
// Loop through all identified studies
studies.forEach(study => {
// Search the rest of the parameters that aren't done via the server call
if (isIndexOf(study.modalities, modality) &&
(new Date(studyDateFrom).setHours(0, 0, 0, 0) <= convertStringToStudyDate(study.studyDate) || !studyDateFrom || studyDateFrom === '') &&
(convertStringToStudyDate(study.studyDate) <= new Date(studyDateTo).setHours(0, 0, 0, 0) || !studyDateTo || studyDateTo === '')) {
// Convert numberOfStudyRelatedInstance string into integer
study.numberOfStudyRelatedInstances = !isNaN(study.numberOfStudyRelatedInstances) ? parseInt(study.numberOfStudyRelatedInstances) : undefined;
// Insert any matching studies into the StudyListStudies Collection
StudyListStudies.insert(study);
}
});
});
}
Template.studylistResult.onCreated(() => {
let instance = Template.instance();
instance.sortOption = new ReactiveVar();
instance.sortingColumns = new ReactiveDict();
// Pagination parameters
// Rows per page is 25 as default
instance.rowsPerPage = new ReactiveVar(25);
// Set currentPage indexed 0
instance.currentPage = new ReactiveVar(0);
instance.recordCount = new ReactiveVar();
// Set sortOption
const sortOptionSession = Session.get('sortOption');
if (sortOptionSession) {
instance.sortingColumns.set(sortOptionSession);
} else {
instance.sortingColumns.set({
patientName: 1,
studyDate: 1,
patientId: 0,
accessionNumber: 0,
studyDescription: 0,
modality: 0,
numberOfStudyRelatedInstances: 0
});
}
});
Template.studylistResult.onRendered(() => {
const instance = Template.instance();
// Initialize daterangepicker
const today = moment();
const lastWeek = moment().subtract(6, 'days');
const lastMonth = moment().subtract(29, 'days');
const datePicker = instance.$('#studyDate').daterangepicker({
maxDate: today,
autoUpdateInput: true,
ranges: {
Today: [today, today],
'Last 7 Days': [lastWeek, today],
'Last 30 Days': [lastMonth, today]
}
}).data('daterangepicker');
// Retrieve all studies
search();
});
function resetSortingColumns(instance, sortingColumn) {
Object.keys(instance.sortingColumns.keys).forEach(key => {
if (key !== sortingColumn) {
instance.sortingColumns.set(key, null);
}
});
}
Template.studylistResult.events({
'keydown input'(event) {
if (event.which === 13) { // Enter
search();
}
},
'onsearch input'() {
search();
},
'change #studyDate'(event) {
let dateRange = $(event.currentTarget).val();
// Remove all space chars
dateRange = dateRange.replace(/ /g, '');
// Split dateRange into subdates
const dates = dateRange.split('-');
studyDateFrom = dates[0];
studyDateTo = dates[1];
if (dateRange !== '') {
search();
}
},
'click div.sortingCell'(event, instance) {
const elementId = event.currentTarget.id;
// Remove _ from id
const columnName = elementId.replace('_', '');
let sortOption = {};
resetSortingColumns(instance, columnName);
const columnObject = instance.sortingColumns.get(columnName);
if (columnObject) {
instance.sortingColumns.set(columnName, columnObject * -1);
sortOption[columnName] = columnObject * -1;
} else {
instance.sortingColumns.set(columnName, 1);
sortOption[columnName] = 1;
}
instance.sortOption.set(sortOption);
Session.set('sortOption', sortOption);
}
});
@@ -0,0 +1,241 @@
@import "{design}/app"
$tableHoverColor = #2c363f
$tableTextPrimaryColor = white
$tableTextSecondaryColor = #91b9cd
$inputBackgroundColor = #2c363f
$inputPlaceholderColor = lightgray
$studyListToolbarHeight = 75px
$tablePadding = 8%
$bodyCellHeight = 40px
.studyListToolbar
theme('background', '$uiGrayDarker')
height: $studyListToolbarHeight
border-bottom: 2px solid black
padding: 0 30px
div
display: inline-block
.header
font-size: 22px
font-weight: 300
color: $tableTextSecondaryColor
padding: 0 10px
line-height: $studyListToolbarHeight
.studylistToolbar
height: $studyListToolbarHeight
line-height: $studyListToolbarHeight
.studyCount
theme('color', '$uiSkyBlue')
font-size: 40px
font-weight: 100
padding: 0 10px
line-height: $studyListToolbarHeight
.theadBackground
theme('background-color', '$uiGrayDarker')
width: 100%
height: 122px
position: absolute
z-index: 1
#studyListContainer
background: black
width: 100%
padding: 0 $tablePadding
position: absolute
z-index: 2
.loadingTextDiv
color: $tableTextSecondaryColor
font-size: 30px
.notFound
color: $tableTextSecondaryColor
font-size: 30px
font-weight: 200
text-align: center
table#tblStudyList
thead
theme('background-color', '$uiGrayDarker')
white-space: nowrap
tr
th
padding: 0
border-bottom: solid 1px #6fbde2
width: 100%
&:first-child
padding-left: $tablePadding
&:last-child
padding-right: $tablePadding
div.sortingCell
display: inline-block
cursor: pointer
width: 100%
min-width: 95px
margin: 0 auto
color: $tableTextPrimaryColor
font-weight: 400
padding: 20px
span
font-size: 15px
float: left
i
margin: 0 5px
&:hover
theme('color', '$activeColor')
&.active, &:active
theme('color', '$activeColor')
input.studylist-search
height: 34px
margin: 0 5px 20px 5px
padding: 20px
cursor: pointer
border: none
background-color: $inputBackgroundColor
color: $inputPlaceholderColor
font-size: 10pt
font-weight: normal
width: calc(100% - 10px)
border-radius: 4px
box-sizing: border-box
transition(all 0.15s ease)
placeholder-color($inputPlaceholderColor)
&.invisible
visibility: hidden
&:active, &:hover
background-color: $inputBackgroundColor
::-webkit-datetime-edit-year-field:not([aria-valuenow]),
::-webkit-datetime-edit-month-field:not([aria-valuenow]),
::-webkit-datetime-edit-day-field:not([aria-valuenow])
color: transparent
tbody
tr
padding: 5px
background-color: black
&:nth-child(even)
theme('background-color', '$uiGrayDarker')
td
height: $bodyCellHeight
line-height: $bodyCellHeight
color: $tableTextPrimaryColor
font-weight: 300
border-top: 1px solid #436270
white-space: nowrap
transition(all 0.1s ease)
&:first-child
padding-left: $tablePadding
&:last-child
padding-right: $tablePadding
&:hover, &:active, &.active
background-color: $tableHoverColor
color: white
td
// This selector is necessary to override bootstrap's 'table' class
border-top: 1px solid #436270
border-bottom: 1px solid #436270
background-color: $tableHoverColor
@media only screen and (max-width: 1362px)
$tablePadding = 5%
#studyListContainer
padding: 0 $tablePadding
table#tblStudyList
thead,
tbody
tr
th,
td
&:first-child
padding-left: $tablePadding
&:last-child
padding-right: $tablePadding
@media only screen and (max-width: 1161px)
$tablePadding = 3%
#studyListContainer
padding: 0 $tablePadding
table#tblStudyList
thead,
tbody
tr
th,
td
&:first-child
padding-left: $tablePadding
&:last-child
padding-right: $tablePadding
@media only screen and (max-width: 1069px)
$tablePaddingMediumScreen = 5px
.studyListToolbar
padding: 0 $tablePaddingMediumScreen
#studyListContainer
padding: 0
table#tblStudyList
thead > tr > th
&:first-child
padding-left: $tablePaddingMediumScreen
&:last-child
padding-right: $tablePaddingMediumScreen
input.worklist-search
padding: 10px
div.sortingCell
padding: 10px 5px
min-width: auto
i
width: auto
tbody > tr > td
&:first-child
padding-left: $tablePaddingMediumScreen
&:last-child
padding-right: $tablePaddingMediumScreen
.worklistPagination
.row
margin-left: 0
margin-right: 0
@@ -0,0 +1,25 @@
<template name="studylistStudy">
<tr class="studylistStudy noselect">
<td class="patientName">
{{formatPN patientName}}
</td>
<td class="patientId">
{{patientId}}
</td>
<td class="accessionNumber">
{{accessionNumber}}
</td>
<td class="studyDate">
{{formatDA studyDate}}
</td>
<td class="modalities">
{{modalities}}
</td>
<td class="studyDescription">
{{studyDescription}}
</td>
<td class="numberOfStudyRelatedInstances">
{{numberOfStudyRelatedInstances}}
</td>
</tr>
</template>
@@ -0,0 +1,166 @@
// Maybe we should use regular StudyList collection?
StudyListSelectedStudies = new Meteor.Collection(null);
StudyListSelectedStudies._debugName = 'StudyListSelectedStudies';
function handleShiftClick(studyRow, data) {
//log.info('shiftKey');
var studyInstanceUid = studyRow.attr('studyInstanceUid');
// Select all rows in between these two rows
if (StudyList.previouslySelected) {
var previous = $(StudyList.previouslySelected);
var rowsInBetween;
if (previous.index() < studyRow.index()) {
// The previously selected row is above (lower index) the
// currently selected row.
// Fill in the rows upwards from the previously selected row
rowsInBetween = previous.nextAll('tr');
} else if (previous.index() > studyRow.index()) {
// The previously selected row is below the currently
// selected row.
// Fill in the rows upwards from the previously selected row
rowsInBetween = previous.prevAll('tr');
} else {
// The rows are the same, deselect the current row.
// TODO: CHECK THIS, pretty sure this is the wrong behaviour
StudyListSelectedStudies.remove({});
StudyList.previouslySelected = undefined;
return;
}
// Loop through the rows in between current and previous selected studies
rowsInBetween.each(function() {
var row = $(this);
if (row.hasClass('active')) {
// If we find one that is already selected, do nothing
return;
}
// Get the relevant studyInstanceUid
var studyInstanceUid = row.attr('studyInstanceUid');
// Retrieve the data context through Blaze
var data = Blaze.getData(this);
// Set the current study as selected
StudyListSelectedStudies.insert(data);
row.addClass('active');
// When we reach the currently clicked-on row, stop the loop
return !row.is(studyRow);
});
} else {
// Set the current study as selected
StudyListSelectedStudies.insert(data);
studyRow.addClass('active');
}
}
function handleCtrlClick(studyRow, data) {
//log.info('ctrlKey');
var studyInstanceUid = studyRow.attr('studyInstanceUid');
if (studyRow.hasClass('active')) {
studyRow.removeClass('active');
// Find the current studyInstanceUid in the stored list and remove it
StudyListSelectedStudies.remove({
studyInstanceUid: data.studyInstanceUid
});
} else {
// Set the current study as selected
StudyListSelectedStudies.insert(data);
studyRow.addClass('active');
// Set this as the previously selected row, so the user can
// use Shift to select from this point onwards
StudyList.previouslySelected = studyRow;
log.info('StudyList PreviouslySelected set: ' + studyRow.index());
}
}
Template.studylistStudy.onRendered(function() {
var instance = this;
var elem = instance.$('tr.studylistStudy').get(0);
// Enable HammerJS to allow touch support
var mc = new Hammer.Manager(elem);
var doubleTapRecognizer = new Hammer.Tap({
event: 'doubletap',
taps: 2,
interval: 500,
threshold: 30,
posThreshold: 30
});
mc.add(doubleTapRecognizer);
});
Template.studylistStudy.events({
'click tr.studylistStudy': function(e) {
var studyRow = $(e.currentTarget);
var data = this;
// Remove the ID so we can directly insert this into our client-side collection
delete data._id;
if (e.shiftKey) {
handleShiftClick(studyRow, data);
} else if (e.ctrlKey || e.metaKey) {
handleCtrlClick(studyRow, data);
} else {
// Select a single study
//log.info('Regular click');
// Clear all selected studies
StudyListSelectedStudies.remove({});
$('tr.studylistStudy').removeClass('active');
// Set the previous study to the currently clicked-on study
StudyList.previouslySelected = studyRow;
//log.info('StudyList PreviouslySelected set: ' + studyRow.index());
// Set the current study as selected
StudyListSelectedStudies.insert(data);
studyRow.addClass('active');
}
},
'mousedown tr.studylistStudy': function(e) {
// This event handler is meant to handle middle-click on a study
if (e.which !== 2) {
return;
}
var data = this;
var middleClickOnStudy = StudyList.callbacks.middleClickOnStudy;
if (middleClickOnStudy && typeof middleClickOnStudy === 'function') {
middleClickOnStudy(data);
}
},
'dblclick tr.studylistStudy, doubletap tr.studylistStudy': function(e) {
if (e.which !== undefined && e.which !== 1) {
return;
}
var data = this;
var dblClickOnStudy = StudyList.callbacks.dblClickOnStudy;
if (dblClickOnStudy && typeof dblClickOnStudy === 'function') {
dblClickOnStudy(data);
}
},
'contextmenu tr.studylistStudy, press tr.studylistStudy': function(e, template) {
$(e.currentTarget).addClass('active');
if (openStudyContextMenu && typeof openStudyContextMenu === 'function') {
e.preventDefault();
openStudyContextMenu(e, template);
return false;
}
}
});
@@ -0,0 +1,2 @@
.studylistStudy
cursor: pointer
@@ -0,0 +1,12 @@
<template name="studylistToolbar">
<div class='studylistToolbar'>
{{#if importSupported }}
<div class="addNewStudy btn-file">
<label for="btnImport">
<i class="fa fa-plus"></i> Add new study
</label>
<input id="btnImport" class='js-import-files' type="file" webkitdirectory directory multiple>
</div>
{{/if }}
</div>
</template>
@@ -0,0 +1,36 @@
import { OHIF } from 'meteor/ohif:core';
import { Template } from 'meteor/templating';
import { Meteor } from 'meteor/meteor';
import { Session } from 'meteor/session';
import { $ } from 'meteor/jquery';
Template.studylistToolbar.onCreated(() => {
Meteor.call('importSupported', (error, result) => {
if (error || !result) {
Session.set('importSupported', false);
} else {
Session.set('importSupported', true);
}
});
});
Template.studylistToolbar.events({
'change .js-import-files'(event) {
// Get selected files located in the client machine
const selectedFiles = $.map(event.currentTarget.files, value => value);
importStudies(selectedFiles);
},
'click .js-import-files'(event) {
// Reset file input
$(event.currentTarget).val('');
}
});
Template.studylistToolbar.helpers({
importSupported() {
const importSupported = Session.get('importSupported');
return (importSupported && OHIF.uiSettings.studyListFunctionsEnabled);
}
});
@@ -0,0 +1,27 @@
@import "{design}/app"
.studylistToolbar
.addNewStudy
margin: 0 10px
label
theme('color', '$textSecondaryColor')
font-weight: 400
cursor: pointer
*
pointer-events: none
&:hover
theme('color', '$hoverColor')
&:active
theme('color', '$activeColor')
input
width: 0.1px;
height: 0.1px
opacity: 0
overflow: hidden
position: absolute
z-index: -1
@@ -0,0 +1,8 @@
<template name="tabTitle">
<li role="presentation" class="tabTitle">
<a data-target="#{{contentid}}" role="tab" data-toggle="tab">
{{title}}
<button class="close">&times;</button>
</a>
</li>
</template>
@@ -0,0 +1,42 @@
Template.tabTitle.events({
/**
* Closes a tab when the close button is pressed in the title
* The next tab to the left is loaded if the current tab is closed
*
* @param e The click event used to close the tab
*/
'click .close': function(e) {
console.log('click .close');
// Identify the tab title DOM node
var tab = $(e.currentTarget).parents('a[data-toggle="tab"]').eq(0);
// Get the relevent contentId that this tab title represents
// Replace any hash marks (#) that were required by Bootstrap's tab switching
var contentId = tab.data('target').replace('#', '');
// Check if we are closing the active tab. If we are, prepare to switch
// to the next tab to the left.
var activeContentId = Session.get('activeContentId');
if (activeContentId === contentId) {
// Find the index of the tab that is being closed
var tabIndex = tab.parent('li').index();
// Find the index the tab to its left
var newActiveTabIndex = Math.max(tabIndex - 1, 0);
// Find the DOM node of the tab that will be activated
var newActiveTab = $('.tabTitle').eq(newActiveTabIndex);
// Find the content ID of the tab that will be switched to
var newActiveTabLink = newActiveTab.find('a[data-toggle=tab]');
var newContentId = newActiveTabLink.data('target').replace('#', '');
// Switch to this tab
switchToTab(newContentId);
}
// Remove any stored data related to this tab from the global ViewerData structure
delete ViewerData[contentId];
}
});
@@ -0,0 +1,4 @@
.tabTitle
button.close
// Prevent blue glow around button in Chrome
outline: none
@@ -0,0 +1,20 @@
<template name="viewSeriesDetailsModal">
<div class="modal" id="viewSeriesDetailsModal" tabindex="-1" role="dialog" aria-labelledby="viewSeriesDetailsModalLabel">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="associationModalLabel">Series Details</h4>
</div>
<div class="modal-body">
{{> seriesDetailsTable
selectedStudies=selectedStudies
}}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal" id='cancelAssociation'>Close</button>
</div>
</div>
</div>
</div>
</template>