##// END OF EJS Templates
pull-requests: allow markup rendered description.
marcink -
r2816:b1852ba4 default
parent child Browse files
Show More
@@ -42,6 +42,10 b' def includeme(config):'
42 42 name='goto_switcher_data',
43 43 pattern='/_goto_data')
44 44
45 config.add_route(
46 name='markup_preview',
47 pattern='/_markup_preview')
48
45 49 # register our static links via redirection mechanism
46 50 routing_links.connect_redirection_links(config)
47 51
@@ -27,7 +27,8 b' from pyramid.view import view_config'
27 27 from rhodecode.apps._base import BaseAppView
28 28 from rhodecode.lib import helpers as h
29 29 from rhodecode.lib.auth import (
30 LoginRequired, NotAnonymous, HasRepoGroupPermissionAnyDecorator)
30 LoginRequired, NotAnonymous, HasRepoGroupPermissionAnyDecorator,
31 CSRFRequired)
31 32 from rhodecode.lib.index import searcher_from_config
32 33 from rhodecode.lib.utils2 import safe_unicode, str2bool, safe_int
33 34 from rhodecode.lib.ext_json import json
@@ -425,3 +426,21 b' class HomeView(BaseAppView):'
425 426 c.repo_groups_data = json.dumps(repo_group_data)
426 427
427 428 return self._get_template_context(c)
429
430 @LoginRequired()
431 @CSRFRequired()
432 @view_config(
433 route_name='markup_preview', request_method='POST',
434 renderer='string', xhr=True)
435 def markup_preview(self):
436 # Technically a CSRF token is not needed as no state changes with this
437 # call. However, as this is a POST is better to have it, so automated
438 # tools don't flag it as potential CSRF.
439 # Post is required because the payload could be bigger than the maximum
440 # allowed by GET.
441
442 text = self.request.POST.get('text')
443 renderer = self.request.POST.get('renderer') or 'rst'
444 if text:
445 return h.render(text, renderer=renderer, mentions=True)
446 return ''
@@ -1418,9 +1418,6 b' table.integrations {'
1418 1418 margin-top: @textmargin;
1419 1419 margin-bottom: @textmargin;
1420 1420 }
1421 .pr-description {
1422 white-space:pre-wrap;
1423 }
1424 1421
1425 1422 .pr-reviewer-rules {
1426 1423 padding: 10px 0px 20px 0px;
@@ -2405,3 +2402,65 b' input[type=radio] {'
2405 2402 height: 16px;
2406 2403 width: 16px;
2407 2404 }
2405
2406
2407 .markup-form .clearfix {
2408 .border-radius(@border-radius);
2409 margin: 0px;
2410 }
2411
2412 .markup-form-area {
2413 padding: 8px 12px;
2414 border: 1px solid @grey4;
2415 .border-radius(@border-radius);
2416 }
2417
2418 .markup-form-area-header .nav-links {
2419 display: flex;
2420 flex-flow: row wrap;
2421 -webkit-flex-flow: row wrap;
2422 width: 100%;
2423 }
2424
2425 .markup-form-area-footer {
2426 display: flex;
2427 }
2428
2429 .markup-form-area-footer .toolbar {
2430
2431 }
2432
2433 // markup Form
2434 div.markup-form {
2435 margin-top: 20px;
2436 }
2437
2438 .markup-form strong {
2439 display: block;
2440 margin-bottom: 15px;
2441 }
2442
2443 .markup-form textarea {
2444 width: 100%;
2445 height: 100px;
2446 font-family: 'Monaco', 'Courier', 'Courier New', monospace;
2447 }
2448
2449 form.markup-form {
2450 margin-top: 10px;
2451 margin-left: 10px;
2452 }
2453
2454 .markup-form .comment-block-ta,
2455 .markup-form .preview-box {
2456 .border-radius(@border-radius);
2457 .box-sizing(border-box);
2458 background-color: white;
2459 }
2460
2461 .markup-form .preview-box.unloaded {
2462 height: 50px;
2463 text-align: center;
2464 padding: 20px;
2465 background-color: white;
2466 }
@@ -76,6 +76,7 b' function registerRCRoutes() {'
76 76 pyroutes.register('admin_settings_search', '/_admin/settings/search', []);
77 77 pyroutes.register('admin_settings_labs', '/_admin/settings/labs', []);
78 78 pyroutes.register('admin_settings_labs_update', '/_admin/settings/labs/update', []);
79 pyroutes.register('admin_settings_automation', '/_admin/_admin/settings/automation', []);
79 80 pyroutes.register('admin_permissions_application', '/_admin/permissions/application', []);
80 81 pyroutes.register('admin_permissions_application_update', '/_admin/permissions/application/update', []);
81 82 pyroutes.register('admin_permissions_global', '/_admin/permissions/global', []);
@@ -141,6 +142,7 b' function registerRCRoutes() {'
141 142 pyroutes.register('user_group_autocomplete_data', '/_user_groups', []);
142 143 pyroutes.register('repo_list_data', '/_repos', []);
143 144 pyroutes.register('goto_switcher_data', '/_goto_data', []);
145 pyroutes.register('markup_preview', '/_markup_preview', []);
144 146 pyroutes.register('journal', '/_admin/journal', []);
145 147 pyroutes.register('journal_rss', '/_admin/journal/rss', []);
146 148 pyroutes.register('journal_atom', '/_admin/journal/atom', []);
@@ -222,6 +224,7 b' function registerRCRoutes() {'
222 224 pyroutes.register('edit_repo_advanced_locking', '/%(repo_name)s/settings/advanced/locking', ['repo_name']);
223 225 pyroutes.register('edit_repo_advanced_journal', '/%(repo_name)s/settings/advanced/journal', ['repo_name']);
224 226 pyroutes.register('edit_repo_advanced_fork', '/%(repo_name)s/settings/advanced/fork', ['repo_name']);
227 pyroutes.register('edit_repo_advanced_hooks', '/%(repo_name)s/settings/advanced/hooks', ['repo_name']);
225 228 pyroutes.register('edit_repo_caches', '/%(repo_name)s/settings/caches', ['repo_name']);
226 229 pyroutes.register('edit_repo_perms', '/%(repo_name)s/settings/permissions', ['repo_name']);
227 230 pyroutes.register('edit_repo_maintenance', '/%(repo_name)s/settings/maintenance', ['repo_name']);
@@ -275,6 +278,7 b' function registerRCRoutes() {'
275 278 pyroutes.register('search', '/_admin/search', []);
276 279 pyroutes.register('search_repo', '/%(repo_name)s/search', ['repo_name']);
277 280 pyroutes.register('user_profile', '/_profiles/%(username)s', ['username']);
281 pyroutes.register('user_group_profile', '/_profile_user_group/%(user_group_name)s', ['user_group_name']);
278 282 pyroutes.register('my_account_profile', '/_admin/my_account/profile', []);
279 283 pyroutes.register('my_account_edit', '/_admin/my_account/edit', []);
280 284 pyroutes.register('my_account_update', '/_admin/my_account/update', []);
@@ -231,6 +231,68 b' var initCodeMirror = function(textAreadI'
231 231 return myCodeMirror;
232 232 };
233 233
234
235 var initMarkupCodeMirror = function(textAreadId, focus, options) {
236 var initialHeight = 100;
237
238 var ta = $(textAreadId).get(0);
239 if (focus === undefined) {
240 focus = true;
241 }
242
243 // default options
244 var codeMirrorOptions = {
245 lineNumbers: false,
246 indentUnit: 4,
247 viewportMargin: 30,
248 // this is a trick to trigger some logic behind codemirror placeholder
249 // it influences styling and behaviour.
250 placeholder: " ",
251 lineWrapping: true,
252 autofocus: focus
253 };
254
255 if (options !== undefined) {
256 // extend with custom options
257 codeMirrorOptions = $.extend(true, codeMirrorOptions, options);
258 }
259
260 var cm = CodeMirror.fromTextArea(ta, codeMirrorOptions);
261 cm.setSize(null, initialHeight);
262 cm.setOption("mode", DEFAULT_RENDERER);
263 CodeMirror.autoLoadMode(cm, DEFAULT_RENDERER); // load rst or markdown mode
264 cmLog.debug('Loading codemirror mode', DEFAULT_RENDERER);
265
266 // start listening on changes to make auto-expanded editor
267 cm.on("change", function(instance, changeObj) {
268 var height = initialHeight;
269 var lines = instance.lineCount();
270 if ( lines > 6 && lines < 20) {
271 height = "auto";
272 }
273 else if (lines >= 20){
274 zheight = 20*15;
275 }
276 instance.setSize(null, height);
277
278 // detect if the change was trigger by auto desc, or user input
279 var changeOrigin = changeObj.origin;
280
281 if (changeOrigin === "setValue") {
282 cmLog.debug('Change triggered by setValue');
283 }
284 else {
285 cmLog.debug('user triggered change !');
286 // set special marker to indicate user has created an input.
287 instance._userDefinedValue = true;
288 }
289
290 });
291
292 return cm;
293 };
294
295
234 296 var initCommentBoxCodeMirror = function(CommentForm, textAreaId, triggerActions){
235 297 var initialHeight = 100;
236 298
@@ -593,3 +655,196 b' var CodeMirrorPreviewEnable = function(e'
593 655 }
594 656 }
595 657 };
658
659
660 /* markup form */
661 (function(mod) {
662
663 if (typeof exports == "object" && typeof module == "object") {
664 // CommonJS
665 module.exports = mod();
666 }
667 else {
668 // Plain browser env
669 (this || window).MarkupForm = mod();
670 }
671
672 })(function() {
673 "use strict";
674
675 function MarkupForm(textareaId) {
676 if (!(this instanceof MarkupForm)) {
677 return new MarkupForm(textareaId);
678 }
679
680 // bind the element instance to our Form
681 $('#' + textareaId).get(0).MarkupForm = this;
682
683 this.withSelectorId = function(selector) {
684 var selectorId = textareaId;
685 return selector + '_' + selectorId;
686 };
687
688 this.previewButton = this.withSelectorId('#preview-btn');
689 this.previewContainer = this.withSelectorId('#preview-container');
690
691 this.previewBoxSelector = this.withSelectorId('#preview-box');
692
693 this.editButton = this.withSelectorId('#edit-btn');
694 this.editContainer = this.withSelectorId('#edit-container');
695
696 this.cmBox = textareaId;
697 this.cm = initMarkupCodeMirror('#' + textareaId);
698
699 this.previewUrl = pyroutes.url('markup_preview');
700
701 // FUNCTIONS and helpers
702 var self = this;
703
704 this.getCmInstance = function(){
705 return this.cm
706 };
707
708 this.setPlaceholder = function(placeholder) {
709 var cm = this.getCmInstance();
710 if (cm){
711 cm.setOption('placeholder', placeholder);
712 }
713 };
714
715 this.initStatusChangeSelector = function(){
716 var formatChangeStatus = function(state, escapeMarkup) {
717 var originalOption = state.element;
718 return '<div class="flag_status ' + $(originalOption).data('status') + ' pull-left"></div>' +
719 '<span>' + escapeMarkup(state.text) + '</span>';
720 };
721 var formatResult = function(result, container, query, escapeMarkup) {
722 return formatChangeStatus(result, escapeMarkup);
723 };
724
725 var formatSelection = function(data, container, escapeMarkup) {
726 return formatChangeStatus(data, escapeMarkup);
727 };
728
729 $(this.submitForm).find(this.statusChange).select2({
730 placeholder: _gettext('Status Review'),
731 formatResult: formatResult,
732 formatSelection: formatSelection,
733 containerCssClass: "drop-menu status_box_menu",
734 dropdownCssClass: "drop-menu-dropdown",
735 dropdownAutoWidth: true,
736 minimumResultsForSearch: -1
737 });
738 $(this.submitForm).find(this.statusChange).on('change', function() {
739 var status = self.getCommentStatus();
740
741 if (status && !self.isInline()) {
742 $(self.submitButton).prop('disabled', false);
743 }
744
745 var placeholderText = _gettext('Comment text will be set automatically based on currently selected status ({0}) ...').format(status);
746 self.setPlaceholder(placeholderText)
747 })
748 };
749
750 // reset the text area into it's original state
751 this.resetMarkupFormState = function(content) {
752 content = content || '';
753
754 $(this.editContainer).show();
755 $(this.editButton).parent().addClass('active');
756
757 $(this.previewContainer).hide();
758 $(this.previewButton).parent().removeClass('active');
759
760 this.setActionButtonsDisabled(true);
761 self.cm.setValue(content);
762 self.cm.setOption("readOnly", false);
763 };
764
765 this.previewSuccessCallback = function(o) {
766 $(self.previewBoxSelector).html(o);
767 $(self.previewBoxSelector).removeClass('unloaded');
768
769 // swap buttons, making preview active
770 $(self.previewButton).parent().addClass('active');
771 $(self.editButton).parent().removeClass('active');
772
773 // unlock buttons
774 self.setActionButtonsDisabled(false);
775 };
776
777 this.setActionButtonsDisabled = function(state) {
778 $(this.editButton).prop('disabled', state);
779 $(this.previewButton).prop('disabled', state);
780 };
781
782 // lock preview/edit/submit buttons on load, but exclude cancel button
783 var excludeCancelBtn = true;
784 this.setActionButtonsDisabled(true);
785
786 // anonymous users don't have access to initialized CM instance
787 if (this.cm !== undefined){
788 this.cm.on('change', function(cMirror) {
789 if (cMirror.getValue() === "") {
790 self.setActionButtonsDisabled(true)
791 } else {
792 self.setActionButtonsDisabled(false)
793 }
794 });
795 }
796
797 $(this.editButton).on('click', function(e) {
798 e.preventDefault();
799
800 $(self.previewButton).parent().removeClass('active');
801 $(self.previewContainer).hide();
802
803 $(self.editButton).parent().addClass('active');
804 $(self.editContainer).show();
805
806 });
807
808 $(this.previewButton).on('click', function(e) {
809 e.preventDefault();
810 var text = self.cm.getValue();
811
812 if (text === "") {
813 return;
814 }
815
816 var postData = {
817 'text': text,
818 'renderer': templateContext.visual.default_renderer,
819 'csrf_token': CSRF_TOKEN
820 };
821
822 // lock ALL buttons on preview
823 self.setActionButtonsDisabled(true);
824
825 $(self.previewBoxSelector).addClass('unloaded');
826 $(self.previewBoxSelector).html(_gettext('Loading ...'));
827
828 $(self.editContainer).hide();
829 $(self.previewContainer).show();
830
831 // by default we reset state of comment preserving the text
832 var previewFailCallback = function(data){
833 alert(
834 "Error while submitting preview.\n" +
835 "Error code {0} ({1}).".format(data.status, data.statusText)
836 );
837 self.resetMarkupFormState(text)
838 };
839 _submitAjaxPOST(
840 self.previewUrl, postData, self.previewSuccessCallback,
841 previewFailCallback);
842
843 $(self.previewButton).parent().addClass('active');
844 $(self.editButton).parent().removeClass('active');
845 });
846
847 }
848
849 return MarkupForm;
850 });
@@ -42,6 +42,29 b' var bindToggleButtons = function() {'
42 42 });
43 43 };
44 44
45
46
47 var _submitAjaxPOST = function(url, postData, successHandler, failHandler) {
48 failHandler = failHandler || function() {};
49 postData = toQueryString(postData);
50 var request = $.ajax({
51 url: url,
52 type: 'POST',
53 data: postData,
54 headers: {'X-PARTIAL-XHR': true}
55 })
56 .done(function (data) {
57 successHandler(data);
58 })
59 .fail(function (data, textStatus, errorThrown) {
60 failHandler(data, textStatus, errorThrown)
61 });
62 return request;
63 };
64
65
66
67
45 68 /* Comment form for main and inline comments */
46 69 (function(mod) {
47 70
@@ -259,24 +282,7 b' var bindToggleButtons = function() {'
259 282 };
260 283
261 284 this.submitAjaxPOST = function(url, postData, successHandler, failHandler) {
262 failHandler = failHandler || function() {};
263 var postData = toQueryString(postData);
264 var request = $.ajax({
265 url: url,
266 type: 'POST',
267 data: postData,
268 headers: {'X-PARTIAL-XHR': true}
269 })
270 .done(function(data) {
271 successHandler(data);
272 })
273 .fail(function(data, textStatus, errorThrown){
274 alert(
275 "Error while submitting comment.\n" +
276 "Error code {0} ({1}).".format(data.status, data.statusText));
277 failHandler()
278 });
279 return request;
285 return _submitAjaxPOST(url, postData, successHandler, failHandler);
280 286 };
281 287
282 288 // overwrite a submitHandler, we need to do it for inline comments
@@ -340,7 +346,11 b' var bindToggleButtons = function() {'
340 346 self.globalSubmitSuccessCallback();
341 347
342 348 };
343 var submitFailCallback = function(){
349 var submitFailCallback = function(data) {
350 alert(
351 "Error while submitting comment.\n" +
352 "Error code {0} ({1}).".format(data.status, data.statusText)
353 );
344 354 self.resetCommentFormState(text);
345 355 };
346 356 self.submitAjaxPOST(
@@ -436,7 +446,11 b' var bindToggleButtons = function() {'
436 446 $(self.previewContainer).show();
437 447
438 448 // by default we reset state of comment preserving the text
439 var previewFailCallback = function(){
449 var previewFailCallback = function(data){
450 alert(
451 "Error while preview of comment.\n" +
452 "Error code {0} ({1}).".format(data.status, data.statusText)
453 );
440 454 self.resetCommentFormState(text)
441 455 };
442 456 self.submitAjaxPOST(
@@ -763,7 +777,11 b' var CommentsController = function() {'
763 777 commentForm.setActionButtonsDisabled(false);
764 778
765 779 };
766 var submitFailCallback = function(){
780 var submitFailCallback = function(data){
781 alert(
782 "Error while submitting comment.\n" +
783 "Error code {0} ({1}).".format(data.status, data.statusText)
784 );
767 785 commentForm.resetCommentFormState(text)
768 786 };
769 787 commentForm.submitAjaxPOST(
@@ -378,49 +378,6 b' var editPullRequest = function(repo_name'
378 378 ajaxPOST(url, postData, success);
379 379 };
380 380
381 var initPullRequestsCodeMirror = function (textAreaId) {
382 var ta = $(textAreaId).get(0);
383 var initialHeight = '100px';
384
385 // default options
386 var codeMirrorOptions = {
387 mode: "text",
388 lineNumbers: false,
389 indentUnit: 4,
390 theme: 'rc-input'
391 };
392
393 var codeMirrorInstance = CodeMirror.fromTextArea(ta, codeMirrorOptions);
394 // marker for manually set description
395 codeMirrorInstance._userDefinedDesc = false;
396 codeMirrorInstance.setSize(null, initialHeight);
397 codeMirrorInstance.on("change", function(instance, changeObj) {
398 var height = initialHeight;
399 var lines = instance.lineCount();
400 if (lines > 6 && lines < 20) {
401 height = "auto"
402 }
403 else if (lines >= 20) {
404 height = 20 * 15;
405 }
406 instance.setSize(null, height);
407
408 // detect if the change was trigger by auto desc, or user input
409 changeOrigin = changeObj.origin;
410
411 if (changeOrigin === "setValue") {
412 cmLog.debug('Change triggered by setValue');
413 }
414 else {
415 cmLog.debug('user triggered change !');
416 // set special marker to indicate user has created an input.
417 instance._userDefinedDesc = true;
418 }
419
420 });
421
422 return codeMirrorInstance
423 };
424 381
425 382 /**
426 383 * Reviewer autocomplete
@@ -350,8 +350,7 b''
350 350 </%def>
351 351
352 352 <%def name="pullrequest_title(title, description)">
353 ${title} <br/>
354 ${h.shorter(description, 40)}
353 ${title}
355 354 </%def>
356 355
357 356 <%def name="pullrequest_comments(comments_nr)">
@@ -375,3 +374,52 b''
375 374 <%def name="pullrequest_author(full_contact)">
376 375 ${base.gravatar_with_user(full_contact, 16)}
377 376 </%def>
377
378
379 <%def name="markup_form(form_id, form_text='', help_text=None)">
380
381 <div class="markup-form">
382 <div class="markup-form-area">
383 <div class="markup-form-area-header">
384 <ul class="nav-links clearfix">
385 <li class="active">
386 <a href="#edit-text" tabindex="-1" id="edit-btn_${form_id}">${_('Write')}</a>
387 </li>
388 <li class="">
389 <a href="#preview-text" tabindex="-1" id="preview-btn_${form_id}">${_('Preview')}</a>
390 </li>
391 </ul>
392 </div>
393
394 <div class="markup-form-area-write" style="display: block;">
395 <div id="edit-container_${form_id}">
396 <textarea id="${form_id}" name="${form_id}" class="comment-block-ta ac-input">${form_text if form_text else ''}</textarea>
397 </div>
398 <div id="preview-container_${form_id}" class="clearfix" style="display: none;">
399 <div id="preview-box_${form_id}" class="preview-box"></div>
400 </div>
401 </div>
402
403 <div class="markup-form-area-footer">
404 <div class="toolbar">
405 <div class="toolbar-text">
406 ${(_('Parsed using %s syntax') % (
407 ('<a href="%s">%s</a>' % (h.route_url('%s_help' % c.visual.default_renderer), c.visual.default_renderer.upper())),
408 )
409 )|n}
410 </div>
411 </div>
412 </div>
413 </div>
414
415 <div class="markup-form-footer">
416 % if help_text:
417 <span class="help-block">${help_text}</span>
418 % endif
419 </div>
420 </div>
421 <script type="text/javascript">
422 new MarkupForm('${form_id}');
423 </script>
424
425 </%def>
This diff has been collapsed as it changes many lines, (633 lines changed) Show them Hide them
@@ -1,4 +1,5 b''
1 1 <%inherit file="/base/base.mako"/>
2 <%namespace name="dt" file="/data_table/_dt_elements.mako"/>
2 3
3 4 <%def name="title()">
4 5 ${c.repo_name} ${_('New pull request')}
@@ -54,14 +55,13 b''
54 55 <label for="pullrequest_desc">${_('Description')}:</label>
55 56 </div>
56 57 <div class="textarea text-area editor">
57 ${h.textarea('pullrequest_desc',size=30, )}
58 <span class="help-block">${_('Write a short description on this pull request')}</span>
58 ${dt.markup_form('pullrequest_desc')}
59 59 </div>
60 60 </div>
61 61
62 62 <div class="field">
63 63 <div class="label label-textarea">
64 <label for="pullrequest_desc">${_('Commit flow')}:</label>
64 <label for="commit_flow">${_('Commit flow')}:</label>
65 65 </div>
66 66
67 67 ## TODO: johbo: Abusing the "content" class here to get the
@@ -164,379 +164,384 b''
164 164 </div>
165 165
166 166 <script type="text/javascript">
167 $(function(){
168 var defaultSourceRepo = '${c.default_repo_data['source_repo_name']}';
169 var defaultSourceRepoData = ${c.default_repo_data['source_refs_json']|n};
170 var defaultTargetRepo = '${c.default_repo_data['target_repo_name']}';
171 var defaultTargetRepoData = ${c.default_repo_data['target_refs_json']|n};
167 $(function(){
168 var defaultSourceRepo = '${c.default_repo_data['source_repo_name']}';
169 var defaultSourceRepoData = ${c.default_repo_data['source_refs_json']|n};
170 var defaultTargetRepo = '${c.default_repo_data['target_repo_name']}';
171 var defaultTargetRepoData = ${c.default_repo_data['target_refs_json']|n};
172 172
173 var $pullRequestForm = $('#pull_request_form');
174 var $pullRequestSubmit = $('#pr_submit', $pullRequestForm);
175 var $sourceRepo = $('#source_repo', $pullRequestForm);
176 var $targetRepo = $('#target_repo', $pullRequestForm);
177 var $sourceRef = $('#source_ref', $pullRequestForm);
178 var $targetRef = $('#target_ref', $pullRequestForm);
173 var $pullRequestForm = $('#pull_request_form');
174 var $pullRequestSubmit = $('#pr_submit', $pullRequestForm);
175 var $sourceRepo = $('#source_repo', $pullRequestForm);
176 var $targetRepo = $('#target_repo', $pullRequestForm);
177 var $sourceRef = $('#source_ref', $pullRequestForm);
178 var $targetRef = $('#target_ref', $pullRequestForm);
179 179
180 var sourceRepo = function() { return $sourceRepo.eq(0).val() };
181 var sourceRef = function() { return $sourceRef.eq(0).val().split(':') };
180 var sourceRepo = function() { return $sourceRepo.eq(0).val() };
181 var sourceRef = function() { return $sourceRef.eq(0).val().split(':') };
182 182
183 var targetRepo = function() { return $targetRepo.eq(0).val() };
184 var targetRef = function() { return $targetRef.eq(0).val().split(':') };
183 var targetRepo = function() { return $targetRepo.eq(0).val() };
184 var targetRef = function() { return $targetRef.eq(0).val().split(':') };
185 185
186 var calculateContainerWidth = function() {
187 var maxWidth = 0;
188 var repoSelect2Containers = ['#source_repo', '#target_repo'];
189 $.each(repoSelect2Containers, function(idx, value) {
190 $(value).select2('container').width('auto');
191 var curWidth = $(value).select2('container').width();
192 if (maxWidth <= curWidth) {
193 maxWidth = curWidth;
194 }
195 $.each(repoSelect2Containers, function(idx, value) {
196 $(value).select2('container').width(maxWidth + 10);
197 });
198 });
199 };
186 var calculateContainerWidth = function() {
187 var maxWidth = 0;
188 var repoSelect2Containers = ['#source_repo', '#target_repo'];
189 $.each(repoSelect2Containers, function(idx, value) {
190 $(value).select2('container').width('auto');
191 var curWidth = $(value).select2('container').width();
192 if (maxWidth <= curWidth) {
193 maxWidth = curWidth;
194 }
195 $.each(repoSelect2Containers, function(idx, value) {
196 $(value).select2('container').width(maxWidth + 10);
197 });
198 });
199 };
200 200
201 var initRefSelection = function(selectedRef) {
202 return function(element, callback) {
203 // translate our select2 id into a text, it's a mapping to show
204 // simple label when selecting by internal ID.
205 var id, refData;
206 if (selectedRef === undefined || selectedRef === null) {
207 id = element.val();
208 refData = element.val().split(':');
201 var initRefSelection = function(selectedRef) {
202 return function(element, callback) {
203 // translate our select2 id into a text, it's a mapping to show
204 // simple label when selecting by internal ID.
205 var id, refData;
206 if (selectedRef === undefined || selectedRef === null) {
207 id = element.val();
208 refData = element.val().split(':');
209 209
210 if (refData.length !== 3){
211 refData = ["", "", ""]
212 }
213 } else {
214 id = selectedRef;
215 refData = selectedRef.split(':');
216 }
210 if (refData.length !== 3){
211 refData = ["", "", ""]
212 }
213 } else {
214 id = selectedRef;
215 refData = selectedRef.split(':');
216 }
217 217
218 var text = refData[1];
219 if (refData[0] === 'rev') {
220 text = text.substring(0, 12);
221 }
218 var text = refData[1];
219 if (refData[0] === 'rev') {
220 text = text.substring(0, 12);
221 }
222 222
223 var data = {id: id, text: text};
224 callback(data);
225 };
226 };
223 var data = {id: id, text: text};
224 callback(data);
225 };
226 };
227 227
228 var formatRefSelection = function(item) {
229 var prefix = '';
230 var refData = item.id.split(':');
231 if (refData[0] === 'branch') {
232 prefix = '<i class="icon-branch"></i>';
233 }
234 else if (refData[0] === 'book') {
235 prefix = '<i class="icon-bookmark"></i>';
236 }
237 else if (refData[0] === 'tag') {
238 prefix = '<i class="icon-tag"></i>';
239 }
228 var formatRefSelection = function(item) {
229 var prefix = '';
230 var refData = item.id.split(':');
231 if (refData[0] === 'branch') {
232 prefix = '<i class="icon-branch"></i>';
233 }
234 else if (refData[0] === 'book') {
235 prefix = '<i class="icon-bookmark"></i>';
236 }
237 else if (refData[0] === 'tag') {
238 prefix = '<i class="icon-tag"></i>';
239 }
240 240
241 var originalOption = item.element;
242 return prefix + item.text;
243 };
241 var originalOption = item.element;
242 return prefix + item.text;
243 };
244 244
245 // custom code mirror
246 var codeMirrorInstance = initPullRequestsCodeMirror('#pullrequest_desc');
245 // custom code mirror
246 var codeMirrorInstance = $('#pullrequest_desc').get(0).MarkupForm.cm;
247 247
248 reviewersController = new ReviewersController();
248 reviewersController = new ReviewersController();
249 249
250 var queryTargetRepo = function(self, query) {
251 // cache ALL results if query is empty
252 var cacheKey = query.term || '__';
253 var cachedData = self.cachedDataSource[cacheKey];
250 var queryTargetRepo = function(self, query) {
251 // cache ALL results if query is empty
252 var cacheKey = query.term || '__';
253 var cachedData = self.cachedDataSource[cacheKey];
254 254
255 if (cachedData) {
256 query.callback({results: cachedData.results});
257 } else {
258 $.ajax({
259 url: pyroutes.url('pullrequest_repo_destinations', {'repo_name': templateContext.repo_name}),
260 data: {query: query.term},
261 dataType: 'json',
262 type: 'GET',
263 success: function(data) {
264 self.cachedDataSource[cacheKey] = data;
265 query.callback({results: data.results});
266 },
267 error: function(data, textStatus, errorThrown) {
268 alert(
269 "Error while fetching entries.\nError code {0} ({1}).".format(data.status, data.statusText));
270 }
271 });
272 }
273 };
255 if (cachedData) {
256 query.callback({results: cachedData.results});
257 } else {
258 $.ajax({
259 url: pyroutes.url('pullrequest_repo_destinations', {'repo_name': templateContext.repo_name}),
260 data: {query: query.term},
261 dataType: 'json',
262 type: 'GET',
263 success: function(data) {
264 self.cachedDataSource[cacheKey] = data;
265 query.callback({results: data.results});
266 },
267 error: function(data, textStatus, errorThrown) {
268 alert(
269 "Error while fetching entries.\nError code {0} ({1}).".format(data.status, data.statusText));
270 }
271 });
272 }
273 };
274 274
275 var queryTargetRefs = function(initialData, query) {
276 var data = {results: []};
277 // filter initialData
278 $.each(initialData, function() {
279 var section = this.text;
280 var children = [];
281 $.each(this.children, function() {
282 if (query.term.length === 0 ||
283 this.text.toUpperCase().indexOf(query.term.toUpperCase()) >= 0 ) {
284 children.push({'id': this.id, 'text': this.text})
285 }
286 });
287 data.results.push({'text': section, 'children': children})
288 });
289 query.callback({results: data.results});
290 };
275 var queryTargetRefs = function(initialData, query) {
276 var data = {results: []};
277 // filter initialData
278 $.each(initialData, function() {
279 var section = this.text;
280 var children = [];
281 $.each(this.children, function() {
282 if (query.term.length === 0 ||
283 this.text.toUpperCase().indexOf(query.term.toUpperCase()) >= 0 ) {
284 children.push({'id': this.id, 'text': this.text})
285 }
286 });
287 data.results.push({'text': section, 'children': children})
288 });
289 query.callback({results: data.results});
290 };
291 291
292 var loadRepoRefDiffPreview = function() {
292 var loadRepoRefDiffPreview = function() {
293 293
294 var url_data = {
295 'repo_name': targetRepo(),
296 'target_repo': sourceRepo(),
297 'source_ref': targetRef()[2],
298 'source_ref_type': 'rev',
299 'target_ref': sourceRef()[2],
300 'target_ref_type': 'rev',
301 'merge': true,
302 '_': Date.now() // bypass browser caching
303 }; // gather the source/target ref and repo here
294 var url_data = {
295 'repo_name': targetRepo(),
296 'target_repo': sourceRepo(),
297 'source_ref': targetRef()[2],
298 'source_ref_type': 'rev',
299 'target_ref': sourceRef()[2],
300 'target_ref_type': 'rev',
301 'merge': true,
302 '_': Date.now() // bypass browser caching
303 }; // gather the source/target ref and repo here
304 304
305 if (sourceRef().length !== 3 || targetRef().length !== 3) {
306 prButtonLock(true, "${_('Please select source and target')}");
307 return;
308 }
309 var url = pyroutes.url('repo_compare', url_data);
305 if (sourceRef().length !== 3 || targetRef().length !== 3) {
306 prButtonLock(true, "${_('Please select source and target')}");
307 return;
308 }
309 var url = pyroutes.url('repo_compare', url_data);
310 310
311 // lock PR button, so we cannot send PR before it's calculated
312 prButtonLock(true, "${_('Loading compare ...')}", 'compare');
311 // lock PR button, so we cannot send PR before it's calculated
312 prButtonLock(true, "${_('Loading compare ...')}", 'compare');
313
314 if (loadRepoRefDiffPreview._currentRequest) {
315 loadRepoRefDiffPreview._currentRequest.abort();
316 }
313 317
314 if (loadRepoRefDiffPreview._currentRequest) {
315 loadRepoRefDiffPreview._currentRequest.abort();
316 }
318 loadRepoRefDiffPreview._currentRequest = $.get(url)
319 .error(function(data, textStatus, errorThrown) {
320 if (textStatus !== 'abort') {
321 alert(
322 "Error while processing request.\nError code {0} ({1}).".format(
323 data.status, data.statusText));
324 }
317 325
318 loadRepoRefDiffPreview._currentRequest = $.get(url)
319 .error(function(data, textStatus, errorThrown) {
320 if (textStatus !== 'abort') {
321 alert(
322 "Error while processing request.\nError code {0} ({1}).".format(
323 data.status, data.statusText));
324 }
326 })
327 .done(function(data) {
328 loadRepoRefDiffPreview._currentRequest = null;
329 $('#pull_request_overview').html(data);
330
331 var commitElements = $(data).find('tr[commit_id]');
325 332
326 })
327 .done(function(data) {
328 loadRepoRefDiffPreview._currentRequest = null;
329 $('#pull_request_overview').html(data);
333 var prTitleAndDesc = getTitleAndDescription(
334 sourceRef()[1], commitElements, 5);
330 335
331 var commitElements = $(data).find('tr[commit_id]');
336 var title = prTitleAndDesc[0];
337 var proposedDescription = prTitleAndDesc[1];
332 338
333 var prTitleAndDesc = getTitleAndDescription(
334 sourceRef()[1], commitElements, 5);
339 var useGeneratedTitle = (
340 $('#pullrequest_title').hasClass('autogenerated-title') ||
341 $('#pullrequest_title').val() === "");
335 342
336 var title = prTitleAndDesc[0];
337 var proposedDescription = prTitleAndDesc[1];
338
339 var useGeneratedTitle = (
340 $('#pullrequest_title').hasClass('autogenerated-title') ||
341 $('#pullrequest_title').val() === "");
343 if (title && useGeneratedTitle) {
344 // use generated title if we haven't specified our own
345 $('#pullrequest_title').val(title);
346 $('#pullrequest_title').addClass('autogenerated-title');
342 347
343 if (title && useGeneratedTitle) {
344 // use generated title if we haven't specified our own
345 $('#pullrequest_title').val(title);
346 $('#pullrequest_title').addClass('autogenerated-title');
348 }
349
350 var useGeneratedDescription = (
351 !codeMirrorInstance._userDefinedValue ||
352 codeMirrorInstance.getValue() === "");
347 353
348 }
354 if (proposedDescription && useGeneratedDescription) {
355 // set proposed content, if we haven't defined our own,
356 // or we don't have description written
357 codeMirrorInstance._userDefinedValue = false; // reset state
358 codeMirrorInstance.setValue(proposedDescription);
359 }
349 360
350 var useGeneratedDescription = (
351 !codeMirrorInstance._userDefinedDesc ||
352 codeMirrorInstance.getValue() === "");
361 // refresh our codeMirror so events kicks in and it's change aware
362 codeMirrorInstance.refresh();
353 363
354 if (proposedDescription && useGeneratedDescription) {
355 // set proposed content, if we haven't defined our own,
356 // or we don't have description written
357 codeMirrorInstance._userDefinedDesc = false; // reset state
358 codeMirrorInstance.setValue(proposedDescription);
359 }
364 var msg = '';
365 if (commitElements.length === 1) {
366 msg = "${_ungettext('This pull request will consist of __COMMITS__ commit.', 'This pull request will consist of __COMMITS__ commits.', 1)}";
367 } else {
368 msg = "${_ungettext('This pull request will consist of __COMMITS__ commit.', 'This pull request will consist of __COMMITS__ commits.', 2)}";
369 }
360 370
361 var msg = '';
362 if (commitElements.length === 1) {
363 msg = "${_ungettext('This pull request will consist of __COMMITS__ commit.', 'This pull request will consist of __COMMITS__ commits.', 1)}";
364 } else {
365 msg = "${_ungettext('This pull request will consist of __COMMITS__ commit.', 'This pull request will consist of __COMMITS__ commits.', 2)}";
366 }
371 msg += ' <a id="pull_request_overview_url" href="{0}" target="_blank">${_('Show detailed compare.')}</a>'.format(url);
367 372
368 msg += ' <a id="pull_request_overview_url" href="{0}" target="_blank">${_('Show detailed compare.')}</a>'.format(url);
369
370 if (commitElements.length) {
371 var commitsLink = '<a href="#pull_request_overview"><strong>{0}</strong></a>'.format(commitElements.length);
372 prButtonLock(false, msg.replace('__COMMITS__', commitsLink), 'compare');
373 }
374 else {
375 prButtonLock(true, "${_('There are no commits to merge.')}", 'compare');
376 }
373 if (commitElements.length) {
374 var commitsLink = '<a href="#pull_request_overview"><strong>{0}</strong></a>'.format(commitElements.length);
375 prButtonLock(false, msg.replace('__COMMITS__', commitsLink), 'compare');
376 }
377 else {
378 prButtonLock(true, "${_('There are no commits to merge.')}", 'compare');
379 }
377 380
378 381
379 });
380 };
382 });
383 };
381 384
382 var Select2Box = function(element, overrides) {
383 var globalDefaults = {
384 dropdownAutoWidth: true,
385 containerCssClass: "drop-menu",
386 dropdownCssClass: "drop-menu-dropdown"
387 };
385 var Select2Box = function(element, overrides) {
386 var globalDefaults = {
387 dropdownAutoWidth: true,
388 containerCssClass: "drop-menu",
389 dropdownCssClass: "drop-menu-dropdown"
390 };
388 391
389 var initSelect2 = function(defaultOptions) {
390 var options = jQuery.extend(globalDefaults, defaultOptions, overrides);
391 element.select2(options);
392 };
392 var initSelect2 = function(defaultOptions) {
393 var options = jQuery.extend(globalDefaults, defaultOptions, overrides);
394 element.select2(options);
395 };
393 396
394 return {
395 initRef: function() {
396 var defaultOptions = {
397 minimumResultsForSearch: 5,
398 formatSelection: formatRefSelection
399 };
397 return {
398 initRef: function() {
399 var defaultOptions = {
400 minimumResultsForSearch: 5,
401 formatSelection: formatRefSelection
402 };
400 403
401 initSelect2(defaultOptions);
402 },
404 initSelect2(defaultOptions);
405 },
403 406
404 initRepo: function(defaultValue, readOnly) {
405 var defaultOptions = {
406 initSelection : function (element, callback) {
407 var data = {id: defaultValue, text: defaultValue};
408 callback(data);
409 }
410 };
407 initRepo: function(defaultValue, readOnly) {
408 var defaultOptions = {
409 initSelection : function (element, callback) {
410 var data = {id: defaultValue, text: defaultValue};
411 callback(data);
412 }
413 };
411 414
412 initSelect2(defaultOptions);
415 initSelect2(defaultOptions);
413 416
414 element.select2('val', defaultSourceRepo);
415 if (readOnly === true) {
416 element.select2('readonly', true);
417 }
418 }
419 };
420 };
417 element.select2('val', defaultSourceRepo);
418 if (readOnly === true) {
419 element.select2('readonly', true);
420 }
421 }
422 };
423 };
421 424
422 var initTargetRefs = function(refsData, selectedRef) {
425 var initTargetRefs = function(refsData, selectedRef) {
423 426
424 Select2Box($targetRef, {
425 placeholder: "${_('Select commit reference')}",
426 query: function(query) {
427 queryTargetRefs(refsData, query);
428 },
429 initSelection : initRefSelection(selectedRef)
430 }).initRef();
427 Select2Box($targetRef, {
428 placeholder: "${_('Select commit reference')}",
429 query: function(query) {
430 queryTargetRefs(refsData, query);
431 },
432 initSelection : initRefSelection(selectedRef)
433 }).initRef();
431 434
432 if (!(selectedRef === undefined)) {
433 $targetRef.select2('val', selectedRef);
434 }
435 };
435 if (!(selectedRef === undefined)) {
436 $targetRef.select2('val', selectedRef);
437 }
438 };
436 439
437 var targetRepoChanged = function(repoData) {
438 // generate new DESC of target repo displayed next to select
439 var prLink = pyroutes.url('pullrequest_new', {'repo_name': repoData['name']});
440 $('#target_repo_desc').html(
441 "<strong>${_('Target repository')}</strong>: {0}. <a href=\"{1}\">Switch base, and use as source.</a>".format(repoData['description'], prLink)
442 );
440 var targetRepoChanged = function(repoData) {
441 // generate new DESC of target repo displayed next to select
442 var prLink = pyroutes.url('pullrequest_new', {'repo_name': repoData['name']});
443 $('#target_repo_desc').html(
444 "<strong>${_('Target repository')}</strong>: {0}. <a href=\"{1}\">Switch base, and use as source.</a>".format(repoData['description'], prLink)
445 );
443 446
444 // generate dynamic select2 for refs.
445 initTargetRefs(repoData['refs']['select2_refs'],
446 repoData['refs']['selected_ref']);
447 // generate dynamic select2 for refs.
448 initTargetRefs(repoData['refs']['select2_refs'],
449 repoData['refs']['selected_ref']);
447 450
448 };
451 };
449 452
450 var sourceRefSelect2 = Select2Box($sourceRef, {
451 placeholder: "${_('Select commit reference')}",
452 query: function(query) {
453 var initialData = defaultSourceRepoData['refs']['select2_refs'];
454 queryTargetRefs(initialData, query)
455 },
456 initSelection: initRefSelection()
457 }
458 );
453 var sourceRefSelect2 = Select2Box($sourceRef, {
454 placeholder: "${_('Select commit reference')}",
455 query: function(query) {
456 var initialData = defaultSourceRepoData['refs']['select2_refs'];
457 queryTargetRefs(initialData, query)
458 },
459 initSelection: initRefSelection()
460 }
461 );
459 462
460 var sourceRepoSelect2 = Select2Box($sourceRepo, {
461 query: function(query) {}
462 });
463 var sourceRepoSelect2 = Select2Box($sourceRepo, {
464 query: function(query) {}
465 });
463 466
464 var targetRepoSelect2 = Select2Box($targetRepo, {
465 cachedDataSource: {},
466 query: $.debounce(250, function(query) {
467 queryTargetRepo(this, query);
468 }),
469 formatResult: formatRepoResult
470 });
467 var targetRepoSelect2 = Select2Box($targetRepo, {
468 cachedDataSource: {},
469 query: $.debounce(250, function(query) {
470 queryTargetRepo(this, query);
471 }),
472 formatResult: formatRepoResult
473 });
471 474
472 sourceRefSelect2.initRef();
475 sourceRefSelect2.initRef();
473 476
474 sourceRepoSelect2.initRepo(defaultSourceRepo, true);
477 sourceRepoSelect2.initRepo(defaultSourceRepo, true);
475 478
476 targetRepoSelect2.initRepo(defaultTargetRepo, false);
479 targetRepoSelect2.initRepo(defaultTargetRepo, false);
477 480
478 $sourceRef.on('change', function(e){
479 loadRepoRefDiffPreview();
480 reviewersController.loadDefaultReviewers(
481 sourceRepo(), sourceRef(), targetRepo(), targetRef());
482 });
481 $sourceRef.on('change', function(e){
482 loadRepoRefDiffPreview();
483 reviewersController.loadDefaultReviewers(
484 sourceRepo(), sourceRef(), targetRepo(), targetRef());
485 });
483 486
484 $targetRef.on('change', function(e){
485 loadRepoRefDiffPreview();
486 reviewersController.loadDefaultReviewers(
487 sourceRepo(), sourceRef(), targetRepo(), targetRef());
488 });
487 $targetRef.on('change', function(e){
488 loadRepoRefDiffPreview();
489 reviewersController.loadDefaultReviewers(
490 sourceRepo(), sourceRef(), targetRepo(), targetRef());
491 });
489 492
490 $targetRepo.on('change', function(e){
491 var repoName = $(this).val();
492 calculateContainerWidth();
493 $targetRef.select2('destroy');
494 $('#target_ref_loading').show();
493 $targetRepo.on('change', function(e){
494 var repoName = $(this).val();
495 calculateContainerWidth();
496 $targetRef.select2('destroy');
497 $('#target_ref_loading').show();
495 498
496 $.ajax({
497 url: pyroutes.url('pullrequest_repo_refs',
498 {'repo_name': templateContext.repo_name, 'target_repo_name':repoName}),
499 data: {},
500 dataType: 'json',
501 type: 'GET',
502 success: function(data) {
503 $('#target_ref_loading').hide();
504 targetRepoChanged(data);
505 loadRepoRefDiffPreview();
506 },
507 error: function(data, textStatus, errorThrown) {
508 alert("Error while fetching entries.\nError code {0} ({1}).".format(data.status, data.statusText));
509 }
510 })
499 $.ajax({
500 url: pyroutes.url('pullrequest_repo_refs',
501 {'repo_name': templateContext.repo_name, 'target_repo_name':repoName}),
502 data: {},
503 dataType: 'json',
504 type: 'GET',
505 success: function(data) {
506 $('#target_ref_loading').hide();
507 targetRepoChanged(data);
508 loadRepoRefDiffPreview();
509 },
510 error: function(data, textStatus, errorThrown) {
511 alert("Error while fetching entries.\nError code {0} ({1}).".format(data.status, data.statusText));
512 }
513 })
511 514
512 });
513
514 $pullRequestForm.on('submit', function(e){
515 prButtonLock(true, null, 'all');
516 });
515 });
517 516
518 prButtonLock(true, "${_('Please select source and target')}", 'all');
517 $pullRequestForm.on('submit', function(e){
518 // Flush changes into textarea
519 codeMirrorInstance.save();
520 prButtonLock(true, null, 'all');
521 });
519 522
520 // auto-load on init, the target refs select2
521 calculateContainerWidth();
522 targetRepoChanged(defaultTargetRepoData);
523 prButtonLock(true, "${_('Please select source and target')}", 'all');
523 524
524 $('#pullrequest_title').on('keyup', function(e){
525 $(this).removeClass('autogenerated-title');
526 });
525 // auto-load on init, the target refs select2
526 calculateContainerWidth();
527 targetRepoChanged(defaultTargetRepoData);
527 528
528 % if c.default_source_ref:
529 // in case we have a pre-selected value, use it now
530 $sourceRef.select2('val', '${c.default_source_ref}');
531 // diff preview load
532 loadRepoRefDiffPreview();
533 // default reviewers
534 reviewersController.loadDefaultReviewers(
535 sourceRepo(), sourceRef(), targetRepo(), targetRef());
536 % endif
529 $('#pullrequest_title').on('keyup', function(e){
530 $(this).removeClass('autogenerated-title');
531 });
537 532
538 ReviewerAutoComplete('#user');
539 });
533 % if c.default_source_ref:
534 // in case we have a pre-selected value, use it now
535 $sourceRef.select2('val', '${c.default_source_ref}');
536 // diff preview load
537 loadRepoRefDiffPreview();
538 // default reviewers
539 reviewersController.loadDefaultReviewers(
540 sourceRepo(), sourceRef(), targetRepo(), targetRef());
541 % endif
542
543 ReviewerAutoComplete('#user');
544 });
540 545 </script>
541 546
542 547 </%def>
@@ -1,5 +1,6 b''
1 1 <%inherit file="/base/base.mako"/>
2 2 <%namespace name="base" file="/base/base.mako"/>
3 <%namespace name="dt" file="/data_table/_dt_elements.mako"/>
3 4
4 5 <%def name="title()">
5 6 ${_('%s Pull Request #%s') % (c.repo_name, c.pull_request.pull_request_id)}
@@ -169,10 +170,10 b''
169 170 <label>${_('Description')}:</label>
170 171 </div>
171 172 <div id="pr-desc" class="input">
172 <div class="pr-description">${h.urlify_commit_message(c.pull_request.description, c.repo_name)}</div>
173 <div class="pr-description">${h.render(c.pull_request.description, renderer=c.visual.default_renderer)}</div>
173 174 </div>
174 175 <div id="pr-desc-edit" class="input textarea editor" style="display: none;">
175 <textarea id="pr-description-input" size="30">${c.pull_request.description}</textarea>
176 ${dt.markup_form('pr-description-input', form_text=c.pull_request.description)}
176 177 </div>
177 178 </div>
178 179
@@ -643,7 +644,7 b''
643 644 $(function(){
644 645
645 646 // custom code mirror
646 var codeMirrorInstance = initPullRequestsCodeMirror('#pr-description-input');
647 var codeMirrorInstance = $('#pr-description-input').get(0).MarkupForm.cm;
647 648
648 649 var PRDetails = {
649 650 editButton: $('#open_edit_pullrequest'),
General Comments 0
You need to be logged in to leave comments. Login now