Add rich text editor to task and protocol description [SCI-3062][SCI-3071]

This commit is contained in:
Oleksii Kriuchykhin 2019-03-14 16:08:52 +01:00
parent 645930b8d7
commit a822227383
17 changed files with 356 additions and 141 deletions

View file

@ -90,7 +90,7 @@ gem 'rufus-scheduler', '~> 3.5'
gem 'discard', '~> 1.0'
gem 'ruby-graphviz', '~> 1.2' # Graphviz for rails
gem 'tinymce-rails', '~> 4.7.13' # Rich text editor - SEE BELOW
gem 'tinymce-rails', '~> 4.9.3' # Rich text editor - SEE BELOW
# Any time you update tinymce-rails Gem, also update the cache_suffix parameter
# in sitewide/tiny_mce.js - to prevent browsers from loading old, cached .js
# TinyMCE files which might cause errors

View file

@ -526,7 +526,7 @@ GEM
thread_safe (0.3.6)
tilt (2.0.8)
timecop (0.9.1)
tinymce-rails (4.7.13)
tinymce-rails (4.9.3)
railties (>= 3.1.1)
turbolinks (5.1.1)
turbolinks-source (~> 5.1)
@ -656,7 +656,7 @@ DEPENDENCIES
spinjs-rails
starscope
timecop
tinymce-rails (~> 4.7.13)
tinymce-rails (~> 4.9.3)
turbolinks (~> 5.1.1)
tzinfo-data
uglifier (>= 1.3.0)

View file

@ -266,8 +266,11 @@ var HelperModule = (function(){
$('.modal').off().modal('hide');
});
/* Fix .selectpicker (bootstrap-select) to work with Turbolinks 5.x */
$(document).on('turbolinks:load', function() {
/* Fix .selectpicker (bootstrap-select) to work with Turbolinks 5.x */
$(window).trigger('load.bs.select.data-api');
/* Clean up TinyMCE */
tinymce.remove();
});
})();

View file

@ -10,6 +10,8 @@ var selectedRow = null;
*/
function init() {
bindEditDueDateAjax();
initEditMyModuleDescription();
initEditProtocolDescription();
initEditDescription();
initCopyToRepository();
initLinkUpdate();
@ -19,6 +21,18 @@ function init() {
setupAssetsLoading();
}
function initEditMyModuleDescription() {
$('#my_module_description_textarea').on('click', function(){
TinyMCE.init('#my_module_description_textarea');
});
}
function initEditProtocolDescription() {
$('#protocol_description_textarea').on('click', function(){
TinyMCE.init('#protocol_description_textarea');
});
}
// Initialize edit description modal window
function initEditDescription() {
var editDescriptionModal = $("#manage-module-description-modal");

View file

@ -0,0 +1,204 @@
/* global _ hljs tinyMCE SmartAnnotation */
var TinyMCE = (function() {
'use strict';
function initHighlightjs() {
$('[class*=language]').each(function(i, block) {
hljs.highlightBlock(block);
});
}
function initHighlightjsIframe(iframe) {
$('[class*=language]', iframe).each(function(i, block) {
hljs.highlightBlock(block);
});
}
// returns a public API for TinyMCE editor
return Object.freeze({
init: function(selector) {
if (typeof tinyMCE !== 'undefined') {
tinyMCE.init({
cache_suffix: '?v=4.9.3', // This suffix should be changed any time library is updated
selector: selector,
menubar: 'file edit view insert format',
toolbar: 'undo redo restoredraft | insert | styleselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link | forecolor backcolor | customimageuploader | codesample',
plugins: 'autosave autoresize customimageuploader link advlist codesample autolink lists charmap hr anchor searchreplace wordcount visualblocks visualchars insertdatetime nonbreaking save directionality paste textcolor colorpicker textpattern',
codesample_languages: [
{ text: 'R', value: 'r' },
{ text: 'MATLAB', value: 'matlab' },
{ text: 'Python', value: 'python' },
{ text: 'JSON', value: 'javascript' },
{ text: 'HTML/XML', value: 'markup' },
{ text: 'JavaScript', value: 'javascript' },
{ text: 'CSS', value: 'css' },
{ text: 'PHP', value: 'php' },
{ text: 'Ruby', value: 'ruby' },
{ text: 'Java', value: 'java' },
{ text: 'C', value: 'c' },
{ text: 'C#', value: 'csharp' },
{ text: 'C++', value: 'cpp' }
],
browser_spellcheck: true,
branding: false,
fixed_toolbar_container: '#mytoolbar',
autosave_interval: '15s',
autosave_retention: '1440m',
removed_menuitems: 'newdocument',
object_resizing: false,
elementpath: false,
forced_root_block: false,
default_link_target: '_blank',
target_list: [
{ title: 'New page', value: '_blank' },
{ title: 'Same page', value: '_self' }
],
style_formats: [
{
title: 'Headers',
items: [
{ title: 'Header 1', format: 'h1' },
{ title: 'Header 2', format: 'h2' },
{ title: 'Header 3', format: 'h3' },
{ title: 'Header 4', format: 'h4' },
{ title: 'Header 5', format: 'h5' },
{ title: 'Header 6', format: 'h6' }
]
},
{
title: 'Inline',
items: [
{ title: 'Bold', icon: 'bold', format: 'bold' },
{ title: 'Italic', icon: 'italic', format: 'italic' },
{ title: 'Underline', icon: 'underline', format: 'underline' },
{ title: 'Strikethrough', icon: 'strikethrough', format: 'strikethrough' },
{ title: 'Superscript', icon: 'superscript', format: 'superscript' },
{ title: 'Subscript', icon: 'subscript', format: 'subscript' },
{ title: 'Code', icon: 'code', format: 'code' }
]
},
{
title: 'Blocks',
items: [
{ title: 'Paragraph', format: 'p' },
{ title: 'Blockquote', format: 'blockquote' }
]
},
{
title: 'Alignment',
items: [
{ title: 'Left', icon: 'alignleft', format: 'alignleft' },
{ title: 'Center', icon: 'aligncenter', format: 'aligncenter' },
{ title: 'Right', icon: 'alignright', format: 'alignright' },
{ title: 'Justify', icon: 'alignjustify', format: 'alignjustify' }
]
}
],
init_instance_callback: function(editor) {
SmartAnnotation.init($(editor.contentDocument.activeElement));
initHighlightjsIframe($(this.iframeElement).contents());
},
setup: function(editor) {
editor.on('keydown', function(e) {
if (e.keyCode === 13 && $(editor.contentDocument.activeElement).atwho('isSelecting')) {
return false;
}
return true;
});
editor.on('NodeChange', function(e) {
var node = e.element;
setTimeout(function() {
if ($(node).is('pre') && !editor.isHidden()) {
initHighlightjsIframe($(editor.iframeElement).contents());
}
}, 200);
});
editor.on('init', function() {
var editorForm = $(editor.getContainer()).closest('form');
var menuBar = editorForm.find('.mce-menubar.mce-toolbar.mce-first .mce-flow-layout');
// Init saved status label
if (editor.getContent() !== '') {
editorForm.find('.tinymce-status-badge').removeClass('hidden');
}
// Init Save button
editorForm
.find('.tinymce-save-button')
.clone()
.appendTo(menuBar)
.on('click', function(event) {
event.preventDefault();
editorForm.clearFormErrors();
editor.setProgressState(1);
editor.save();
editorForm.submit();
});
// After save action
editorForm
.on('ajax:success', function() {
editor.save();
editor.setProgressState(0);
editorForm.find('.tinymce-status-badge').removeClass('hidden');
$(editor.getContainer())
.find('.tinymce-save-button').addClass('hidden');
}).on('ajax:error', function(ev, data) {
var model = editor.getElement().dataset.objectType;
$(this).renderFormErrors(model, data.responseJSON);
editor.setProgressState(0);
});
// Init Cancel button
editorForm
.find('.tinymce-cancel-button')
.clone()
.appendTo(menuBar)
.on('click', function(event) {
event.preventDefault();
if (editor.isDirty()) {
editor.setContent($(selector).val());
}
editor.remove();
})
.removeClass('hidden');
});
editor.on('Dirty', function() {
var editorForm = $(editor.getContainer()).closest('form');
editorForm.find('.tinymce-status-badge').addClass('hidden');
$(editor.getContainer())
.find('.tinymce-save-button').removeClass('hidden');
});
editor.on('remove', function() {
var menuBar = $(editor.getContainer()).find('.mce-menubar.mce-toolbar.mce-first .mce-flow-layout');
menuBar.find('.tinymce-save-button').remove();
menuBar.find('.tinymce-cancel-button').remove();
});
},
codesample_content_css: $(selector).data('highlightjs-path')
});
}
},
destroyAll: function() {
_.each(tinyMCE.editors, function(editor) {
if (editor) {
editor.destroy();
initHighlightjs();
}
});
},
refresh: function() {
this.destroyAll();
this.init();
},
getContent: function() {
return tinyMCE.editors[0].getContent();
},
highlight: initHighlightjs
});
}());

View file

@ -1,108 +0,0 @@
var TinyMCE = (function() {
'use strict';
function initHighlightjs() {
$('[class*=language]').each(function(i, block) {
hljs.highlightBlock(block);
});
}
function initHighlightjsIframe(iframe) {
$('[class*=language]', iframe).each(function(i, block) {
hljs.highlightBlock(block);
});
}
// returns a public API for TinyMCE editor
return Object.freeze({
init : function() {
if (typeof tinyMCE != 'undefined') {
tinyMCE.init({
cache_suffix: '?v=4.7.13', // This suffix should be changed any time library is updated
selector: 'textarea.tinymce',
toolbar: ["undo redo | insert | styleselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link | forecolor backcolor | customimageuploader | codesample"],
plugins: "autoresize,customimageuploader,link,advlist,codesample,autolink,lists,charmap,hr,anchor,searchreplace,wordcount,visualblocks,visualchars,insertdatetime,nonbreaking,save,contextmenu,directionality,paste,textcolor,colorpicker,textpattern",
codesample_languages: [{"text":"R","value":"r"},{"text":"MATLAB","value":"matlab"},{"text":"Python","value":"python"},{"text":"JSON","value":"javascript"},{"text":"HTML/XML","value":"markup"},{"text":"JavaScript","value":"javascript"},{"text":"CSS","value":"css"},{"text":"PHP","value":"php"},{"text":"Ruby","value":"ruby"},{"text":"Java","value":"java"},{"text":"C","value":"c"},{"text":"C#","value":"csharp"},{"text":"C++","value":"cpp"}],
removed_menuitems: 'newdocument',
object_resizing: false,
elementpath: false,
forced_root_block: false,
default_link_target: '_blank',
target_list: [
{title: 'New page', value: '_blank'},
{title: 'Same page', value: '_self'}
],
style_formats: [
{title: 'Headers', items: [
{title: 'Header 1', format: 'h1'},
{title: 'Header 2', format: 'h2'},
{title: 'Header 3', format: 'h3'},
{title: 'Header 4', format: 'h4'},
{title: 'Header 5', format: 'h5'},
{title: 'Header 6', format: 'h6'}
]},
{title: 'Inline', items: [
{title: 'Bold', icon: 'bold', format: 'bold'},
{title: 'Italic', icon: 'italic', format: 'italic'},
{title: 'Underline', icon: 'underline', format: 'underline'},
{title: 'Strikethrough', icon: 'strikethrough', format: 'strikethrough'},
{title: 'Superscript', icon: 'superscript', format: 'superscript'},
{title: 'Subscript', icon: 'subscript', format: 'subscript'},
{title: 'Code', icon: 'code', format: 'code'}
]},
{title: 'Blocks', items: [
{title: 'Paragraph', format: 'p'},
{title: 'Blockquote', format: 'blockquote'}
]},
{title: 'Alignment', items: [
{title: 'Left', icon: 'alignleft', format: 'alignleft'},
{title: 'Center', icon: 'aligncenter', format: 'aligncenter'},
{title: 'Right', icon: 'alignright', format: 'alignright'},
{title: 'Justify', icon: 'alignjustify', format: 'alignjustify'}
]}
],
init_instance_callback: function(editor) {
SmartAnnotation.init($(editor.contentDocument.activeElement));
initHighlightjsIframe($(this.iframeElement).contents());
},
setup: function(editor) {
editor.on('keydown', function(e) {
if(e.keyCode == 13 && $(editor.contentDocument.activeElement).atwho('isSelecting')) {
return false;
}
});
editor.on('NodeChange', function(e) {
var node = e.element;
var editor = this;
setTimeout(function() {
if($(node).is('pre') && !editor.isHidden()){
initHighlightjsIframe($(editor.iframeElement).contents());
}
}, 200);
});
},
codesample_content_css: '<%= asset_path('highlightjs-github-theme') %>'
});
}
},
destroyAll: function() {
_.each(tinymce.editors, function(editor) {
if(editor) {
editor.destroy();
initHighlightjs();
}
});
},
refresh: function() {
this.destroyAll();
this.init();
},
getContent: function() {
return tinymce.editors[0].getContent();
},
highlight: initHighlightjs
});
})();

View file

@ -2,7 +2,20 @@
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/
@import 'constants';
@import "constants";
.tinymce-textarea {
border-color: $color-white;
&:hover {
border-color: $color-alto;
cursor: pointer;
}
}
.protocol-description-content {
margin-top: 20px;
}
// Protocols index page
.task-due-date,

View file

@ -0,0 +1,21 @@
@import "constants";
.tinymce-save-button,
.tinymce-cancel-button {
cursor: pointer;
.fas {
font-family: "Font Awesome 5 Free";
font-weight: 900;
margin-top: 3px;
}
}
.tinymce-status-badge {
color: $color-silver-chalice;
margin-top: -20px;
}
.tinymce-placeholder-text {
color: $color-silver-chalice;
}

View file

@ -15,7 +15,7 @@ class MyModulesController < ApplicationController
assign_repository_records unassign_repository_records
unassign_repository_records_modal
assign_repository_records_modal
repositories_dropdown)
repositories_dropdown update_protocol_description)
before_action :load_vars_nested, only: %i(new create)
before_action :load_repository, only: %i(assign_repository_records
unassign_repository_records
@ -25,7 +25,8 @@ class MyModulesController < ApplicationController
before_action :load_projects_tree, only: %i(protocols results activities
samples repository archive)
before_action :check_manage_permissions_archive, only: %i(update destroy)
before_action :check_manage_permissions, only: %i(description due_date)
before_action :check_manage_permissions,
only: %i(description due_date update_protocol_description)
before_action :check_view_permissions, only:
%i(show activities activities_tab protocols results samples samples_index
archive repositories_dropdown)
@ -254,6 +255,20 @@ class MyModulesController < ApplicationController
end
end
def update_protocol_description
protocol = @my_module.protocol
return render_404 unless protocol
respond_to do |format|
format.json do
if protocol.update(description: params.require(:protocol)[:description])
render json: { data: protocol }
else
render json: protocol.errors, status: :unprocessable_entity
end
end
end
end
def protocols
@protocol = @my_module.protocol
current_team_switch(@protocol.team)

View file

@ -270,7 +270,7 @@ module BootstrapFormHelper
# Returns <textarea> helper tag for tinyMCE editor
def tiny_mce_editor(name, options = {})
options.merge!(class: 'tinymce', cols: 120, rows: 15)
options.merge!(class: 'tinymce-textarea', cols: 120, rows: 10)
text_area(name, options)
end
end

View file

@ -0,0 +1,12 @@
<%= bootstrap_form_for @my_module, url: my_module_path(@my_module, format: :json), remote: :true do |f| %>
<%= render partial: 'shared/tiny_mce_extra_buttons.html.erb' %>
<%= f.tiny_mce_editor(:description,
id: :my_module_description_textarea,
hide_label: true,
value: sanitize_input(@my_module.description),
placeholder: t('my_modules.module_header.empty_description_edit_label'),
data: {
object_type: 'my_module',
object_id: @my_module.id,
highlightjs_path: asset_path('highlightjs-github-theme') } ) %>
<% end %>

View file

@ -63,32 +63,20 @@
</div>
</div>
<div>
<div class="badge-icon">
<% if can_manage_module?(@my_module) %>
<%= link_to description_my_module_path(@my_module, format: :json), remote: true, class: "description-link", style: "color: inherit" do %>
<span class="fas fa-info-circle"></span>
<% end %>
<% else %>
<span class="fas fa-info-circle"></span>
<% end %>
</div>
<div class="well well-sm">
<% if can_manage_module?(@my_module) %>
<%= link_to description_my_module_path(@my_module, format: :json), remote: true, class: "description-label description-link description-refresh", style: "color: inherit" do %>
<% if @my_module.description.present? and not @my_module.description.empty? %>
<%= @my_module.description %>
<% else %>
<em><%=t "my_modules.module_header.no_description" %></em>
<% end %>
<% end %>
<% else %>
<% if @my_module.description.present? and not @my_module.description.empty? %>
<%= @my_module.description %>
<% else %>
<em><%=t "my_modules.module_header.no_description" %></em>
<% end %>
<div class="row">
<div class="col-xs-12">
<h4>
<%= t('my_modules.module_header.description_label') %>
</h4>
<div class="my-module-description-content">
<% if can_manage_module?(@my_module) %>
<%= render partial: "description_form" %>
<% elsif @my_module.description.present? %>
<%= sanitize_input(@my_module.description) %>
<% else %>
<%= t('my_modules.module_header.no_description') %>
<% end %>
</div>
</div>
</div>

View file

@ -16,6 +16,10 @@
<div class="content-pane">
<%= render partial: "module_header" %>
<h2>
<%= t('Protocol') %>
</h2>
<div>
<div data-role="protocol-status-bar" style="display: inline;">
<%= render partial: "my_modules/protocols/protocol_status_bar.html.erb" %>
@ -24,6 +28,20 @@
<%= render partial: "my_modules/state_buttons.html.erb" %>
</div>
<div class="row">
<div class="col-xs-12">
<div class="protocol-description-content">
<% if can_manage_module?(@my_module) %>
<%= render partial: "my_modules/protocols/protocol_description_form" %>
<% elsif @my_module.protocol.description.present? %>
<%= sanitize_input(@my_module.protocol.description) %>
<% else %>
<%= t('my_modules.protocols.protocol_status_bar.no_description') %>
<% end %>
</div>
</div>
</div>
<div data-role="steps-container">
<%= render partial: "protocols/steps.html.erb" %>
</div>

View file

@ -0,0 +1,12 @@
<%= bootstrap_form_for @my_module.protocol, url: update_protocol_description_my_module_path(@my_module, format: :json), remote: :true do |f| %>
<%= render partial: 'shared/tiny_mce_extra_buttons.html.erb' %>
<%= f.tiny_mce_editor(:description,
id: :protocol_description_textarea,
hide_label: true,
value: sanitize_input(@my_module.protocol.description),
placeholder: t('my_modules.protocols.protocol_status_bar.empty_description_edit_label'),
data: {
object_type: 'protocol',
object_id: @my_module.protocol.id,
highlightjs_path: asset_path('highlightjs-github-theme') } ) %>
<% end %>

View file

@ -0,0 +1,16 @@
<div class="hidden tinymce-cancel-button mce-widget mce-btn mce-menubtn mce-flow-layout-item mce-btn-has-text pull-right" tabindex="-1">
<button type="button" tabindex="-1">
<span class="fas fa-times"></span>
<span class="mce-txt"><%= t('general.cancel') %></span>
</button>
</div>
<div class="hidden tinymce-save-button mce-widget mce-btn mce-menubtn mce-flow-layout-item mce-btn-has-text mce-last pull-right" tabindex="-1">
<button type="button" tabindex="-1" >
<span class="fas fa-check"></span>
<span class="mce-txt"><%= t('general.save') %></span>
</button>
</div>
<div class="hidden tinymce-status-badge pull-right">
<i class="fas fa-check-circle"></i>
<span><%= t('tiny_mce.saved_label') %></span>
</div>

View file

@ -600,6 +600,8 @@ en:
no_tags: "click here to add Task Tags (optional)"
manage_tags: Manage tags
no_description: "No description"
description_label: "Description"
empty_description_edit_label: "Click here to enter Task Description (optional)"
protocols:
head_title: "%{project} | %{module} | Protocols"
protocol_status_bar:
@ -611,6 +613,7 @@ en:
added_by_tooltip: "Added at %{ts}"
private_protocol_desc: "The parent protocol is private. Only its author can manage it."
no_description: "no description"
empty_description_edit_label: "Click here to enter Protocol Description (optional)"
keywords: "Keywords"
no_keywords: "no keywords"
btns:
@ -1971,6 +1974,7 @@ en:
insert_btn: 'Insert'
error_message: 'You must choose a file'
server_not_respond: "Didn't get a response from the server"
saved_label: "Saved"
general:
save: "Save"
update: "Update"

View file

@ -363,6 +363,9 @@ Rails.application.routes.draw do
get 'activities'
get 'activities_tab' # Activities in tab view for single module
get 'due_date'
patch 'protocol_description',
to: 'my_modules#update_protocol_description',
as: 'update_protocol_description'
get 'protocols' # Protocols view for single module
get 'results' # Results view for single module
# get 'samples' # Samples view for single module