PageRenderTime 79ms CodeModel.GetById 45ms RepoModel.GetById 0ms app.codeStats 0ms

/static/scripts/mvc/collection/paired-collection-creator.js

https://bitbucket.org/hbc/galaxy-central-hbc/
JavaScript | 1488 lines | 1053 code | 141 blank | 294 comment | 122 complexity | 9062354cdae9f1bab57ac483c5f4d564 MD5 | raw file
Possible License(s): CC-BY-3.0

Large files files are truncated, but you can click here to view the full file

  1. define([
  2. "mvc/base-mvc",
  3. "utils/localization"
  4. ], function( baseMVC, _l ){
  5. //=============================================================================
  6. /** An interface for building collections of paired datasets.
  7. */
  8. var PairedCollectionCreator = Backbone.View.extend( baseMVC.LoggableMixin ).extend({
  9. className: 'collection-creator flex-row-container',
  10. /** set up initial options, instance vars, behaviors, and autopair (if set to do so) */
  11. initialize : function( attributes ){
  12. //console.debug( '-- PairedCollectionCreator:', attributes );
  13. attributes = _.defaults( attributes, {
  14. datasets : [],
  15. filters : this.DEFAULT_FILTERS,
  16. automaticallyPair : true,
  17. matchPercentage : 1.0,
  18. //matchPercentage : 0.9,
  19. //matchPercentage : 0.8,
  20. //strategy : 'levenshtein'
  21. strategy : 'lcs'
  22. });
  23. //console.debug( 'attributes now:', attributes );
  24. /** unordered, original list */
  25. this.initialList = attributes.datasets;
  26. /** is this from a history? if so, what's its id? */
  27. this.historyId = attributes.historyId;
  28. /** which filters should be used initially? (String[2] or name in commonFilters) */
  29. this.filters = this.commonFilters[ attributes.filters ] || this.commonFilters[ this.DEFAULT_FILTERS ];
  30. if( _.isArray( attributes.filters ) ){
  31. this.filters = attributes.filters;
  32. }
  33. /** try to auto pair the unpaired datasets on load? */
  34. this.automaticallyPair = attributes.automaticallyPair;
  35. /** distance/mismatch level allowed for autopairing */
  36. this.matchPercentage = attributes.matchPercentage;
  37. /** what method to use for auto pairing (will be passed aggression level) */
  38. this.strategy = this.strategies[ attributes.strategy ] || this.strategies[ this.DEFAULT_STRATEGY ];
  39. if( _.isFunction( attributes.strategy ) ){
  40. this.strategy = attributes.strategy;
  41. }
  42. /** fn to call when the cancel button is clicked (scoped to this) - if falsy, no btn is displayed */
  43. this.oncancel = attributes.oncancel;
  44. /** fn to call when the collection is created (scoped to this) */
  45. this.oncreate = attributes.oncreate;
  46. /** is the unpaired panel shown? */
  47. this.unpairedPanelHidden = false;
  48. /** is the paired panel shown? */
  49. this.pairedPanelHidden = false;
  50. this._dataSetUp();
  51. this._setUpBehaviors();
  52. },
  53. /** map of common filter pairs by name */
  54. commonFilters : {
  55. none : [ '', '' ],
  56. illumina : [ '_1', '_2' ]
  57. },
  58. /** which commonFilter to use by default */
  59. DEFAULT_FILTERS : 'illumina',
  60. //DEFAULT_FILTERS : 'none',
  61. /** map of name->fn for autopairing */
  62. strategies : {
  63. 'lcs' : 'autoPairLCSs',
  64. 'levenshtein' : 'autoPairLevenshtein'
  65. },
  66. /** default autopair strategy name */
  67. DEFAULT_STRATEGY : 'lcs',
  68. // ------------------------------------------------------------------------ process raw list
  69. /** set up main data: cache initialList, sort, and autopair */
  70. _dataSetUp : function(){
  71. //console.debug( '-- _dataSetUp' );
  72. this.paired = [];
  73. this.unpaired = [];
  74. //this.fwdSelectedIds = [];
  75. //this.revSelectedIds = [];
  76. this.selectedIds = [];
  77. // sort initial list, add ids if needed, and save new working copy to unpaired
  78. this._sortInitialList();
  79. this._ensureIds();
  80. this.unpaired = this.initialList.slice( 0 );
  81. if( this.automaticallyPair ){
  82. this.autoPair();
  83. }
  84. },
  85. /** sort initial list */
  86. _sortInitialList : function(){
  87. //console.debug( '-- _sortInitialList' );
  88. this._sortDatasetList( this.initialList );
  89. //this._printList( this.unpaired );
  90. },
  91. /** sort a list of datasets */
  92. _sortDatasetList : function( list ){
  93. // currently only natural sort by name
  94. list.sort( function( a, b ){ return naturalSort( a.name, b.name ); });
  95. return list;
  96. },
  97. /** add ids to dataset objs in initial list if none */
  98. _ensureIds : function(){
  99. this.initialList.forEach( function( dataset ){
  100. if( !dataset.hasOwnProperty( 'id' ) ){
  101. dataset.id = _.uniqueId();
  102. }
  103. });
  104. //this._printList( this.unpaired );
  105. return this.initialList;
  106. },
  107. /** split initial list into two lists, those that pass forward filters & those passing reverse */
  108. _splitByFilters : function( filters ){
  109. var fwd = [],
  110. rev = [];
  111. this.unpaired.forEach( function( unpaired ){
  112. if( this._filterFwdFn( unpaired ) ){
  113. fwd.push( unpaired );
  114. }
  115. if( this._filterRevFn( unpaired ) ){
  116. rev.push( unpaired );
  117. }
  118. }.bind( this ) );
  119. return [ fwd, rev ];
  120. },
  121. /** filter fn to apply to forward datasets */
  122. _filterFwdFn : function( dataset ){
  123. // simple contains for now - add more when new feat. are added (regex, etc.)
  124. return dataset.name.indexOf( this.filters[0] ) >= 0;
  125. },
  126. /** filter fn to apply to reverse datasets */
  127. _filterRevFn : function( dataset ){
  128. return dataset.name.indexOf( this.filters[1] ) >= 0;
  129. },
  130. /** add a dataset to the unpaired list in it's proper order */
  131. _addToUnpaired : function( dataset ){
  132. // currently, unpaired is natural sorted by name, use binary search to find insertion point
  133. var binSearchSortedIndex = function( low, hi ){
  134. if( low === hi ){ return low; }
  135. var mid = Math.floor( ( hi - low ) / 2 ) + low,
  136. compared = naturalSort( dataset.name, this.unpaired[ mid ].name );
  137. if( compared < 0 ){
  138. return binSearchSortedIndex( low, mid );
  139. } else if( compared > 0 ){
  140. return binSearchSortedIndex( mid + 1, hi );
  141. }
  142. // walk the equal to find the last
  143. while( this.unpaired[ mid ] && this.unpaired[ mid ].name === dataset.name ){ mid++; }
  144. return mid;
  145. }.bind( this );
  146. this.unpaired.splice( binSearchSortedIndex( 0, this.unpaired.length ), 0, dataset );
  147. },
  148. // ------------------------------------------------------------------------ auto pairing
  149. //TODO: lots of boiler plate btwn the three auto pair fns
  150. /** two passes to automatically create pairs:
  151. * use both simpleAutoPair, then the fn mentioned in strategy
  152. */
  153. autoPair : function( strategy ){
  154. strategy = strategy || this.strategy;
  155. //console.debug( '-- autoPair', strategy );
  156. this.simpleAutoPair();
  157. return this[ strategy ].call( this );
  158. },
  159. /** attempts to pair forward with reverse when names exactly match (after removing filters) */
  160. simpleAutoPair : function(){
  161. //console.debug( '-- simpleAutoPair' );
  162. // simplified auto pair that moves down unpaired lists *in order*,
  163. // removes filters' strings from fwd and rev,
  164. // and, if names w/o filters *exactly* match, creates a pair
  165. // possibly good as a first pass
  166. var i = 0, j,
  167. split = this._splitByFilters(),
  168. fwdList = split[0],
  169. revList = split[1],
  170. fwdName, revName,
  171. matchFound = false;
  172. while( i<fwdList.length ){
  173. var fwd = fwdList[ i ];
  174. //TODO: go through the filterFwdFn
  175. fwdName = fwd.name.replace( this.filters[0], '' );
  176. //console.debug( i, 'fwd:', fwdName );
  177. matchFound = false;
  178. for( j=0; j<revList.length; j++ ){
  179. var rev = revList[ j ];
  180. revName = rev.name.replace( this.filters[1], '' );
  181. //console.debug( '\t ', j, 'rev:', revName );
  182. if( fwd !== rev && fwdName === revName ){
  183. matchFound = true;
  184. // if it is a match, keep i at current, pop fwd, pop rev and break
  185. //console.debug( '---->', fwdName, revName );
  186. this._pair(
  187. fwdList.splice( i, 1 )[0],
  188. revList.splice( j, 1 )[0],
  189. { silent: true }
  190. );
  191. break;
  192. }
  193. }
  194. if( !matchFound ){ i += 1; }
  195. }
  196. //console.debug( 'remaining Forward:' );
  197. //this._printList( this.unpairedForward );
  198. //console.debug( 'remaining Reverse:' );
  199. //this._printList( this.unpairedReverse );
  200. //console.debug( '' );
  201. },
  202. /** attempt to autopair using edit distance between forward and reverse (after removing filters) */
  203. autoPairLevenshtein : function(){
  204. //precondition: filters are set, both lists are not empty, and all filenames.length > filters[?].length
  205. //console.debug( '-- autoPairLevenshtein' );
  206. var i = 0, j,
  207. split = this._splitByFilters(),
  208. fwdList = split[0],
  209. revList = split[1],
  210. fwdName, revName,
  211. distance, bestIndex, bestDist;
  212. while( i<fwdList.length ){
  213. var fwd = fwdList[ i ];
  214. //TODO: go through the filterFwdFn
  215. fwdName = fwd.name.replace( this.filters[0], '' );
  216. //console.debug( i, 'fwd:', fwdName );
  217. bestDist = Number.MAX_VALUE;
  218. for( j=0; j<revList.length; j++ ){
  219. var rev = revList[ j ];
  220. revName = rev.name.replace( this.filters[1], '' );
  221. //console.debug( '\t ', j, 'rev:', revName );
  222. if( fwd !== rev ){
  223. if( fwdName === revName ){
  224. //console.debug( '\t\t exactmatch:', fwdName, revName );
  225. bestIndex = j;
  226. bestDist = 0;
  227. break;
  228. }
  229. distance = levenshteinDistance( fwdName, revName );
  230. //console.debug( '\t\t distance:', distance, 'bestDist:', bestDist );
  231. if( distance < bestDist ){
  232. bestIndex = j;
  233. bestDist = distance;
  234. }
  235. }
  236. }
  237. //console.debug( '---->', fwd.name, bestIndex, bestDist );
  238. //console.debug( '---->', fwd.name, revList[ bestIndex ].name, bestDist );
  239. var percentage = 1.0 - ( bestDist / ( Math.max( fwdName.length, revName.length ) ) );
  240. //console.debug( '----> %', percentage * 100 );
  241. if( percentage >= this.matchPercentage ){
  242. this._pair(
  243. fwdList.splice( i, 1 )[0],
  244. revList.splice( bestIndex, 1 )[0],
  245. { silent: true }
  246. );
  247. if( fwdList.length <= 0 || revList.length <= 0 ){
  248. return;
  249. }
  250. } else {
  251. i += 1;
  252. }
  253. }
  254. //console.debug( 'remaining Forward:' );
  255. //this._printList( this.unpairedForward );
  256. //console.debug( 'remaining Reverse:' );
  257. //this._printList( this.unpairedReverse );
  258. //console.debug( '' );
  259. },
  260. /** attempt to auto pair using common substrings from both front and back (after removing filters) */
  261. autoPairLCSs : function(){
  262. //precondition: filters are set, both lists are not empty
  263. //console.debug( '-- autoPairLCSs' );
  264. var i = 0, j,
  265. split = this._splitByFilters(),
  266. fwdList = split[0],
  267. revList = split[1],
  268. fwdName, revName,
  269. currMatch, bestIndex, bestMatch;
  270. if( !fwdList.length || !revList.length ){ return; }
  271. //console.debug( fwdList, revList );
  272. while( i<fwdList.length ){
  273. var fwd = fwdList[ i ];
  274. fwdName = fwd.name.replace( this.filters[0], '' );
  275. //console.debug( i, 'fwd:', fwdName );
  276. bestMatch = 0;
  277. for( j=0; j<revList.length; j++ ){
  278. var rev = revList[ j ];
  279. revName = rev.name.replace( this.filters[1], '' );
  280. //console.debug( '\t ', j, 'rev:', revName );
  281. if( fwd !== rev ){
  282. if( fwdName === revName ){
  283. //console.debug( '\t\t exactmatch:', fwdName, revName );
  284. bestIndex = j;
  285. bestMatch = fwdName.length;
  286. break;
  287. }
  288. var match = this._naiveStartingAndEndingLCS( fwdName, revName );
  289. currMatch = match.length;
  290. //console.debug( '\t\t match:', match, 'currMatch:', currMatch, 'bestMatch:', bestMatch );
  291. if( currMatch > bestMatch ){
  292. bestIndex = j;
  293. bestMatch = currMatch;
  294. }
  295. }
  296. }
  297. //console.debug( '---->', i, fwd.name, bestIndex, revList[ bestIndex ].name, bestMatch );
  298. var percentage = bestMatch / ( Math.min( fwdName.length, revName.length ) );
  299. //console.debug( '----> %', percentage * 100 );
  300. if( percentage >= this.matchPercentage ){
  301. this._pair(
  302. fwdList.splice( i, 1 )[0],
  303. revList.splice( bestIndex, 1 )[0],
  304. { silent: true }
  305. );
  306. if( fwdList.length <= 0 || revList.length <= 0 ){
  307. return;
  308. }
  309. } else {
  310. i += 1;
  311. }
  312. }
  313. //console.debug( 'remaining Forward:' );
  314. //this._printList( this.unpairedForward );
  315. //console.debug( 'remaining Reverse:' );
  316. //this._printList( this.unpairedReverse );
  317. //console.debug( '' );
  318. },
  319. /** return the concat'd longest common prefix and suffix from two strings */
  320. _naiveStartingAndEndingLCS : function( s1, s2 ){
  321. var fwdLCS = '',
  322. revLCS = '',
  323. i = 0, j = 0;
  324. while( i < s1.length && i < s2.length ){
  325. if( s1[ i ] !== s2[ i ] ){
  326. break;
  327. }
  328. fwdLCS += s1[ i ];
  329. i += 1;
  330. }
  331. if( i === s1.length ){ return s1; }
  332. if( i === s2.length ){ return s2; }
  333. i = ( s1.length - 1 );
  334. j = ( s2.length - 1 );
  335. while( i >= 0 && j >= 0 ){
  336. if( s1[ i ] !== s2[ j ] ){
  337. break;
  338. }
  339. revLCS = [ s1[ i ], revLCS ].join( '' );
  340. i -= 1;
  341. j -= 1;
  342. }
  343. return fwdLCS + revLCS;
  344. },
  345. // ------------------------------------------------------------------------ pairing / unpairing
  346. /** create a pair from fwd and rev, removing them from unpaired, and placing the new pair in paired */
  347. _pair : function( fwd, rev, options ){
  348. options = options || {};
  349. //TODO: eventing, options
  350. //console.debug( '_pair:', fwd, rev );
  351. var pair = this._createPair( fwd, rev, options.name );
  352. this.paired.push( pair );
  353. this.unpaired = _.without( this.unpaired, fwd, rev );
  354. if( !options.silent ){
  355. this.trigger( 'pair:new', pair );
  356. }
  357. return pair;
  358. },
  359. /** create a pair Object from fwd and rev, adding the name attribute (will guess if not given) */
  360. _createPair : function( fwd, rev, name ){
  361. // ensure existance and don't pair something with itself
  362. if( !( fwd && rev ) || ( fwd === rev ) ){
  363. throw new Error( 'Bad pairing: ' + [ JSON.stringify( fwd ), JSON.stringify( rev ) ] );
  364. }
  365. name = name || this._guessNameForPair( fwd, rev );
  366. return { forward : fwd, name : name, reverse : rev };
  367. },
  368. /** try to find a good pair name for the given fwd and rev datasets */
  369. _guessNameForPair : function( fwd, rev ){
  370. var lcs = this._naiveStartingAndEndingLCS(
  371. fwd.name.replace( this.filters[0], '' ),
  372. rev.name.replace( this.filters[1], '' )
  373. );
  374. //TODO: optionally remove extension
  375. return lcs || ( fwd.name + ' & ' + rev.name );
  376. },
  377. ///** find datasets with fwdId and revID and pair them */
  378. //_pairById : function( fwdId, revId, name ){
  379. // var both = this.unpaired.filter( function( unpaired ){
  380. // return unpaired.id === fwdId || unpaired.id === revId;
  381. // }),
  382. // fwd = both[0], rev = both[1];
  383. // if( both[0].id === revId ){
  384. // fwd = rev; rev = both[0];
  385. // }
  386. // return this._pair( fwd, rev, name );
  387. //},
  388. /** unpair a pair, removing it from paired, and adding the fwd,rev datasets back into unpaired */
  389. _unpair : function( pair, options ){
  390. options = options || {};
  391. if( !pair ){
  392. throw new Error( 'Bad pair: ' + JSON.stringify( pair ) );
  393. }
  394. this.paired = _.without( this.paired, pair );
  395. this._addToUnpaired( pair.forward );
  396. this._addToUnpaired( pair.reverse );
  397. if( !options.silent ){
  398. this.trigger( 'pair:unpair', [ pair ] );
  399. }
  400. return pair;
  401. },
  402. /** unpair all paired datasets */
  403. unpairAll : function(){
  404. var pairs = [];
  405. while( this.paired.length ){
  406. pairs.push( this._unpair( this.paired[ 0 ], { silent: true }) );
  407. }
  408. this.trigger( 'pair:unpair', pairs );
  409. },
  410. // ------------------------------------------------------------------------ API
  411. /** convert a pair into JSON compatible with the collections API */
  412. _pairToJSON : function( pair ){
  413. //TODO: consider making this the pair structure when created instead
  414. return {
  415. collection_type : 'paired',
  416. src : 'new_collection',
  417. name : pair.name,
  418. element_identifiers : [{
  419. name : 'forward',
  420. id : pair.forward.id,
  421. //TODO: isn't necessarily true
  422. src : 'hda'
  423. }, {
  424. name : 'reverse',
  425. id : pair.reverse.id,
  426. //TODO: isn't necessarily true
  427. src : 'hda'
  428. }]
  429. };
  430. },
  431. /** create the collection via the API
  432. * @returns {jQuery.xhr Object} the jquery ajax request
  433. */
  434. createList : function(){
  435. var creator = this,
  436. url;
  437. if( creator.historyId ){
  438. url = '/api/histories/' + this.historyId + '/contents/dataset_collections';
  439. //} else {
  440. //
  441. }
  442. //var xhr = jQuery.ajax({
  443. var ajaxData = {
  444. type : 'dataset_collection',
  445. collection_type : 'list:paired',
  446. name : _.escape( creator.$( '.collection-name' ).val() ),
  447. element_identifiers : creator.paired.map( function( pair ){
  448. return creator._pairToJSON( pair );
  449. })
  450. };
  451. //console.debug( JSON.stringify( ajaxData ) );
  452. return jQuery.ajax( url, {
  453. type : 'POST',
  454. contentType : 'application/json',
  455. data : JSON.stringify( ajaxData )
  456. })
  457. .fail( function( xhr, status, message ){
  458. //TODO: extract method
  459. console.error( xhr, status, message );
  460. var content = _l( 'An error occurred while creating this collection' );
  461. if( xhr ){
  462. if( xhr.readyState === 0 && xhr.status === 0 ){
  463. content += ': ' + _l( 'Galaxy could not be reached and may be updating.' )
  464. + _l( ' Try again in a few minutes.' );
  465. } else if( xhr.responseJSON ){
  466. content += '<br /><pre>' + JSON.stringify( xhr.responseJSON ) + '</pre>';
  467. } else {
  468. content += ': ' + message;
  469. }
  470. }
  471. creator._showAlert( content, 'alert-danger' );
  472. })
  473. .done( function( response, message, xhr ){
  474. //console.info( 'ok', response, message, xhr );
  475. creator.trigger( 'collection:created', response, message, xhr );
  476. if( typeof creator.oncreate === 'function' ){
  477. creator.oncreate.call( this, response, message, xhr );
  478. }
  479. });
  480. },
  481. // ------------------------------------------------------------------------ rendering
  482. /** render the entire interface */
  483. render : function( speed, callback ){
  484. //console.debug( '-- _render' );
  485. //this.$el.empty().html( PairedCollectionCreator.templates.main() );
  486. this.$el.empty().html( PairedCollectionCreator.templates.main() );
  487. this._renderHeader( speed );
  488. this._renderMiddle( speed );
  489. this._renderFooter( speed );
  490. this._addPluginComponents();
  491. return this;
  492. },
  493. /** render the header section */
  494. _renderHeader : function( speed, callback ){
  495. //console.debug( '-- _renderHeader' );
  496. var $header = this.$( '.header' ).empty().html( PairedCollectionCreator.templates.header() )
  497. .find( '.help-content' ).prepend( $( PairedCollectionCreator.templates.helpContent() ) );
  498. this._renderFilters();
  499. return $header;
  500. },
  501. /** fill the filter inputs with the filter values */
  502. _renderFilters : function(){
  503. return this.$( '.forward-column .column-header input' ).val( this.filters[0] )
  504. .add( this.$( '.reverse-column .column-header input' ).val( this.filters[1] ) );
  505. },
  506. /** render the middle including unpaired and paired sections (which may be hidden) */
  507. _renderMiddle : function( speed, callback ){
  508. var $middle = this.$( '.middle' ).empty().html( PairedCollectionCreator.templates.middle() );
  509. //TODO: use replaceWith
  510. if( this.unpairedPanelHidden ){
  511. this.$( '.unpaired-columns' ).hide();
  512. } else if( this.pairedPanelHidden ){
  513. this.$( '.paired-columns' ).hide();
  514. }
  515. this._renderUnpaired();
  516. this._renderPaired();
  517. return $middle;
  518. },
  519. /** render the unpaired section, showing datasets accrd. to filters, update the unpaired counts */
  520. _renderUnpaired : function( speed, callback ){
  521. //console.debug( '-- _renderUnpaired' );
  522. var creator = this,
  523. $fwd, $rev, $prd = [],
  524. split = this._splitByFilters();
  525. // update unpaired counts
  526. this.$( '.forward-column .title' )
  527. .text([ split[0].length, _l( 'unpaired forward' ) ].join( ' ' ));
  528. this.$( '.forward-column .unpaired-info' )
  529. .text( this._renderUnpairedDisplayStr( this.unpaired.length - split[0].length ) );
  530. this.$( '.reverse-column .title' )
  531. .text([ split[1].length, _l( 'unpaired reverse' ) ].join( ' ' ));
  532. this.$( '.reverse-column .unpaired-info' )
  533. .text( this._renderUnpairedDisplayStr( this.unpaired.length - split[1].length ) );
  534. // show/hide the auto pair button if any unpaired are left
  535. this.$( '.autopair-link' ).toggle( this.unpaired.length !== 0 );
  536. if( this.unpaired.length === 0 ){
  537. this._renderUnpairedEmpty();
  538. //TODO: would be best to return here (the $columns)
  539. }
  540. // create the dataset dom arrays
  541. $rev = split[1].map( function( dataset, i ){
  542. // if there'll be a fwd dataset across the way, add a button to pair the row
  543. if( ( split[0][ i ] !== undefined )
  544. && ( split[0][ i ] !== dataset ) ){
  545. $prd.push( creator._renderPairButton() );
  546. }
  547. return creator._renderUnpairedDataset( dataset );
  548. });
  549. $fwd = split[0].map( function( dataset ){
  550. return creator._renderUnpairedDataset( dataset );
  551. });
  552. // add to appropo cols
  553. //TODO: not the best way to render
  554. this.$( '.unpaired-columns .column-datasets' ).empty();
  555. return this.$( '.unpaired-columns .forward-column .column-datasets' ).append( $fwd )
  556. .add( this.$( '.unpaired-columns .paired-column .column-datasets' ).append( $prd ) )
  557. .add( this.$( '.unpaired-columns .reverse-column .column-datasets' ).append( $rev ) );
  558. },
  559. /** return a string to display the count of filtered out datasets */
  560. _renderUnpairedDisplayStr : function( numFiltered ){
  561. return [ '(', numFiltered, ' ', _l( 'filtered out' ), ')' ].join('');
  562. },
  563. /** return an unattached jQuery DOM element to represent an unpaired dataset */
  564. _renderUnpairedDataset : function( dataset ){
  565. //TODO: to underscore template
  566. return $( '<li/>')
  567. .attr( 'id', 'dataset-' + dataset.id )
  568. .addClass( 'dataset unpaired' )
  569. .attr( 'draggable', true )
  570. .addClass( dataset.selected? 'selected': '' )
  571. .append( $( '<span/>' ).addClass( 'dataset-name' ).text( dataset.name ) )
  572. //??
  573. .data( 'dataset', dataset );
  574. },
  575. /** render the button that may go between unpaired datasets, allowing the user to pair a row */
  576. _renderPairButton : function(){
  577. //TODO: *not* a dataset - don't pretend like it is
  578. return $( '<li/>').addClass( 'dataset unpaired' )
  579. .append( $( '<span/>' ).addClass( 'dataset-name' ).text( _l( 'Pair these datasets' ) ) );
  580. },
  581. /** a message to display when no unpaired left */
  582. _renderUnpairedEmpty : function(){
  583. //console.debug( '-- renderUnpairedEmpty' );
  584. var $msg = $( '<div class="empty-message"></div>' )
  585. .text( '(' + _l( 'no remaining unpaired datasets' ) + ')' );
  586. this.$( '.unpaired-columns .paired-column .column-datasets' ).prepend( $msg );
  587. return $msg;
  588. },
  589. /** render the paired section and update counts of paired datasets */
  590. _renderPaired : function( speed, callback ){
  591. //console.debug( '-- _renderPaired' );
  592. var $fwd = [],
  593. $prd = [],
  594. $rev = [];
  595. this.$( '.paired-column-title .title' ).text([ this.paired.length, _l( 'paired' ) ].join( ' ' ) );
  596. // show/hide the unpair all link
  597. this.$( '.unpair-all-link' ).toggle( this.paired.length !== 0 );
  598. if( this.paired.length === 0 ){
  599. this._renderPairedEmpty();
  600. //TODO: would be best to return here (the $columns)
  601. }
  602. this.paired.forEach( function( pair, i ){
  603. $fwd.push( $( '<li/>').addClass( 'dataset paired' )
  604. .append( $( '<span/>' ).addClass( 'dataset-name' ).text( pair.forward.name ) ) );
  605. $prd.push( $( '<li/>').addClass( 'dataset paired' )
  606. .append( $( '<span/>' ).addClass( 'dataset-name' ).text( pair.name ) ) );
  607. $rev.push( $( '<li/>').addClass( 'dataset paired' )
  608. .append( $( '<span/>' ).addClass( 'dataset-name' ).text( pair.reverse.name ) ) );
  609. $rev.push( $( '<button/>' ).addClass( 'unpair-btn' )
  610. .append( $( '<span/>' ).addClass( 'fa fa-unlink' ).attr( 'title', _l( 'Unpair' ) ) ) );
  611. });
  612. //TODO: better as one swell foop (instead of 4 repaints)
  613. this.$( '.paired-columns .column-datasets' ).empty();
  614. return this.$( '.paired-columns .forward-column .column-datasets' ).prepend( $fwd )
  615. .add( this.$( '.paired-columns .paired-column .column-datasets' ).prepend( $prd ) )
  616. .add( this.$( '.paired-columns .reverse-column .column-datasets' ).prepend( $rev ) );
  617. },
  618. /** a message to display when none paired */
  619. _renderPairedEmpty : function(){
  620. var $msg = $( '<div class="empty-message"></div>' )
  621. .text( '(' + _l( 'no paired datasets yet' ) + ')' );
  622. this.$( '.paired-columns .paired-column .column-datasets' ).prepend( $msg );
  623. return $msg;
  624. },
  625. /** render the footer, completion controls, and cancel controls */
  626. _renderFooter : function( speed, callback ){
  627. var $footer = this.$( '.footer' ).empty().html( PairedCollectionCreator.templates.footer() );
  628. if( typeof this.oncancel === 'function' ){
  629. this.$( '.cancel-create.btn' ).show();
  630. }
  631. return $footer;
  632. },
  633. /** add any jQuery/bootstrap/custom plugins to elements rendered */
  634. _addPluginComponents : function(){
  635. this._chooseFiltersPopover( '.choose-filters-link' );
  636. this.$( '.help-content i' ).hoverhighlight( '.collection-creator', 'rgba( 192, 255, 255, 1.0 )' );
  637. },
  638. /** build a filter selection popover allowing selection of common filter pairs */
  639. _chooseFiltersPopover : function( selector ){
  640. function filterChoice( val1, val2 ){
  641. return [
  642. '<button class="filter-choice btn" ',
  643. 'data-forward="', val1, '" data-reverse="', val2, '">',
  644. _l( 'Forward' ), ': ', val1, ', ',
  645. _l( 'Reverse' ), ': ', val2,
  646. '</button>'
  647. ].join('');
  648. }
  649. var $popoverContent = $( _.template([
  650. '<div class="choose-filters">',
  651. '<div class="help">',
  652. _l( 'Choose from the following filters to change which unpaired reads are shown in the display' ),
  653. ':</div>',
  654. //TODO: connect with common filters
  655. filterChoice( '_1', '_2' ),
  656. filterChoice( '_R1', '_R2' ),
  657. '</div>'
  658. ].join(''), {}));
  659. return this.$( selector ).popover({
  660. container : '.collection-creator',
  661. placement : 'bottom',
  662. html : true,
  663. //animation : false,
  664. content : $popoverContent
  665. });
  666. },
  667. /** add (or clear if clear is truthy) a validation warning to what */
  668. _validationWarning : function( what, clear ){
  669. var VALIDATION_CLASS = 'validation-warning';
  670. if( what === 'name' ){
  671. what = this.$( '.collection-name' ).add( this.$( '.collection-name-prompt' ) );
  672. this.$( '.collection-name' ).focus().select();
  673. }
  674. if( clear ){
  675. what = what || this.$( '.' + VALIDATION_CLASS );
  676. what.removeClass( VALIDATION_CLASS );
  677. } else {
  678. what.addClass( VALIDATION_CLASS );
  679. }
  680. },
  681. // ------------------------------------------------------------------------ events
  682. /** set up event handlers on self */
  683. _setUpBehaviors : function(){
  684. this.on( 'pair:new', function(){
  685. //TODO: ideally only re-render the columns (or even elements) involved
  686. this._renderUnpaired();
  687. this._renderPaired();
  688. // scroll to bottom where new pairs are added
  689. //TODO: this doesn't seem to work - innerHeight sticks at 133...
  690. // may have to do with improper flex columns
  691. //var $pairedView = this.$( '.paired-columns' );
  692. //$pairedView.scrollTop( $pairedView.innerHeight() );
  693. //console.debug( $pairedView.height() )
  694. this.$( '.paired-columns' ).scrollTop( 8000000 );
  695. });
  696. this.on( 'pair:unpair', function( pairs ){
  697. //TODO: ideally only re-render the columns (or even elements) involved
  698. this._renderUnpaired();
  699. this._renderPaired();
  700. });
  701. this.on( 'filter-change', function(){
  702. this.filters = [
  703. this.$( '.forward-unpaired-filter input' ).val(),
  704. this.$( '.reverse-unpaired-filter input' ).val()
  705. ];
  706. this._renderFilters();
  707. this._renderUnpaired();
  708. });
  709. this.on( 'autopair', function(){
  710. this._renderUnpaired();
  711. this._renderPaired();
  712. });
  713. //this.on( 'all', function(){
  714. // console.info( arguments );
  715. //});
  716. return this;
  717. },
  718. events : {
  719. // header
  720. 'click .more-help' : '_clickMoreHelp',
  721. 'click .less-help' : '_clickLessHelp',
  722. 'click .header .alert button' : '_hideAlert',
  723. 'click .forward-column .column-title' : '_clickShowOnlyUnpaired',
  724. 'click .reverse-column .column-title' : '_clickShowOnlyUnpaired',
  725. 'click .unpair-all-link' : '_clickUnpairAll',
  726. //TODO: this seems kinda backasswards
  727. 'change .forward-unpaired-filter input' : function( ev ){ this.trigger( 'filter-change' ); },
  728. 'focus .forward-unpaired-filter input' : function( ev ){ $( ev.currentTarget ).select(); },
  729. 'click .autopair-link' : '_clickAutopair',
  730. 'click .choose-filters .filter-choice' : '_clickFilterChoice',
  731. 'click .clear-filters-link' : '_clearFilters',
  732. 'change .reverse-unpaired-filter input' : function( ev ){ this.trigger( 'filter-change' ); },
  733. 'focus .reverse-unpaired-filter input' : function( ev ){ $( ev.currentTarget ).select(); },
  734. // unpaired
  735. 'click .forward-column .dataset.unpaired' : '_clickUnpairedDataset',
  736. 'click .reverse-column .dataset.unpaired' : '_clickUnpairedDataset',
  737. 'click .paired-column .dataset.unpaired' : '_clickPairRow',
  738. 'click .unpaired-columns' : 'clearSelectedUnpaired',
  739. // divider
  740. 'click .paired-column-title' : '_clickShowOnlyPaired',
  741. 'mousedown .flexible-partition-drag' : '_startPartitionDrag',
  742. // paired
  743. 'click .paired-columns .paired-column .dataset-name' : '_clickPairName',
  744. 'click .unpair-btn' : '_clickUnpair',
  745. 'mouseover .dataset.paired' : '_hoverPaired',
  746. 'mouseout .dataset.paired' : '_hoverOutPaired',
  747. // footer
  748. 'change .collection-name' : '_changeName',
  749. 'click .cancel-create' : function( ev ){
  750. if( typeof this.oncancel === 'function' ){
  751. this.oncancel.call( this );
  752. }
  753. },
  754. 'click .create-collection' : '_clickCreate'
  755. },
  756. // ........................................................................ header
  757. /** expand help */
  758. _clickMoreHelp : function( ev ){
  759. this.$( '.main-help' ).css( 'max-height', 'none' );
  760. this.$( '.main-help .help-content p:first-child' ).css( 'white-space', 'normal' );
  761. this.$( '.more-help' ).hide();
  762. },
  763. /** collapse help */
  764. _clickLessHelp : function( ev ){
  765. this.$( '.main-help' ).css( 'max-height', '' );
  766. this.$( '.main-help .help-content p:first-child' ).css( 'white-space', '' );
  767. this.$( '.more-help' ).show();
  768. },
  769. /** show an alert on the top of the interface containing message (alertClass is bootstrap's alert-*)*/
  770. _showAlert : function( message, alertClass ){
  771. alertClass = alertClass || 'alert-danger';
  772. this.$( '.main-help' ).hide();
  773. this.$( '.header .alert' ).attr( 'class', 'alert alert-dismissable' ).addClass( alertClass ).show()
  774. .find( '.alert-message' ).html( message );
  775. },
  776. /** hide the alerts at the top */
  777. _hideAlert : function( message ){
  778. this.$( '.main-help' ).show();
  779. this.$( '.header .alert' ).hide();
  780. },
  781. //TODO: consolidate these
  782. /** toggle between showing only unpaired and split view */
  783. _clickShowOnlyUnpaired : function( ev ){
  784. //console.debug( 'click unpaired', ev.currentTarget );
  785. if( this.$( '.paired-columns' ).is( ':visible' ) ){
  786. this.hidePaired();
  787. } else {
  788. this.splitView();
  789. }
  790. },
  791. /** toggle between showing only paired and split view */
  792. _clickShowOnlyPaired : function( ev ){
  793. //console.debug( 'click paired' );
  794. if( this.$( '.unpaired-columns' ).is( ':visible' ) ){
  795. this.hideUnpaired();
  796. } else {
  797. this.splitView();
  798. }
  799. },
  800. /** hide unpaired, show paired */
  801. hideUnpaired : function( speed, callback ){
  802. speed = speed || 0;
  803. this.$( '.unpaired-columns' ).hide( speed, callback );
  804. //this.$( '.unpaired-filter' ).hide( speed );
  805. this.$( '.paired-columns' ).show( speed ).css( 'flex', '1 0 auto' );
  806. this.unpairedPanelHidden = true;
  807. },
  808. /** hide paired, show unpaired */
  809. hidePaired : function( speed, callback ){
  810. speed = speed || 0;
  811. this.$( '.unpaired-columns' ).show( speed ).css( 'flex', '1 0 auto' );
  812. //this.$( '.unpaired-filter' ).show( speed );
  813. this.$( '.paired-columns' ).hide( speed, callback );
  814. this.pairedPanelHidden = true;
  815. },
  816. /** show both paired and unpaired (splitting evenly) */
  817. splitView : function( speed, callback ){
  818. speed = speed || 0;
  819. this.unpairedPanelHidden = this.pairedPanelHidden = false;
  820. //this.$( '.unpaired-filter' ).show( speed );
  821. this._renderMiddle( speed );
  822. return this;
  823. },
  824. /** unpair all paired and do other super neat stuff which I'm not really sure about yet... */
  825. _clickUnpairAll : function( ev ){
  826. this.unpairAll();
  827. },
  828. /** attempt to autopair */
  829. _clickAutopair : function( ev ){
  830. var paired = this.autoPair();
  831. //console.debug( 'autopaired', paired );
  832. //TODO: an indication of how many pairs were found - if 0, assist
  833. this.trigger( 'autopair', paired );
  834. },
  835. /** set the filters based on the data attributes of the button click target */
  836. _clickFilterChoice : function( ev ){
  837. var $selected = $( ev.currentTarget );
  838. this.$( '.forward-unpaired-filter input' ).val( $selected.data( 'forward' ) );
  839. this.$( '.reverse-unpaired-filter input' ).val( $selected.data( 'reverse' ) );
  840. this._hideChooseFilters();
  841. this.trigger( 'filter-change' );
  842. },
  843. /** hide the choose filters popover */
  844. _hideChooseFilters : function(){
  845. //TODO: update bootstrap and remove the following hack
  846. // see also: https://github.com/twbs/bootstrap/issues/10260
  847. this.$( '.choose-filters-link' ).popover( 'hide' );
  848. this.$( '.popover' ).css( 'display', 'none' );
  849. },
  850. /** clear both filters */
  851. _clearFilters : function( ev ){
  852. this.$( '.forward-unpaired-filter input' ).val( '' );
  853. this.$( '.reverse-unpaired-filter input' ).val( '' );
  854. this.trigger( 'filter-change' );
  855. },
  856. // ........................................................................ unpaired
  857. /** select an unpaired dataset */
  858. _clickUnpairedDataset : function( ev ){
  859. ev.stopPropagation();
  860. return this.toggleSelectUnpaired( $( ev.currentTarget ) );
  861. },
  862. /** Toggle the selection of an unpaired dataset representation.
  863. * @param [jQuery] $dataset the unpaired dataset dom rep to select
  864. * @param [Boolean] options.force if defined, force selection based on T/F; otherwise, toggle
  865. */
  866. toggleSelectUnpaired : function( $dataset, options ){
  867. options = options || {};
  868. var dataset = $dataset.data( 'dataset' ),
  869. select = options.force !== undefined? options.force: !$dataset.hasClass( 'selected' );
  870. //console.debug( id, options.force, $dataset, dataset );
  871. if( !$dataset.size() || dataset === undefined ){ return $dataset; }
  872. if( select ){
  873. $dataset.addClass( 'selected' );
  874. if( !options.waitToPair ){
  875. this.pairAllSelected();
  876. }
  877. } else {
  878. $dataset.removeClass( 'selected' );
  879. //delete dataset.selected;
  880. }
  881. return $dataset;
  882. },
  883. /** pair all the currently selected unpaired datasets */
  884. pairAllSelected : function( options ){
  885. options = options || {};
  886. var creator = this,
  887. fwds = [],
  888. revs = [],
  889. pairs = [];
  890. //TODO: could be made more concise
  891. creator.$( '.unpaired-columns .forward-column .dataset.selected' ).each( function(){
  892. fwds.push( $( this ).data( 'dataset' ) );
  893. });
  894. creator.$( '.unpaired-columns .reverse-column .dataset.selected' ).each( function(){
  895. revs.push( $( this ).data( 'dataset' ) );
  896. });
  897. fwds.length = revs.length = Math.min( fwds.length, revs.length );
  898. //console.debug( fwds );
  899. //console.debug( revs );
  900. fwds.forEach( function( fwd, i ){
  901. try {
  902. pairs.push( creator._pair( fwd, revs[i], { silent: true }) );
  903. } catch( err ){
  904. //TODO: preserve selected state of those that couldn't be paired
  905. //TODO: warn that some could not be paired
  906. console.error( err );
  907. }
  908. });
  909. if( pairs.length && !options.silent ){
  910. this.trigger( 'pair:new', pairs );
  911. }
  912. return pairs;
  913. },
  914. /** clear the selection on all unpaired datasets */
  915. clearSelectedUnpaired : function(){
  916. this.$( '.unpaired-columns .dataset.selected' ).removeClass( 'selected' );
  917. },
  918. /** attempt to pair two datasets directly across from one another */
  919. _clickPairRow : function( ev ){
  920. //if( !ev.currentTarget ){ return true; }
  921. var rowIndex = $( ev.currentTarget ).index(),
  922. fwd = $( '.unpaired-columns .forward-column .dataset' ).eq( rowIndex ).data( 'dataset' ),
  923. rev = $( '.unpaired-columns .reverse-column .dataset' ).eq( rowIndex ).data( 'dataset' );
  924. //console.debug( 'row:', rowIndex, fwd, rev );
  925. //TODO: animate
  926. this._pair( fwd, rev );
  927. },
  928. // ........................................................................ divider/partition
  929. /** start dragging the visible divider/partition between unpaired and paired panes */
  930. _startPartitionDrag : function( ev ){
  931. var creator = this,
  932. startingY = ev.pageY;
  933. //console.debug( 'partition drag START:', ev );
  934. $( 'body' ).css( 'cursor', 'ns-resize' );
  935. creator.$( '.flexible-partition-drag' ).css( 'color', 'black' );
  936. function endDrag( ev ){
  937. //console.debug( 'partition drag STOP:', ev );
  938. // doing this by an added class didn't really work well - kept flashing still
  939. creator.$( '.flexible-partition-drag' ).css( 'color', '' );
  940. $( 'body' ).css( 'cursor', '' ).unbind( 'mousemove', trackMouse );
  941. }
  942. function trackMouse( ev ){
  943. var offset = ev.pageY - startingY;
  944. if( !creator.adjPartition( offset ) ){
  945. $( 'body' ).trigger( 'mouseup' );
  946. }
  947. startingY += offset;
  948. }
  949. $( 'body' ).mousemove( trackMouse );
  950. $( 'body' ).one( 'mouseup', endDrag );
  951. },
  952. /** adjust the parition up/down +/-adj pixels */
  953. adjPartition : function( adj ){
  954. var $unpaired = this.$( '.unpaired-columns' ),
  955. $paired = this.$( '.paired-columns' ),
  956. unpairedHi = parseInt( $unpaired.css( 'height' ), 10 ),
  957. pairedHi = parseInt( $paired.css( 'height' ), 10 );
  958. //console.debug( adj, 'hi\'s:', unpairedHi, pairedHi, unpairedHi + adj, pairedHi - adj );
  959. unpairedHi = Math.max( 10, unpairedHi + adj );
  960. pairedHi = pairedHi - adj;
  961. if( unpairedHi <= 10 ){
  962. this.hideUnpaired();
  963. return false;
  964. } else if( !$unpaired.is( 'visible' ) ){
  965. $unpaired.show();
  966. //this.$( '.unpaired-filter' ).show();
  967. }
  968. if( pairedHi <= 15 ){
  969. this.hidePaired();
  970. if( pairedHi < 10 ){ return false; }
  971. } else if( !$paired.is( 'visible' ) ){
  972. $paired.show();
  973. }
  974. $unpaired.css({
  975. height : unpairedHi + 'px',
  976. flex : '0 0 auto'
  977. });
  978. return true;
  979. },
  980. // ........................................................................ paired
  981. /** rename a pair when the pair name is clicked */
  982. _clickPairName : function( ev ){
  983. var $control = $( ev.currentTarget ),
  984. pair = this.paired[ $control.parent().index() ],
  985. response = prompt( 'Enter a new name for the pair:', pair.name );
  986. if( response ){
  987. pair.name = response;
  988. $control.text( pair.name );
  989. }
  990. },
  991. /** unpair this pair */
  992. _clickUnpair : function( ev ){
  993. //if( !ev.currentTarget ){ return true; }
  994. //TODO: this is a hack bc each paired rev now has two elems (dataset, button)
  995. var pairIndex = Math.floor( $( ev.currentTarget ).index() / 2 );
  996. //console.debug( 'pair:', pairIndex );
  997. //TODO: animate
  998. this._unpair( this.paired[ pairIndex ] );
  999. },
  1000. //TODO: the following is ridiculous
  1001. _hoverPaired : function( ev ){
  1002. var $target = $( ev.currentTarget ),
  1003. index = $target.index();
  1004. //TODO: this is a hack bc each paired rev now has two elems (dataset, button)
  1005. if( $target.parents( '.reverse-column' ).size() ){
  1006. index = Math.floor( index / 2 );
  1007. }
  1008. //console.debug( 'index:', index );
  1009. this.emphasizePair( index );
  1010. },
  1011. _hoverOutPaired : function( ev ){
  1012. var $target = $( ev.currentTarget ),
  1013. index = $target.index();
  1014. //TODO: this is a hack bc each paired rev now has two elems (dataset, button)
  1015. if( $target.parents( '.reverse-column' ).size() ){
  1016. index = Math.floor( index / 2 );
  1017. }
  1018. //console.debug( 'index:', index );
  1019. this.deemphasizePair( index );
  1020. },
  1021. emphasizePair : function( index ){
  1022. //console.debug( 'emphasizePairedBorder:', index );
  1023. this.$( '.paired-columns .forward-column .dataset.paired' ).eq( index )
  1024. .add( this.$( '.paired-columns .paired-column .dataset.paired' ).eq( index ) )
  1025. .add( this.$( '.paired-columns .reverse-column .dataset.paired' ).eq( index ) )
  1026. .addClass( 'emphasized' );
  1027. },
  1028. deemphasizePair : function( index ){
  1029. //console.debug( 'deemphasizePairedBorder:', index );
  1030. this.$( '.paired-columns .forward-column .dataset.paired' ).eq( index )
  1031. .add( this.$( '.paired-columns .paired-column .dataset.paired' ).eq( index ) )
  1032. .add( this.$( '.paired-columns .reverse-column .dataset.paired' ).eq( index ) )
  1033. .removeClass( 'emphasized' );
  1034. },
  1035. // ........................................................................ footer
  1036. /** handle a collection name change */
  1037. _changeName : function( ev ){
  1038. this._validationWarning( 'name', !!this._getName() );
  1039. },
  1040. /** get the current collection name */
  1041. _getName : function(){
  1042. return _.escape( this.$( '.collection-name' ).val() );
  1043. },
  1044. /** attempt to create the current collection */
  1045. _clickCreate : function( ev ){
  1046. var name = this._getName();
  1047. if( !name ){
  1048. this._validationWarning( 'name' );
  1049. } else {
  1050. this.createList();
  1051. }
  1052. },
  1053. // ------------------------------------------------------------------------ misc
  1054. /** debug a dataset list */
  1055. _printList : function( list ){
  1056. var creator = this;
  1057. _.each( list, function( e ){
  1058. if( list === creator.paired ){
  1059. creator._printPair( e );
  1060. } else {
  1061. //console.debug( e );
  1062. }
  1063. });
  1064. },
  1065. /** print a pair Object */
  1066. _printPair : function( pair ){
  1067. console.debug( pair.forward.name, pair.reverse.name, ': ->', pair.name );
  1068. },
  1069. /** string rep */
  1070. toString : function(){ return 'PairedCollectionCreator'; }
  1071. });
  1072. //TODO: move to require text plugin and load these as text
  1073. //TODO: underscore currently unnecc. bc no vars are used
  1074. //TODO: better way of localizing text-nodes in long strings
  1075. /** underscore template fns attached to class */
  1076. PairedCollectionCreator.templates = PairedCollectionCreator.templates || {
  1077. /** the skeleton */
  1078. main : _.template([
  1079. '<div class="header flex-row no-flex"></div>',
  1080. '<div class="middle flex-row flex-row-container"></div>',
  1081. '<div class="footer flex-row no-flex">'
  1082. ].join('')),
  1083. /** the header (not including help text) */
  1084. header : _.template([
  1085. '<div class="main-help well clear">',
  1086. '<a class="more-help" href="javascript:void(0);">', _l( 'More help' ), '</a>',
  1087. '<div class="help-content">',
  1088. '<a class="less-help" href="javascript:void(0);">', _l( 'Less' ), '</a>',
  1089. '</div>',
  1090. '</div>',
  1091. '<div class="alert alert-dismissable">',
  1092. '<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>',
  1093. '<span class="alert-message"></span>',
  1094. '</div>',
  1095. '<div class="column-headers vertically-spaced flex-column-container">',
  1096. '<div class="forward-column flex-column column">',
  1097. '<div class="column-header">',
  1098. '<div class="column-title">',
  1099. '<span class="title">', _l( 'Unpaired forward' ), '</span>',
  1100. '<span class="title-info unpaired-info"></span>',
  1101. '</div>',
  1102. '<div class="unpaired-filter forward-unpaired-filter pull-left">',
  1103. '<input class="search-query" placeholder="', _l( 'Filter this list' ), '" />',
  1104. '</div>',
  1105. '</div>',
  1106. '</div>',
  1107. '<div class="paired-column flex-column no-flex column">',
  1108. '<div class="column-header">',
  1109. '<a class="choose-filters-link" href…

Large files files are truncated, but you can click here to view the full file