Merge pull request #2923 from biosistemika/release/1.20.2

Release/1.20.2
This commit is contained in:
Miha Mencin 2020-10-28 12:29:58 +01:00 committed by GitHub
commit f493837399
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 824 additions and 416 deletions

View file

@ -37,6 +37,7 @@ gem 'jsonapi-renderer', '~> 0.2.2'
gem 'jwt', '~> 1.5'
gem 'kaminari'
gem 'rack-attack'
gem 'rack-cors'
# JS datetime library, requirement of datetime picker
gem 'momentjs-rails', '~> 2.17.1'

View file

@ -410,6 +410,8 @@ GEM
rack (2.2.3)
rack-attack (6.1.0)
rack (>= 1.0, < 3)
rack-cors (1.1.1)
rack (>= 2.0.0)
rack-proxy (0.6.5)
rack
rack-test (1.1.0)
@ -668,6 +670,7 @@ DEPENDENCIES
pry-rails
puma
rack-attack
rack-cors
rails (~> 6.0.0)
rails-controller-testing
rails_12factor

View file

@ -1 +1 @@
1.20.1
1.20.2

View file

@ -0,0 +1,6 @@
(function() {
// Show button only on mobile devices
if ('ontouchstart' in window) {
$('.open-mobile-app-container').show();
}
}());

View file

@ -453,7 +453,8 @@ var MyModuleRepositories = (function() {
FULL_VIEW_MODAL.on('show.bs.modal', function() {
FULL_VIEW_MODAL.find('.table-container').empty();
FULL_VIEW_MODAL.find('.repository-name').empty();
FULL_VIEW_MODAL.find('.repository-title').empty();
FULL_VIEW_MODAL.find('.repository-version').empty();
updateFullViewRowsCount('');
});
}
@ -518,29 +519,31 @@ var MyModuleRepositories = (function() {
function updateFullViewRowsCount(value) {
FULL_VIEW_MODAL.data('rows-count', value);
FULL_VIEW_MODAL.find('.repository-name').attr('data-rows-count', value);
FULL_VIEW_MODAL.find('.repository-version').attr('data-rows-count', value);
}
function renderFullViewRepositoryName(name, snapshotDate, assignMode) {
var title;
var repositoryName = name || FULL_VIEW_MODAL.find('.repository-name').data('repository-name');
var version;
var repositoryName = name || FULL_VIEW_MODAL.find('.repository-title').data('repository-name');
if (assignMode) {
title = I18n.t('my_modules.repository.full_view.assign_modal_header', {
repository_name: repositoryName
});
version = '';
} else if (snapshotDate) {
title = I18n.t('my_modules.repository.full_view.modal_snapshot_header', {
repository_name: repositoryName,
title = repositoryName;
version = I18n.t('my_modules.repository.full_view.modal_snapshot_header', {
snaphot_date: snapshotDate
});
} else {
title = I18n.t('my_modules.repository.full_view.modal_live_header', {
repository_name: repositoryName
});
title = repositoryName;
version = I18n.t('my_modules.repository.full_view.modal_live_header');
}
FULL_VIEW_MODAL.find('.repository-name').data('repository-name', repositoryName);
FULL_VIEW_MODAL.find('.repository-name').html(title);
FULL_VIEW_MODAL.find('.repository-title').data('repository-name', repositoryName);
FULL_VIEW_MODAL.find('.repository-title').html(title);
FULL_VIEW_MODAL.find('.repository-version').html(version);
}
function initRepoistoryAssignView() {

View file

@ -5,21 +5,9 @@
@include font-h3;
line-height: 22px;
overflow: hidden;
padding-right: 55px;
position: relative;
text-overflow: ellipsis;
white-space: nowrap;
&::after {
color: $color-alto;
content: '[' attr(data-rows-count) ']';
display: inline-block;
line-height: 22px;
padding-left: 5px;
position: absolute;
right: 0;
width: 55px;
}
}
.my-module-inventories {
@ -131,6 +119,16 @@
.assigned-repository-title {
@include my-module-repository-title;
padding-right: 2.2em;
&::after {
color: $color-alto;
content: '[' attr(data-rows-count) ']';
display: inline-block;
padding-right: .7em;
position: absolute;
right: 0;
}
}
.action-buttons {
@ -218,11 +216,26 @@
flex-grow: 1;
max-width: calc(100% - 20px);
.repository-name {
.repository-name-container {
display: flex;
}
.repository-title {
@include my-module-repository-title;
@include font-h2;
}
.repository-version {
@include font-h2;
flex-shrink: 0;
padding-right: .7em;
&::after {
color: $color-alto;
content: '[' attr(data-rows-count) ']';
display: inline-block;
width: 100%;
padding-left: .3em;
}
}
.breadcrumbs {

View file

@ -106,6 +106,30 @@
}
}
&.btn-light-link {
background: transparent;
border: $border-transparent;
color: $brand-primary;
&:hover {
background: $color-concrete;
border: $border-transparent;
color: $brand-primary;
}
&:active,
&.active {
background: $color-alto;
border: $border-transparent;
color: $brand-primary;
}
&:focus {
box-shadow: 0 0 0 1px $brand-focus;
color: $brand-primary;
}
}
&.btn-danger {
background: $brand-danger;
border: $border-danger;
@ -128,6 +152,28 @@
}
}
&.btn-dark-background {
background: $color-white;
border: $border-default;
color: $brand-primary;
&:hover {
background: $color-concrete;
color: $brand-primary-hover;
}
&:active,
&.active {
background: $color-alto;
color: $brand-primary-press;
}
&:focus {
box-shadow: 0 0 0 1px $brand-focus;
color: $brand-primary;
}
}
&.icon-btn {
padding: 7px;
width: 36px;
@ -137,6 +183,12 @@
}
}
&.btn-large {
font-size: $font-size-h2;
height: 3.1em;
padding: 1em 3em;
}
&.disabled,
&:disabled {
color: $color-silver-chalice;
@ -155,7 +207,9 @@
}
&.btn-secondary,
&.btn-light {
&.btn-light,
&.btn-light-link,
&.btn-dark-background {
background: $color-white;
&:hover {

View file

@ -46,13 +46,21 @@
.navbar-default .navbar-brand {
align-items: center;
display: flex;
padding: 0 15px;
padding: 0 .3em 0 .8em;
#logo {
max-height: 22px;
}
}
.open-mobile-app-container {
display: none;
}
.open-mobile-app-button {
margin-top: 8px;
}
.dropdown-notifications {
max-height: 500px;
overflow-x: hidden;
@ -306,7 +314,7 @@
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
width: 250px;
width: 230px;
}
.btn-group {

View file

@ -1784,7 +1784,7 @@ a.disabled-with-click-events {
margin-bottom: 45px;
.doorkeeper-scinote-logo {
background-image: asset-url("/images/scinote_icon.jpg");
background-image: asset-url("/images/doorkeeper_auth.png");
background-position: center center;
background-repeat: no-repeat;
background-size: contain;

View file

@ -3,6 +3,10 @@
# The base controller for all ActiveStorage controllers.
module ActiveStorage
class CustomBaseController < ApplicationController
include TokenAuthentication
include ActiveStorage::SetCurrent
prepend_before_action :authenticate_request!, if: -> { request.headers['Authorization'].present? }
skip_before_action :authenticate_user!, if: -> { current_user.present? }
end
end

View file

@ -2,8 +2,8 @@
module Api
class ApiController < ActionController::API
attr_reader :iss
attr_reader :token
include TokenAuthentication
attr_reader :current_user
before_action :authenticate_request!, except: %i(status health)
@ -53,45 +53,5 @@ module Api
end
render json: response, status: :ok
end
private
def azure_jwt_auth
return unless iss =~ %r{windows.net/|microsoftonline.com/}
token_payload, = Api::AzureJwt.decode(token)
@current_user = User.from_azure_jwt_token(token_payload)
unless current_user
raise JWT::InvalidPayload, I18n.t('api.core.no_azure_user_mapping')
end
end
def authenticate_request!
@token = request.headers['Authorization']&.sub('Bearer ', '')
unless @token
raise JWT::VerificationError, I18n.t('api.core.missing_token')
end
@iss = CoreJwt.read_iss(token)
raise JWT::InvalidPayload, I18n.t('api.core.no_iss') unless @iss
Extends::API_PLUGABLE_AUTH_METHODS.each do |auth_method|
method(auth_method).call
return true if current_user
end
# Default token implementation
unless iss == Rails.configuration.x.core_api_token_iss
raise JWT::InvalidPayload, I18n.t('api.core.wrong_iss')
end
payload = CoreJwt.decode(token)
@current_user = User.find_by_id(payload['sub'])
unless current_user
raise JWT::InvalidPayload, I18n.t('api.core.no_user_mapping')
end
end
def auth_params
params.permit(:grant_type, :email, :password)
end
end
end

View file

@ -3,6 +3,8 @@
module Api
module V1
class ChecklistItemsController < BaseController
include ApplicationHelper
before_action :load_team, :load_project, :load_experiment, :load_task, :load_protocol, :load_step, :load_checklist
before_action only: :show do
load_checklist_item(:id)
@ -31,6 +33,23 @@ module Api
@checklist_item.assign_attributes(checklist_item_params)
if @checklist_item.changed? && @checklist_item.save!
if @checklist_item.saved_change_to_attribute?(:checked)
completed_items = @checklist_item.checklist.checklist_items.where(checked: true).count
all_items = @checklist_item.checklist.checklist_items.count
text_activity = smart_annotation_parser(@checklist_item.text).gsub(/\s+/, ' ')
type_of = if @checklist_item.saved_change_to_attribute(:checked).last
:check_step_checklist_item
else
:uncheck_step_checklist_item
end
log_activity(type_of,
my_module: @task.id,
step: @step.id,
step_position: { id: @step.id, value_for: 'position_plus_one' },
checkbox: text_activity,
num_completed: completed_items.to_s,
num_all: all_items.to_s)
end
render jsonapi: @checklist_item, serializer: ChecklistItemSerializer, status: :ok
else
render body: nil, status: :no_content
@ -54,6 +73,18 @@ module Api
@checklist_item = @checklist.checklist_items.find(params.require(:id))
raise PermissionError.new(Protocol, :manage) unless can_manage_protocol_in_module?(@protocol)
end
def log_activity(type_of, message_items = {})
default_items = { step: @step.id, step_position: { id: @step.id, value_for: 'position_plus_one' } }
message_items = default_items.merge(message_items)
Activities::CreateActivityService.call(activity_type: type_of,
owner: current_user,
subject: @protocol,
team: @team,
project: @project,
message_items: message_items)
end
end
end
end

View file

@ -41,6 +41,14 @@ module Api
@step.assign_attributes(step_params)
if @step.changed? && @step.save!
if @step.saved_change_to_attribute?(:completed)
completed_steps = @protocol.steps.where(completed: true).count
all_steps = @protocol.steps.count
type_of = @step.saved_change_to_attribute(:completed).last ? :complete_step : :uncomplete_step
log_activity(type_of, my_module: @task.id,
num_completed: completed_steps.to_s,
num_all: all_steps.to_s)
end
render jsonapi: @step, serializer: StepSerializer, status: :ok
else
render body: nil, status: :no_content
@ -68,6 +76,18 @@ module Api
@step = @protocol.steps.find(params.require(:id))
raise PermissionError.new(Protocol, :manage) unless can_manage_protocol_in_module?(@step.protocol)
end
def log_activity(type_of, message_items = {})
default_items = { step: @step.id, step_position: { id: @step.id, value_for: 'position_plus_one' } }
message_items = default_items.merge(message_items)
Activities::CreateActivityService.call(activity_type: type_of,
owner: current_user,
subject: @protocol,
team: @team,
project: @project,
message_items: message_items)
end
end
end
end

View file

@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base
acts_as_token_authentication_handler_for User
acts_as_token_authentication_handler_for User, unless: -> { current_user.present? }
# Prevent CSRF attacks by raising an exception.
# For APIs, you may want to use :null_session instead.
protect_from_forgery with: :exception, prepend: true

View file

@ -0,0 +1,37 @@
# frozen_string_literal: true
module TokenAuthentication
extend ActiveSupport::Concern
private
def azure_jwt_auth
return unless @token_iss.match?(%r{windows.net/|microsoftonline.com/})
token_payload, = Api::AzureJwt.decode(@token)
@current_user = User.from_azure_jwt_token(token_payload)
raise JWT::InvalidPayload, I18n.t('api.core.no_azure_user_mapping') unless current_user
end
def authenticate_request!
@token = request.headers['Authorization']&.sub('Bearer ', '')
raise JWT::VerificationError, I18n.t('api.core.missing_token') unless @token
@token_iss = Api::CoreJwt.read_iss(@token)
raise JWT::InvalidPayload, I18n.t('api.core.no_iss') unless @token_iss
Extends::API_PLUGABLE_AUTH_METHODS.each do |auth_method|
method(auth_method).call
return true if current_user
end
# Default token implementation
unless @token_iss == Rails.configuration.x.core_api_token_iss
raise JWT::InvalidPayload, I18n.t('api.core.wrong_iss')
end
payload = Api::CoreJwt.decode(@token)
@current_user = User.find_by(id: payload['sub'])
raise JWT::InvalidPayload, I18n.t('api.core.no_user_mapping') unless current_user
end
end

18
app/helpers/pwa_helper.rb Normal file
View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
module PwaHelper
def pwa_mobile_app_url(team_id, project_id, experiment_id, task_id, protocol_id, step_id, domain)
url = Constants::PWA_URL.dup
{
':pwa_domain' => Rails.configuration.x.pwa_domain,
':team_id' => team_id,
':project_id' => project_id,
':experiment_id' => experiment_id,
':task_id' => task_id,
':protocol_id' => protocol_id,
':step_id' => step_id,
':domain' => domain
}.each { |k, v| url.gsub!(k, v.to_s) }
url
end
end

View file

@ -10,10 +10,16 @@ module MyModuleStatusConsequences
my_module.assigned_repositories.each do |repository|
repository_snapshot = ::RepositorySnapshot.create_preliminary(repository, my_module)
service = Repositories::SnapshotProvisioningService.call(repository_snapshot: repository_snapshot)
unless service.succeed?
repository_snapshot.failed!
raise StandardError, service.errors
end
snapshot = service.repository_snapshot
unless snapshot.my_module.repository_snapshots.where(parent_id: snapshot.parent_id).find_by(selected: true)
snapshot.update!(selected: true)
end
end
end
end

View file

@ -364,6 +364,10 @@ class Protocol < ApplicationRecord
steps.where(completed: true)
end
def first_step_id
steps.find_by(position: 0)&.id
end
def space_taken
st = 0
steps.find_each do |step|

View file

@ -75,6 +75,8 @@ class RepositoryChecklistValue < ApplicationRecord
end
def self.import_from_text(text, attributes, _options = {})
return nil if text.blank?
value = new(attributes)
column = attributes.dig(:repository_cell_attributes, :repository_column)
RepositoryImportParser::Util.split_by_delimiter(text: text, delimiter: column.delimiter_char).each do |item_text|

View file

@ -66,6 +66,8 @@ class RepositoryListValue < ApplicationRecord
end
def self.import_from_text(text, attributes, _options = {})
return nil if text.blank?
value = new(attributes)
column = attributes.dig(:repository_cell_attributes, :repository_column)
list_item = column.repository_list_items.find { |item| item.data == text }

View file

@ -34,53 +34,5 @@ class Reports::Docx
end
@docx
end
def self.link_prepare(scinote_url, link)
link[0] == '/' ? scinote_url + link : link
end
def self.render_p_element(docx, element, options = {})
scinote_url = options[:scinote_url]
link_style = options[:link_style]
docx.p do
element[:children].each do |text_el|
if text_el[:type] == 'text'
style = text_el[:style] || {}
text text_el[:value], style
text ' ' if text_el[:value] != ''
elsif text_el[:type] == 'br' && !options[:skip_br]
br
elsif text_el[:type] == 'a'
if text_el[:link]
link_url = Reports::Docx.link_prepare(scinote_url, text_el[:link])
link text_el[:value], link_url, link_style
else
text text_el[:value], link_style
end
text ' ' if text_el[:value] != ''
end
end
end
end
def self.render_img_element(docx, element, options = {})
style = element[:style]
if options[:table]
max_width = (style[:max_width] / options[:table][:columns].to_f)
if style[:width] > max_width
style[:height] = (max_width / style[:width].to_f) * style[:height]
style[:width] = max_width
end
end
docx.img element[:data] do
data element[:blob].download
width style[:width]
height style[:height]
align style[:align] || :left
end
end
end
# rubocop:enable Style/ClassAndModuleChildren

View file

@ -22,7 +22,8 @@ module Reports::Docx::DrawExperiment
link_style
end
html = custom_auto_link(experiment.description, team: @report_team)
html_to_word_converter(html)
Reports::HtmlToWordConverter.new(@docx, { scinote_url: scinote_url,
link_style: link_style }).html_to_word_converter(html)
@docx.p
subject['children'].each do |child|
public_send("draw_#{child['type_of']}", child, experiment)

View file

@ -66,7 +66,8 @@ module Reports::Docx::DrawMyModule
if my_module.description.present?
html = custom_auto_link(my_module.description, team: @report_team)
html_to_word_converter(html)
Reports::HtmlToWordConverter.new(@docx, { scinote_url: scinote_url,
link_style: link_style }).html_to_word_converter(html)
else
@docx.p I18n.t('projects.reports.elements.module.no_description')
end

View file

@ -20,7 +20,7 @@ module Reports::Docx::DrawMyModuleActivity
sanitize_input(generate_activity_content(activity, true))
end
@docx.p I18n.l(activity_ts, format: :full), color: color[:gray]
html_to_word_converter(activity_text)
Reports::HtmlToWordConverter.new(@docx).html_to_word_converter(activity_text)
@docx.p
end
end

View file

@ -11,7 +11,7 @@ module Reports::Docx::DrawMyModuleProtocol
timestamp: I18n.l(protocol.created_at, format: :full)
@docx.hr
html = custom_auto_link(protocol.description, team: @report_team)
html_to_word_converter(html)
Reports::HtmlToWordConverter.new(@docx).html_to_word_converter(html)
@docx.p
@docx.p
end

View file

@ -17,7 +17,7 @@ module Reports::Docx::DrawResultAsset
user: result.user.full_name, timestamp: I18n.l(timestamp, format: :full)), color: color[:gray]
end
asset_image_preparing(asset) if asset.image?
Reports::DocxRenderer.render_asset_image(@docx, asset) if asset.image?
subject['children'].each do |child|
public_send("draw_#{child['type_of']}", child, result)

View file

@ -17,7 +17,8 @@ module Reports::Docx::DrawResultComments
date: I18n.l(comment_ts, format: :full_date),
time: I18n.l(comment_ts, format: :time)), italic: true
html = custom_auto_link(comment.message, team: @report_team)
html_to_word_converter(html)
Reports::HtmlToWordConverter.new(@docx, { scinote_url: @scinote_url,
link_style: @link_style }).html_to_word_converter(html)
@docx.p
end
end

View file

@ -17,7 +17,8 @@ module Reports::Docx::DrawResultText
timestamp: I18n.l(timestamp, format: :full), user: result.user.full_name), color: color[:gray]
end
html = custom_auto_link(result_text.text, team: @report_team)
html_to_word_converter(html)
Reports::HtmlToWordConverter.new(@docx, { scinote_url: @scinote_url,
link_style: @link_style }).html_to_word_converter(html)
subject['children'].each do |child|
public_send("draw_#{child['type_of']}", child, result)

View file

@ -27,7 +27,8 @@ module Reports::Docx::DrawStep
end
if step.description.present?
html = custom_auto_link(step.description, team: @report_team)
html_to_word_converter(html)
Reports::HtmlToWordConverter.new(@docx, { scinote_url: @scinote_url,
link_style: @link_style }).html_to_word_converter(html)
else
@docx.p I18n.t 'projects.reports.elements.step.no_description'
end

View file

@ -15,6 +15,6 @@ module Reports::Docx::DrawStepAsset
timestamp: I18n.l(timestamp, format: :full)), color: color[:gray]
end
asset_image_preparing(asset) if asset.image?
Reports::DocxRenderer.render_asset_image(@docx, asset) if asset.image?
end
end

View file

@ -17,7 +17,8 @@ module Reports::Docx::DrawStepComments
date: I18n.l(comment_ts, format: :full_date),
time: I18n.l(comment_ts, format: :time)), italic: true
html = custom_auto_link(comment.message, team: @report_team)
html_to_word_converter(html)
Reports::HtmlToWordConverter.new(@docx, { scinote_url: @scinote_url,
link_style: @link_style }).html_to_word_converter(html)
@docx.p
end
end

View file

@ -3,222 +3,6 @@
module Reports::Docx::PrivateMethods
private
# RTE fields support
def html_to_word_converter(text)
html = Nokogiri::HTML(text)
raw_elements = recursive_children(html.css('body').children, [])
# Combined raw text blocks in paragraphs
elements = combine_docx_elements(raw_elements)
# Draw elements
elements.each do |elem|
if elem[:type] == 'p'
Reports::Docx.render_p_element(@docx, elem, scinote_url: @scinote_url, link_style: @link_style)
elsif elem[:type] == 'table'
tiny_mce_table(elem[:data])
elsif elem[:type] == 'newline'
style = elem[:style] || {}
# print heading if its heading
# Mixing heading with other style setting causes problems for Word
if %w(h1 h2 h3 h4 h5).include?(style[:style])
@docx.public_send(style[:style], elem[:value])
else
@docx.p elem[:value] do
align style[:align]
color style[:color]
bold style[:bold]
italic style[:italic]
end
end
elsif elem[:type] == 'image'
Reports::Docx.render_img_element(@docx, elem)
end
end
end
def combine_docx_elements(raw_elements)
elements = []
temp_p = []
raw_elements.each do |elem|
if %w(image newline table).include? elem[:type]
unless temp_p.empty?
elements.push(type: 'p', children: temp_p)
temp_p = []
end
elements.push(elem)
elsif %w(br text a).include? elem[:type]
temp_p.push(elem)
end
end
elements.push(type: 'p', children: temp_p)
elements
end
# Convert HTML structure to plain text structure
def recursive_children(children, elements, options = {})
children.each do |elem|
if elem.class == Nokogiri::XML::Text
next if elem.text.strip == ' ' # Invisible symbol
style = paragraph_styling(elem.parent)
type = (style[:align] && style[:align] != :justify) || style[:style] ? 'newline' : 'text'
text = smart_annotation_check(elem)
elements.push(
type: type,
value: text.strip.delete(' '), # Invisible symbol
style: style
)
next
end
if elem.name == 'br'
elements.push(type: 'br')
next
end
if elem.name == 'img' && elem.attributes['data-mce-token']
image = TinyMceAsset.find_by(id: Base62.decode(elem.attributes['data-mce-token'].value))
next unless image
image_path = image_path(image.image)
dimension = FastImage.size(image_path)
next unless dimension
style = image_styling(elem, dimension)
elements.push(
type: 'image',
data: image_path.split('&')[0],
blob: image.blob,
style: style
)
next
end
if elem.name == 'a'
elements.push(link_element(elem))
next
end
if elem.name == 'table'
elem = tiny_mce_table(elem, nested_table: true) if options[:nested_tables]
elements.push(
type: 'table',
data: elem
)
next
end
elements = recursive_children(elem.children, elements) if elem.children
end
elements
end
def link_element(elem)
text = elem.text
link = elem.attributes['href'].value if elem.attributes['href']
if elem.attributes['class']&.value == 'record-info-link'
link = nil
text = "##{text}"
end
text = "##{text}" if elem.parent.attributes['class']&.value == 'atwho-inserted'
text = "@#{text}" if elem.attributes['class']&.value == 'atwho-user-popover'
{
type: 'a',
value: text,
link: link
}
end
def smart_annotation_check(elem)
return "[#{elem.text}]" if elem.parent.attributes['class']&.value == 'sa-type'
elem.text
end
# Prepare style for text
def paragraph_styling(elem)
style = elem.attributes['style']
result = {}
result[:style] = elem.name if elem.name.include? 'h'
result[:bold] = true if elem.name == 'strong'
result[:italic] = true if elem.name == 'em'
style_keys = %w(text-align color)
if style
style_keys.each do |key|
style_el = style.value.split(';').select { |i| (i.include? key) }[0]
next unless style_el
value = style_el.split(':')[1].strip if style_el
if key == 'text-align'
result[:align] = value.to_sym
elsif key == 'color' && calculate_color_hsp(value) < 190
result[:color] = value.delete('#')
end
end
end
result
end
# Prepare style for images
def image_styling(elem, dimension)
dimension[0] = elem.attributes['width'].value.to_i if elem.attributes['width']
dimension[1] = elem.attributes['height'].value.to_i if elem.attributes['height']
if elem.attributes['style']
align = if elem.attributes['style'].value.include? 'margin-right'
:center
elsif elem.attributes['style'].value.include? 'float: right'
:right
else
:left
end
end
margins = Constants::REPORT_DOCX_MARGIN_LEFT + Constants::REPORT_DOCX_MARGIN_RIGHT
max_width = (Constants::REPORT_DOCX_WIDTH - margins) / 20
if dimension[0] > max_width
x = max_width
y = dimension[1] * max_width / dimension[0]
else
x = dimension[0]
y = dimension[1]
end
{
width: x,
height: y,
align: align,
max_width: max_width
}
end
def asset_image_preparing(asset)
return unless asset
image_path = image_path(asset.file)
dimension = FastImage.size(image_path)
x = dimension[0]
y = dimension[1]
if x > 300
y = y * 300 / x
x = 300
end
@docx.img image_path.split('&')[0] do
data asset.blob.download
width x
height y
end
end
def initial_document_load
@docx.page_size do
width Constants::REPORT_DOCX_WIDTH
@ -269,60 +53,4 @@ module Reports::Docx::PrivateMethods
green: '2dbe61'
}
end
def tiny_mce_table(table_data, options = {})
docx_table = []
scinote_url = @scinote_url
link_style = @link_style
table_data.css('tbody').first.children.each do |row|
docx_row = []
next unless row.name == 'tr'
row.children.each do |cell|
next unless cell.name == 'td'
# Parse cell content
formated_cell = recursive_children(cell.children, [], nested_tables: true)
# Combine text elements to single paragraph
formated_cell = combine_docx_elements(formated_cell)
docx_cell = Caracal::Core::Models::TableCellModel.new do |c|
formated_cell.each do |cell_content|
if cell_content[:type] == 'p'
Reports::Docx.render_p_element(c, cell_content,
scinote_url: scinote_url, link_style: link_style, skip_br: true)
elsif cell_content[:type] == 'table'
c.table formated_cell_content[:data], border_size: Constants::REPORT_DOCX_TABLE_BORDER_SIZE
elsif cell_content[:type] == 'image'
Reports::Docx.render_img_element(c, cell_content, table: { columns: row.children.length / 3 })
end
end
end
docx_row.push(docx_cell)
end
docx_table.push(docx_row)
end
if options[:nested_table]
docx_table
else
@docx.table docx_table, border_size: Constants::REPORT_DOCX_TABLE_BORDER_SIZE
end
end
def image_path(attachment)
attachment.service_url
end
def calculate_color_hsp(color)
return 255 if color.length != 7
color = color.delete('#').scan(/.{1,2}/)
rgb = color.map(&:hex)
Math.sqrt(
0.299 * (rgb[0]**2) +
0.587 * (rgb[1]**2) +
0.114 * (rgb[2]**2)
)
end
end

View file

@ -0,0 +1,162 @@
# frozen_string_literal: true
module Reports
class DocxRenderer
def self.render_p_element(docx, element, options = {})
docx.p do
element[:children].each do |text_el|
if text_el[:type] == 'text'
style = text_el[:style] || {}
text text_el[:value], style
text ' ' if text_el[:value] != ''
elsif text_el[:type] == 'br' && !options[:skip_br]
br
elsif text_el[:type] == 'a'
Reports::DocxRenderer.render_link_element(self, text_el, options)
end
end
end
end
def self.render_link_element(node, link_item, options = {})
scinote_url = options[:scinote_url]
link_style = options[:link_style]
if link_item[:link]
link_url = Reports::Utils.link_prepare(scinote_url, link_item[:link])
node.link link_item[:value], link_url, link_style
else
node.text link_item[:value], link_style
end
node.text ' ' if link_item[:value] != ''
end
def self.render_img_element(docx, element, options = {})
style = element[:style]
if options[:table]
max_width = (style[:max_width] / options[:table][:columns].to_f)
if style[:width] > max_width
style[:height] = (max_width / style[:width].to_f) * style[:height]
style[:width] = max_width
end
end
docx.img element[:data] do
data element[:blob].download
width style[:width]
height style[:height]
align style[:align] || :left
end
end
def self.render_list_element(docx, element, options = {})
bookmark_items = Reports::DocxRenderer.recursive_list_items_renderer(docx, element)
bookmark_items.each_with_index do |(key, item), index|
if item[:type] == 'image'
docx.bookmark_start id: index, name: key
docx.p do
br
text item[:blob]&.filename.to_s
end
Reports::DocxRenderer.render_img_element(docx, item)
docx.bookmark_end id: index
elsif item[:type] == 'table'
docx.bookmark_start id: index, name: key
# Bookmark won't work with table only, empty p element added
docx.p do
br
text ''
end
Reports::DocxRenderer.render_table_element(docx, item, options)
docx.bookmark_end id: index
end
end
end
# rubocop:disable Metrics/BlockLength
def self.recursive_list_items_renderer(node, element, bookmark_items: {})
node.public_send(element[:type]) do
element[:data].each do |values_array|
li do
values_array.each do |item|
case item
when Hash
if %w(ul ol li).include?(item[:type])
Reports::DocxRenderer.recursive_list_items_renderer(self, item, bookmark_items: bookmark_items)
elsif %w(a).include?(item[:type])
Reports::DocxRenderer.render_link_element(self, item)
elsif %w(image).include?(item[:type])
bookmark_items[item[:bookmark_id]] = item
link I18n.t('projects.reports.renderers.lists.appended_image',
name: item[:blob]&.filename), item[:bookmark_id] do
internal true
end
elsif %w(table).include?(item[:type])
bookmark_items[item[:bookmark_id]] = item
link I18n.t('projects.reports.renderers.lists.appended_table'), item[:bookmark_id] do
internal true
end
elsif %w(text).include?(item[:type])
# TODO: Text with styles, not working yet.
style = item[:style] || {}
text item[:value], style
end
else
text item
end
end
end
end
end
bookmark_items
end
# rubocop:enable Metrics/BlockLength
def self.render_table_element(docx, element, options = {})
docx_table = []
element[:data].each do |row|
docx_row = []
row[:data].each do |cell|
docx_cell = Caracal::Core::Models::TableCellModel.new do |c|
cell.each do |content|
if content[:type] == 'p'
Reports::DocxRenderer.render_p_element(c, content, options.merge({ skip_br: true }))
elsif content[:type] == 'table'
Reports::DocxRenderer.render_table_element(c, content, options)
elsif content[:type] == 'image'
Reports::DocxRenderer.render_img_element(c, content, table: { columns: row.children.length / 3 })
end
end
end
docx_row.push(docx_cell)
end
docx_table.push(docx_row)
end
docx.table docx_table, border_size: Constants::REPORT_DOCX_TABLE_BORDER_SIZE
end
def self.render_asset_image(docx, asset)
return unless asset
image_path = Reports::Utils.image_path(asset.file)
dimension = FastImage.size(image_path)
return unless dimension
x = dimension[0]
y = dimension[1]
if x > 300
y = y * 300 / x
x = 300
end
docx.img image_path.split('&')[0] do
data asset.blob.download
width x
height y
end
end
end
end

View file

@ -0,0 +1,259 @@
# frozen_string_literal: true
module Reports
class HtmlToWordConverter
def initialize(document, options = {})
@docx = document
@scinote_url = options[:scinote_url]
@link_style = options[:link_style]
end
def html_to_word_converter(text)
html = Nokogiri::HTML(text)
raw_elements = recursive_children(html.css('body').children, []).compact
# Combined raw text blocks in paragraphs
elements = combine_docx_elements(raw_elements)
# Draw elements
elements.each do |elem|
if elem[:type] == 'p'
Reports::DocxRenderer.render_p_element(@docx, elem, scinote_url: @scinote_url, link_style: @link_style)
elsif elem[:type] == 'table'
Reports::DocxRenderer.render_table_element(@docx, elem)
elsif elem[:type] == 'newline'
style = elem[:style] || {}
# print heading if its heading
# Mixing heading with other style setting causes problems for Word
if %w(h1 h2 h3 h4 h5).include?(style[:style])
@docx.public_send(style[:style], elem[:value])
else
@docx.p elem[:value] do
align style[:align]
color style[:color]
bold style[:bold]
italic style[:italic]
end
end
elsif elem[:type] == 'image'
Reports::DocxRenderer.render_img_element(@docx, elem)
elsif %w(ul ol).include?(elem[:type])
Reports::DocxRenderer.render_list_element(@docx, elem)
end
end
end
private
def combine_docx_elements(raw_elements)
# Word does not support some nested elements, move some elements to root level
elements = []
temp_p = []
raw_elements.each do |elem|
if %w(image newline table ol ul).include? elem[:type]
unless temp_p.empty?
elements.push(type: 'p', children: temp_p)
temp_p = []
end
elements.push(elem)
elsif %w(br text a).include? elem[:type]
temp_p.push(elem)
end
end
elements.push(type: 'p', children: temp_p)
elements
end
# Convert HTML structure to plain text structure
# rubocop:disable Metrics/BlockLength
def recursive_children(children, elements)
children.each do |elem|
if elem.class == Nokogiri::XML::Text
next if elem.text.strip == ' ' # Invisible symbol
style = paragraph_styling(elem.parent)
type = (style[:align] && style[:align] != :justify) || style[:style] ? 'newline' : 'text'
text = smart_annotation_check(elem)
elements.push(
type: type,
value: text.strip.delete(' '), # Invisible symbol
style: style
)
next
end
if elem.name == 'br'
elements.push(type: 'br')
next
end
if elem.name == 'img'
elements.push(img_element(elem))
next
end
if elem.name == 'a'
elements.push(link_element(elem))
next
end
if elem.name == 'table'
elements.push(tiny_mce_table_element(elem))
next
end
if %w(ul ol).include?(elem.name)
elements.push(list_element(elem))
next
end
elements = recursive_children(elem.children, elements) if elem.children
end
elements
end
# rubocop:enable Metrics/BlockLength
def img_element(elem)
return unless elem.attributes['data-mce-token']
image = TinyMceAsset.find_by(id: Base62.decode(elem.attributes['data-mce-token'].value))
return unless image
image_path = Reports::Utils.image_path(image.image)
dimension = FastImage.size(image_path)
return unless dimension
style = image_styling(elem, dimension)
{ type: 'image', data: image_path.split('&')[0], blob: image.blob, style: style }
end
def link_element(elem)
text = elem.text
link = elem.attributes['href'].value if elem.attributes['href']
if elem.attributes['class']&.value == 'record-info-link'
link = nil
text = "##{text}"
end
text = "##{text}" if elem.parent.attributes['class']&.value == 'atwho-inserted'
text = "@#{text}" if elem.attributes['class']&.value == 'atwho-user-popover'
{
type: 'a',
value: text,
link: link
}
end
def list_element(list_element)
allowed_elements = %w(li ul ol a img strong em h1 h2 h2 h3 h4 h5 span)
data_array = list_element.children.select { |n| allowed_elements.include?(n.name) }.map do |li_child|
li_child.children.map do |item|
if item.is_a? Nokogiri::XML::Text
item.text.chomp
elsif %w(ul ol).include?(item.name)
list_element(item)
elsif %w(a).include?(item.name)
link_element(item)
elsif %w(img).include?(item.name)
img_element(item)&.merge(bookmark_id: SecureRandom.hex)
elsif %w(table).include?(item.name)
tiny_mce_table_element(item).merge(bookmark_id: SecureRandom.hex)
elsif %w(strong em h1 h2 h2 h3 h4 h5 span).include?(item.name)
# Pass styles and extend renderer for li with style, some limitations on li items
# { type: 'text', value: item[:value], style: paragraph_styling(item) }
item.children.text
end
end.reject(&:blank?)
end
{ type: list_element.name, data: data_array }
end
def smart_annotation_check(elem)
return "[#{elem.text}]" if elem.parent.attributes['class']&.value == 'sa-type'
elem.text
end
# Prepare style for text
def paragraph_styling(elem)
style = elem.attributes['style']
result = {}
result[:style] = elem.name if elem.name.include? 'h'
result[:bold] = true if elem.name == 'strong'
result[:italic] = true if elem.name == 'em'
style_keys = %w(text-align color)
if style
style_keys.each do |key|
style_el = style.value.split(';').select { |i| (i.include? key) }[0]
next unless style_el
value = style_el.split(':')[1].strip if style_el
if key == 'text-align'
result[:align] = value.to_sym
elsif key == 'color' && Reports::Utils.calculate_color_hsp(value) < 190
result[:color] = value.delete('#')
end
end
end
result
end
# Prepare style for images
def image_styling(elem, dimension)
dimension[0] = elem.attributes['width'].value.to_i if elem.attributes['width']
dimension[1] = elem.attributes['height'].value.to_i if elem.attributes['height']
if elem.attributes['style']
align = if elem.attributes['style'].value.include? 'margin-right'
:center
elsif elem.attributes['style'].value.include? 'float: right'
:right
else
:left
end
end
margins = Constants::REPORT_DOCX_MARGIN_LEFT + Constants::REPORT_DOCX_MARGIN_RIGHT
max_width = (Constants::REPORT_DOCX_WIDTH - margins) / 20
if dimension[0] > max_width
x = max_width
y = dimension[1] * max_width / dimension[0]
else
x = dimension[0]
y = dimension[1]
end
{
width: x,
height: y,
align: align,
max_width: max_width
}
end
def tiny_mce_table_element(table_element)
# array of elements
rows = table_element.css('tbody').first.children.map do |row|
next unless row.name == 'tr'
cells = row.children.map do |cell|
next unless cell.name == 'td'
# Parse cell content
formated_cell = recursive_children(cell.children, [])
# Combine text elements to single paragraph
formated_cell = combine_docx_elements(formated_cell)
formated_cell
end.reject(&:blank?)
{ type: 'tr', data: cells }
end.reject(&:blank?)
{ type: 'table', data: rows }
end
end
end

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Reports
class Utils
def self.link_prepare(scinote_url, link)
link[0] == '/' ? scinote_url + link : link
end
def self.image_path(attachment)
attachment.service_url
end
def self.calculate_color_hsp(color)
return 255 if color.length != 7
color = color.delete('#').scan(/.{1,2}/)
rgb = color.map(&:hex)
Math.sqrt(
0.299 * (rgb[0]**2) +
0.587 * (rgb[1]**2) +
0.114 * (rgb[2]**2)
)
end
end
end

View file

@ -1,4 +1,12 @@
<% provide(:head_title, t("my_modules.protocols.head_title", project: h(@project.name), module: h(@my_module.name)).html_safe) %>
<% content_for :open_mobile_app_button do %>
<span class="open-mobile-app-container">
<%= link_to(pwa_mobile_app_url(@current_team.id, @project.id, @experiment.id, @my_module.id, @protocol.id, @protocol.first_step_id, request.host),
class: 'btn btn-light-link open-mobile-app-button') do %>
<%= t('my_modules.open_mobile_app') %>
<% end %>
</span>
<% end %>
<%= render partial: 'shared/drag_n_drop_overlay' %>
<%= render partial: "shared/sidebar", locals: { current_task: @my_module, page: 'task' } %>
@ -132,3 +140,4 @@
<%= javascript_include_tag("my_modules/status_flow") %>
<%= javascript_pack_tag 'emoji_button' %>
<%= javascript_include_tag("my_modules/repositories") %>
<%= javascript_include_tag("my_modules/pwa_mobile_app") %>

View file

@ -11,7 +11,8 @@
<span class="my-module" title="<%= @my_module.name %>"><%= @my_module.name %></span>
</div>
<div class="repository-name-container">
<span class="repository-name"></span>
<span class="repository-title"></span>
<span class="repository-version"></span>
</div>
</div>
<button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span></button>

View file

@ -12,8 +12,11 @@
</button>
<% end %>
<%= link_to(root_path, class: 'navbar-brand', title: t('nav.label.scinote')) do %>
<%= image_tag('/images/scinote_icon.svg', id: 'logo') %>
<span class="hidden-lg"><%= image_tag('/images/sn-icon.svg', id: 'logo') %></span>
<span class="visible-lg-block"><%= image_tag('/images/scinote_icon.svg', id: 'logo') %></span>
<% end %>
<%= yield :open_mobile_app_button %>
</div>
<% if user_signed_in? %>

View file

@ -112,6 +112,9 @@ Rails.application.configure do
# Enable sign in with LinkedIn account
config.x.linkedin_signin_enabled = ENV['LINKEDIN_SIGNIN_ENABLED'] == 'true'
# Set up domain for pwa SciNote mobile app
config.x.pwa_domain = ENV['PWA_DOMAIN'] || 'm.scinote.net'
# Use an evented file watcher to asynchronously detect changes in source code,
# routes, locales, etc. This feature depends on the listen gem.
config.file_watcher = ActiveSupport::EventedFileUpdateChecker

View file

@ -118,6 +118,9 @@ Rails.application.configure do
# Enable sign in with LinkedIn account
config.x.linkedin_signin_enabled = ENV['LINKEDIN_SIGNIN_ENABLED'] == 'true'
# Set up domain for pwa SciNote mobile app
config.x.pwa_domain = ENV['PWA_DOMAIN'] || 'm.scinote.net'
# Use a different logger for distributed setups.
# require 'syslog/logger'
# config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name')

View file

@ -38,6 +38,7 @@ Rails.application.config.assets.precompile += %w(my_modules/status_flow.js)
Rails.application.config.assets.precompile +=
%w(my_modules/protocols/protocol_status_bar.js)
Rails.application.config.assets.precompile += %w(my_modules/results.js)
Rails.application.config.assets.precompile += %w(my_modules/pwa_mobile_app.js)
Rails.application.config.assets.precompile += %w(assets/wopi/create_wopi_file.js)
Rails.application.config.assets.precompile += %w(results/result_tables.js)
Rails.application.config.assets.precompile += %w(results/result_assets.js)

View file

@ -207,6 +207,8 @@ class Constants
ACADEMY_BL_LINK = 'https://scinote.net/academy/?utm_source=SciNote%20software%20BL&utm_medium=SciNote%20software%20BL'.freeze
PWA_URL = 'https://:pwa_domain/teams/:team_id/projects/:project_id/experiments/:experiment_id/tasks/:task_id/protocol/:protocol_id/:step_id?domain=:domain'.freeze
TWO_FACTOR_URL = {
google: {
android: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2',

View file

@ -5,12 +5,22 @@
# Read more: https://github.com/cyu/rack-cors
# Rails.application.config.middleware.insert_before 0, Rack::Cors do
# allow do
# origins 'example.com'
#
# resource '*',
# headers: :any,
# methods: [:get, :post, :put, :patch, :delete, :options, :head]
# end
# end
if ENV['SCINOTE_PWA_DOMAIN_NAME'].present?
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins ENV['SCINOTE_PWA_DOMAIN_NAME']
resource '/oauth/token',
headers: :any,
methods: %i(post)
resource '/rails/active_storage/*',
headers: :any,
methods: %i(get post options head)
resource '/api/*',
headers: :any,
methods: %i(get post put patch delete options head)
end
end
end

View file

@ -453,6 +453,10 @@ en:
nothing_selected: "Nothing selected"
generate_PDF:
generated_on: "Report generated by SciNote on: %{timestamp}"
renderers:
lists:
appended_image: "Appended image - %{name}"
appended_table: "Appended table"
elements:
modals:
project_contents:
@ -811,8 +815,8 @@ en:
head_title: "%{project} | %{module} | Inventory %{repository}"
export: 'Export'
full_view:
modal_live_header: '%{repository_name}: Live version'
modal_snapshot_header: '%{repository_name}: Snapshot of %{snaphot_date}'
modal_live_header: ': Live version'
modal_snapshot_header: ': Snapshot of %{snaphot_date}'
assign_modal_header: 'Assign from %{repository_name} inventory'
snapshots:
simple_view:
@ -865,6 +869,7 @@ en:
unshared_inventory:
title_html: The inventory <b>%{inventory_name}</b> is no longer shared with your team.
body_html: This inventory has been ushared with your team by the inventorys owner. To view the item/s that are assigned to your task/s contact the <b>%{team_name}</b> team administrator <b>%{admin_name}</b> (<b>%{admin_email}</b>).
open_mobile_app: "Open mobile app"
experiments:
new:
create: 'New Experiment'

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="28" fill="none" viewBox="0 0 36 28">
<path fill="#231F20" d="M28.947 8.094h-.166v11.954L19.01 8.094h-3.762v8.938c.262.227.512.474.747.74 1.015 1.147 1.435 2.613 1.435 4.199 0 1.44-.41 2.794-1.227 3.988-.278.41-.598.786-.955 1.127v.689h4.488V15.82l9.797 11.954h3.736V12.748l-4.322-4.654z"/>
<path fill="#104DA9" d="M.826 26.507c.65.334 1.397.624 2.244.87 1.427.415 2.914.623 4.427.623 1.773 0 3.286-.268 4.523-.806 1.245-.537 2.17-1.264 2.793-2.182.622-.91.934-1.932.934-3.041 0-1.29-.338-2.322-1.012-3.084-.675-.762-1.487-1.316-2.43-1.672-.942-.355-2.153-.701-3.632-1.056-1.418-.32-2.482-.641-3.173-.962-.692-.33-1.038-.823-1.038-1.49s.294-1.204.9-1.611c.605-.407 1.53-.615 2.792-.615 1.799 0 3.589.511 5.388 1.533v-4.14c-.534-.224-1.108-.41-1.721-.563-1.185-.295-2.395-.442-3.632-.442-1.781 0-3.286.268-4.514.805C2.447 9.22 1.531 9.93.917 10.85.303 11.767 0 12.798 0 13.924c0 1.29.337 2.33 1.012 3.11.674.78 1.487 1.342 2.43 1.697.942.355 2.153.702 3.631 1.057.96.225 1.73.433 2.292.615.562.182 1.02.433 1.384.736.363.304.544.693.544 1.152 0 .633-.302 1.135-.916 1.516-.606.382-1.548.572-2.828.572-1.15 0-2.291-.182-3.442-.554-1.1-.36-2.442-1.075-3.28-1.677v4.359zM24.566 0h9.678C35.05 0 35.7.653 35.7 1.46v10.556L24.566 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,63 @@
# frozen_string_literal: true
require 'rails_helper'
describe Reports::HtmlToWordConverter do
let(:user) { create :user }
let(:team) { create :team }
let(:docx) { double('docx') }
let(:report) { described_class.new(docx) }
describe 'html_list' do
let(:text) do
'<body><ul><li>1</li><li>2<ul><li>one</li><li>two<ol><li>uno</li><li>due</li>'\
'</ol></li></ul></li><li>3</li><li>4</li><li>5</li></ul></body>'
end
let(:xml_elements) { Nokogiri::HTML(text).css('body').children.first }
let(:result) do
{
type: 'ul',
data: [%w(1),
['2', { type: 'ul', data: [%w(one), ['two', { type: 'ol', data: [%w(uno), %w(due)] }]] }],
%w(3), %w(4), %w(5)]
}
end
it '' do
expect(report.__send__(:list_element, xml_elements)).to be == result
end
end
describe '.tiny_mce_table_element' do
let(:text) do
# rubocop:disable Layout/LineLength
'<body><table style="border-collapse: collapse; width: 100%; height: 28px;" border="1" data-mce-style="border-collapse: collapse; width: 100%; height: 28px;"><tbody><tr style="height: 10px;"><td style="width: 50%; height: 10px;">1</td><td style="width: 50%; height: 10px;">2</td></tr><tr style="height: 18px;"><td style="width: 50%; height: 18px;">3</td><td style="width: 50%; height: 18px;">4</td></tr></tbody></table></body>'
# rubocop:enable Layout/LineLength
end
let(:xml_elements) { Nokogiri::HTML(text).css('body').children.first }
let(:result) do
{
data: [
{
data: [
[{ children: [{ style: {}, type: 'text', value: '1' }], type: 'p' }],
[{ children: [{ style: {}, type: 'text', value: '2' }], type: 'p' }]
],
type: 'tr'
},
{
data: [
[{ children: [{ style: {}, type: 'text', value: '3' }], type: 'p' }],
[{ children: [{ style: {}, type: 'text', value: '4' }], type: 'p' }]
],
type: 'tr'
}
],
type: 'table'
}
end
it '' do
expect(report.__send__(:tiny_mce_table_element, xml_elements)).to be == result
end
end
end