quorum 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/Gemfile.lock +35 -28
- data/HISTORY.md +4 -0
- data/app/assets/javascripts/quorum/application.js +1 -0
- data/app/assets/javascripts/quorum/jobs.js +5 -345
- data/app/assets/javascripts/quorum/quorum.js +275 -0
- data/app/assets/javascripts/quorum/template_settings.js +8 -0
- data/app/assets/javascripts/quorum/utilities.js +12 -0
- data/app/assets/stylesheets/quorum/application.css +0 -4
- data/app/assets/stylesheets/quorum/autohint.css +4 -0
- data/app/controllers/quorum/jobs_controller.rb +9 -9
- data/app/views/quorum/jobs/form/_blastn_form.html.erb +62 -62
- data/app/views/quorum/jobs/form/_blastp_form.html.erb +62 -62
- data/app/views/quorum/jobs/form/_blastx_form.html.erb +62 -62
- data/app/views/quorum/jobs/form/_tblastn_form.html.erb +62 -62
- data/app/views/quorum/jobs/show.html.erb +1 -1
- data/app/views/quorum/jobs/templates/_blast_detailed_report_template.html.erb +22 -22
- data/app/views/quorum/jobs/templates/_blast_template.html.erb +29 -29
- data/app/views/shared/_error_messages.html.erb +8 -8
- data/lib/quorum/version.rb +1 -1
- data/lib/tasks/jasmine.rake +8 -0
- data/quorum.gemspec +1 -0
- data/spec/data/seqs_not_fa.txt +16 -16
- data/spec/javascripts/fixtures/formatted_sequence.html +6 -0
- data/spec/javascripts/fixtures/quorum_search_form.html +461 -0
- data/spec/javascripts/fixtures/quorum_tabs.html +10 -0
- data/spec/javascripts/helpers/jasmine-jquery.js +288 -0
- data/spec/javascripts/jobs_spec.js +99 -0
- data/spec/javascripts/jquery/jquery-ui.min.js +791 -0
- data/spec/javascripts/jquery/jquery.min.js +4 -0
- data/spec/javascripts/jquery/jquery_ujs.js +373 -0
- data/spec/javascripts/quorum_spec.js +106 -0
- data/spec/javascripts/string_spec.js +18 -0
- data/spec/javascripts/support/jasmine.yml +84 -0
- data/spec/javascripts/support/jasmine_config.rb +23 -0
- data/spec/javascripts/support/jasmine_runner.rb +33 -0
- data/spec/requests/jobs_spec.rb +34 -33
- data/vendor/assets/javascripts/jquery.autohint.js +87 -0
- metadata +62 -26
@@ -0,0 +1,275 @@
|
|
1
|
+
//
|
2
|
+
// QUORUM
|
3
|
+
//---------------------------------------------------------------------------//
|
4
|
+
|
5
|
+
var QUORUM = {
|
6
|
+
|
7
|
+
//
|
8
|
+
// Supported algorithms.
|
9
|
+
//
|
10
|
+
algorithms: ["blastn", "blastx", "tblastn", "blastp"],
|
11
|
+
|
12
|
+
//
|
13
|
+
// Poll quorum search results asynchronously and insert them into
|
14
|
+
// the DOM via #blast_template.
|
15
|
+
//
|
16
|
+
pollResults: function(id, interval, algos) {
|
17
|
+
|
18
|
+
// Set the default poll interval to 5 seconds.
|
19
|
+
interval = interval || 5000;
|
20
|
+
|
21
|
+
// Algorithms
|
22
|
+
algos = algos || QUORUM.algorithms;
|
23
|
+
|
24
|
+
_.each(algos, function(a) {
|
25
|
+
$.getJSON(
|
26
|
+
'/quorum/jobs/' + id + '/get_quorum_search_results.json?algo=' + a,
|
27
|
+
function(data) {
|
28
|
+
if (data.length === 0) {
|
29
|
+
setTimeout(function() {
|
30
|
+
QUORUM.pollResults(id, interval, [a]);
|
31
|
+
}, interval);
|
32
|
+
} else {
|
33
|
+
$('#' + a + '-results').empty();
|
34
|
+
var temp = _.template(
|
35
|
+
$('#blast_template').html(), {
|
36
|
+
data: data,
|
37
|
+
algo: a
|
38
|
+
}
|
39
|
+
);
|
40
|
+
$('#' + a + '-results').html(temp);
|
41
|
+
return;
|
42
|
+
}
|
43
|
+
}
|
44
|
+
);
|
45
|
+
});
|
46
|
+
},
|
47
|
+
|
48
|
+
//
|
49
|
+
// Display jQuery UI modal box containing detailed report of all hits
|
50
|
+
// to the same query. After the modal box is inserted into the DOM,
|
51
|
+
// automatically scroll to the highlighted hit.
|
52
|
+
//
|
53
|
+
viewDetailedReport: function(id, focus_id, query, algo) {
|
54
|
+
// Create the modal box.
|
55
|
+
$('#detailed_report_dialog').html(
|
56
|
+
"<p class='center'>" +
|
57
|
+
"Loading... <img src='/assets/quorum/loading.gif' alt='Loading'>" +
|
58
|
+
"</p>"
|
59
|
+
).dialog({
|
60
|
+
modal: true,
|
61
|
+
width: 850,
|
62
|
+
position: 'top'
|
63
|
+
});
|
64
|
+
|
65
|
+
$.getJSON(
|
66
|
+
'/quorum/jobs/' + id + '/get_quorum_search_results.json?algo=' + algo +
|
67
|
+
'&query=' + query,
|
68
|
+
function(data) {
|
69
|
+
var temp = _.template(
|
70
|
+
$('#detailed_report_template').html(), {
|
71
|
+
data: data,
|
72
|
+
query: query,
|
73
|
+
algo: algo
|
74
|
+
}
|
75
|
+
);
|
76
|
+
|
77
|
+
// Insert the detailed report data.
|
78
|
+
$('#detailed_report_dialog').empty().html(temp);
|
79
|
+
|
80
|
+
// Add tipsy to the sequence data.
|
81
|
+
$('a[rel=quorum-tipsy]').tipsy({ gravity: 's' });
|
82
|
+
|
83
|
+
// Highlight the selected id.
|
84
|
+
$('#' + focus_id).addClass("ui-state-highlight");
|
85
|
+
|
86
|
+
// Automatically scroll to the selected id.
|
87
|
+
QUORUM.autoScroll(focus_id, false);
|
88
|
+
}
|
89
|
+
);
|
90
|
+
},
|
91
|
+
|
92
|
+
//
|
93
|
+
// Helper to add title sequence position attribute for tipsy.
|
94
|
+
//
|
95
|
+
// If from > to decrement index; otherwise increment.
|
96
|
+
// If the algo is tblastn and type is hit OR algo is blastx and type is query,
|
97
|
+
// increment / decrement by 3; otherwise increment / decrement by 1.
|
98
|
+
//
|
99
|
+
addBaseTitleIndex: function(bases, from, to, algo, type) {
|
100
|
+
var forward = true;
|
101
|
+
var value = 1;
|
102
|
+
var index = from;
|
103
|
+
|
104
|
+
if (from > to) {
|
105
|
+
forward = false;
|
106
|
+
}
|
107
|
+
|
108
|
+
// Set value to 3 for the below.
|
109
|
+
if ((type === "hit" && algo === "tblastn") ||
|
110
|
+
(type === "query" && algo === "blastx")) {
|
111
|
+
value = 3;
|
112
|
+
}
|
113
|
+
|
114
|
+
// Add tipsy to each base.
|
115
|
+
return _.map(bases.split(''), function(c) {
|
116
|
+
var str = "<a rel='quorum-tipsy' title=" + index + ">" + c + "</a>";
|
117
|
+
forward ? index += value : index -= value;
|
118
|
+
return str;
|
119
|
+
}).join('');
|
120
|
+
},
|
121
|
+
|
122
|
+
//
|
123
|
+
// Format sequence data for detailed report.
|
124
|
+
//
|
125
|
+
// If q_from > q_to or h_from > h_to, subtract by increment; otherwise add
|
126
|
+
// by increment.
|
127
|
+
//
|
128
|
+
// If algo is tblastn or blastx, multiple increment by 3.
|
129
|
+
//
|
130
|
+
formatSequenceReport: function(qseq, midline, hseq, q_from, q_to, h_from, h_to, algo) {
|
131
|
+
var max = qseq.length; // max length
|
132
|
+
var increment = 60; // increment value
|
133
|
+
var s = 0; // start position
|
134
|
+
var e = increment; // end position
|
135
|
+
var seq = "\n"; // seq string to return
|
136
|
+
|
137
|
+
while(true) {
|
138
|
+
seq += "qseq " + QUORUM.addBaseTitleIndex(qseq.slice(s, e), q_from, q_to, algo, 'query') + "\n";
|
139
|
+
seq += " " + midline.slice(s, e) + "\n";
|
140
|
+
seq += "hseq " + QUORUM.addBaseTitleIndex(hseq.slice(s, e), h_from, h_to, algo, 'hit') + "\n\n";
|
141
|
+
|
142
|
+
if (e >= max) {
|
143
|
+
break;
|
144
|
+
}
|
145
|
+
|
146
|
+
s += increment;
|
147
|
+
e += increment;
|
148
|
+
|
149
|
+
// If the algorithm is blastx, increment * 3 only for qseq.
|
150
|
+
if (algo === "blastx") {
|
151
|
+
q_from < q_to ? q_from += (increment * 3) : q_from -= (increment * 3);
|
152
|
+
} else {
|
153
|
+
q_from < q_to ? q_from += increment : q_from -= increment;
|
154
|
+
}
|
155
|
+
|
156
|
+
// If the algorithm is tblastn, increment * 3 only for hseq.
|
157
|
+
if (algo === "tblastn") {
|
158
|
+
h_from < h_to ? h_from += (increment * 3) : h_from -= (increment * 3);
|
159
|
+
} else {
|
160
|
+
h_from < h_to ? h_from += increment : h_from -= increment;
|
161
|
+
}
|
162
|
+
}
|
163
|
+
return "<p class='small'>Alignment (Mouse over for positions):</p>" +
|
164
|
+
"<span class='small'><pre>" + seq + "</pre></span>";
|
165
|
+
},
|
166
|
+
|
167
|
+
//
|
168
|
+
// Format Query and Hit Strand.
|
169
|
+
//
|
170
|
+
// If query_frame or hit_frame < 0, print 'reverse'; print 'forward' otherwise.
|
171
|
+
//
|
172
|
+
formatStrand: function(qstrand, hstrand) {
|
173
|
+
var q = "";
|
174
|
+
var h = "";
|
175
|
+
|
176
|
+
qstrand < 0 ? q = "reverse" : q = "forward";
|
177
|
+
hstrand < 0 ? h = "reverse" : h = "forward";
|
178
|
+
|
179
|
+
return q + " / " + h;
|
180
|
+
},
|
181
|
+
|
182
|
+
//
|
183
|
+
// Display links to Hsps in the same group.
|
184
|
+
//
|
185
|
+
displayHspLinks: function(focus, group, data) {
|
186
|
+
if (group !== null) {
|
187
|
+
var str = "Related <a onclick=\"(QUORUM.openWindow(" +
|
188
|
+
"'http://www.ncbi.nlm.nih.gov/books/NBK62051/def-item/blast_glossary.HSP'," +
|
189
|
+
"'HSP', 800, 300))\">HSPs</a>: ";
|
190
|
+
|
191
|
+
var ids = _.map(group.split(","), function(i) { return parseInt(i, 10); });
|
192
|
+
|
193
|
+
var selected = _(data).chain()
|
194
|
+
.reject(function(d) { return !_.include(ids, d.id); })
|
195
|
+
.sortBy(function(d) { return d.id; })
|
196
|
+
.value();
|
197
|
+
|
198
|
+
_.each(selected, function(e) {
|
199
|
+
if (e.id !== focus) {
|
200
|
+
str += "<a onclick='(QUORUM.autoScroll(" + e.id + ", true))'>" + e.hsp_num + "</a> ";
|
201
|
+
} else {
|
202
|
+
str += e.hsp_num + " ";
|
203
|
+
}
|
204
|
+
});
|
205
|
+
return str;
|
206
|
+
}
|
207
|
+
},
|
208
|
+
|
209
|
+
//
|
210
|
+
// Download Blast hit sequence.
|
211
|
+
//
|
212
|
+
downloadSequence: function(id, algo_id, algo, el) {
|
213
|
+
$(el).html('Fetching sequence...');
|
214
|
+
|
215
|
+
$.getJSON(
|
216
|
+
"/quorum/jobs/" + id + "/get_quorum_blast_hit_sequence.json?algo_id=" +
|
217
|
+
algo_id + "&algo=" + algo,
|
218
|
+
function(data) {
|
219
|
+
QUORUM.getSequenceFile(id, data[0].meta_id, el);
|
220
|
+
}
|
221
|
+
);
|
222
|
+
},
|
223
|
+
|
224
|
+
//
|
225
|
+
// Poll application for Blast hit sequence.
|
226
|
+
//
|
227
|
+
getSequenceFile: function(id, meta_id, el) {
|
228
|
+
var url = "/quorum/jobs/" + id +
|
229
|
+
"/send_quorum_blast_hit_sequence?meta_id=" + meta_id;
|
230
|
+
$.get(
|
231
|
+
url,
|
232
|
+
function(data) {
|
233
|
+
if (data.length === 0) {
|
234
|
+
setTimeout(function() { QUORUM.getSequenceFile(id, meta_id, el) }, 2500);
|
235
|
+
} else {
|
236
|
+
if (data.indexOf("error") !== -1) {
|
237
|
+
// Print error message.
|
238
|
+
$(el).addClass('ui-state-error').html(data);
|
239
|
+
} else {
|
240
|
+
// Force browser to download file via iframe.
|
241
|
+
$(el).addClass('ui-state-highlight').html('Sequence Downloaded Successfully');
|
242
|
+
$('.quorum_sequence_download').remove();
|
243
|
+
$('body').append('<iframe class="quorum_sequence_download"></iframe>');
|
244
|
+
$('.quorum_sequence_download').attr('src', url).hide();
|
245
|
+
}
|
246
|
+
}
|
247
|
+
}
|
248
|
+
);
|
249
|
+
},
|
250
|
+
|
251
|
+
//
|
252
|
+
// Autoscroll to given div id.
|
253
|
+
//
|
254
|
+
autoScroll: function(id, highlight) {
|
255
|
+
$('html, body').animate({
|
256
|
+
scrollTop: $('#' + id).offset().top
|
257
|
+
}, 1000);
|
258
|
+
|
259
|
+
if (highlight) {
|
260
|
+
$('#' + id).effect("highlight", {}, 4000);
|
261
|
+
}
|
262
|
+
},
|
263
|
+
|
264
|
+
//
|
265
|
+
// Open URL in new window.
|
266
|
+
//
|
267
|
+
openWindow: function(url, name, width, height) {
|
268
|
+
|
269
|
+
var windowSize = "width=" + width + ",height=" + height + ",scrollbars=yes";
|
270
|
+
|
271
|
+
window.open(url, name, windowSize);
|
272
|
+
}
|
273
|
+
|
274
|
+
};
|
275
|
+
|
@@ -0,0 +1,12 @@
|
|
1
|
+
//
|
2
|
+
// Truncate string to length n using word boundary.
|
3
|
+
//
|
4
|
+
String.prototype.trunc = function(n) {
|
5
|
+
var longStr = this.length > n;
|
6
|
+
var str = longStr ? this.slice(0, n) : this;
|
7
|
+
|
8
|
+
longStr ? str = str.slice(0, str.lastIndexOf(' ')) : str;
|
9
|
+
|
10
|
+
return longStr ? str + '...' : str;
|
11
|
+
}
|
12
|
+
|
@@ -61,13 +61,13 @@ module Quorum
|
|
61
61
|
queued = "#{params[:algo]}_job".to_sym
|
62
62
|
report = "#{params[:algo]}_job_reports".to_sym
|
63
63
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
64
|
+
begin
|
65
|
+
job = Job.find(params[:id])
|
66
|
+
rescue ActiveRecord::RecordNotFound => e
|
67
|
+
json = empty
|
68
|
+
else
|
69
|
+
if job.method(queued).call.present?
|
70
|
+
if job.method(report).call.present?
|
71
71
|
if params[:query]
|
72
72
|
json = job.method(report).call.by_query(params[:query]).default_order
|
73
73
|
else
|
@@ -76,8 +76,8 @@ module Quorum
|
|
76
76
|
else
|
77
77
|
json = []
|
78
78
|
end
|
79
|
-
|
80
|
-
|
79
|
+
end
|
80
|
+
end
|
81
81
|
end
|
82
82
|
|
83
83
|
respond_with json
|
@@ -4,68 +4,68 @@
|
|
4
4
|
<%= n.check_box :queue %>
|
5
5
|
</p>
|
6
6
|
<div id="blastn" class="toggle">
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
7
|
+
<table class="options">
|
8
|
+
<tr>
|
9
|
+
<td>
|
10
|
+
<label>Database(s)</label>
|
11
|
+
</td>
|
12
|
+
<td>
|
13
13
|
<%= n.select :blast_dbs, @blast_dbs[:blastn], {}, { :multiple => true } %>
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
14
|
+
</td>
|
15
|
+
<td>
|
16
|
+
<%= n.label :filter, "DUST" %>
|
17
|
+
</td>
|
18
|
+
<td>
|
19
|
+
<%= n.check_box :filter %>
|
20
|
+
</td>
|
21
|
+
</tr>
|
22
|
+
<tr>
|
23
|
+
<td>
|
24
|
+
<%= n.label :expectation %>
|
25
|
+
</td>
|
26
|
+
<td>
|
27
|
+
<%= n.text_field :expectation, :size => 15, :title => "5e-20",
|
28
|
+
:class => "auto-hint" %>
|
29
|
+
</td>
|
30
|
+
<td>
|
31
|
+
<%= n.label :min_bit_score %>
|
32
|
+
</td>
|
33
|
+
<td>
|
34
|
+
<%= n.text_field :min_bit_score, :size => 15, :title => "0",
|
35
|
+
:class => "auto-hint" %>
|
36
|
+
</td>
|
37
|
+
</tr>
|
38
|
+
<tr>
|
39
|
+
<td>
|
40
|
+
<%= n.label :max_score %>
|
41
|
+
</td>
|
42
|
+
<td>
|
43
|
+
<%= n.text_field :max_score, :size => 15, :title => "25",
|
44
|
+
:class => "auto-hint" %>
|
45
|
+
</td>
|
46
|
+
<td>
|
47
|
+
<%= n.label :gapped_alignments %>
|
48
|
+
</td>
|
49
|
+
<td>
|
50
|
+
<%= n.select :gapped_alignments, [['No', false], ['Yes', true]] %>
|
51
|
+
</td>
|
52
|
+
</tr>
|
53
|
+
<tr>
|
54
|
+
<td>
|
55
|
+
<%= n.label :gap_opening_extension %>
|
56
|
+
</td>
|
57
|
+
<td>
|
58
|
+
<%=
|
59
|
+
n.select :gap_opening_extension,
|
60
|
+
options_for_select(
|
61
|
+
@job.blastn_job.gap_opening_extension_values,
|
62
|
+
@job.blastn_job.gap_opening_extension
|
63
|
+
)
|
64
|
+
%>
|
65
|
+
</td>
|
66
|
+
<td></td>
|
67
|
+
<td></td>
|
68
|
+
</tr>
|
69
|
+
</table>
|
70
70
|
</div>
|
71
71
|
<% end %>
|
@@ -4,68 +4,68 @@
|
|
4
4
|
<%= n.check_box :queue %>
|
5
5
|
</p>
|
6
6
|
<div id="blastp" class="toggle">
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
7
|
+
<table class="options">
|
8
|
+
<tr>
|
9
|
+
<td>
|
10
|
+
<label>Database(s)</label>
|
11
|
+
</td>
|
12
|
+
<td>
|
13
13
|
<%= n.select :blast_dbs, @blast_dbs[:blastp], {}, { :multiple => true } %>
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
14
|
+
</td>
|
15
|
+
<td>
|
16
|
+
<%= n.label :filter, "SEG" %>
|
17
|
+
</td>
|
18
|
+
<td>
|
19
|
+
<%= n.check_box :filter %>
|
20
|
+
</td>
|
21
|
+
</tr>
|
22
|
+
<tr>
|
23
|
+
<td>
|
24
|
+
<%= n.label :expectation %>
|
25
|
+
</td>
|
26
|
+
<td>
|
27
|
+
<%= n.text_field :expectation, :size => 15, :title => "5e-20",
|
28
|
+
:class => "auto-hint" %>
|
29
|
+
</td>
|
30
|
+
<td>
|
31
|
+
<%= n.label :min_bit_score %>
|
32
|
+
</td>
|
33
|
+
<td>
|
34
|
+
<%= n.text_field :min_bit_score, :size => 15, :title => "0",
|
35
|
+
:class => "auto-hint" %>
|
36
|
+
</td>
|
37
|
+
</tr>
|
38
|
+
<tr>
|
39
|
+
<td>
|
40
|
+
<%= n.label :max_score %>
|
41
|
+
</td>
|
42
|
+
<td>
|
43
|
+
<%= n.text_field :max_score, :size => 15, :title => "25",
|
44
|
+
:class => "auto-hint" %>
|
45
|
+
</td>
|
46
|
+
<td>
|
47
|
+
<%= n.label :gapped_alignments %>
|
48
|
+
</td>
|
49
|
+
<td>
|
50
|
+
<%= n.select :gapped_alignments, [['No', false], ['Yes', true]] %>
|
51
|
+
</td>
|
52
|
+
</tr>
|
53
|
+
<tr>
|
54
|
+
<td>
|
55
|
+
<%= n.label :gap_opening_extension %>
|
56
|
+
</td>
|
57
|
+
<td>
|
58
|
+
<%=
|
59
|
+
n.select :gap_opening_extension,
|
60
|
+
options_for_select(
|
61
|
+
@job.blastn_job.gap_opening_extension_values,
|
62
|
+
@job.blastn_job.gap_opening_extension
|
63
|
+
)
|
64
|
+
%>
|
65
|
+
</td>
|
66
|
+
<td></td>
|
67
|
+
<td></td>
|
68
|
+
</tr>
|
69
|
+
</table>
|
70
70
|
</div>
|
71
71
|
<% end %>
|