Merge branch 'develop' into features/ui-tweaks

This commit is contained in:
Martin Artnik 2024-07-22 10:59:12 +02:00
commit 7f2d4cba3e
163 changed files with 4032 additions and 1042 deletions

View file

@ -99,7 +99,7 @@ Style/GuardClause:
MinBodyLength: 1
Style/HashSyntax:
EnforcedStyle: ruby19
EnforcedShorthandSyntax: never
Style/IfUnlessModifier:
Enabled: true
@ -299,9 +299,6 @@ Naming/VariableName:
Naming/VariableNumber:
EnforcedStyle: normalcase
Naming/BlockForwarding:
EnforcedStyle: explicit
Style/WordArray:
EnforcedStyle: percent
MinSize: 0

View file

@ -49,6 +49,7 @@ gem 'bcrypt', '~> 3.1.10'
# gem 'caracal'
gem 'caracal',
git: 'https://github.com/scinote-eln/caracal.git', branch: 'rubyzip2' # Build docx report
gem 'caxlsx' # Build XLSX files
gem 'deface', '~> 1.9'
gem 'down', '~> 5.0'
gem 'faker' # Generate fake data
@ -106,6 +107,7 @@ group :development, :test do
gem 'awesome_print'
gem 'better_errors'
gem 'binding_of_caller'
gem 'brakeman', require: false
gem 'bullet'
gem 'byebug'
gem 'factory_bot_rails'

View file

@ -208,6 +208,8 @@ GEM
debug_inspector (>= 0.0.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
brakeman (6.1.2)
racc
builder (3.2.4)
bullet (7.0.7)
activesupport (>= 3.0.0)
@ -227,6 +229,11 @@ GEM
mail
case_transform (0.2)
activesupport
caxlsx (4.0.0)
htmlentities (~> 4.3, >= 4.3.4)
marcel (~> 1.0)
nokogiri (~> 1.10, >= 1.10.4)
rubyzip (>= 1.3.0, < 3)
cgi (0.4.1)
childprocess (4.1.0)
chunky_png (1.4.0)
@ -353,6 +360,7 @@ GEM
nokogiri (~> 1.0)
hashdiff (1.0.1)
hashie (5.0.0)
htmlentities (4.3.4)
http (5.1.1)
addressable (~> 2.8)
http-cookie (~> 1.0)
@ -791,12 +799,14 @@ DEPENDENCIES
better_errors
binding_of_caller
bootsnap
brakeman
bullet
byebug
canaid!
capybara
capybara-email
caracal!
caxlsx
cssbundling-rails
cucumber-rails
database_cleaner

View file

@ -1 +1 @@
1.35.0.1
1.35.0.2

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

View file

@ -340,8 +340,8 @@ var MyModuleRepositories = (function() {
json.state.columns[0].visible = false;
}
if ($(tableContainer).data('type') !== 'snapshot') {
json.state.columns[6].visible = false;
json.state.columns[7].visible = false;
json.state.columns[9].visible = false;
json.state.columns[10].visible = false;
}
if (json.state.search) delete json.state.search;
if ($(tableContainer).data('stockConsumptionColumn')) {

View file

@ -132,8 +132,8 @@ $.fn.dataTable.render.editRepositoryNumberValue = function(formId, columnId, cel
});
$input.on('input', function() {
const regexp = decimals === 0 ? /[^0-9]/g : /[^0-9.]/g;
const decimalsRegex = new RegExp(`^\\d*(\\.\\d{0,${decimals}})?`);
const regexp = decimals === 0 ? /[^-0-9]/g : /[^-0-9.]/g;
const decimalsRegex = new RegExp(`^-?\\d*(\\.\\d{0,${decimals}})?`);
let value = this.value;
value = value.replace(regexp, '');
value = value.match(decimalsRegex)[0];

View file

@ -64,9 +64,9 @@ $.fn.dataTable.render.newRepositoryNumberValue = function(formId, columnId, $cel
});
$input.on('input', function() {
const decimalsRegex = new RegExp(`^\\d*(\\.\\d{0,${decimals}})?`);
const decimalsRegex = new RegExp(`^-?\\d*(\\.\\d{0,${decimals}})?`);
let value = this.value;
value = value.replace(/[^0-9.]/g, '');
value = value.replace(/[^-0-9.]/g, '');
value = value.match(decimalsRegex)[0];
this.value = value;
});

View file

@ -641,7 +641,8 @@ var RepositoryDatatable = (function(global) {
visible: true,
render: function(data, type, row) {
return "<a href='" + row.recordInfoUrl + "'"
+ "class='record-info-link' data-e2e='e2e-TL-invInventoryTR-Item-" + row.DT_RowId + "'>" + data + '</a>';
+ " class='record-info-link' data-e2e='e2e-TL-invInventoryTR-Item-" + row.DT_RowId + "'"
+ " title='" + data + "'>" + data + '</a>';
}
}, {
targets: 4,
@ -661,6 +662,14 @@ var RepositoryDatatable = (function(global) {
targets: 6,
class: 'added-by',
visible: true
}, {
targets: 7,
class: 'updated-on',
visible: true
}, {
targets: 8,
class: 'updated-by',
visible: true
}, {
targets: '_all',
render: function(data) {
@ -751,8 +760,8 @@ var RepositoryDatatable = (function(global) {
var state = localStorage.getItem(`datatables_repositories_state/${repositoryId}/${viewType}`);
json.state.start = state !== null ? JSON.parse(state).start : 0;
if (json.state.columns[7]) json.state.columns[7].visible = archived;
if (json.state.columns[8]) json.state.columns[8].visible = archived;
if (json.state.columns[9]) json.state.columns[9].visible = archived;
if (json.state.columns[10]) json.state.columns[10].visible = archived;
if (json.state.search) delete json.state.search;
if (json.state.ColSizes && json.state.ColSizes.length > 0) {

View file

@ -33,6 +33,15 @@
$('#parse-records-modal').modal('show');
repositoryRecordsImporter();
});
// Handling cancel click in #parse-records-modal
$('#parse-records-modal').on('click', '#parse-records-cancel-btn', () => {
$('#parse-records-modal').modal('hide');
// remove previous modal (necessary to get the new relevant data instead of old data)
setTimeout(() => {
$('#parse-records-modal').remove();
}, 200);
});
}
function initTable() {
@ -56,7 +65,7 @@
data.responseJSON.message + '</span>');
});
submitBtn.on('click', function(event) {
submitBtn.one('click', (event) => {
var data = new FormData();
submitBtn.attr('disabled', true);
$('#parse-sheet-loader').removeClass('hidden');
@ -78,8 +87,12 @@
function initImportRecordsModal() {
$('.repository-show').on('click', '#importRecordsButton', function() {
$('#modal-import-records').modal('show');
initParseRecordsModal();
window.importRepositoryModalComponent.open();
});
// Handling cancel click in #modal-import-records
$('#modal-import-records').on('click', '#import-records-cancel-btn', () => {
$('#modal-import-records').modal('hide');
});
}

View file

@ -202,10 +202,14 @@ var MarvinJsEditorApi = (function() {
}
$(marvinJsModal).modal('hide');
config.editor.focus();
if (config.editor) config.editor.focus();
config.button.dataset.inProgress = false;
if (MarvinJsEditor.saveCallback) MarvinJsEditor.saveCallback();
if (MarvinJsEditor.saveCallback) {
MarvinJsEditor.saveCallback();
delete MarvinJsEditor.saveCallback;
}
},
error: function(response) {
if (response.status === 403) {
@ -237,7 +241,9 @@ var MarvinJsEditorApi = (function() {
enabled: function() {
return ($('#MarvinJsModal').length > 0);
},
isRemote: function() {
return marvinJsMode === 'remote';
},
open: function(config) {
if (!MarvinJsEditor.enabled()) {
$('#MarvinJsPromoModal').modal('show');
@ -262,8 +268,8 @@ var MarvinJsEditorApi = (function() {
MarvinJsEditor.save(config);
} else if (config.mode === 'edit') {
config.objectType = 'Asset';
MarvinJsEditor.saveCallback = (() => window.location.reload());
MarvinJsEditor.update(config);
location.reload();
} else if (config.mode === 'new-tinymce') {
config.objectType = 'TinyMceAsset';
MarvinJsEditor.save(config);
@ -322,21 +328,22 @@ $(document).on('click', '.gene-sequence-edit-button', function() {
function initMarvinJs() {
MarvinJsEditor = MarvinJsEditorApi();
let isRemote = $('#marvinjs-editor')[0].dataset.marvinjsMode === 'remote';
// MarvinJS is disabled, nothing to initialize
if (!MarvinJsEditor.enabled()) return;
if (MarvinJsEditor.enabled()) {
if (isRemote && typeof (ChemicalizeMarvinJs) === 'undefined') {
setTimeout(initMarvinJs, 100);
return;
}
if (isRemote) {
ChemicalizeMarvinJs.createEditor('#marvinjs-sketch').then(function(marvin) {
marvin.setDisplaySettings({ toolbars: 'reporting' });
marvinJsRemoteEditor = marvin;
});
}
// wait for remote MarvinJS to initialize
if (MarvinJsEditor.isRemote() && typeof (ChemicalizeMarvinJs) === 'undefined') {
setTimeout(initMarvinJs, 100);
return;
}
if (MarvinJsEditor.isRemote()) {
ChemicalizeMarvinJs.createEditor('#marvinjs-sketch').then(function(marvin) {
marvin.setDisplaySettings({ toolbars: 'reporting' });
marvinJsRemoteEditor = marvin;
});
}
MarvinJsEditor.initNewButton('.new-marvinjs-upload-button');
}

View file

@ -53,15 +53,21 @@ function prepareRepositoryHeaderForExport(th) {
case 'added-on':
val = -6;
break;
case 'archived-by':
case 'updated-on':
val = -7;
break;
case 'archived-on':
case 'updated-by':
val = -8;
break;
case 'relationship':
case 'archived-by':
val = -9;
break;
case 'archived-on':
val = -10;
break;
case 'relationship':
val = -11;
break;
default:
val = th.attr('id');
}

View file

@ -14,6 +14,8 @@
initDisclaimer('.log-in-button', '#new_user');
initDisclaimer('.btn-okta', '#oktaForm');
initDisclaimer('.btn-azure-ad', '.azureAdForm');
initDisclaimer('.btn-openid-connect', '#openidConnectForm');
initDisclaimer('.btn-saml', '#samlForm');
initDisclaimer('.sign-up-button', '#sign-up-form');
initDisclaimer('.forgot-password-submit', '#forgot-password-form');
initDisclaimer('.invitation-submit', '#invitation-form');

View file

@ -115,7 +115,9 @@
.task-section-header {
align-items: center;
display: flex;
display: grid;
gap: 1rem;
grid-template-columns: auto auto;
min-height: 4rem;
.actions-block {

View file

@ -125,12 +125,6 @@
z-index: 2;
}
}
.asset-context-menu {
position: absolute;
right: 1rem;
top: 1rem;
}
}
.inline-attachment-container {
@ -209,8 +203,8 @@
grid-template-columns: max-content max-content;
}
.asset-context-menu {
margin-left: auto;
.inline-attachment-action-buttons {
display: none;
}
}
@ -218,6 +212,17 @@
padding: 5em 1em 1em;
text-align: center;
}
&:hover,
&.context-menu-open,
&.menu-dropdown-open {
.header {
.inline-attachment-action-buttons {
display: flex;
flex-direction: row;
}
}
}
}
.list-attachment-container {
@ -225,7 +230,7 @@
align-items: center;
display: flex;
grid-column: 1/-1;
height: 3em;
height: 40px;
padding: .5em;
.file-icon {
@ -267,7 +272,96 @@
}
.asset-context-menu {
margin-left: 1rem;
background: transparent;
}
.icon-btn {
height: 36px;
width: 36px;
}
// normal screen
@media (min-width: 640px) {
&:hover {
#action-buttons {
display: flex;
.icon-btn {
&:hover {
background-color: var(--sn-light-grey);
}
}
}
#file-metadata {
display: none;
}
}
// context menu dropdown is open
&.context-menu-open,
&.menu-dropdown-open {
display: flex;
justify-content: space-between;
#action-buttons {
display: flex;
}
#file-metadata {
display: none;
}
}
// context menu dropdown is closed
#action-buttons {
display: none;
}
}
// small screens
@media (max-width: 640px) {
display: grid;
height: 60px;
#file-metadata {
margin-left: 2rem;
}
&:hover {
#action-buttons {
display: block;
margin-bottom: 20px;
position: absolute;
right: 144px;
.icon-btn {
&:hover {
background-color: var(--sn-light-grey);
}
}
}
}
// context menu dropdown is open
&.context-menu-open {
#action-buttons {
display: block;
margin-bottom: 20px;
position: absolute;
right: 144px;
}
}
// context menu dropdown is closed
#action-buttons {
display: none;
}
}
}

View file

@ -3,7 +3,7 @@
:root {
--attachment-column-width: 13.625rem;
--attachment-row-height: 2.5rem;
--attachment-row-height: 3rem;
}
@ -88,6 +88,14 @@
}
}
.attachments:has(> .list-attachment-container) {
grid-row-gap: 10px;
@media (max-width: 640px) {
grid-row-gap: 30px;
}
}
.add-file-modal {
.file-drop-zone {
align-items: center;

View file

@ -103,4 +103,8 @@
@apply absolute -bottom-5 text-sn-alert-passion text-xs;
content: attr(data-error);
}
.sci-error-text {
@apply text-xs text-sn-alert-passion;
}
}

View file

@ -40,7 +40,7 @@
}
.modal .modal-dialog.modal-lg {
@apply w-[900px];
@apply max-w-[900px] w-full;
}
.modal.fade .modal-dialog {

View file

@ -385,21 +385,11 @@ mark,.mark {
.azure-sign-in-actions {
margin-bottom: 10px;
margin-top: 10px;
.btn-azure-ad {
background-color: $office-ms-word;
color: $color-white;
}
}
.okta-sign-in-actions {
margin-bottom: 10px;
margin-top: 10px;
.btn-okta {
background-color: #00297a;
color: $color-white;
}
}
.navbar-secondary {

View file

@ -47,6 +47,7 @@ module Api
end
def destroy
@inventory_item.update!(last_modified_by: current_user)
@inventory_cell.destroy!
render body: nil
end

View file

@ -10,6 +10,7 @@ module Dashboard
def create_task
my_module = CreateMyModuleService.new(current_user, current_team,
my_module: create_my_module_params,
project: @project || create_project_params,
experiment: @experiment || create_experiment_params).call
if my_module.errors.blank?
@ -25,16 +26,16 @@ module Dashboard
.page(params[:page] || 1)
.per(Constants::SEARCH_LIMIT)
.select(:id, :name)
projects = projects.map { |i| { value: i.id, label: escape_input(i.name) } }
if (projects.map { |i| i[:label] }.exclude? params[:query]) && params[:query].present?
projects = [{ value: 0, label: params[:query] }] + projects
projects = projects.map { |i| [i.id, escape_input(i.name)] }
if (projects.map { |i| i[1] }.exclude? params[:query]) && params[:query].present?
projects = [[0, params[:query]]] + projects
end
render json: projects, status: :ok
render json: { data: projects }, status: :ok
end
def experiment_filter
if create_project_params.present? && params[:query].present?
experiments = [{ value: 0, label: params[:query] }]
experiments = [[0, params[:query]]]
elsif @project
experiments = @project.experiments
.managable_by_user(current_user)
@ -42,20 +43,24 @@ module Dashboard
.page(params[:page] || 1)
.per(Constants::SEARCH_LIMIT)
.select(:id, :name)
experiments = experiments.map { |i| { value: i.id, label: escape_input(i.name) } }
if (experiments.map { |i| i[:label] }.exclude? params[:query]) &&
experiments = experiments.map { |i| [i.id, escape_input(i.name)] }
if (experiments.map { |i| i[1] }.exclude? params[:query]) &&
params[:query].present? &&
can_create_project_experiments?(@project)
experiments = [{ value: 0, label: params[:query] }] + experiments
experiments = [[0, params[:query]]] + experiments
end
end
render json: experiments || [], status: :ok
render json: { data: experiments || [] }, status: :ok
end
private
def create_my_module_params
params.require(:my_module).permit(:name)
end
def create_project_params
params.require(:project).permit(:name, :visibility)
params.require(:project).permit(:name, :visibility, :default_public_user_role_id)
end
def create_experiment_params

View file

@ -3,12 +3,13 @@
class MyModuleRepositoriesController < ApplicationController
include ApplicationHelper
before_action :load_my_module
before_action :load_my_module, except: :assign_my_modules
before_action :load_repository, except: %i(repositories_dropdown_list repositories_list_html create)
before_action :check_my_module_view_permissions, except: %i(update consume_modal update_consumption)
before_action :check_my_module_view_permissions, except: %i(update consume_modal update_consumption assign_my_modules)
before_action :check_repository_view_permissions, except: %i(repositories_dropdown_list repositories_list_html create)
before_action :check_repository_row_consumption_permissions, only: %i(consume_modal update_consumption)
before_action :check_assign_repository_records_permissions, only: %i(update create)
before_action :load_my_modules, only: :assign_my_modules
def index_dt
@draw = params[:draw].to_i
@ -41,6 +42,31 @@ class MyModuleRepositoriesController < ApplicationController
render rows_view
end
def assign_my_modules
assigned_count = 0
skipped_count = 0
status = :ok
ActiveRecord::Base.transaction do
@my_modules.find_each do |my_module|
service = RepositoryRows::MyModuleAssignUnassignService.call(
my_module:,
repository: @repository,
user: current_user,
params:
)
unless service.succeed?
status = :unprocessable_entity
raise ActiveRecord::Rollback
end
assigned_count += service.assigned_rows_count
skipped_count += (params[:rows_to_assign].length - service.assigned_rows_count)
end
end
render json: { assigned_count:, skipped_count: }, status:
end
def create
repository_row = RepositoryRow.find(params[:repository_row_id])
repository = repository_row.repository
@ -215,6 +241,12 @@ class MyModuleRepositoriesController < ApplicationController
render_404 unless @repository
end
def load_my_modules
@my_modules = MyModule.where(id: params[:my_module_ids])
render_403 unless @my_modules.all? { |my_module| can_assign_my_module_repository_rows?(my_module) }
end
def check_my_module_view_permissions
render_403 unless can_read_my_module?(@my_module)
end

View file

@ -19,10 +19,10 @@ class NavigationsController < ApplicationController
end
def navigator_state
session[:navigator_collapsed] = params[:state] == 'collapsed'
current_user.update_simple_setting(key: 'navigator_collapsed', value: params[:state] == 'collapsed')
width = params[:width].to_i
session[:navigator_width] = width if width.positive?
current_user.update_simple_setting(key: 'navigator_width', value: width) if width.positive?
end
private

View file

@ -27,6 +27,7 @@ class RepositoriesController < ApplicationController
before_action :set_inline_name_editing, only: %i(show)
before_action :load_repository_row, only: %i(show)
before_action :set_breadcrumbs_items, only: %i(index show)
before_action :validate_file_type, only: %i(export_repository export_repositories)
layout 'fluid'
@ -36,7 +37,7 @@ class RepositoriesController < ApplicationController
render 'index'
end
format.json do
repositories = Lists::RepositoriesService.new(@repositories, params).call
repositories = Lists::RepositoriesService.new(@repositories, params, user: current_user).call
render json: repositories, each_serializer: Lists::RepositorySerializer, user: current_user,
meta: pagination_dict(repositories)
end
@ -53,13 +54,21 @@ class RepositoriesController < ApplicationController
end
def show
current_team_switch(@repository.team) unless @repository.shared_with?(current_team)
@display_edit_button = can_create_repository_rows?(@repository)
@display_delete_button = can_delete_repository_rows?(@repository)
@display_duplicate_button = can_create_repository_rows?(@repository)
@snapshot_provisioning = @repository.repository_snapshots.provisioning.any?
respond_to do |format|
format.html do
current_team_switch(@repository.team) unless @repository.shared_with?(current_team)
@display_edit_button = can_create_repository_rows?(@repository)
@display_delete_button = can_delete_repository_rows?(@repository)
@display_duplicate_button = can_create_repository_rows?(@repository)
@snapshot_provisioning = @repository.repository_snapshots.provisioning.any?
@busy_printer = LabelPrinter.where.not(current_print_job_ids: []).first
@busy_printer = LabelPrinter.where.not(current_print_job_ids: []).first
end
format.json do
# render serialized repository json
render json: @repository, serializer: RepositorySerializer
end
end
end
def table_toolbar
@ -207,29 +216,6 @@ class RepositoriesController < ApplicationController
}
end
def export_modal
if current_user.has_available_exports?
render json: {
html: render_to_string(
partial: 'export_repositories_modal',
locals: { team_name: current_team.name,
counter: params[:counter].to_i,
export_limit: TeamZipExport.exports_limit,
num_of_requests_left: current_user.exports_left - 1 },
formats: :html
)
}
else
render json: {
html: render_to_string(
partial: 'export_limit_exceeded_modal',
locals: { requests_limit: TeamZipExport.exports_limit },
formats: :html
)
}
end
end
def copy
@tmp_repository = Repository.new(
team: current_team,
@ -271,81 +257,99 @@ class RepositoriesController < ApplicationController
render_403 unless can_create_repository_rows?(@repository)
unless import_params[:file]
repository_response(t('repositories.parse_sheet.errors.no_file_selected'))
unprocessable_entity_repository_response(t('repositories.parse_sheet.errors.no_file_selected'))
return
end
begin
parsed_file = ImportRepository::ParseRepository.new(
file: import_params[:file],
repository: @repository,
date_format: current_user.settings['date_format'],
session: session
)
if parsed_file.too_large?
repository_response(t('general.file.size_exceeded',
file_size: Rails.configuration.x.file_max_size_mb))
render json: { error: t('general.file.size_exceeded', file_size: Rails.configuration.x.file_max_size_mb) },
status: :unprocessable_entity
elsif parsed_file.has_too_many_rows?
repository_response(
t('repositories.import_records.error_message.items_limit',
items_size: Constants::IMPORT_REPOSITORY_ITEMS_LIMIT)
)
render json: { error: t('repositories.import_records.error_message.items_limit',
items_size: Constants::IMPORT_REPOSITORY_ITEMS_LIMIT) }, status: :unprocessable_entity
elsif parsed_file.has_too_little_rows?
render json: { error: t('repositories.parse_sheet.errors.items_min_limit') },
status: :unprocessable_entity
else
@import_data = parsed_file.data
if @import_data.header.blank? || @import_data.columns.blank?
return repository_response(t('repositories.parse_sheet.errors.empty_file'))
return render json: { error: t('repositories.parse_sheet.errors.empty_file') }, status: :unprocessable_entity
end
if (@temp_file = parsed_file.generate_temp_file)
render json: {
html: render_to_string(partial: 'repositories/parse_records_modal', formats: :html)
}
render json: { import_data: @import_data, temp_file: @temp_file }
else
repository_response(t('repositories.parse_sheet.errors.temp_file_failure'))
render json: { error: t('repositories.parse_sheet.errors.temp_file_failure') }, status: :unprocessable_entity
end
end
rescue ArgumentError, CSV::MalformedCSVError
repository_response(t('repositories.parse_sheet.errors.invalid_file',
encoding: ''.encoding))
render json: { error: t('repositories.parse_sheet.errors.invalid_file', encoding: ''.encoding) },
status: :unprocessable_entity
rescue TypeError
repository_response(t('repositories.parse_sheet.errors.invalid_extension'))
render json: { error: t('repositories.parse_sheet.errors.invalid_extension') }, status: :unprocessable_entity
end
end
def import_records
render_403 unless can_create_repository_rows?(Repository.accessible_by_teams(current_team)
.find_by_id(import_params[:id]))
.find_by(id: import_params[:id]))
# Check if there exist mapping for repository record (it's mandatory)
if import_params[:mappings].value?('-1')
import_records = repostiory_import_actions
status = import_records.import!
if import_params[:mappings].present? && import_params[:mappings].value?('-1')
status = ImportRepository::ImportRecords
.new(
temp_file: TempFile.find_by(id: import_params[:file_id]),
repository: Repository.accessible_by_teams(current_team).find_by(id: import_params[:id]),
mappings: import_params[:mappings],
session: session,
user: current_user,
can_edit_existing_items: import_params[:can_edit_existing_items],
should_overwrite_with_empty_cells: import_params[:should_overwrite_with_empty_cells],
preview: import_params[:preview]
).import!
message = t('repositories.import_records.partial_success_flash',
successful_rows_count: (status[:created_rows_count] + status[:updated_rows_count]),
total_rows_count: status[:total_rows_count])
if status[:status] == :ok
log_activity(:import_inventory_items,
num_of_items: status[:nr_of_added])
unless import_params[:preview] || (status[:created_rows_count] + status[:updated_rows_count]).zero?
log_activity(
:inventory_items_added_or_updated_with_import,
created_rows_count: status[:created_rows_count],
updated_rows_count: status[:updated_rows_count]
)
end
flash[:success] = t('repositories.import_records.success_flash',
number_of_rows: status[:nr_of_added],
total_nr: status[:total_nr])
render json: {}, status: :ok
render json: import_params[:preview] ? status : { message: message }, status: :ok
else
flash[:alert] =
t('repositories.import_records.partial_success_flash',
nr: status[:nr_of_added], total_nr: status[:total_nr])
render json: {}, status: :unprocessable_entity
render json: { message: message }, status: :unprocessable_entity
end
else
render json: {
html: render_to_string(
partial: 'shared/flash_errors',
formats: :html,
locals: { error_title: t('repositories.import_records.error_message.errors_list_title'),
error: t('repositories.import_records.error_message.no_repository_name') }
)
}, status: :unprocessable_entity
render json: { error: t('repositories.import_records.error_message.mapping_error') },
status: :unprocessable_entity
end
end
def export_empty_repository
col_ids = [-3, -4, -5, -6, -7, -8, -9, -10]
col_ids << -11 if Repository.repository_row_connections_enabled?
col_ids += @repository.repository_columns.map(&:id)
xlsx = RepositoryXlsxExport.to_empty_xlsx(@repository, col_ids)
send_data(
xlsx,
filename: "#{@repository.name.gsub(/\s/, '_')}_template_#{Date.current}.xlsx",
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
end
def export_repository
if params[:row_ids] && params[:header_ids]
RepositoryZipExportJob.perform_later(
@ -354,8 +358,10 @@ class RepositoriesController < ApplicationController
repository_id: @repository.id,
row_ids: params[:row_ids],
header_ids: params[:header_ids]
}
},
file_type: params[:file_type]
)
update_user_export_file_type if current_user.settings[:repository_export_file_type] != params[:file_type]
log_activity(:export_inventory_items)
render json: { message: t('zip_export.export_request_success') }
else
@ -365,9 +371,10 @@ class RepositoriesController < ApplicationController
def export_repositories
repositories = Repository.viewable_by_user(current_user, current_team).where(id: params[:repository_ids])
if repositories.present? && current_user.has_available_exports?
current_user.increase_daily_exports_counter!
RepositoriesExportJob.perform_later(repositories.pluck(:id), user_id: current_user.id, team_id: current_team.id)
if repositories.present?
RepositoriesExportJob
.perform_later(params[:file_type], repositories.pluck(:id), user_id: current_user.id, team_id: current_team.id)
update_user_export_file_type if current_user.settings[:repository_export_file_type] != params[:file_type]
log_activity(:export_inventories, inventories: repositories.pluck(:name).join(', '))
render json: { message: t('zip_export.export_request_success') }
else
@ -443,16 +450,6 @@ class RepositoriesController < ApplicationController
private
def repostiory_import_actions
ImportRepository::ImportRecords.new(
temp_file: TempFile.find_by_id(import_params[:file_id]),
repository: Repository.accessible_by_teams(current_team).find_by_id(import_params[:id]),
mappings: import_params[:mappings],
session: session,
user: current_user
)
end
def load_repository
repository_id = params[:id] || params[:repository_id]
@repository = Repository.accessible_by_teams(current_user.teams).find_by(id: repository_id)
@ -534,7 +531,8 @@ class RepositoriesController < ApplicationController
end
def import_params
params.permit(:id, :file, :file_id, mappings: {}).to_h
params.permit(:id, :file, :file_id, :preview, :can_edit_existing_items,
:should_overwrite_with_empty_cells, :preview, mappings: {}).to_h
end
def repository_response(message)
@ -592,4 +590,12 @@ class RepositoriesController < ApplicationController
item[:label] = "(A) #{item[:label]}" if item[:archived]
end
end
def validate_file_type
render json: { message: 'Invalid file type' }, status: :bad_request unless %w(csv xlsx).include?(params[:file_type])
end
def update_user_export_file_type
current_user.update_simple_setting(key: 'repository_export_file_type', value: params[:file_type])
end
end

View file

@ -29,6 +29,11 @@ class RepositoryColumnsController < ApplicationController
end
def describe_all
additional_columns = [
%w(updated_on RepositoryDateTimeValue),
%w(updated_by RepositoryUserValue)
]
response_json = @repository.repository_columns
.where(data_type: Extends::REPOSITORY_ADVANCED_SEARCHABLE_COLUMNS)
.map do |column|
@ -39,6 +44,12 @@ class RepositoryColumnsController < ApplicationController
items: column.items&.map { |item| { value: item.id, label: escape_input(item.data) } }
}
end
additional_columns.each do |column, column_type|
response_json << { id: column,
name: I18n.t("repositories.table.#{column}"),
data_type: column_type }
end
render json: { response: response_json }
end

View file

@ -5,15 +5,18 @@ class ResultOrderableElementsController < ApplicationController
before_action :check_manage_permissions
def reorder
position_changed = false
ActiveRecord::Base.transaction do
params[:result_orderable_element_positions].each do |id, position|
result_element = @result.result_orderable_elements.find(id)
result_element.insert_at(position)
position_changed ||= result_element.insert_at(position)
end
end
log_activity(:result_content_rearranged, @my_module.experiment.project, my_module: @my_module.id)
@result.touch
if position_changed
log_activity(:result_content_rearranged, @my_module.experiment.project, my_module: @my_module.id)
@result.touch
end
render json: params[:result_orderable_element_positions], status: :ok
rescue ActiveRecord::RecordInvalid

View file

@ -22,12 +22,13 @@ class ResultsController < ApplicationController
@my_module.results.active
end
apply_sort!
update_and_apply_user_sort_preference!
apply_filters!
@results = @results.page(params.dig(:page, :number) || 1)
render json: @results, each_serializer: ResultSerializer, scope: current_user
render json: @results, each_serializer: ResultSerializer, scope: current_user,
meta: { sort: @sort_preference }
end
format.html do
@ -121,7 +122,7 @@ class ResultsController < ApplicationController
def destroy
name = @result.name
if @result.destroy
if @result.discard
log_activity(:destroy_result, { destroyed_result: name })
render json: {}, status: :ok
else
@ -146,8 +147,18 @@ class ResultsController < ApplicationController
params.require(:result).permit(:name)
end
def apply_sort!
case params[:sort]
def update_and_apply_user_sort_preference!
if params[:sort].present?
current_user.update_nested_setting(key: 'results_order', id: @my_module.id.to_s, value: params[:sort])
@sort_preference = params[:sort]
else
@sort_preference = current_user.settings.fetch('results_order', {})[@my_module.id.to_s] || 'created_at_desc'
end
apply_sort!(@sort_preference)
end
def apply_sort!(sort_order)
case sort_order
when 'updated_at_asc'
@results = @results.order('results.updated_at' => :asc)
when 'updated_at_desc'

View file

@ -6,16 +6,23 @@ class StepOrderableElementsController < ApplicationController
def reorder
@step.with_lock do
position_changed = false
params[:step_orderable_element_positions].each do |id, position|
@step.step_orderable_elements.find(id).update_column(:position, position)
step_element = @step.step_orderable_elements.find(id)
if step_element.position != position
position_changed = true
step_element.update_column(:position, position)
end
end
if @protocol.in_module?
log_activity(:task_step_content_rearranged, @my_module.experiment.project, my_module: @my_module.id)
else
log_activity(:protocol_step_content_rearranged, nil, protocol: @protocol.id)
if position_changed
if @protocol.in_module?
log_activity(:task_step_content_rearranged, @my_module.experiment.project, my_module: @my_module.id)
else
log_activity(:protocol_step_content_rearranged, nil, protocol: @protocol.id)
end
@step.touch
end
@step.touch
end
render json: params[:step_orderable_element_positions], status: :ok

View file

@ -241,16 +241,23 @@ class StepsController < ApplicationController
def reorder
@protocol.with_lock do
position_changed = false
params[:step_positions].each do |id, position|
@protocol.steps.find(id).update_column(:position, position)
step = @protocol.steps.find(id)
if position != step.position
position_changed = true
step.update_column(:position, position)
end
end
if @protocol.in_module?
log_activity(:task_steps_rearranged, @my_module.experiment.project, my_module: @my_module.id)
else
log_activity(:protocol_steps_rearranged, nil, protocol: @protocol.id)
if position_changed
if @protocol.in_module?
log_activity(:task_steps_rearranged, @my_module.experiment.project, my_module: @my_module.id)
else
log_activity(:protocol_steps_rearranged, nil, protocol: @protocol.id)
end
@protocol.touch
end
@protocol.touch
end
render json: {

View file

@ -14,7 +14,14 @@ module Users
key = setting[:key]
data = setting[:data]
current_user.settings[key] = data if Extends::WHITELISTED_USER_SETTINGS.include?(key.to_s)
next unless Extends::WHITELISTED_USER_SETTINGS.include?(key.to_s)
case key.to_s
when 'task_step_states'
update_task_step_states(data)
else
current_user.settings[key] = data
end
end
if current_user.save
@ -24,6 +31,22 @@ module Users
status: :unprocessable_entity
end
end
private
def update_task_step_states(task_step_states_data)
current_states = current_user.settings.fetch('task_step_states', {})
task_step_states_data.each do |step_id, collapsed|
if collapsed
current_states[step_id] = true
else
current_states.delete(step_id)
end
end
current_user.settings['task_step_states'] = current_states
end
end
end
end

View file

@ -239,8 +239,10 @@ module RepositoryDatatableHelper
'4': "#{record.parent_connections_count || 0} / #{record.child_connections_count || 0}",
'5': I18n.l(record.created_at, format: :full),
'6': escape_input(record.created_by.full_name),
'7': (record.archived_on ? I18n.l(record.archived_on, format: :full) : ''),
'8': escape_input(record.archived_by&.full_name)
'7': (record.updated_at ? I18n.l(record.updated_at, format: :full) : ''),
'8': escape_input(record.last_modified_by.full_name),
'9': (record.archived_on ? I18n.l(record.archived_on, format: :full) : ''),
'10': escape_input(record.archived_by&.full_name)
}
end

View file

@ -326,6 +326,9 @@ window.TinyMCE = (() => {
// Remove transition class
$('.tox-editor-header').removeClass('tox-editor-dock-fadeout');
// Fixes the overflowing vertical controls bar for inserted text
$('.tox-editor-header').css('display', 'contents');
// Init image toolbar
initCssOverrides(editor);

View file

@ -0,0 +1,16 @@
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import DashboardNewTask from '../../vue/dashboard/new_task.vue';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
const app = createApp({
data() {
return {
modalKey: 0
};
}
});
app.component('DashboardNewTask', DashboardNewTask);
app.config.globalProperties.i18n = window.I18n;
app.use(PerfectScrollbar);
mountWithTurbolinks(app, '#DashboardNewTask');

View file

@ -2,6 +2,7 @@ import { createApp } from 'vue/dist/vue.esm-bundler.js';
import { shallowRef } from 'vue';
import WizardModal from '../../../vue/shared/wizard_modal.vue';
import InfoModal from '../../../vue/shared/info_modal.vue';
import Step1 from './wizard_steps/step_1.vue';
import Step2 from './wizard_steps/step_2.vue';
import Step3 from './wizard_steps/step_3.vue';
@ -20,25 +21,26 @@ const app = createApp({
},
data() {
return {
// Wizard modal
wizardConfig: {
title: 'Wizard steps',
subtitle: 'Wizard subtitle description',
steps: [
{
id: 'step1',
icon: 'sn-icon sn-icon-open',
icon: 'sn-icon-open',
label: 'Step 1',
component: shallowRef(Step1)
},
{
id: 'step2',
icon: 'sn-icon sn-icon-edit',
icon: 'sn-icon-edit',
label: 'Step 2',
component: shallowRef(Step2)
},
{
id: 'step3',
icon: 'sn-icon sn-icon-inventory',
icon: 'sn-icon-inventory',
label: 'Step 3',
component: shallowRef(Step3)
}
@ -47,10 +49,49 @@ const app = createApp({
wizardParams: {
text: 'Some text'
},
showWizard: false
showWizard: false,
// Info modal
infoParams: {
title: 'Guide for updating the inventory',
elements: [
{
id: 'el1',
icon: 'sn-icon-export',
label: 'Export inventory',
subtext: "Before making edits, we advise you to export the latest inventory information. If you're only adding new items, consider exporting empty inventory."
},
{
id: 'el2',
icon: 'sn-icon-edit',
label: 'Edit your data',
subtext: 'Make sure to include header names in first row, followed by item data.'
},
{
id: 'el3',
icon: 'sn-icon-import',
label: 'Import new or update items',
subtext: 'Upload your data using .xlsx, .csv or .txt files.'
},
{
id: 'el4',
icon: 'sn-icon-tables',
label: 'Merge your data',
subtext: 'Complete the process by merging the columns you want to update.'
},
{
id: 'el5',
icon: 'sn-icon-open',
label: 'Learn more',
linkTo: 'https://knowledgebase.scinote.net/en/knowledge/how-to-add-items-to-an-inventory'
}
]
},
showInfo: false
};
}
});
app.component('WizardModal', WizardModal);
app.component('InfoModal', InfoModal);
app.config.globalProperties.i18n = window.I18n;
mountWithTurbolinks(app, '#modals');

View file

@ -0,0 +1,11 @@
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import 'vue3-perfect-scrollbar/dist/vue3-perfect-scrollbar.css';
import ImportRepositoryModal from '../../vue/repositories/modals/import/container.vue';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
const app = createApp({});
app.component('ImportRepositoryModal', ImportRepositoryModal);
app.use(PerfectScrollbar);
app.config.globalProperties.i18n = window.I18n;
mountWithTurbolinks(app, '#importRepositoryModal');

View file

@ -94,7 +94,7 @@
</label>
<SelectDropdown
:value="selectedTask"
:value="selectedTasks"
:disabled="!selectedExperiment"
:searchable="true"
ref="tasksSelector"
@ -102,6 +102,8 @@
:options="tasks"
:isLoading="tasksLoading"
:placeholder="tasksSelectorPlaceholder"
:multiple="true"
:withCheckboxes="true"
:no-options-placeholder="
i18n.t(
'repositories.modal_assign_items_to_task.body.task_select.no_options_placeholder'
@ -115,7 +117,7 @@
type="button"
class="btn btn-primary"
data-dismiss="modal"
:disabled="!selectedTask"
:disabled="!selectedTasks.length"
@click="assign"
>
{{ i18n.t("repositories.modal_assign_items_to_task.assign.text") }}
@ -127,6 +129,7 @@
</template>
<script>
/* global HelperModule */
import SelectDropdown from "../shared/select_dropdown.vue";
export default {
@ -143,7 +146,7 @@ export default {
tasks: [],
selectedProject: null,
selectedExperiment: null,
selectedTask: null,
selectedTasks: [],
projectsLoading: null,
experimentsLoading: null,
tasksLoading: null
@ -206,9 +209,6 @@ export default {
taskURL() {
return `${this.urls.tasks}?experiment_id=${this.selectedExperiment
|| ''}`;
},
assignURL() {
return this.urls.assign.replace(':module_id', this.selectedTask);
}
},
watch: {
@ -259,7 +259,7 @@ export default {
});
},
changeTask(value) {
this.selectedTask = value;
this.selectedTasks = value;
},
resetProjectSelector() {
this.projects = [];
@ -271,7 +271,7 @@ export default {
},
resetTaskSelector() {
this.tasks = [];
this.selectedTask = null;
this.selectedTasks = [];
},
resetSelectors() {
this.resetTaskSelector();
@ -279,20 +279,33 @@ export default {
this.resetProjectSelector();
},
assign() {
if (!this.selectedTask) return;
if (!this.selectedTasks.length) return;
$.ajax({
url: this.assignURL,
type: 'PATCH',
url: this.urls.assign,
type: 'POST',
dataType: 'json',
data: { rows_to_assign: this.rowsToAssign }
}).done(({ assigned_count }) => {
const skipped_count = this.rowsToAssign.length - assigned_count;
if (skipped_count) {
HelperModule.flashAlertMsg(this.i18n.t('repositories.modal_assign_items_to_task.assign.flash_some_assignments_success', { assigned_count, skipped_count }), 'success');
data: {
rows_to_assign: this.rowsToAssign,
my_module_ids: this.selectedTasks
}
}).done(({ assigned_count: assignedCount, skipped_count: skippedCount }) => {
if (skippedCount) {
HelperModule.flashAlertMsg(
this.i18n.t(
'repositories.modal_assign_items_to_task.assign.flash_some_assignments_success',
{ assigned_count: assignedCount, skipped_count: skippedCount }
),
'success'
);
} else {
HelperModule.flashAlertMsg(this.i18n.t('repositories.modal_assign_items_to_task.assign.flash_all_assignments_success', { count: assigned_count }), 'success');
HelperModule.flashAlertMsg(
this.i18n.t(
'repositories.modal_assign_items_to_task.assign.flash_all_assignments_success',
{ count: assignedCount }
),
'success'
);
}
}).fail(() => {
HelperModule.flashAlertMsg(this.i18n.t('repositories.modal_assign_items_to_task.assign.flash_assignments_failure'), 'danger');
@ -309,7 +322,7 @@ export default {
.dataTable()
.api()
.ajax
.reload();
.reload(null, false);
}
}
};

View file

@ -0,0 +1,218 @@
<template>
<div>
<div class="mb-4">
<label class="sci-label">{{ i18n.t("dashboard.create_task_modal.task_name") }}</label>
<div class="sci-input-container-v2" :class="{
'error': !validTaskName && taskName.length > 0
}" >
<input type="text" ref="taskName" class="sci-input" v-model="taskName" :placeholder="i18n.t('dashboard.create_task_modal.task_name_placeholder')" />
</div>
<span v-if="!validTaskName && taskName.length > 0" class="sci-error-text">
{{ i18n.t("dashboard.create_task_modal.task_name_error", { length: minLength }) }}
</span>
</div>
<div class="mb-2">
<label class="sci-label">{{ i18n.t("dashboard.create_task_modal.project") }}</label>
<SelectDropdown
:optionsUrl="projectsUrl"
:searchable="true"
:value="selectedProject"
:optionRenderer="newProjectRenderer"
:placeholder="i18n.t('dashboard.create_task_modal.project_placeholder')"
@change="changeProject"
/>
</div>
<div v-if="selectedProject == 0">
<div class="flex gap-2 text-xs items-center">
<div class="sci-checkbox-container">
<input type="checkbox" class="sci-checkbox" v-model="publicProject" value="visible"/>
<span class="sci-checkbox-label"></span>
</div>
<span v-html="i18n.t('projects.index.modal_new_project.visibility_html')"></span>
</div>
<div class="mt-4" :class="{'hidden': !publicProject}">
<label class="sci-label">{{ i18n.t("dashboard.create_task_modal.user_role") }}</label>
<SelectDropdown
:options="userRoles"
:value="defaultRole"
@change="changeRole"
:placeholder="i18n.t('dashboard.create_task_modal.user_role_placeholder')"
/>
</div>
</div>
<div class="mt-4">
<label class="sci-label">{{ i18n.t("dashboard.create_task_modal.experiment") }}</label>
<SelectDropdown
:optionsUrl="experimentsUrl"
:urlParams="{ project: {
id: selectedProject,
name: newProjectName
}}"
:disabled="!(selectedProject != null && selectedProject >= 0)"
:searchable="true"
:value="selectedExperiment"
:placeholder="i18n.t('dashboard.create_task_modal.experiment_placeholder')"
:optionRenderer="newExperimentRenderer"
@change="changeExperiment"
/>
</div>
<hr class="my-6">
<div class="flex items-center justify-end gap-4">
<button class="btn btn-light" @click="closeModal">
{{ i18n.t("dashboard.create_task_modal.cancel") }}
</button>
<button class="btn btn-primary" @click="createMyModule" :disabled="!validTaskName || !validExperiment || !validProject || creatingTask">
{{ i18n.t("dashboard.create_task_modal.create") }}
</button>
</div>
</div>
</template>
<script>
/* global GLOBAL_CONSTANTS */
import SelectDropdown from '../shared/select_dropdown.vue';
import axios from '../../packs/custom_axios.js';
export default {
name: 'DashboardNewTask',
components: {
SelectDropdown
},
computed: {
validTaskName() {
return this.taskName.length >= this.minLength;
},
validExperiment() {
return this.selectedExperiment != null || this.newExperimentName.length > this.minLength;
},
validProject() {
return this.selectedProject != null || this.newProjectName.length > this.minLength;
},
minLength() {
return GLOBAL_CONSTANTS.NAME_MIN_LENGTH;
}
},
created() {
this.fetchUserRoles();
$('#create-task-modal').on('hidden.bs.modal', () => {
this.$emit('close');
});
$('#create-task-modal').on('shown.bs.modal', this.focusInput);
},
unmounted() {
$('#create-task-modal').off('shown.bs.modal', this.focusInput);
},
props: {
projectsUrl: {
type: String,
required: true
},
experimentsUrl: {
type: String,
required: true
},
rolesUrl: {
type: String,
required: true
},
createUrl: {
type: String,
required: true
}
},
data() {
return {
projects: [],
selectedProject: null,
newProjectName: '',
experiments: [],
selectedExperiment: null,
newExperimentName: '',
userRoles: [],
taskName: '',
publicProject: false,
defaultRole: null,
creatingTask: false
};
},
methods: {
createMyModule() {
if (this.creatingTask) return;
this.creatingTask = true;
axios.post(this.createUrl, {
my_module: {
name: this.taskName
},
experiment: {
id: this.selectedExperiment,
name: this.newExperimentName
},
project: {
id: this.selectedProject,
name: this.newProjectName,
visibility: (this.publicProject ? 'visible' : 'hidden'),
default_public_user_role_id: this.defaultRole
}
})
.then((response) => {
this.creatingTask = false;
window.location.href = response.data.my_module_path;
});
},
changeRole(value) {
this.defaultRole = value;
},
changeProject(value, label) {
this.selectedProject = value;
this.newProjectName = label;
},
changeExperiment(value, label) {
this.selectedExperiment = value;
this.newExperimentName = label;
},
newProjectRenderer(option) {
if (option[0] > 0) {
return option[1];
}
return `
<div class="flex items-center gap-2 truncate">
<span class="sn-icon sn-icon-new-task"></span>
<span class="truncate">${this.i18n.t('dashboard.create_task_modal.new_project', { name: option[1] })}</span
</div>
`;
},
newExperimentRenderer(option) {
if (option[0] > 0) {
return option[1];
}
return `
<div class="flex items-center gap-2 truncate">
<span class="sn-icon sn-icon-new-task"></span>
<span class="truncate">${this.i18n.t('dashboard.create_task_modal.new_experiment', { name: option[1] })}</span
</div>
`;
},
closeModal() {
$('#create-task-modal').modal('hide');
this.taskName = '';
this.selectedProject = null;
this.newProjectName = '';
this.selectedExperiment = null;
this.newExperimentName = '';
},
fetchUserRoles() {
axios.get(this.rolesUrl)
.then((response) => {
this.userRoles = response.data.data;
});
},
focusInput() {
this.$refs.taskName.focus();
}
}
};
</script>

View file

@ -64,6 +64,7 @@ export default {
},
mounted() {
SmartAnnotation.init($(this.$refs.description), false);
$(this.$refs.modal).on('hidden.bs.modal', this.handleAtWhoModalClose);
},
mixins: [modalMixin],
methods: {
@ -80,6 +81,9 @@ export default {
HelperModule.flashAlertMsg(error.response.data.message, 'danger');
});
},
handleAtWhoModalClose() {
$('.atwho-view.old').css('display', 'none');
}
},
};
</script>

View file

@ -174,7 +174,7 @@ export default {
columns.push({
field: 'comments',
headerName: this.i18n.t('experiments.table.column.comments_html'),
sortable: false,
sortable: true,
cellRenderer: CommentsRenderer,
notSelectable: true
});

View file

@ -17,7 +17,7 @@
<div class="sci-input-container-v2" :class="{'error': error}" :data-error="error">
<input type="text" v-model="name"
class="sci-input-field" autofocus="true" ref="input"
:placeholder="i18n.t('projects.index.modal_new_project.name_placeholder')" />
:placeholder="i18n.t('projects.index.modal_new_project_folder.name_placeholder')" />
</div>
</div>
</div>

View file

@ -1,46 +1,46 @@
<template>
<div v-if="protocol.id" class="task-protocol">
<div ref="header" class="task-section-header ml-[-1rem] w-[calc(100%_+_2rem)] px-4 bg-sn-white sticky top-0 transition" v-if="!inRepository">
<div class="flex items-center grow">
<div class="portocol-header-left-part grow">
<template v-if="headerSticked && moduleName">
<i class="sn-icon sn-icon-navigator sci--layout--navigator-open cursor-pointer p-1.5 border rounded border-sn-light-grey mr-4"></i>
<div @click="scrollTop" class="task-section-title w-[calc(100%_-_35rem)] min-w-[5rem] cursor-pointer">
<h2 class="truncate leading-6">{{ moduleName }}</h2>
</div>
</template>
<template v-else>
<a class="task-section-caret" tabindex="0" role="button" data-toggle="collapse" href="#protocol-content" aria-expanded="true" aria-controls="protocol-content">
<i class="sn-icon sn-icon-right"></i>
<div class="task-section-title truncate">
<h2>{{ i18n.t('Protocol') }}</h2>
</div>
</a>
</template>
<div :class="{'hidden': headerSticked}">
<div class="my-module-protocol-status">
<!-- protocol status dropdown gets mounted here -->
<div class="portocol-header-left-part grow" :class="{'overflow-hidden': headerSticked && moduleName}">
<template v-if="headerSticked && moduleName">
<i class="sn-icon sn-icon-navigator sci--layout--navigator-open cursor-pointer p-1.5 border rounded border-sn-light-grey mr-4"></i>
<div @click="scrollTop" class="task-section-title min-w-[5rem] cursor-pointer" :title="moduleName">
<h2 class="truncate leading-6">{{ moduleName }}</h2>
</div>
</template>
<template v-else>
<a class="task-section-caret" tabindex="0" role="button" data-toggle="collapse" href="#protocol-content" aria-expanded="true" aria-controls="protocol-content">
<i class="sn-icon sn-icon-right"></i>
<div class="task-section-title truncate">
<h2>{{ i18n.t('Protocol') }}</h2>
</div>
</a>
</template>
<div :class="{'hidden': headerSticked}">
<div class="my-module-protocol-status">
<!-- protocol status dropdown gets mounted here -->
</div>
</div>
</div>
<div class="actions-block">
<div class="protocol-buttons-group shrink-0">
<div class="protocol-buttons-group shrink-0 bg-sn-white">
<a v-if="urls.add_step_url"
class="btn btn-secondary"
class="btn btn-secondary icon-btn xl:!px-4"
:title="i18n.t('protocols.steps.new_step_title')"
@keyup.enter="addStep(steps.length)"
@click="addStep(steps.length)"
tabindex="0">
<span class="sn-icon sn-icon-new-task" aria-hidden="true"></span>
<span>{{ i18n.t("protocols.steps.new_step") }}</span>
<span class="tw-hidden xl:inline">{{ i18n.t("protocols.steps.new_step") }}</span>
</a>
<template v-if="steps.length > 0">
<button class="btn btn-secondary" @click="collapseSteps" tabindex="0">
{{ i18n.t("protocols.steps.collapse_label") }}
<button :title="i18n.t('protocols.steps.collapse_label')" v-if="!stepCollapsed" class="btn btn-secondary icon-btn xl:!px-4" @click="collapseSteps" tabindex="0">
<i class="sn-icon sn-icon-collapse-all"></i>
<span class="tw-hidden xl:inline">{{ i18n.t("protocols.steps.collapse_label") }}</span>
</button>
<button class="btn btn-secondary" @click="expandSteps" tabindex="0">
{{ i18n.t("protocols.steps.expand_label") }}
<button v-else :title="i18n.t('protocols.steps.expand_label')" class="btn btn-secondary icon-btn xl:!px-4" @click="expandSteps" tabindex="0">
<i class="sn-icon sn-icon-expand-all"></i>
<span class="tw-hidden xl:inline">{{ i18n.t("protocols.steps.expand_label") }}</span>
</button>
</template>
<ProtocolOptions
@ -154,9 +154,11 @@
</a>
<div v-if="steps.length > 0" class="flex justify-between items-center gap-4">
<button @click="collapseSteps" class="btn btn-secondary flex px-4" tabindex="0" data-e2e="e2e-BT-protocol-templateSteps-collapse">
<i class="sn-icon sn-icon-collapse-all"></i>
{{ i18n.t("protocols.steps.collapse_label") }}
</button>
<button @click="expandSteps" class="btn btn-secondary flex px-4" tabindex="0" data-e2e="e2e-BT-protocol-templateSteps-expand">
<i class="sn-icon sn-icon-expand-all"></i>
{{ i18n.t("protocols.steps.expand_label") }}
</button>
<a v-if="steps.length > 0 && urls.reorder_steps_url"
@ -193,7 +195,9 @@
@step:attachemnts:loaded="stepToReload = null"
@step:move_attachment="reloadStep"
@step:drag_enter="dragEnter"
@step:collapsed="checkStepsState"
:reorderStepUrl="steps.length > 1 ? urls.reorder_steps_url : null"
:userSettingsUrl="userSettingsUrl"
:assignableMyModuleId="protocol.attributes.assignable_my_module_id"
/>
<div v-if="(index === steps.length - 1) && urls.add_step_url" class="insert-step" @click="addStep(index + 1)" data-e2e="e2e-BT-protocol-templateSteps-insertStep">
@ -250,7 +254,7 @@ import ReorderableItemsModal from '../shared/reorderable_items_modal.vue';
import PublishProtocol from './modals/publish_protocol.vue';
import clipboardPasteModal from '../shared/content/attachments/clipboard_paste_modal.vue';
import AssetPasteMixin from '../shared/content/attachments/mixins/paste.js';
import axios from '../../packs/custom_axios';
import UtilsMixin from '../mixins/utils.js';
import stackableHeadersMixin from '../mixins/stackableHeadersMixin';
import moduleNameObserver from '../mixins/moduleNameObserver';
@ -290,10 +294,13 @@ export default {
reordering: false,
publishing: false,
stepToReload: null,
activeDragStep: null
activeDragStep: null,
userSettingsUrl: null,
stepCollapsed: false
};
},
mounted() {
this.userSettingsUrl = document.querySelector('meta[name="user-settings-url"]').getAttribute('content');
$.get(this.protocolUrl, (result) => {
this.protocol = result.data;
this.$nextTick(() => {
@ -320,11 +327,41 @@ export default {
reloadStep(step) {
this.stepToReload = step;
},
checkStepsState() {
this.stepCollapsed = this.$refs.steps.every((step) => step.isCollapsed);
},
collapseSteps() {
$('.step-container .collapse').collapse('hide');
this.updateStepStateSettings(true);
this.$refs.steps.forEach((step) => step.isCollapsed = true);
this.stepCollapsed = true;
},
expandSteps() {
$('.step-container .collapse').collapse('show');
this.updateStepStateSettings(false);
this.$refs.steps.forEach((step) => step.isCollapsed = false);
this.stepCollapsed = false;
},
updateStepStateSettings(newState) {
const updatedData = this.steps.reduce((acc, currentStep) => {
acc[currentStep.id] = newState;
return acc;
}, {});
this.steps = this.steps.map((step) => ({
...step,
attributes: {
...step.attributes,
collapsed: newState
}
}));
const settings = {
key: 'task_step_states',
data: updatedData
};
axios.put(this.userSettingsUrl, { settings: [settings] });
},
deleteSteps() {
$.post(this.urls.delete_steps_url, () => {

View file

@ -15,7 +15,7 @@
<div class="step-header">
<div class="step-element-header" :class="{ 'no-hover': !urls.update_url }">
<div class="flex items-center gap-4 py-0.5 border-0 border-y border-transparent border-solid">
<a class="step-collapse-link hover:no-underline focus:no-underline"
<a ref="toggleElement" class="step-collapse-link hover:no-underline focus:no-underline"
:href="'#stepBody' + step.id"
data-toggle="collapse"
data-remote="true"
@ -207,6 +207,9 @@
reorderStepUrl: {
required: false
},
userSettingsUrl: {
required: false
},
assignableMyModuleId: {
type: Number,
required: false
@ -293,9 +296,27 @@
if (this.activeDragStep != this.step.id && this.dragingFile) {
this.dragingFile = false;
}
},
step: {
handler(newVal) {
if (this.isCollapsed !== newVal.attributes.collapsed) {
this.toggleCollapsed();
}
},
deep: true
}
},
mounted() {
this.$nextTick(() => {
const stepId = `#stepBody${this.step.id}`;
this.isCollapsed = this.step.attributes.collapsed;
if (this.isCollapsed) {
$(stepId).collapse('hide');
} else {
$(stepId).collapse('show');
}
this.$emit('step:collapsed');
});
$(this.$refs.comments).data('closeCallback', this.closeCommentsSidebar);
$(this.$refs.comments).data('openCallback', this.closeCommentsSidebar);
$(this.$refs.actionsDropdownButton).on('shown.bs.dropdown hidden.bs.dropdown', () => {
@ -466,6 +487,16 @@
},
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
this.step.attributes.collapsed = this.isCollapsed;
const settings = {
key: 'task_step_states',
data: { [this.step.id]: this.isCollapsed }
};
this.$emit('step:collapsed');
axios.put(this.userSettingsUrl, { settings: [settings] });
},
showDeleteModal() {
this.confirmingDelete = true;
@ -588,6 +619,10 @@
$.post(this.urls[`create_${elementType}_url`], { tableDimensions: tableDimensions, plateTemplate: plateTemplate }, (result) => {
result.data.isNew = true;
this.elements.push(result.data)
if (this.isCollapsed) {
this.$refs.toggleElement.click();
}
this.$emit('stepUpdated')
}).fail(() => {
HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger');

View file

@ -0,0 +1,70 @@
<template>
<div ref="modal" @keydown.esc="cancel" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<form @submit.prevent="submit" class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><i class="sn-icon sn-icon-close"></i></button>
<h4 class="modal-title">
{{ this.i18n.t('repositories.index.modal_export.title') }}
</h4>
</div>
<div class="modal-body">
<p class="description-p1 mb-6" v-html="i18n.t('repositories.index.modal_export.description_p1_html', {
team_name: rows[0].team,
count: rows.length})"></p>
<p class="bg-sn-super-light-blue p-3 mb-6"> {{ this.i18n.t('repositories.index.modal_export.description_alert') }} </p>
<p class="mb-6"> {{ this.i18n.t('repositories.index.modal_export.description_p2') }} </p>
<div class="sci-radio-container mt-3">
<input type="radio" class="sci-radio" name="file_type" value="xlsx" v-model="selectedOption">
<span class="sci-radio-label"></span>
</div>
<label class="font-normal ml-3 mb-0">.xlsx</label>
<div class="sci-radio-container ml-[30px]">
<input type="radio" class="sci-radio" name="file_type" value="csv" v-model="selectedOption">
<span class="sci-radio-label"></span>
</div>
<label class="font-normal ml-3 mb-0">.csv</label>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
<button type="submit" class="btn btn-primary"> {{ i18n.t('repositories.index.modal_export.export') }} </button>
</div>
</form>
</div>
</div>
</template>
<script>
/* global HelperModule */
import axios from '../../../packs/custom_axios.js';
import modalMixin from '../../shared/modal_mixin';
export default {
name: 'ExportRepositoryModal',
props: {
rows: Object,
exportAction: Object
},
mixins: [modalMixin],
data() {
return {
selectedOption: this.exportAction.export_file_type
};
},
methods: {
submit() {
const payload = {
repository_ids: this.rows.map((row) => row.id),
file_type: this.selectedOption
};
axios.post(this.exportAction.path, payload).then((response) => {
this.$emit('export');
HelperModule.flashAlertMsg(response.data.message, 'success');
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
}
}
};
</script>

View file

@ -0,0 +1,135 @@
<template>
<div v-if="modalOpened" class="relative">
<component
v-if="activeStep !== 'ExportModal'"
:is="activeStep"
:params="params"
:key="modalId"
:loading="loading"
@uploadFile="uploadFile"
@generatePreview="generatePreview"
@changeStep="changeStep"
@importRows="importRecords"
@updateAutoMapping="updateAutoMapping"
/>
<ExportModal
v-else
:rows="[{id: params.id, team: params.attributes.team_name}]"
:exportAction="params.attributes.export_actions"
@close="activeStep ='UploadStep'"
@export="activeStep = 'UploadStep'"
/>
</div>
</template>
<script>
/* global HelperModule */
import axios from '../../../../packs/custom_axios';
import InfoModal from '../../../shared/info_modal.vue';
import UploadStep from './upload_step.vue';
import MappingStep from './mapping_step.vue';
import PreviewStep from './preview_step.vue';
import ExportModal from '../export.vue';
export default {
name: 'ImportRepositoryModal',
components: {
InfoModal,
UploadStep,
MappingStep,
PreviewStep,
ExportModal
},
props: {
repositoryUrl: String,
required: true
},
data() {
return {
modalOpened: false,
activeStep: 'UploadStep',
params: { autoMapping: true },
modalId: null,
loading: false
};
},
created() {
window.importRepositoryModalComponent = this;
},
methods: {
open() {
this.activeStep = 'UploadStep';
this.params.selectedItems = null;
this.params.autoMapping = true;
this.fetchRepository();
},
fetchRepository() {
axios.get(this.repositoryUrl)
.then((response) => {
this.params = { ...this.params, ...response.data.data };
this.modalId = Math.random().toString(36);
this.modalOpened = true;
});
},
uploadFile(params) {
this.params = { ...this.params, ...params };
this.activeStep = 'MappingStep';
},
updateAutoMapping(value) {
this.params.autoMapping = value;
},
generatePreview(selectedItems, updateWithEmptyCells, onlyAddNewItems) {
this.params.selectedItems = selectedItems;
this.params.updateWithEmptyCells = updateWithEmptyCells;
this.params.onlyAddNewItems = onlyAddNewItems;
this.importRecords(true);
},
changeStep(step) {
this.activeStep = step;
},
generateMapping() {
return this.params.selectedItems.reduce((obj, item) => {
obj[item.index] = item.key || '';
return obj;
}, {});
},
importRecords(preview) {
if (this.loading) {
return;
}
const jsonData = {
file_id: this.params.temp_file.id,
mappings: this.generateMapping(),
id: this.params.id,
preview: preview,
should_overwrite_with_empty_cells: this.params.updateWithEmptyCells,
can_edit_existing_items: !this.params.onlyAddNewItems
};
this.loading = true;
axios.post(this.params.attributes.urls.import_records, jsonData)
.then((response) => {
if (preview) {
this.params.preview = response.data.changes;
this.params.import_date = response.data.import_date;
this.activeStep = 'PreviewStep';
} else {
HelperModule.flashAlertMsg(response.data.message, 'success');
this.modalOpened = false;
this.activeStep = null;
$('.dataTable.repository-dataTable').DataTable().ajax.reload(null, false);
}
this.loading = false;
})
.catch((error) => {
HelperModule.flashAlertMsg(error.response.data.message, 'danger');
this.loading = false;
});
}
}
};
</script>

View file

@ -0,0 +1,220 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<Loading v-if="loading" />
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" data-e2e="e2e-BT-newInventoryModal-close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate" id="edit-project-modal-label" data-e2e="e2e-TX-newInventoryModal-title">
{{ i18n.t('repositories.import_records.steps.step2.title') }}
</h4>
</div>
<div class="modal-body">
<p class="text-sn-dark-grey">
{{ this.i18n.t('repositories.import_records.steps.step2.subtitle') }}
</p>
<div class="flex gap-6 items-center my-6">
<div class="flex items-center gap-2" :title="i18n.t('repositories.import_records.steps.step2.autoMappingTooltip')">
<div class="sci-checkbox-container">
<input type="checkbox" class="sci-checkbox" @change="$emit('update-auto-mapping', $event.target.checked)" :checked="params.autoMapping" />
<span class="sci-checkbox-label"></span>
</div>
{{ i18n.t('repositories.import_records.steps.step2.autoMappingText') }}
</div>
<!--
<div class="flex items-center gap-1">
<div class="sci-checkbox-container my-auto">
<input type="checkbox" class="sci-checkbox" :checked="updateWithEmptyCells" @change="toggleUpdateWithEmptyCells"/>
<span class="sci-checkbox-label"></span>
</div>
{{ i18n.t('repositories.import_records.steps.step2.updateEmptyCellsText') }}
</div>
<div class="flex items-center gap-1">
<div class="sci-checkbox-container my-auto">
<input type="checkbox" class="sci-checkbox" :checked="onlyAddNewItems" @change="toggleOnlyAddNewItems" />
<span class="sci-checkbox-label"></span>
</div>
{{ i18n.t('repositories.import_records.steps.step2.onlyAddNewItemsText') }}
</div>
-->
</div>
{{ i18n.t('repositories.import_records.steps.step2.importedFileText') }} {{ params.file_name }}
<hr class="m-0 mt-6">
<div class="grid grid-cols-[3rem_14.5rem_1.5rem_14.5rem_5rem_14.5rem] px-2">
<div v-for="(column, key) in columnLabels" class="flex items-center px-2 py-2 font-bold">{{ column }}</div>
<template v-for="(item, index) in params.import_data.header" :key="item">
<MappingStepTableRow
:index="index"
:item="item"
:dropdownOptions="computedDropdownOptions"
:params="params"
:value="this.selectedItems.find((item) => item.index === index)"
@selection:changed="handleChange"
:autoMapping="params.autoMapping"
/>
</template>
</div>
<!-- imported/ignored section -->
<div class="flex gap-1 mt-6"
v-html="i18n.t('repositories.import_records.steps.step2.importedIgnoredSection', {
imported: computedImportedIgnoredInfo.importedSum,
ignored: computedImportedIgnoredInfo.ignoredSum
})"
>
</div>
</div>
<!-- footer -->
<div class="modal-footer">
<div id="error" class="flex flex-row gap-3 text-sn-delete-red">
<i v-if="error" class="sn-icon sn-icon-alert-warning my-auto"></i>
<div class="my-auto">{{ error ? error : '' }}</div>
</div>
<button class="btn btn-secondary ml-auto" @click="close" aria-label="Close">
{{ i18n.t('repositories.import_records.steps.step2.cancelBtnText') }}
</button>
<button class="btn btn-primary" :disabled="!canSubmit" @click="importRecords">
{{ i18n.t('repositories.import_records.steps.step2.confirmBtnText') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from '../../../../packs/custom_axios';
import SelectDropdown from '../../../shared/select_dropdown.vue';
import MappingStepTableRow from './mapping_step_table_row.vue';
import modalMixin from '../../../shared/modal_mixin';
import Loading from '../../../shared/loading.vue';
export default {
name: 'MappingStep',
emits: ['close', 'generatePreview', 'updateAutoMapping'],
mixins: [modalMixin],
components: {
SelectDropdown,
MappingStepTableRow,
Loading
},
props: {
params: {
type: Object,
required: true
},
loading: {
type: Boolean,
required: true
}
},
data() {
return {
updateWithEmptyCells: false,
onlyAddNewItems: false,
columnLabels: {
0: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.number'),
1: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.importedColumns'),
2: '',
3: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.scinoteColumns'),
4: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.status'),
5: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.exampleData')
},
selectedItems: [],
importRecordsUrl: null,
teamId: null,
repositoryId: null,
availableFields: [],
alwaysAvailableFields: [],
repositoryColumns: null,
error: null
};
},
methods: {
handleChange(payload) {
this.error = null;
const { index, key, value } = payload;
const item = this.selectedItems.find((i) => i.index === index);
const usedBeforeItem = this.selectedItems.find((i) => i.key === key && i.index !== index);
if (usedBeforeItem && usedBeforeItem.key !== 'do_not_import') {
usedBeforeItem.key = null;
usedBeforeItem.value = null;
}
item.key = key;
item.value = value;
},
loadAvailableFields() {
// Adding alreadySelected attribute for tracking
this.availableFields = [];
Object.entries(this.params.import_data.available_fields).forEach(([key, value]) => {
let columnTypeName = '';
if (key === '-1') {
columnTypeName = this.i18n.t('repositories.import_records.steps.step2.computedDropdownOptions.name');
} else if (key === '0') {
columnTypeName = this.i18n.t('repositories.import_records.steps.step2.computedDropdownOptions.id');
} else {
const column = this.repositoryColumns.find((el) => el[0] === parseInt(key, 10));
columnTypeName = this.i18n.t(`repositories.import_records.steps.step2.computedDropdownOptions.${column[2]}`);
}
const field = {
key, value, alreadySelected: false, typeName: columnTypeName
};
this.availableFields.push(field);
});
},
loadSelectedItems() {
if (this.params.selectedItems) {
this.selectedItems = this.params.selectedItems;
} else {
this.selectedItems = this.params.import_data.header.map((item, index) => ({ index, key: null, value: null }));
}
},
importRecords() {
if (!this.selectedItems.find((item) => item.key === '-1')) {
this.error = this.i18n.t('repositories.import_records.steps.step2.selectNamePropertyError');
return '';
}
this.$emit(
'generatePreview',
this.selectedItems
);
return true;
}
},
computed: {
computedDropdownOptions() {
let options = this.availableFields.map((el) => [String(el.key), `${String(el.value)} (${el.typeName})`]);
options = [
['do_not_import', this.i18n.t('repositories.import_records.steps.step2.table.tableRow.placeholders.doNotImport')]
].concat(options);
// options = [['new', this.i18n.t('repositories.import_records.steps.step2.table.tableRow.importAsNewColumn')]].concat(options);
return options;
},
computedImportedIgnoredInfo() {
const importedSum = this.selectedItems.filter((i) => i.key && i.key !== 'do_not_import').length;
const ignoredSum = this.selectedItems.length - importedSum;
return { importedSum, ignoredSum };
},
canSubmit() {
return this.selectedItems.filter((i) => i.key && i.key !== 'do_not_import').length > 0;
}
},
created() {
this.repositoryColumns = this.params.attributes.repository_columns;
this.loadAvailableFields();
this.loadSelectedItems();
}
};
</script>

View file

@ -0,0 +1,178 @@
<template>
<!-- number col -->
<div class="py-1 min-h-12 px-2 flex items-center" :class="{
'bg-sn-super-light-blue': selected
}">{{ index + 1 }}</div>
<div class="py-1 truncate min-h-12 px-2 flex items-center" :title="item" :class="{
'bg-sn-super-light-blue': selected
}">{{ item }}</div>
<div class="py-1 min-h-12 flex items-center justify-center text-sn-grey" :class="{
'bg-sn-super-light-blue': selected
}">
<i class="sn-icon sn-icon-arrow-right text-sn-gray"></i>
</div>
<div class="py-1 min-h-12 flex items-center flex-col gap-2 px-2" :class="{
'bg-sn-super-light-blue': selected
}">
<SelectDropdown
:options="dropdownOptions"
@change="changeSelected"
:size="'sm'"
class="max-w-96"
:searchable="true"
:class="{
'outline-sn-alert-brittlebush outline-1 outline rounded': computeMatchNotFound
}"
:placeholder="i18n.t('repositories.import_records.steps.step2.table.tableRow.placeholders.matchNotFound')"
:title="this.selectedColumnType?.value"
:value="this.selectedColumnType?.key"
></SelectDropdown>
<template v-if="false">
<SelectDropdown
:options="newColumnTypes"
@change="(v) => { newColumn.type = v }"
:value="newColumn.type"
size="sm"
placeholder="Select column type"
></SelectDropdown>
<div class="sci-input-container-v2 w-full">
<input type="text" v-model="newColumn.name" class="sci-input" placeholder="Name">
</div>
</template>
</div>
<div class="py-1 min-h-12 px-2 flex items-center" :class="{
'bg-sn-super-light-blue': selected
}">
<i v-if="differentMapingName" :title="i18n.t('repositories.import_records.steps.step2.table.tableRow.importedColumnTitle')"
class="sn-icon sn-icon-info text-sn-science-blue"></i>
<i v-else-if="columnMapped" :title="i18n.t('repositories.import_records.steps.step2.table.tableRow.importedColumnTitle')" class="sn-icon sn-icon-check"></i>
<i v-else-if="matchNotFound && !isSystemColumn(item)" :title="i18n.t('repositories.import_records.steps.step2.table.tableRow.matchNotFoundColumnTitle')"
class="sn-icon sn-icon-close text-sn-alert-brittlebush"></i>
<i v-else :title="i18n.t('repositories.import_records.steps.step2.table.tableRow.doNotImportColumnTitle')" class="sn-icon sn-icon-close text-sn-sleepy-grey"></i>
</div>
<div class="py-1 min-h-12 px-2 flex items-center" :title="params.import_data.columns[index]" :class="{
'bg-sn-super-light-blue': selected
}">{{ params.import_data.columns[index] }}</div>
</template>
<script>
import SelectDropdown from '../../../shared/select_dropdown.vue';
export default {
name: 'SecondStepTableRow',
emits: ['selection:changed'],
components: {
SelectDropdown
},
props: {
index: {
type: Number,
required: true
},
dropdownOptions: {
type: Array,
required: true
},
item: {
type: String,
required: true
},
params: {
type: Object,
required: true
},
autoMapping: {
type: Boolean,
required: true
},
value: Object
},
data() {
return {
selectedColumnType: null,
newColumn: {
type: 'Text',
name: ''
},
systemColumns: [
this.i18n.t('repositories.import_records.steps.step2.systemColumns.added_by'),
this.i18n.t('repositories.import_records.steps.step2.systemColumns.created_on'),
this.i18n.t('repositories.import_records.steps.step2.systemColumns.updated_by'),
this.i18n.t('repositories.import_records.steps.step2.systemColumns.updated_on'),
this.i18n.t('repositories.import_records.steps.step2.systemColumns.archived_by'),
this.i18n.t('repositories.import_records.steps.step2.systemColumns.archived_on'),
this.i18n.t('repositories.import_records.steps.step2.systemColumns.parents'),
this.i18n.t('repositories.import_records.steps.step2.systemColumns.children')
],
newColumnTypes: [
['Text', this.i18n.t('repositories.import_records.steps.step2.table.tableRow.newColumnType.text')],
['List', this.i18n.t('repositories.import_records.steps.step2.table.tableRow.newColumnType.list')]
]
};
},
watch: {
selected() {
if (this.value?.key === null) {
this.selectedColumnType = null;
}
},
autoMapping(newVal) {
if (newVal === true) {
this.autoMap();
} else {
this.clearAutoMap();
}
}
},
computed: {
computeMatchNotFound() {
return this.autoMapping && !this.isSystemColumn(this.item) && ((this.selectedColumnType && !this.selectedColumnType.key) || !this.selectedColumnType);
},
selected() {
return this.columnMapped;
},
differentMapingName() {
return this.columnMapped && this.selectedColumnType?.value !== this.item && this.value?.key !== 'do_not_import';
},
matchNotFound() {
return this.autoMapping && this.columnMapped;
},
columnMapped() {
return this.selectedColumnType?.key && this.selectedColumnType?.key !== 'do_not_import';
}
},
methods: {
isSystemColumn(column) {
return this.systemColumns.includes(column);
},
autoMap() {
this.changeSelected(null);
Object.entries(this.params.import_data.available_fields).forEach(([key, value]) => {
if (this.item === value) {
this.changeSelected(key);
}
});
},
clearAutoMap() {
this.changeSelected('do_not_import');
},
changeSelected(e) {
const value = this.params.import_data.available_fields[e];
this.selectedColumnType = { index: this.index, key: e, value };
this.$emit('selection:changed', this.selectedColumnType);
}
},
mounted() {
if (this.autoMapping) {
this.autoMap();
} else {
this.selectedColumnType = this.value;
}
}
};
</script>

View file

@ -0,0 +1,215 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<Loading v-if="loading" />
<div class="modal-content grow">
<div class="modal-header gap-4">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" data-e2e="e2e-BT-newInventoryModal-close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate !block" id="edit-project-modal-label" data-e2e="e2e-TX-newInventoryModal-title">
{{ i18n.t('repositories.import_records.steps.step3.title') }}
</h4>
</div>
<div class="modal-body">
<p class="text-sn-dark-grey mb-6">
{{ i18n.t('repositories.import_records.steps.step3.subtitle', { inventory: params.attributes.name }) }}
</p>
<div class="flex items-center justify-between text-sn-dark-gray text-sm">
<div>
<div v-html="i18n.t('repositories.import_records.steps.step3.updated_items')"></div>
<hr class="my-1">
<h2 class="m-0 text-sn-alert-green">{{ counters.updated }}</h2>
</div>
<div>
<div v-html="i18n.t('repositories.import_records.steps.step3.new_items')"></div>
<hr class="my-1">
<h2 class="m-0 text-sn-alert-green">{{ counters.created }}</h2>
</div>
<div>
<div v-html="i18n.t('repositories.import_records.steps.step3.unchanged_items')"></div>
<hr class="my-1">
<h2 class="m-0 ">{{ counters.unchanged }}</h2>
</div>
<div>
<div v-html="i18n.t('repositories.import_records.steps.step3.duplicated_items')"></div>
<hr class="my-1">
<h2 class="m-0 text-sn-alert-passion">{{ counters.duplicated }}</h2>
</div>
<div>
<div v-html="i18n.t('repositories.import_records.steps.step3.invalid_items')"></div>
<hr class="my-1">
<h2 class="m-0 text-sn-alert-passion">{{ counters.invalid }}</h2>
</div>
<div>
<div v-html="i18n.t('repositories.import_records.steps.step3.archived_items')"></div>
<hr class="my-1">
<h2 class="m-0">{{ counters.archived }}</h2>
</div>
</div>
<div class="my-6">
{{ i18n.t('repositories.import_records.steps.step2.importedFileText') }} {{ params.file_name }}
</div>
<div class="h-80">
<ag-grid-vue
class="ag-theme-alpine w-full flex-grow h-full z-10"
:columnDefs="columnDefs"
:defaultColDef="{
resizable: true,
sortable: false,
suppressMovable: true
}"
:rowData="tableData"
:suppressRowTransform="true"
:suppressRowClickSelection="true"
:enableCellTextSelection="true"
></ag-grid-vue>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="$emit('changeStep', 'MappingStep')">
{{ i18n.t('general.back') }}
</button>
<button type="button" class="btn btn-primary" @click="$emit('importRows')">
{{ i18n.t('repositories.import_records.steps.step3.import') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { AgGridVue } from 'ag-grid-vue3';
import modalMixin from '../../../shared/modal_mixin';
import Loading from '../../../shared/loading.vue';
export default {
name: 'PreviewStep',
mixins: [modalMixin],
props: {
params: {
type: Object,
required: true
},
loading: {
type: Boolean,
required: true
}
},
components: {
AgGridVue,
Loading
},
data() {
return {
};
},
computed: {
counters() {
return {
updated: this.filterRows('updated').length,
created: this.filterRows('created').length,
unchanged: this.filterRows('unchanged').length,
duplicated: this.filterRows('duplicated').length,
invalid: this.filterRows('invalid').length,
archived: this.filterRows('archived').length
};
},
columnDefs() {
const columns = [
{
field: 'code',
headerName: this.i18n.t('repositories.import_records.steps.step3.code'),
cellRenderer: this.highlightRenderer
},
{
field: 'name',
headerName: this.i18n.t('repositories.import_records.steps.step3.name'),
cellRenderer: this.highlightRenderer
}
];
this.params.attributes.repository_columns.forEach((col) => {
columns.push({
field: `col_${col[0]}`,
headerName: col[1],
cellRenderer: this.highlightRenderer
});
});
columns.push({
field: 'import_status',
headerName: this.i18n.t('repositories.import_records.steps.step3.status'),
cellRenderer: this.statusRenderer,
pinned: 'right'
});
return columns;
},
tableData() {
const data = this.params.preview.data.map((row) => {
const rowFormated = row.attributes;
row.relationships.repository_cells.data.forEach((c) => {
const cell = this.params.preview.included.find((c1) => c1.id === c.id);
if (cell) {
rowFormated[`col_${cell.attributes.repository_column_id}`] = cell.attributes.formatted_value;
}
});
return rowFormated;
});
return data;
}
},
methods: {
filterRows(status) {
return this.params.preview.data.filter((r) => r.attributes.import_status === status);
},
highlightRenderer(params) {
const { import_status: importStatus } = params.data;
let color = '';
if (importStatus === 'created' || importStatus === 'updated') {
color = 'text-sn-alert-green';
} else if (importStatus === 'duplicated' || importStatus === 'invalid') {
color = 'text-sn-alert-passion';
}
return `<span class="${color}">${params.value || ''}</span>`;
},
statusRenderer(params) {
const { import_status: importStatus, import_message: importMessage } = params.data;
let message = '';
let color = '';
let icon = '';
if (importStatus === 'created' || importStatus === 'updated') {
message = this.i18n.t(`repositories.import_records.steps.step3.status_message.${importStatus}`);
color = 'text-sn-alert-green';
icon = 'check';
} else if (importStatus === 'unchanged' || importStatus === 'archived') {
message = this.i18n.t(`repositories.import_records.steps.step3.status_message.${importStatus}`);
icon = 'hamburger';
} else if (importStatus === 'duplicated' || importStatus === 'invalid') {
message = this.i18n.t(`repositories.import_records.steps.step3.status_message.${importStatus}`);
color = 'text-sn-alert-passion';
icon = 'close';
}
if (importMessage) {
message = importMessage;
}
return `
<div title="${message}" class="flex items-center ${color} gap-2.5">
<i class="sn-icon sn-icon-${icon} "></i>
<span class="truncate">${message}</span>
</div>
`;
}
}
};
</script>

View file

@ -0,0 +1,146 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog flex" role="document" :class="{'!w-[900px]': showingInfo}" data-e2e="e2e-MD-invInventoryImport-upload">
<div v-if="showingInfo" class="w-[300px] shrink-0 h-full bg-sn-super-light-grey p-6 rounded-s text-sn-dark-grey">
<h3 class="my-0 mb-4" data-e2e="e2e-TX-invInventoryImport-uploadModal-help-title">{{ this.i18n.t('repositories.import_records.info_sidebar.title') }}</h3>
<div v-for="i in 4" :key="i" class="flex gap-3 mb-4">
<span class="btn btn-secondary icon-btn !text-sn-black !pointer-events-none">
<i class="sn-icon"
:class="i18n.t(`repositories.import_records.info_sidebar.elements.element${i - 1}.icon`)"
:data-e2e="`e2e-IC-invInventoryImport-uploadModal-help-icon${i}`"
></i>
</span>
<div>
<div
class="font-bold mb-2"
:data-e2e="`e2e-TX-invInventoryImport-uploadModal-help-title${i}`"
>{{ i18n.t(`repositories.import_records.info_sidebar.elements.element${i - 1}.label`) }}</div>
<div
:data-e2e="`e2e-TX-invInventoryImport-uploadModal-help-text${i}`"
>{{ i18n.t(`repositories.import_records.info_sidebar.elements.element${i - 1}.subtext`) }}</div>
</div>
</div>
<div class="flex gap-3 mb-4 items-center">
<span class="btn btn-secondary icon-btn !text-sn-black !pointer-events-none">
<i class="sn-icon sn-icon-open"></i>
</span>
<a :href="i18n.t('repositories.import_records.info_sidebar.elements.element4.linkTo')" class="font-bold" target="_blank">
{{ i18n.t('repositories.import_records.info_sidebar.elements.element4.label') }}
</a>
</div>
</div>
<div class="modal-content grow flex flex-col" :class="{'!rounded-s-none': showingInfo}">
<div class="modal-header gap-4">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" data-e2e="e2e-BT-invInventoryImport-uploadModal-close">
<i class="sn-icon sn-icon-close"></i>
</button>
<button class="btn btn-light btn-sm mr-auto" @click="showingInfo = !showingInfo" data-e2e="e2e-BT-invInventoryImport-uploadModal-help">
<i class="sn-icon sn-icon-help-s"></i>
{{ i18n.t('repositories.import_records.steps.step1.helpText') }}
</button>
<h4 class="modal-title truncate !block !mr-0" id="edit-project-modal-label" data-e2e="e2e-TX-invInventoryImport-uploadModal-title">
{{ i18n.t('repositories.import_records.steps.step1.title') }}
</h4>
</div>
<div class="modal-body flex flex-col grow">
<p class="text-sn-dark-grey" data-e2e="e2e-TX-invInventoryImport-uploadModal-description">
{{ this.i18n.t('repositories.import_records.steps.step1.subtitle') }}
</p>
<h3 class="my-0 text-sn-dark-grey mb-3" data-e2e="e2e-TX-invInventoryImport-uploadModal-exportSubtitle">
{{ i18n.t('repositories.import_records.steps.step1.exportTitle') }}
</h3>
<div class="flex gap-4 mb-6">
<button class="btn btn-secondary btn-sm" @click="$emit('changeStep', 'ExportModal')" data-e2e="e2e-BT-invInventoryImport-uploadModal-exportAll">
<i class="sn-icon sn-icon-export"></i>
{{ i18n.t('repositories.import_records.steps.step1.exportFullInvBtnText') }}
</button>
<a
:href="params.attributes.urls.export_empty_repository"
target="_blank"
class="btn btn-secondary btn-sm"
data-e2e="e2e-BT-invInventoryImport-uploadModal-downloadTemplate"
>
<i class="sn-icon sn-icon-export"></i>
{{ i18n.t('repositories.import_records.steps.step1.exportEmptyInvBtnText') }}
</a>
</div>
<h3 class="my-0 text-sn-dark-grey mb-3" data-e2e="e2e-TX-invInventoryImport-uploadModal-importSubtitle">
{{ i18n.t('repositories.import_records.steps.step1.importTitle') }}
</h3>
<DragAndDropUpload
class="h-60"
@file:dropped="uploadFile"
@file:error="handleError"
@file:error:clear="this.error = null"
:supportingText="`${i18n.t('repositories.import_records.steps.step1.dragAndDropSupportingText')}`"
:supportedFormats="['xlsx', 'csv', 'xls', 'txt', 'tsv']"
:dataE2e="'invInventoryImport-uploadModal-dragDrop'"
/>
</div>
<div class="modal-footer">
<div v-if="error" class="flex flex-row gap-2 my-auto mr-auto text-sn-delete-red">
<i class="sn-icon sn-icon-alert-warning"></i>
<div class="my-auto">{{ error }}</div>
</div>
<div v-if="exportInventoryMessage" class="flex flex-row gap-2 my-auto mr-auto text-sn-alert-green">
<i class="sn-icon sn-icon-check"></i>
<div class="my-auto">{{ exportInventoryMessage }}</div>
</div>
<button class="btn btn-secondary" @click="close" aria-label="Close" data-e2e="e2e-BT-invInventoryImport-uploadModal-cancel">
{{ i18n.t('repositories.import_records.steps.step1.cancelBtnText') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import DragAndDropUpload from '../../../shared/drag_and_drop_upload.vue';
import modalMixin from '../../../shared/modal_mixin';
import axios from '../../../../packs/custom_axios';
export default {
name: 'UploadStep',
emits: ['uploadFile', 'close'],
components: {
DragAndDropUpload
},
mixins: [modalMixin],
props: {
params: {
type: Object,
required: true
}
},
data() {
return {
showingInfo: false,
error: null,
parseSheetUrl: null,
exportInventoryMessage: null
};
},
methods: {
handleError(error) {
this.error = error;
},
uploadFile(file) {
const formData = new FormData();
// required payload
formData.append('file', file);
axios.post(this.params.attributes.urls.parse_sheet, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
.then((response) => {
this.$emit('uploadFile', { ...this.params, ...response.data, file_name: file.name });
}).catch((error) => {
this.handleError(error.response.data.error);
});
}
}
};
</script>

View file

@ -28,13 +28,13 @@
ref="deleteModal"
:e2eAttributes="deleteModal.e2eAttributes"
></ConfirmationModal>
<ConfirmationModal
:title="exportModal.title"
:description="exportModal.description"
confirmClass="btn btn-primary"
:confirmText="i18n.t('repositories.index.modal_export.export')"
ref="exportModal"
></ConfirmationModal>
<ExportRepositoryModal
v-if="exportRepository"
:rows="exportRepository"
:exportAction="exportAction"
@close="exportRepository = null; exportAction = null"
@export="updateTable"
></ExportRepositoryModal>
<NewRepositoryModal
v-if="newRepository"
:createUrl="createUrl"
@ -62,6 +62,7 @@
import axios from '../../packs/custom_axios.js';
import ConfirmationModal from '../shared/confirmation_modal.vue';
import ExportRepositoryModal from './modals/export.vue';
import NewRepositoryModal from './modals/new.vue';
import EditRepositoryModal from './modals/edit.vue';
import DuplicateRepositoryModal from './modals/duplicate.vue';
@ -73,6 +74,7 @@ export default {
components: {
DataTable,
ConfirmationModal,
ExportRepositoryModal,
NewRepositoryModal,
EditRepositoryModal,
DuplicateRepositoryModal,
@ -106,10 +108,12 @@ export default {
data() {
return {
reloadingTable: false,
exportRepository: null,
newRepository: false,
editRepository: null,
duplicateRepository: null,
shareRepository: null,
exportAction: null,
deleteModal: {
title: '',
description: '',
@ -148,7 +152,8 @@ export default {
},
{
field: 'shared_label',
headerName: this.i18n.t('libraries.index.table.shared')
headerName: this.i18n.t('libraries.index.table.shared'),
sortable: true
},
{
field: 'team',
@ -206,6 +211,8 @@ export default {
this.editRepository = null;
this.duplicateRepository = null;
this.shareRepository = null;
this.exportRepository = null;
this.exportAction = null;
},
archive(event, rows) {
axios.post(event.path, { repository_ids: rows.map((row) => row.id) }).then((response) => {
@ -223,39 +230,6 @@ export default {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
},
async exportRepositories(event, rows) {
this.exportModal.title = this.i18n.t('repositories.index.modal_export.title');
this.exportModal.description = `
<p class="description-p1">
${this.i18n.t('repositories.index.modal_export.description_p1_html', {
team_name: rows[0].team,
count: rows.length
})}
</p>
<p class="bg-sn-super-light-blue p-3">
${this.i18n.t('repositories.index.modal_export.description_alert')}
</p>
<p class="mt-3">
${this.i18n.t('repositories.index.modal_export.description_p2')}
</p>
<p>
${this.i18n.t('repositories.index.modal_export.description_p3_html', {
remaining_export_requests: event.num_of_requests_left,
requests_limit: event.export_limit
})}
</p>
`;
const ok = await this.$refs.exportModal.show();
if (ok) {
axios.post(event.path, { repository_ids: rows.map((row) => row.id) }).then((response) => {
HelperModule.flashAlertMsg(response.data.message, 'success');
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
}
},
async deleteRepository(event, rows) {
const [repository] = rows;
this.deleteModal.e2eAttributes = {
@ -288,6 +262,10 @@ export default {
});
}
},
exportRepositories(action, rows) {
this.exportRepository = rows;
this.exportAction = action;
},
update(_event, rows) {
const [repository] = rows;
this.editRepository = repository;

View file

@ -10,7 +10,7 @@
@dropdown:changed="updateOperator"
/>
</div>
<div v-if="users" class="users-filter-dropdown">
<div v-if="users" class="users-filter-dropdown max-w-[360px]">
<DropdownSelector
:optionClass="'checkbox-icon'"
:dataCombineTags="true"

View file

@ -97,6 +97,26 @@
</span>
</div>
<!-- UPDATED ON-->
<div class="flex flex-col ">
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.updated_on')
}}</span>
<span class="inline-block text-sn-dark-grey line-clamp-3" :title="defaultColumns?.updated_on" data-e2e="e2e-TX-itemCard-updatedOn">
{{ defaultColumns?.updated_on }}
</span>
</div>
<!-- UPDATED BY -->
<div class="flex flex-col ">
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.updated_by')
}}</span>
<span class="inline-block text-sn-dark-grey line-clamp-3" :title="defaultColumns?.updated_by" data-e2e="e2e-TX-itemCard-updatedBy">
{{ defaultColumns?.updated_by }}
</span>
</div>
<!-- ARCHIVED ON -->
<div v-if="defaultColumns.archived_on" class="flex flex-col ">
<div class="sci-divider pb-4"></div>

View file

@ -46,6 +46,7 @@
:label="i18n.t('repository_stock_values.manage_modal.amount')"
:required="true"
:min="0"
:negativeNumbersEnabled="this.operation == 'set'"
:error="errors.amount"
/>
</div>
@ -252,7 +253,6 @@ export default {
const newErrors = {};
if (!this.unit) { newErrors.unit = I18n.t('repository_stock_values.manage_modal.unit_error'); }
if (!this.amount) { newErrors.amount = I18n.t('repository_stock_values.manage_modal.amount_error'); }
if (this.amount && this.amount < 0) { newErrors.amount = I18n.t('repository_stock_values.manage_modal.negative_error'); }
if (this.reminderEnabled && !this.lowStockTreshold) { newErrors.tresholdAmount = I18n.t('repository_stock_values.manage_modal.amount_error'); }
if (this.comment && this.comment.length > 255) { newErrors.comment = I18n.t('repository_stock_values.manage_modal.comment_limit'); }
this.errors = newErrors;

View file

@ -16,7 +16,7 @@
<div :class="{ 'opacity-0 pointer-events-none': dragingFile }">
<div class="result-header flex justify-between">
<div class="result-head-left flex items-start flex-grow gap-4">
<a class="result-collapse-link hover:no-underline focus:no-underline py-0.5 border-0 border-y border-transparent border-solid text-sn-black"
<a ref="toggleElement" class="result-collapse-link hover:no-underline focus:no-underline py-0.5 border-0 border-y border-transparent border-solid text-sn-black"
:href="'#resultBody' + result.id"
data-toggle="collapse"
data-remote="true"
@ -217,6 +217,14 @@ export default {
if (this.activeDragResult !== this.result.id && this.dragingFile) {
this.dragingFile = false;
}
},
result: {
handler(newVal) {
if (this.isCollapsed !== newVal.attributes.collapsed) {
this.toggleCollapsed();
}
},
deep: true
}
},
computed: {
@ -328,6 +336,7 @@ export default {
methods: {
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
this.result.attributes.collapsed = this.isCollapsed;
},
dragEnter(e) {
if (!this.urls.upload_attachment_url) return;
@ -456,6 +465,10 @@ export default {
$.post(this.urls[`create_${elementType}_url`], { tableDimensions, plateTemplate }, (result) => {
result.data.isNew = true;
this.elements.push(result.data);
if (this.isCollapsed) {
this.$refs.toggleElement.click();
}
this.$emit('resultUpdated');
}).fail(() => {
HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger');

View file

@ -69,7 +69,7 @@ export default {
data() {
return {
results: [],
sort: 'created_at_desc',
sort: null,
filters: {},
resultToReload: null,
nextPageUrl: null,
@ -107,8 +107,10 @@ export default {
if (window.scrollY + window.innerHeight >= document.body.scrollHeight - 20) {
this.loadingPage = true;
axios.get(this.nextPageUrl, { params: { sort: this.sort, ...this.filters } }).then((response) => {
const params = this.sort ? { ...this.filters, sort: this.sort } : { ...this.filters };
axios.get(this.nextPageUrl, { params }).then((response) => {
this.results = this.results.concat(response.data.data);
this.sort = response.data.meta.sort;
this.nextPageUrl = response.data.links.next;
this.loadingPage = false;
});
@ -139,9 +141,20 @@ export default {
},
expandAll() {
$('.result-wrapper .collapse').collapse('show');
this.toggleCollapsed(false);
},
collapseAll() {
$('.result-wrapper .collapse').collapse('hide');
this.toggleCollapsed(true);
},
toggleCollapsed(newState) {
this.results = this.results.map((result) => ({
...result,
attributes: {
...result.attributes,
collapsed: newState
}
}));
},
removeResult(result_id) {
this.results = this.results.filter((r) => r.id != result_id);

View file

@ -46,7 +46,7 @@
:listItems="this.sortMenu"
:btnClasses="'btn btn-light icon-btn'"
:position="'right'"
:btnIcon="'sn-icon sn-icon-sort'"
:btnIcon="'sn-icon sn-icon-sort-down'"
@sort="setSort"
></MenuDropdown>
</div>
@ -123,10 +123,10 @@ export default {
this.$emit('setFilters', filters);
},
collapseResults() {
$('.result-wrapper .collapse').collapse('hide');
this.$emit('collapseAll');
},
expandResults() {
$('.result-wrapper .collapse').collapse('show');
this.$emit('expandAll');
},
scrollTop() {
window.scrollTo(0, 0);

View file

@ -0,0 +1,52 @@
<template>
<div class="flex items-center gap-1.5 justify-end w-[184px]">
<OpenMenu
:attachment="attachment"
@open="$emit('attachment:toggle_menu', $event)"
@close="$emit('attachment:toggle_menu', $event)"
@menu-dropdown-toggle="$emit('attachment:toggle_menu', $event)"
@option:click="$emit('attachment:open', $event)"
/>
<a v-if="attachment.attributes.urls.move"
@click.prevent.stop="$emit('attachment:move_modal')"
class="btn btn-light icon-btn thumbnail-action-btn"
:title="i18n.t('attachments.thumbnail.buttons.move')">
<i class="sn-icon sn-icon-move"></i>
</a>
<a class="btn btn-light icon-btn thumbnail-action-btn"
:title="i18n.t('attachments.thumbnail.buttons.download')"
:href="attachment.attributes.urls.download" data-turbolinks="false">
<i class="sn-icon sn-icon-export"></i>
</a>
<ContextMenu
:attachment="attachment"
@attachment:viewMode="$emit('attachment:viewMode', $event)"
@attachment:delete="$emit('attachment:delete', $event)"
@attachment:moved="$emit('attachment:moved', $event)"
@attachment:uploaded="$emit('attachment:uploaded', $event)"
@attachment:changed="$emit('attachment:changed', $event)"
@attachment:update="$emit('attachment:update', $event)"
@menu-toggle="$emit('attachment:toggle_menu', $event)"
:withBorder="withBorder"
/>
</div>
</template>
<script>
import OpenLocallyMixin from './mixins/open_locally.js';
import OpenMenu from './open_menu.vue';
import ContextMenu from './context_menu.vue';
export default {
name: 'attachmentActions',
props: {
attachment: Object,
withBorder: false
},
mixins: [OpenLocallyMixin],
components: {
OpenMenu,
ContextMenu
}
};
</script>

View file

@ -1,7 +1,6 @@
<template>
<div class="asset-context-menu"
ref="menu"
@mouseenter="fetchLocalAppInfo"
>
<a class="marvinjs-edit-button hidden"
v-if="attachment.attributes.asset_type == 'marvinjs' && attachment.attributes.urls.marvin_js_start_edit"
@ -27,19 +26,15 @@
<MenuDropdown
class="ml-auto"
:listItems="this.menu"
:btnClasses="`btn btn-sm icon-btn !bg-sn-white ${ withBorder ? 'btn-secondary' : 'btn-light'}`"
:btnClasses="`btn icon-btn bg-sn-white ${ withBorder ? 'btn-secondary' : 'btn-light'}`"
:position="'right'"
:btnIcon="'sn-icon sn-icon-more-hori'"
@open_ove_editor="openOVEditor(attachment.attributes.urls.open_vector_editor_edit)"
@open_marvinjs_editor="openMarvinJsEditor"
@open_scinote_editor="openScinoteEditor"
@open_locally="openLocally"
@delete="deleteModal = true"
@rename="renameModal = true"
@duplicate="duplicate"
@viewMode="changeViewMode"
@move="showMoveModal"
@menu-visibility-changed="$emit('menu-visibility-changed', $event)"
@menu-toggle="$emit('menu-toggle', $event)"
></MenuDropdown>
<Teleport to="body">
<RenameAttachmentModal
@ -54,27 +49,12 @@
@confirm="deleteAttachment"
@cancel="deleteModal = false"
/>
<moveAssetModal
<MoveAssetModal
v-if="movingAttachment"
:parent_type="attachment.attributes.parent_type"
:targets_url="attachment.attributes.urls.move_targets"
@confirm="moveAttachment($event)" @cancel="closeMoveModal"
/>
<NoPredefinedAppModal
v-if="showNoPredefinedAppModal"
:fileName="attachment.attributes.file_name"
@close="showNoPredefinedAppModal = false"
/>
<UpdateVersionModal
v-if="showUpdateVersionModal"
@close="showUpdateVersionModal = false"
/>
<editLaunchingApplicationModal
v-if="editAppModal"
:fileName="attachment.attributes.file_name"
:application="this.localAppName"
@close="editAppModal = false"
/>
</Teleport>
</div>
</template>
@ -82,9 +62,8 @@
<script>
import RenameAttachmentModal from '../modal/rename_modal.vue';
import deleteAttachmentModal from './delete_modal.vue';
import moveAssetModal from '../modal/move.vue';
import MoveAssetModal from '../modal/move.vue';
import MoveMixin from './mixins/move.js';
import OpenLocallyMixin from './mixins/open_locally.js';
import MenuDropdown from '../../menu_dropdown.vue';
import axios from '../../../../packs/custom_axios.js';
@ -93,16 +72,20 @@ export default {
components: {
RenameAttachmentModal,
deleteAttachmentModal,
moveAssetModal,
MoveAssetModal,
MenuDropdown
},
mixins: [MoveMixin, OpenLocallyMixin],
mixins: [MoveMixin],
props: {
attachment: {
type: Object,
required: true
},
withBorder: { default: false, type: Boolean }
withBorder: { default: false, type: Boolean },
displayInDropdown: {
type: Array,
default: []
}
},
data() {
return {
@ -114,59 +97,12 @@ export default {
computed: {
menu() {
const menu = [];
if (this.attachment.attributes.wopi && this.attachment.attributes.urls.edit_asset) {
if (this.displayInDropdown.includes('download')) {
menu.push({
text: this.attachment.attributes.wopi_context.button_text,
url: this.attachment.attributes.urls.edit_asset,
text: this.i18n.t('Download'),
url: this.attachment.attributes.urls.download,
url_target: '_blank',
data_e2e: 'e2e-BT-attachmentOptions-openInWopi'
});
}
if (this.attachment.attributes.asset_type === 'gene_sequence' && this.attachment.attributes.urls.open_vector_editor_edit) {
menu.push({
text: this.i18n.t('open_vector_editor.edit_sequence'),
emit: 'open_ove_editor',
data_e2e: 'e2e-BT-attachmentOptions-openInOve'
});
}
if (this.attachment.attributes.asset_type === 'marvinjs' && this.attachment.attributes.urls.marvin_js_start_edit) {
menu.push({
text: this.i18n.t('assets.file_preview.edit_in_marvinjs'),
emit: 'open_marvinjs_editor',
data_e2e: 'e2e-BT-attachmentOptions-openInMarvin'
});
}
if (this.attachment.attributes.asset_type !== 'marvinjs'
&& this.attachment.attributes.image_editable
&& this.attachment.attributes.urls.start_edit_image) {
menu.push({
text: this.i18n.t('assets.file_preview.edit_in_scinote'),
emit: 'open_scinote_editor',
data_e2e: 'e2e-BT-attachmentOptions-openInImageEditor'
});
}
if (this.canOpenLocally) {
const text = this.localAppName
? this.i18n.t('attachments.open_locally_in', { application: this.localAppName })
: this.i18n.t('attachments.open_locally');
menu.push({
text,
emit: 'open_locally',
data_e2e: 'e2e-BT-attachmentOptions-openLocally'
});
}
menu.push({
text: this.i18n.t('Download'),
url: this.attachment.attributes.urls.download,
url_target: '_blank',
data_e2e: 'e2e-BT-attachmentOptions-download'
});
if (this.attachment.attributes.urls.move_targets) {
menu.push({
text: this.i18n.t('assets.context_menu.move'),
emit: 'move',
data_e2e: 'e2e-BT-attachmentOptions-move'
data_e2e: 'e2e-BT-attachmentOptions-download'
});
}
if (this.attachment.attributes.urls.duplicate) {
@ -229,16 +165,6 @@ export default {
},
reloadAttachments() {
this.$emit('attachment:uploaded');
},
openMarvinJsEditor() {
MarvinJsEditor.initNewButton(
this.$refs.marvinjsEditButton,
this.reloadAttachments
);
$(this.$refs.marvinjsEditButton).trigger('click');
},
openScinoteEditor() {
$(this.$refs.imageEditButton).trigger('click');
}
}
};

View file

@ -1,9 +1,12 @@
<template>
<div class="inline-attachment-container asset"
:data-asset-id="attachment.id"
:data-e2e="`e2e-CO-${dataE2e}-attachment${attachment.id}-inline`"
<div
class="inline-attachment-container asset"
:class="[{'menu-dropdown-open': isMenuDropdownOpen}, {'context-menu-open': isContextMenuOpen }]"
:data-e2e="`e2e-CO-${dataE2e}-attachment${attachment.id}-inline`"
ref="inlineAttachmentContainer"
:data-asset-id="attachment.id"
>
<div class="header">
<div class="header justify-between">
<div class="file-info">
<a :href="attachment.attributes.urls.blob"
class="file-preview-link file-name"
@ -21,22 +24,27 @@
</a>
<div class="file-metadata">
<span>
{{ i18n.t('assets.placeholder.modified_label') }}
{{ attachment.attributes.updated_at_formatted }}
</span>
<span>
{{ i18n.t('assets.placeholder.size_label', {size: attachment.attributes.file_size_formatted}) }}
{{ attachment.attributes.file_size_formatted }}
</span>
</div>
</div>
<ContextMenu
:attachment="attachment"
@attachment:viewMode="updateViewMode"
@attachment:delete="deleteAttachment"
@attachment:moved="attachmentMoved"
@attachment:uploaded="reloadAttachments"
@attachment:update="$emit('attachment:update', $event)"
/>
<div class="flex items-center ml-auto gap-2">
<AttachmentActions
:attachment="attachment"
@attachment:viewMode="updateViewMode"
@attachment:delete="deleteAttachment"
@attachment:moved="attachmentMoved"
@attachment:uploaded="reloadAttachments"
@attachment:changed="$emit('attachment:changed', $event)"
@attachment:update="$emit('attachment:update', $event)"
@attachment:toggle_menu="toggleMenuDropdown"
@attachment:move_modal="showMoveModal"
@attachment:open="$emit($event)"
/>
</div>
</div>
<template v-if="attachment.attributes.wopi">
<div v-if="showWopi"
@ -65,6 +73,14 @@
<i class="text-sn-grey sn-icon" :class="attachment.attributes.icon"></i>
</div>
</template>
<Teleport to="body">
<MoveAssetModal
v-if="movingAttachment"
:parent_type="attachment.attributes.parent_type"
:targets_url="attachment.attributes.urls.move_targets"
@confirm="moveAttachment($event)" @cancel="closeMoveModal"
/>
</Teleport>
</div>
</template>
@ -74,11 +90,22 @@ import AttachmentMovedMixin from './mixins/attachment_moved.js';
import ContextMenuMixin from './mixins/context_menu.js';
import ContextMenu from './context_menu.vue';
import PdfViewer from '../../pdf_viewer.vue';
import MoveAssetModal from '../modal/move.vue';
import MoveMixin from './mixins/move.js';
import OpenLocallyMixin from './mixins/open_locally.js';
import AttachmentActions from './attachment_actions.vue';
import OpenMenu from './open_menu.vue';
export default {
name: 'inlineAttachment',
mixins: [ContextMenuMixin, AttachmentMovedMixin],
components: { ContextMenu, PdfViewer },
mixins: [ContextMenuMixin, AttachmentMovedMixin, MoveMixin, OpenLocallyMixin],
components: {
ContextMenu,
PdfViewer,
MoveAssetModal,
OpenMenu,
AttachmentActions
},
props: {
attachment: {
type: Object,
@ -95,7 +122,9 @@ export default {
},
data() {
return {
showWopi: false
showWopi: false,
isMenuDropdownOpen: false,
isContextMenuOpen: false
};
},
mounted() {
@ -120,7 +149,13 @@ export default {
}
}
});
},
toggleContextMenu(isOpen) {
this.isContextMenuOpen = isOpen;
},
toggleMenuDropdown(isOpen) {
this.isMenuDropdownOpen = isOpen;
}
}
},
};
</script>

View file

@ -21,23 +21,37 @@
<img :src="this.imageLoadError ? attachment.attributes.urls.blob : attachment.attributes.medium_preview" @error="ActiveStoragePreviews.reCheckPreview"
@load="ActiveStoragePreviews.showPreview"/>
</div>
<div class="file-metadata">
<span>
<div class="flex items-center gap-2 text-xs text-sn-grey overflow-hidden ml-auto">
<span class="truncate" :title="i18n.t('assets.placeholder.modified_label') + ' ' + attachment.attributes.updated_at_formatted">
{{ i18n.t('assets.placeholder.modified_label') }}
{{ attachment.attributes.updated_at_formatted }}
</span>
<span>
<span class="truncate" :title="i18n.t('assets.placeholder.size_label', {size: attachment.attributes.file_size_formatted})">
{{ i18n.t('assets.placeholder.size_label', {size: attachment.attributes.file_size_formatted}) }}
</span>
</div>
<ContextMenu
:attachment="attachment"
@attachment:viewMode="updateViewMode"
@attachment:delete="deleteAttachment"
@attachment:moved="attachmentMoved"
@attachment:uploaded="reloadAttachments"
@attachment:update="$emit('attachment:update', $event)"
/>
<div class="attachment-actions shrink-0 ml-auto">
<AttachmentActions
:attachment="attachment"
@attachment:viewMode="updateViewMode"
@attachment:delete="deleteAttachment"
@attachment:moved="attachmentMoved"
@attachment:uploaded="reloadAttachments"
@attachment:changed="$emit('attachment:changed', $event)"
@attachment:update="$emit('attachment:update', $event)"
@attachment:toggle_menu="toggleMenuDropdown"
@attachment:move_modal="showMoveModal"
@attachment:open="$emit($event)"
/>
</div>
<Teleport to="body">
<moveAssetModal
v-if="movingAttachment"
:parent_type="attachment.attributes.parent_type"
:targets_url="attachment.attributes.urls.move_targets"
@confirm="moveAttachment($event)" @cancel="closeMoveModal"
/>
</Teleport>
</div>
</template>
@ -45,11 +59,20 @@
import AttachmentMovedMixin from './mixins/attachment_moved.js';
import ContextMenuMixin from './mixins/context_menu.js';
import ContextMenu from './context_menu.vue';
import MoveMixin from './mixins/move.js';
import MoveAssetModal from '../modal/move.vue';
import AttachmentActions from './attachment_actions.vue';
import OpenMenu from './open_menu.vue';
export default {
name: 'listAttachment',
mixins: [ContextMenuMixin, AttachmentMovedMixin],
components: { ContextMenu },
mixins: [ContextMenuMixin, AttachmentMovedMixin, MoveMixin],
components: {
ContextMenu,
MoveAssetModal,
OpenMenu,
AttachmentActions
},
props: {
attachment: {
type: Object,
@ -66,13 +89,21 @@ export default {
},
data() {
return {
imageLoadError: false
imageLoadError: false,
isContextMenuOpen: false,
isMenuDropdownOpen: false
};
},
methods: {
handleImageError() {
this.imageLoadError = true;
},
toggleContextMenu(isOpen) {
this.isContextMenuOpen = isOpen;
},
toggleMenuDropdown(isOpen) {
this.isMenuDropdownOpen = isOpen;
}
}
},
};
</script>

View file

@ -14,6 +14,9 @@ export default {
methods: {
openOVEditor() {
window.showIFrameModal(this.OVEurl);
if (this.isCollapsed) {
this.$refs.toggleElement.click();
}
},
initOVE() {
$(window.iFrameModal).on('sequenceSaved', () => {

View file

@ -0,0 +1,175 @@
<template>
<span>
<!-- multiple options -->
<MenuDropdown
v-if="multipleOpenOptions.length > 1"
:listItems="multipleOpenOptions"
:btnClasses="'btn btn-light icon-btn thumbnail-action-btn'"
:position="'left'"
:btnIcon="'sn-icon sn-icon-open'"
:title="i18n.t('attachments.thumbnail.buttons.open')"
@menu-toggle="toggleMenu"
@open_locally="openLocally"
@open_scinote_editor="openScinoteEditor"
>
</MenuDropdown>
<!-- open locally -->
<a
class="btn btn-light icon-btn thumbnail-action-btn"
v-else-if="canOpenLocally"
@click="openLocally"
:title="i18n.t('attachments.thumbnail.buttons.open')"
>
<i class="sn-icon sn-icon-open"></i>
</a>
<!-- wopi -->
<a
class="btn btn-light icon-btn thumbnail-action-btn"
v-else-if="this.attachment.attributes.wopi && this.attachment.attributes.urls.edit_asset"
:href="attachment.attributes.urls.edit_asset"
:title="i18n.t('attachments.thumbnail.buttons.open')"
id="wopi_file_edit_button"
:class="attachment.attributes.wopi_context.edit_supported ? '' : 'disabled'"
target="_blank"
>
<i class="sn-icon sn-icon-open"></i>
</a>
<!-- gene sequence -->
<a
class="btn btn-light icon-btn thumbnail-action-btn ove-edit-button"
v-else-if="attachment.attributes.asset_type == 'gene_sequence' && attachment.attributes.urls.open_vector_editor_edit"
@click="openOVEditor(attachment.attributes.urls.open_vector_editor_edit)"
>
<i class="sn-icon sn-icon-open"></i>
</a>
<!-- marvin js -->
<a
class="btn btn-light icon-btn thumbnail-action-btn marvinjs-edit-button"
v-else-if="attachment.attributes.asset_type == 'marvinjs' && attachment.attributes.urls.marvin_js_start_edit"
:data-sketch-id="attachment.id"
:data-update-url="attachment.attributes.urls.marvin_js"
:data-sketch-start-edit-url="attachment.attributes.urls.marvin_js_start_edit"
:data-sketch-name="attachment.attributes.metadata.name"
:data-sketch-description="attachment.attributes.metadata.description"
>
<i class="sn-icon sn-icon-open"></i>
</a>
<!-- editing -->
<a
class="btn btn-light icon-btn thumbnail-action-btn image-edit-button"
v-else-if="attachment.attributes.image_editable && attachment.attributes.urls.edit_asset"
:title="i18n.t('attachments.thumbnail.buttons.open')"
:data-image-id="attachment.id"
:data-image-name="attachment.attributes.file_name"
:data-image-url="attachment.attributes.urls.asset_file"
:data-image-quality="attachment.attributes.image_context && attachment.attributes.image_context.quality"
:data-image-mime-type="attachment.attributes.image_context && attachment.attributes.image_context.type"
:data-image-start-edit-url="attachment.attributes.urls.start_edit_image"
>
<i class="sn-icon sn-icon-open"></i>
</a>
<!-- hidden -->
<a class="image-edit-button hidden"
v-if="attachment.attributes.asset_type != 'marvinjs'
&& attachment.attributes.image_editable
&& attachment.attributes.urls.start_edit_image"
ref="imageEditButton"
:data-image-id="attachment.id"
:data-image-name="attachment.attributes.file_name"
:data-image-url="attachment.attributes.urls.asset_file"
:data-image-quality="attachment.attributes.image_context.quality"
:data-image-mime-type="attachment.attributes.image_context.type"
:data-image-start-edit-url="attachment.attributes.urls.start_edit_image"
></a>
<Teleport to="body">
<NoPredefinedAppModal
v-if="showNoPredefinedAppModal"
:fileName="attachment.attributes.file_name"
@close="showNoPredefinedAppModal = false"
/>
<UpdateVersionModal
v-if="showUpdateVersionModal"
@close="showUpdateVersionModal = false"
/>
<editLaunchingApplicationModal
v-if="editAppModal"
:fileName="attachment.attributes.file_name"
:application="this.localAppName"
@close="editAppModal = false"
/>
</Teleport>
</span>
</template>
<script>
import OpenLocallyMixin from './mixins/open_locally.js';
import MenuDropdown from '../../../shared/menu_dropdown.vue';
export default {
name: 'multipleOpen',
mixins: [OpenLocallyMixin],
emits: ['menu-dropdown-toggle'],
components: {
MenuDropdown
},
props: {
attachment: {
type: Object,
required: true
}
},
mounted() {
this.fetchLocalAppInfo();
},
computed: {
multipleOpenOptions() {
const options = [];
if (this.attachment.attributes.wopi && this.attachment.attributes.urls.edit_asset) {
options.push({
text: this.attachment.attributes.wopi_context.button_text,
url: this.attachment.attributes.urls.edit_asset,
url_target: '_blank'
});
}
if (this.attachment.attributes.asset_type !== 'marvinjs'
&& this.attachment.attributes.image_editable
&& this.attachment.attributes.urls.start_edit_image) {
options.push({
text: this.i18n.t('assets.file_preview.edit_in_scinote'),
emit: 'open_scinote_editor'
});
}
if (this.canOpenLocally) {
const text = this.localAppName
? this.i18n.t('attachments.open_locally_in', { application: this.localAppName })
: this.i18n.t('attachments.open_locally');
options.push({
text,
emit: 'open_locally',
data_e2e: 'e2e-BT-attachmentOptions-openLocally'
});
}
return options;
}
},
methods: {
toggleMenu(isOpen) {
this.$emit('menu-dropdown-toggle', isOpen);
},
openScinoteEditor() {
this.$refs.imageEditButton.click();
},
openOVEditor(url) {
window.showIFrameModal(url);
}
}
};
</script>

View file

@ -33,7 +33,7 @@
<div :class="{ hidden: !showOptions }" class="hovered-thumbnail h-full">
<a
:href="attachment.attributes.urls.blob"
class="file-preview-link file-name"
class="file-preview-link file-name max-h-36 overflow-auto"
:id="`modal_link${attachment.id}`"
data-no-turbolink="true"
:data-id="attachment.id"
@ -45,95 +45,23 @@
<div class="absolute bottom-16 text-sn-grey">
{{ attachment.attributes.file_size_formatted }}
</div>
<div class="absolute bottom-4 w-[184px] grid grid-cols-[repeat(4,_2.5rem)] justify-between">
<MenuDropdown
v-if="multipleOpenOptions.length > 1"
:listItems="multipleOpenOptions"
:btnClasses="'btn btn-light icon-btn thumbnail-action-btn'"
:position="'left'"
:btnIcon="'sn-icon sn-icon-open'"
:title="i18n.t('attachments.thumbnail.buttons.open')"
@menu-visibility-changed="handleMenuVisibilityChange"
@open_locally="openLocally"
@open_scinote_editor="openScinoteEditor"
></MenuDropdown>
<a class="btn btn-light icon-btn thumbnail-action-btn"
v-else-if="canOpenLocally"
@click="openLocally"
:title="i18n.t('attachments.thumbnail.buttons.open')"
>
<i class="sn-icon sn-icon-open"></i>
</a>
<a class="btn btn-light icon-btn thumbnail-action-btn"
v-else-if="this.attachment.attributes.wopi && this.attachment.attributes.urls.edit_asset"
:href="attachment.attributes.urls.edit_asset"
:title="i18n.t('attachments.thumbnail.buttons.open')"
id="wopi_file_edit_button"
:class="attachment.attributes.wopi_context.edit_supported ? '' : 'disabled'"
target="_blank"
>
<i class="sn-icon sn-icon-open"></i>
</a>
<a class="btn btn-light icon-btn thumbnail-action-btn ove-edit-button"
v-else-if="attachment.attributes.asset_type == 'gene_sequence' && attachment.attributes.urls.open_vector_editor_edit"
@click="openOVEditor(attachment.attributes.urls.open_vector_editor_edit)"
>
<i class="sn-icon sn-icon-open"></i>
</a>
<a class="btn btn-light icon-btn thumbnail-action-btn marvinjs-edit-button"
v-else-if="attachment.attributes.asset_type == 'marvinjs' && attachment.attributes.urls.marvin_js_start_edit"
:data-sketch-id="attachment.id"
:data-update-url="attachment.attributes.urls.marvin_js"
:data-sketch-start-edit-url="attachment.attributes.urls.marvin_js_start_edit"
:data-sketch-name="attachment.attributes.metadata.name"
:data-sketch-description="attachment.attributes.metadata.description"
>
<i class="sn-icon sn-icon-open"></i>
</a>
<a class="btn btn-light icon-btn thumbnail-action-btn image-edit-button"
v-else-if="attachment.attributes.image_editable && attachment.attributes.urls.edit_asset"
:title="i18n.t('attachments.thumbnail.buttons.open')"
:data-image-id="attachment.id"
:data-image-name="attachment.attributes.file_name"
:data-image-url="attachment.attributes.urls.asset_file"
:data-image-quality="attachment.attributes.image_context && attachment.attributes.image_context.quality"
:data-image-mime-type="attachment.attributes.image_context && attachment.attributes.image_context.type"
:data-image-start-edit-url="attachment.attributes.urls.start_edit_image"
>
<i class="sn-icon sn-icon-open"></i>
</a>
<a v-if="attachment.attributes.urls.move"
@click.prevent.stop="showMoveModal"
class="btn btn-light icon-btn thumbnail-action-btn"
:title="i18n.t('attachments.thumbnail.buttons.move')">
<i class="sn-icon sn-icon-move"></i>
</a>
<a class="btn btn-light icon-btn thumbnail-action-btn"
:title="i18n.t('attachments.thumbnail.buttons.download')"
:href="attachment.attributes.urls.download" data-turbolinks="false">
<i class="sn-icon sn-icon-export"></i>
</a>
<template v-if="attachment.attributes.urls.delete">
<a class="btn btn-light icon-btn thumbnail-action-btn"
:title="i18n.t('attachments.thumbnail.buttons.delete')"
@click.prevent.stop="deleteModal = true">
<i class="sn-icon sn-icon-delete"></i>
</a>
</template>
<div class="absolute bottom-4">
<AttachmentActions
:withBorder="true"
:attachment="attachment"
:showOptions="showOptions"
@attachment:viewMode="updateViewMode"
@attachment:delete="deleteAttachment"
@attachment:moved="attachmentMoved"
@attachment:uploaded="reloadAttachments"
@attachment:changed="$emit('attachment:changed', $event)"
@attachment:update="$emit('attachment:update', $event)"
@attachment:toggle_menu="toggleMenu"
@attachment:move_modal="showMoveModal"
@attachment:open="$emit($event)"
/>
</div>
</div>
<ContextMenu
v-show="showOptions"
:attachment="attachment"
@attachment:viewMode="updateViewMode"
@attachment:delete="deleteAttachment"
@attachment:moved="attachmentMoved"
@attachment:uploaded="reloadAttachments"
@attachment:changed="$emit('attachment:changed', $event)"
@attachment:update="$emit('attachment:update', $event)"
@menu-visibility-changed="handleMenuVisibilityChange"
:withBorder="true"
/>
<Teleport to="body">
<deleteAttachmentModal
v-if="deleteModal"
@ -141,27 +69,12 @@
@confirm="deleteAttachment"
@cancel="deleteModal = false"
/>
<moveAssetModal
<MoveAssetModal
v-if="movingAttachment"
:parent_type="attachment.attributes.parent_type"
:targets_url="attachment.attributes.urls.move_targets"
@confirm="moveAttachment($event)" @cancel="closeMoveModal"
/>
<NoPredefinedAppModal
v-if="showNoPredefinedAppModal"
:fileName="attachment.attributes.file_name"
@close="showNoPredefinedAppModal = false"
/>
<UpdateVersionModal
v-if="showUpdateVersionModal"
@close="showUpdateVersionModal = false"
/>
<editLaunchingApplicationModal
v-if="editAppModal"
:fileName="attachment.attributes.file_name"
:application="this.localAppName"
@close="editAppModal = false"
/>
</Teleport>
<a class="image-edit-button hidden"
v-if="attachment.attributes.asset_type != 'marvinjs'
@ -186,17 +99,20 @@ import deleteAttachmentModal from './delete_modal.vue';
import MenuDropdown from '../../../shared/menu_dropdown.vue';
import MoveAssetModal from '../modal/move.vue';
import MoveMixin from './mixins/move.js';
import OpenLocallyMixin from './mixins/open_locally.js';
import OpenMenu from './open_menu.vue';
import AttachmentActions from './attachment_actions.vue';
import { vOnClickOutside } from '@vueuse/components';
export default {
name: 'thumbnailAttachment',
mixins: [ContextMenuMixin, AttachmentMovedMixin, MoveMixin, OpenLocallyMixin],
mixins: [ContextMenuMixin, AttachmentMovedMixin, MoveMixin],
components: {
ContextMenu,
deleteAttachmentModal,
MoveAssetModal,
MenuDropdown
MenuDropdown,
OpenMenu,
AttachmentActions
},
props: {
attachment: {
@ -222,38 +138,6 @@ export default {
directives: {
'click-outside': vOnClickOutside
},
computed: {
multipleOpenOptions() {
const options = [];
if (this.attachment.attributes.wopi && this.attachment.attributes.urls.edit_asset) {
options.push({
text: this.attachment.attributes.wopi_context.button_text,
url: this.attachment.attributes.urls.edit_asset,
url_target: '_blank'
});
}
if (this.attachment.attributes.asset_type !== 'marvinjs'
&& this.attachment.attributes.image_editable
&& this.attachment.attributes.urls.start_edit_image) {
options.push({
text: this.i18n.t('assets.file_preview.edit_in_scinote'),
emit: 'open_scinote_editor'
});
}
if (this.canOpenLocally) {
const text = this.localAppName
? this.i18n.t('attachments.open_locally_in', { application: this.localAppName })
: this.i18n.t('attachments.open_locally');
options.push({
text,
emit: 'open_locally',
data_e2e: 'e2e-BT-attachmentOptions-openLocally'
});
}
return options;
}
},
mounted() {
$(this.$nextTick(() => {
$('.attachment-preview img')
@ -286,10 +170,9 @@ export default {
}
},
async handleMouseEnter() {
await this.fetchLocalAppInfo();
this.showOptions = true;
},
handleMenuVisibilityChange(isMenuOpen) {
toggleMenu(isMenuOpen) {
this.isMenuOpen = isMenuOpen;
if (isMenuOpen) {
this.showOptions = true;

View file

@ -149,6 +149,7 @@ export default {
onBlurHandler() {
this.$nextTick(() => {
this.editingText = false;
this.$emit('editEnd');
});
},
updateText(text, withKey) {

View file

@ -30,6 +30,7 @@ export default {
},
loadFromComputer() {
this.uploadFiles(this.$refs.fileSelector.files);
this.toggleCollapsedSection();
},
openMarvinJsModal(button) {
MarvinJsEditor.initNewButton('.new-marvinjs-upload-button', this.loadAttachments);
@ -40,11 +41,17 @@ export default {
if (status === 'success') {
const attachment = attachmentData.data;
this.addAttachment(attachment);
this.toggleCollapsedSection();
} else {
HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger');
}
});
},
toggleCollapsedSection() {
if (this.isCollapsed) {
this.$refs.toggleElement.click();
}
},
addAttachment(attachment) {
this.attachments.push(attachment);
this.showFileModal = false;

View file

@ -267,6 +267,16 @@ export default {
this.$emit('update', this.element, false, callback);
},
updateTableData() {
if (this.editingTable === false) return;
this.updatingTableData = true;
this.$nextTick(() => {
this.update(() => {
this.editingCell = false;
});
});
},
loadTableData() {
const container = this.$refs.hotTable;
const data = JSON.parse(this.element.attributes.orderable.contents);
@ -294,12 +304,19 @@ export default {
}
},
afterChange: () => {
if (this.editingTable === false) return;
this.updatingTableData = true;
this.$nextTick(() => {
this.update(() => this.editingCell = false);
});
this.updateTableData();
},
afterRemoveRow: () => {
this.updateTableData();
},
afterRemoveCol: () => {
this.updateTableData();
},
afterCreateCol: () => {
this.updateTableData();
},
afterCreateRow: () => {
this.updateTableData();
},
beforeKeyDown: (e) => {
if (e.keyCode === 27) { // esc

View file

@ -0,0 +1,131 @@
<template>
<div
ref="dragAndDropUpload"
@drop.prevent="dropFile"
@dragenter.prevent="dragEnter($event)"
@dragleave.prevent="dragLeave($event)"
@dragover.prevent
@click="handleImportClick"
class="flex h-full w-full p-6 rounded border border-sn-light-grey bg-sn-super-light-blue cursor-pointer"
:data-e2e="`e2e-CO-${dataE2e}`"
>
<div id="centered-content" class="flex flex-col gap-4 items-center h-fit w-fit m-auto">
<!-- icon -->
<i class="sn-icon sn-icon-import text-sn-dark-grey"></i>
<!-- text section -->
<div class="flex flex-col gap-1">
<div class="text-sn-dark-grey">
<span class="text-sn-science-blue hover:cursor-pointer" >
{{ i18n.t('repositories.import_records.dragAndDropUpload.importText.firstPart') }}
</span> {{ i18n.t('repositories.import_records.dragAndDropUpload.importText.secondPart') }}
</div>
<div class="text-sn-grey">
{{ supportingText }}
</div>
</div>
</div>
<!-- hidden input for importing via 'Import' click -->
<input type="file" ref="fileInput" style="display: none" @change="handleFileSelect">
</div>
</template>
<script>
export default {
name: 'DragAndDropUpload',
props: {
supportingText: {
type: String,
required: true
},
supportedFormats: {
type: Array,
required: true,
default: () => []
},
dataE2e: {
type: String,
default: ''
}
},
emits: ['file:dropped', 'file:error'],
data() {
return {
draggingFile: false,
uploading: false
};
},
methods: {
validateFile(file) {
// check if it's a single file
if (file.length > 1) {
const error = I18n.t('repositories.import_records.dragAndDropUpload.notSingleFileError');
this.$emit('file:error', error);
return false;
}
// check if it's a correct file type
const fileExtension = file.name.split('.')[1];
if (!this.supportedFormats.includes(fileExtension)) {
const error = I18n.t('repositories.import_records.dragAndDropUpload.wrongFileTypeError');
this.$emit('file:error', error);
return false;
}
// check if file is not empty
if (!file.size > 0) {
const error = I18n.t('repositories.import_records.dragAndDropUpload.emptyFileError');
this.$emit('file:error', error);
return false;
}
// check if it's conforming to size limit
if (file.size > GLOBAL_CONSTANTS.FILE_MAX_SIZE_MB * 1024 * 1024) {
const error = `${I18n.t('repositories.import_records.dragAndDropUpload.fileTooLargeError')} ${GLOBAL_CONSTANTS.FILE_MAX_SIZE_MB}`;
this.$emit('file:error', error);
return false;
}
return true;
},
dragEnter(e) {
// Detect if dragged element is a file
// https://stackoverflow.com/a/8494918
const dt = e.dataTransfer;
if (dt.types && (dt.types.indexOf ? dt.types.indexOf('Files') !== -1 : dt.types.contains('Files'))) {
this.draggingFile = true;
}
},
dragLeave() {
this.draggingFile = false;
},
dropFile(e) {
if (e.dataTransfer && e.dataTransfer.files.length) {
this.draggingFile = false;
this.uploading = true;
const droppedFile = e.dataTransfer.files[0];
const fileIsValid = this.validateFile(droppedFile);
// successful drop
if (fileIsValid) {
this.$emit('file:dropped', droppedFile);
this.$emit('file:error:clear');
}
}
},
handleImportClick() {
this.$refs.fileInput.click();
},
handleFileSelect(event) {
const file = event.target.files[0];
const fileIsValid = this.validateFile(file);
if (fileIsValid) {
this.$emit('file:dropped', file);
}
}
}
};
</script>

View file

@ -0,0 +1,39 @@
<template>
<div class="!w-[300px] rounded bg-sn-super-light-grey gap-4 p-6 flex flex-col h-full">
<div id="info-component-header">
<h3 class="modal-title text-sn-dark-grey">{{ infoParams.title }}</h3>
</div>
<div class="grid grid-flow-row h-fit" v-for="(element, _index) in infoParams.elements" :key="element.id">
<a v-if="element.linkTo" :href="element.linkTo" target="_blank" class="flex flex-row gap-3 w-fit text-sn-blue hover:no-underline hover:text-sn-blue-hover">
<button class="btn btn-secondary btn-sm icon-btn hover:!border-sn-light-grey">
<i :class="`sn-icon ${element.icon}`" class="h-fit size-9"></i>
</button>
<div class="flex flex-col gap-2 w-fit">
<div class="text-sn-blue font-bold hover:text-sn-blue-hover my-auto">{{ element.label }}</div>
</div>
</a>
<div v-else class="flex flex-row gap-3">
<button class="btn btn-secondary btn-sm icon-btn hover:!border-sn-light-grey hover:cursor-auto">
<i :class="`sn-icon ${element.icon}`" class="h-fit text-sn-dark-grey size-9"></i>
</button>
<div class="flex flex-col gap-2 w-52">
<div class="text-sn-dark-grey font-bold">{{ element.label }}</div>
<div class="text-sn-dark-grey">{{ element.subtext }}</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'InfoComponent',
props: {
infoParams: {
type: Object,
required: true
}
}
};
</script>

View file

@ -0,0 +1,80 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog" data-backdrop="static" data-keyboard="false">
<div class="modal-dialog" role="document" :class="[{'!w-[900px]' : showingInfo}, {'!w-fit' : !showingInfo}]">
<div class="modal-content !p-0 bg-sn-white w-full h-full flex" :class="[{'flex-row': showingInfo}, {'flex-col': !showingInfo}]">
<div id="body-container" class="flex flex-row w-full h-full">
<!-- info -->
<div id="info-part" ref="infoPartRef">
<InfoComponent
v-if="showingInfo"
:infoParams="infoParams"
/>
</div>
<!-- content -->
<div id="content-part" class="flex flex-col w-full p-6 gap-6">
<!-- header -->
<div id="info-modal-header" class="flex flex-col h-fit w-full gap-2">
<div id="title-part" class="flex flex-row h-fit w-full justify-between">
<div id="title-with-help" class="flex flex-row gap-3">
<h3 class="modal-title text-sn-dark-grey">{{ title }}</h3>
<button v-if="helpText" class="btn btn-light btn-sm" @click="showingInfo = !showingInfo">
<i class="sn-icon sn-icon-help-s"></i>
{{ helpText }}
</button>
</div>
<button id="close-btn" type="button" class="close my-auto" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
</div>
<div id="subtitle-part" class="text-sn-dark-grey">
{{ subtitle }}
</div>
</div>
<!-- main content -->
<div id="info-modal-main-content" class="h-full">
<slot></slot>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import modalMixin from './modal_mixin';
import InfoComponent from './info_component.vue';
export default {
name: 'InfoModal',
props: {
title: {
type: String,
required: true
},
subtitle: {
type: String,
required: false
},
helpText: {
type: String,
required: false
},
infoParams: {
type: Object,
required: true
},
startHidden: {
type: Boolean,
default: false
}
},
mixins: [modalMixin],
components: { InfoComponent },
data() {
return {
showingInfo: false
};
}
};
</script>

View file

@ -38,7 +38,8 @@ export default {
error: { type: String, required: false },
min: { type: [String, Number] },
max: { type: [String, Number] },
blockInvalidInput: { type: Boolean, default: true }
blockInvalidInput: { type: Boolean, default: true },
negativeNumbersEnabled: {type: Boolean, required: false }
},
data() {
return {
@ -56,6 +57,13 @@ export default {
},
computed: {
pattern() {
if (this.negativeNumbersEnabled) {
if (this.type === 'number' && this.decimals) {
return `-?[0-9]+([\\.]-?[0-9]{0,${this.decimals}})?`;
} else if (this.type === 'number') {
return '-?[0-9]+';
}
}
if (this.type === 'number' && this.decimals) {
return `[0-9]+([\\.][0-9]{0,${this.decimals}})?`;
} else if (this.type === 'number') {

View file

@ -0,0 +1,11 @@
<template>
<div class="flex absolute top-0 items-center justify-center w-full flex-grow h-full z-[3000]">
<div class="absolute top-0 left-0 w-full h-full bg-black opacity-10"></div>
<img src="/images/medium/loading.svg" alt="Loading" class="" />
</div>
</template>
<script>
export default {
name: 'Loading'
};
</script>

View file

@ -9,7 +9,7 @@
<template v-if="isOpen">
<teleport to="body">
<div ref="flyout"
class="fixed z-[3000] sn-menu-dropdown bg-sn-white inline-block rounded p-2.5 sn-shadow-menu-sm flex flex-col gap-[1px]"
class="fixed z-[3000] sn-menu-dropdown bg-sn-white rounded p-2.5 sn-shadow-menu-sm flex flex-col gap-[1px]"
:class="{
'right-0': position === 'right',
'left-0': position === 'left',
@ -95,7 +95,7 @@ export default {
mixins: [FixedFlyoutMixin],
watch: {
isOpen(newValue) {
this.$emit('menu-visibility-changed', newValue);
this.$emit('menu-toggle', newValue);
}
},
methods: {

View file

@ -1,6 +1,9 @@
export default {
mounted() {
$(this.$refs.modal).modal('show');
if (!this.startHidden) {
$(this.$refs.modal).modal('show');
}
this.$refs.input?.focus();
$(this.$refs.modal).on('hidden.bs.modal', () => {
this.$emit('close');
@ -13,6 +16,10 @@ export default {
close() {
this.$emit('close');
$(this.$refs.modal).modal('hide');
},
open() {
this.$emit('open');
$(this.$refs.modal).modal('show');
}
},
}
}
};

View file

@ -28,8 +28,9 @@
v-else
v-model="query"
:placeholder="placeholderRender"
@keyup="fetchOptions"
@change.stop
class="w-full border-0 outline-none pl-0 placeholder:text-sn-grey" />
class="w-full bg-transparent border-0 outline-none pl-0 placeholder:text-sn-grey" />
</template>
<div v-else class="flex items-center gap-1 flex-wrap">
<div v-for="tag in tags" class="px-2 py-1 rounded-sm bg-sn-super-light-grey grid grid-cols-[auto_1fr] items-center gap-1">
@ -51,8 +52,8 @@
</div>
</div>
<i v-if="canClear" @click="clear" class="sn-icon ml-auto sn-icon-close"></i>
<i v-else class="sn-icon ml-auto"
:class="{ 'sn-icon-down': !isOpen, 'sn-icon-up': isOpen, 'text-sn-grey': disabled}"></i>
<i v-else class="sn-icon ml-auto" @click="handleClickArrow"
:class="{ 'sn-icon-down pointer-events-none': !isOpen, 'sn-icon-up': isOpen, 'text-sn-grey': disabled}"></i>
</div>
<template v-if="isOpen">
<teleport to="body">
@ -141,7 +142,8 @@ export default {
selectAllState: 'unchecked',
query: '',
fixedWidth: true,
focusedOption: null
focusedOption: null,
skipQueryCallback: false
};
},
mixins: [FixedFlyoutMixin],
@ -262,7 +264,8 @@ export default {
value(newValue) {
this.newValue = newValue;
},
isOpen() {
isOpen(newVal) {
this.$emit('isOpen', newVal);
if (this.isOpen) {
this.$nextTick(() => {
this.setPosition();
@ -270,8 +273,13 @@ export default {
});
}
},
query() {
if (this.optionsUrl) this.fetchOptions();
urlParams: {
handler(oldVal, newVal) {
if (!this.compareObjects(oldVal, newVal)) {
this.fetchOptions();
}
},
deep: true
}
},
methods: {
@ -296,7 +304,7 @@ export default {
clear() {
this.newValue = this.multiple ? [] : null;
this.query = '';
this.$emit('change', this.newValue);
this.$emit('change', this.newValue, '');
},
close(e) {
if (e && e.target.closest('.sn-select-dropdown')) return;
@ -306,7 +314,7 @@ export default {
this.$nextTick(() => {
this.isOpen = false;
if (this.valueChanged) {
this.$emit('change', this.newValue);
this.$emit('change', this.newValue, this.getLabels(this.newValue));
}
this.query = '';
});
@ -327,7 +335,7 @@ export default {
},
removeTag(value) {
this.newValue = this.newValue.filter((v) => v !== value);
this.$emit('change', this.newValue);
this.$emit('change', this.newValue, this.getLabels(this.newValue));
},
selectAll() {
if (this.selectAllState === 'checked') {
@ -335,7 +343,14 @@ export default {
} else {
this.newValue = this.rawOptions.map((option) => option[0]);
}
this.$emit('change', this.newValue);
this.$emit('change', this.newValue, this.getLabels(this.newValue));
},
getLabels(value) {
if (typeof value === 'string' || typeof value === 'number') {
const option = this.rawOptions.find((i) => i[0] === value);
return option[1];
}
return this.rawOptions.filter((option) => value.includes(option[0])).map((option) => option[1]);
},
fetchOptions() {
if (this.optionsUrl) {
@ -374,6 +389,17 @@ export default {
if (this.$refs.options) {
this.$refs.options[this.focusedOption]?.scrollIntoView({ block: 'nearest' });
}
},
compareObjects(o1, o2) {
const normalizedObj1 = Object.fromEntries(Object.entries(o1).sort(([k1], [k2]) => k1.localeCompare(k2)));
const normalizedObj2 = Object.fromEntries(Object.entries(o2).sort(([k1], [k2]) => k1.localeCompare(k2)));
return JSON.stringify(normalizedObj1) === JSON.stringify(normalizedObj2);
},
handleClickArrow(e) {
if (this.isOpen) {
e.stopPropagation();
this.close();
}
}
}
};

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
class CleanupUserSettingsJob < ApplicationJob
queue_as :default
def perform(record_type, record_id)
raise ArgumentError, 'Invalid record_type' unless %w(task_step_states results_order).include?(record_type)
sanitized_record_id = record_id.to_i.to_s
raise ArgumentError, 'Invalid record_id' unless sanitized_record_id == record_id.to_s
sql = <<-SQL.squish
UPDATE users
SET settings = (settings#>>'{}')::jsonb #- '{#{record_type},#{sanitized_record_id}}'
WHERE (settings#>>'{}')::jsonb->'#{record_type}' ? '#{sanitized_record_id}';
SQL
ActiveRecord::Base.connection.execute(sql)
end
end

View file

@ -4,10 +4,11 @@ class RepositoriesExportJob < ApplicationJob
include StringUtility
include FailedDeliveryNotifiableJob
def perform(repository_ids, user_id:, team_id:)
def perform(file_type, repository_ids, user_id:, team_id:)
@user = User.find(user_id)
@team = Team.find(team_id)
@repositories = Repository.viewable_by_user(@user, @team).where(id: repository_ids).order(:id)
@file_type = file_type.to_sym
zip_input_dir = FileUtils.mkdir_p(Rails.root.join("tmp/temp_zip_#{Time.now.to_i}")).first
zip_dir = FileUtils.mkdir_p(Rails.root.join('tmp/zip-ready')).first
@ -32,11 +33,11 @@ class RepositoriesExportJob < ApplicationJob
team_path = "#{tmp_dir}/#{to_filesystem_name(@team.name)}"
FileUtils.mkdir_p(team_path)
@repositories.each_with_index do |repository, idx|
save_repository_to_csv(team_path, repository, idx)
save_repository_as_file(team_path, repository, idx)
end
end
def save_repository_to_csv(path, repository, idx)
def save_repository_as_file(path, repository, idx)
repository_name = "#{to_filesystem_name(repository.name)} (#{idx})"
# Attachments dir
@ -44,12 +45,12 @@ class RepositoriesExportJob < ApplicationJob
attachments_path = "#{path}/#{relative_attachments_path}"
FileUtils.mkdir_p(attachments_path)
# CSV file
csv_file = FileUtils.touch("#{path}/#{repository_name}.csv").first
# File creation
repository_items_file_name = FileUtils.touch("#{path}/#{repository_name}.#{@file_type}").first
# Define headers and columns IDs
col_ids = [-3, -4, -5, -6, -7, -8]
col_ids << -9 if Repository.repository_row_connections_enabled?
col_ids = [-3, -4, -5, -6, -7, -8, -9, -10]
col_ids << -11 if Repository.repository_row_connections_enabled?
col_ids += repository.repository_columns.map(&:id)
# Define callback function for file name
@ -66,9 +67,12 @@ class RepositoriesExportJob < ApplicationJob
return "=HYPERLINK(\"#{relative_path}\", \"#{relative_path}\")"
end
# Generate CSV
csv_data = RepositoryZipExport.to_csv(repository.repository_rows, col_ids, @user, repository, handle_name_func)
File.binwrite(csv_file, csv_data.encode('UTF-8', invalid: :replace, undef: :replace))
# Generate CSV / XLSX
service = RepositoryExportService
.new(@file_type, repository.repository_rows, col_ids, @user, repository, handle_name_func)
exported_data = service.export!
File.binwrite(repository_items_file_name, exported_data)
# Save all attachments (it doesn't work directly in callback function
assets.each do |asset, asset_path|

View file

@ -29,13 +29,15 @@ class RepositoryZipExportJob < ZipExportJob
.index_by(&:id)
rows = ordered_row_ids.collect { |id| id_row_map[id.to_i] }
end
data = RepositoryZipExport.to_csv(rows,
params[:header_ids].map(&:to_i),
@user,
repository,
nil,
params[:my_module_id].present?)
File.binwrite("#{dir}/export.csv", data.encode('UTF-8', invalid: :replace, undef: :replace))
service = RepositoryExportService
.new(@file_type,
rows,
params[:header_ids].map(&:to_i),
@user,
repository,
in_module: params[:my_module_id].present?)
exported_data = service.export!
File.binwrite("#{dir}/export.#{@file_type}", exported_data)
end
def failed_notification_title

View file

@ -276,7 +276,7 @@ class TeamZipExportJob < ZipExportJob
end
# Generate CSV
csv_data = RepositoryZipExport.to_csv(repo.repository_rows, col_ids, @user, repo, handle_name_func)
csv_data = RepositoryCsvExport.to_csv(repo.repository_rows, col_ids, @user, repo, handle_name_func, false)
File.binwrite(csv_file_path, csv_data.encode('UTF-8', invalid: :replace, undef: :replace))
# Save all attachments (it doesn't work directly in callback function

View file

@ -3,8 +3,9 @@
class ZipExportJob < ApplicationJob
include FailedDeliveryNotifiableJob
def perform(user_id:, params: {})
def perform(user_id:, params: {}, file_type: :csv)
@user = User.find(user_id)
@file_type = file_type.to_sym
I18n.backend.date_format = @user.settings[:date_format] || Constants::DEFAULT_DATE_FORMAT
zip_input_dir = FileUtils.mkdir_p(Rails.root.join("tmp/temp_zip_#{Time.now.to_i}").to_s).first
zip_dir = FileUtils.mkdir_p(Rails.root.join('tmp/zip-ready').to_s).first

View file

@ -35,7 +35,7 @@ class Asset < ApplicationRecord
has_one :result_asset, inverse_of: :asset, dependent: :destroy
has_one :result, through: :result_asset, touch: true
has_one :repository_asset_value, inverse_of: :asset, dependent: :destroy
has_one :repository_cell, through: :repository_asset_value
has_one :repository_cell, through: :repository_asset_value, touch: true
has_many :report_elements, inverse_of: :asset, dependent: :destroy
has_one :asset_text_datum, inverse_of: :asset, dependent: :destroy
has_many :asset_sync_tokens, dependent: :destroy
@ -245,7 +245,7 @@ class Asset < ApplicationRecord
def can_perform_action(action)
if ENV['WOPI_ENABLED'] == 'true'
file_ext = file_name.split('.').last
file_ext = file_name.split('.').last&.downcase
if file_ext == 'wopitest' &&
(!ENV['WOPI_TEST_ENABLED'] || ENV['WOPI_TEST_ENABLED'] == 'false')
@ -297,7 +297,7 @@ class Asset < ApplicationRecord
end
def favicon_url(action)
file_ext = file_name.split('.').last
file_ext = file_name.split('.').last&.downcase
action = get_action(file_ext, action)
action[:icon] if action[:icon]
end
@ -385,6 +385,10 @@ class Asset < ApplicationRecord
new_image_filename = "#{new_name}.png"
preview_image.blob.update!(filename: new_image_filename)
end
# rubocop:disable Rails/SkipsModelValidations
touch
# rubocop:enable Rails/SkipsModelValidations
end
end

View file

@ -8,6 +8,21 @@ module SettingsModel
after_initialize :init_default_settings, if: :new_record?
end
def update_simple_setting(key:, value:)
raise ArgumentError, 'Unauthorized setting key' unless Extends::WHITELISTED_USER_SETTINGS.include?(key.to_s)
settings[key] = value
save
end
def update_nested_setting(key:, id:, value:)
raise ArgumentError, 'Unauthorized setting key' unless Extends::WHITELISTED_USER_SETTINGS.include?(key.to_s)
settings[key] ||= {}
settings[key][id.to_s] = value
save
end
protected
def init_default_settings

View file

@ -29,17 +29,15 @@ module TinyMceImages
)[0]
next unless tm_asset_to_update
unless export_all
tm_asset = tm_asset.image.representation(resize_to_limit: Constants::LARGE_PIC_FORMAT).processed
tm_asset = tm_asset.image.representation(resize_to_limit: Constants::LARGE_PIC_FORMAT).processed
width_attr = tm_asset_to_update.attributes['width']
height_attr = tm_asset_to_update.attributes['height']
width_attr = tm_asset_to_update.attributes['width']
height_attr = tm_asset_to_update.attributes['height']
if width_attr && height_attr && (width_attr.value.to_i >= Constants::LARGE_PIC_FORMAT[0] ||
height_attr.value.to_i >= Constants::LARGE_PIC_FORMAT[1])
width_attr.value = tm_asset.image.blob.metadata['width'].to_s
height_attr.value = tm_asset.image.blob.metadata['height'].to_s
end
if width_attr && height_attr && (width_attr.value.to_i >= Constants::LARGE_PIC_FORMAT[0] ||
height_attr.value.to_i >= Constants::LARGE_PIC_FORMAT[1])
width_attr.value = tm_asset.image.blob.metadata['width'].to_s
height_attr.value = tm_asset.image.blob.metadata['height'].to_s
end
tm_asset_to_update.attributes['src'].value = convert_to_base64(tm_asset.image)

View file

@ -20,8 +20,11 @@ class LinkedRepository < Repository
'assigned',
'repository_rows.id',
'repository_rows.name',
'relationships',
'repository_rows.created_at',
'users.full_name',
'repository_rows.updated_at',
'last_modified_bies_repository_rows.full_name',
'repository_rows.archived_on',
'archived_bies_repository_rows.full_name',
'repository_rows.external_id'

View file

@ -56,6 +56,7 @@ class MyModule < ApplicationRecord
belongs_to :changing_from_my_module_status, optional: true, class_name: 'MyModuleStatus'
delegate :my_module_status_flow, to: :my_module_status, allow_nil: true
has_many :results, inverse_of: :my_module, dependent: :destroy
has_many :results_include_discarded, -> { with_discarded }, class_name: 'Result', inverse_of: :my_module
has_many :my_module_tags, inverse_of: :my_module, dependent: :destroy
has_many :tags, through: :my_module_tags, dependent: :destroy
has_many :task_comments, foreign_key: :associated_id, dependent: :destroy

View file

@ -71,6 +71,7 @@ class MyModuleRepositoryRow < ApplicationRecord
team_id: my_module.experiment.project.team.id
}
)
stock_value.last_modified_by_id = last_modified_by_id
stock_value.save!
save!
end

View file

@ -102,6 +102,8 @@ class Repository < RepositoryBase
'relationships',
'repository_rows.created_at',
'users.full_name',
'repository_rows.updated_at',
'last_modified_bies_repository_rows.full_name',
'repository_rows.archived_on',
'archived_bies_repository_rows.full_name'
]
@ -160,6 +162,7 @@ class Repository < RepositoryBase
def importable_repository_fields
fields = {}
# First and foremost add record name
fields['0'] = I18n.t('repositories.id_column')
fields['-1'] = I18n.t('repositories.default_column')
# Add all other custom columns
repository_columns.order(:created_at).each do |rc|
@ -201,11 +204,6 @@ class Repository < RepositoryBase
new_repo
end
def import_records(sheet, mappings, user)
importer = RepositoryImportParser::Importer.new(sheet, mappings, user, self)
importer.run
end
def assigned_rows(my_module)
repository_rows.joins(:my_module_repository_rows).where(my_module_repository_rows: { my_module_id: my_module.id })
end

View file

@ -12,7 +12,7 @@ class RepositoryAssetValue < ApplicationRecord
belongs_to :asset,
inverse_of: :repository_asset_value,
dependent: :destroy
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value, touch: true
accepts_nested_attributes_for :repository_cell
validates :asset, :repository_cell, presence: true

View file

@ -3,9 +3,9 @@
class RepositoryCell < ApplicationRecord
include ReminderRepositoryCellJoinable
attr_accessor :importing
attr_accessor :importing, :to_destroy
belongs_to :repository_row
belongs_to :repository_row, touch: true
belongs_to :repository_column
belongs_to :value, polymorphic: true, inverse_of: :repository_cell, dependent: :destroy
@ -45,10 +45,16 @@ class RepositoryCell < ApplicationRecord
uniqueness: { scope: :repository_column },
unless: :importing
after_touch :update_repository_row_last_modified_by
scope :with_active_reminder, lambda { |user|
reminder_repository_cells_scope(self, user)
}
def update_repository_row_last_modified_by
repository_row.update!(last_modified_by_id: value.last_modified_by_id)
end
def self.create_with_value!(row, column, data, user)
cell = new(repository_row: row, repository_column: column)
cell.transaction do

View file

@ -5,4 +5,19 @@ class RepositoryChecklistItemsValue < ApplicationRecord
belongs_to :repository_checklist_value, inverse_of: :repository_checklist_items_values
validates :repository_checklist_item, :repository_checklist_value, presence: true
after_commit :touch_repository_checklist_value
private
# rubocop:disable Rails/SkipsModelValidations
def touch_repository_checklist_value
# check if value was deleted, if so, touch repositroy_row directly
if RepositoryChecklistValue.exists?(repository_checklist_value.id)
repository_checklist_value.touch
elsif RepositoryRow.exists?(repository_checklist_value.repository_cell.repository_row.id)
repository_checklist_value.repository_cell.repository_row.touch
end
end
# rubocop:enable Rails/SkipsModelValidations
end

View file

@ -1,11 +1,13 @@
# frozen_string_literal: true
class RepositoryChecklistValue < ApplicationRecord
attribute :current_repository_checklist_items
belongs_to :created_by, foreign_key: 'created_by_id', class_name: 'User',
inverse_of: :created_repository_checklist_values
belongs_to :last_modified_by, foreign_key: 'last_modified_by_id', class_name: 'User',
inverse_of: :modified_repository_checklist_values
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value, touch: true
has_many :repository_checklist_items_values, dependent: :destroy
has_many :repository_checklist_items, -> { order('data ASC') },
through: :repository_checklist_items_values,
@ -20,7 +22,8 @@ class RepositoryChecklistValue < ApplicationRecord
EXTRA_PRELOAD_INCLUDE = :repository_checklist_items
def formatted(separator: ' | ')
repository_checklist_items.pluck(:data).join(separator)
checklist_items = current_repository_checklist_items || repository_checklist_items
checklist_items.map(&:data).join(separator)
end
def export_formatted
@ -70,17 +73,25 @@ class RepositoryChecklistValue < ApplicationRecord
end
end
def update_data!(new_data, user)
def update_data!(new_data, user, preview: false)
item_ids = new_data.is_a?(String) ? JSON.parse(new_data) : new_data
return destroy! if item_ids.blank?
# update!(repository_checklist_items: repository_cell.repository_column.repository_checklist_items.where(id: item_ids), last_modified_by: user)
self.repository_checklist_items = repository_cell.repository_column
.repository_checklist_items
.where(id: item_ids)
self.last_modified_by = user
save!
if preview
self.current_repository_checklist_items = repository_checklist_items
clear_current_repository_checklist_items_change
self.current_repository_checklist_items =
repository_cell.repository_column.repository_checklist_items.where(id: item_ids)
validate
else
self.repository_checklist_items = repository_cell.repository_column
.repository_checklist_items
.where(id: item_ids)
save!
end
end
def snapshot!(cell_snapshot)

View file

@ -7,7 +7,7 @@ class RepositoryDateTimeRangeValueBase < ApplicationRecord
inverse_of: :created_repository_date_time_values
belongs_to :last_modified_by, foreign_key: :last_modified_by_id, class_name: 'User', optional: true,
inverse_of: :modified_repository_date_time_values
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value, touch: true
accepts_nested_attributes_for :repository_cell
validates :repository_cell, :start_time, :end_time, :type, presence: true

View file

@ -7,7 +7,7 @@ class RepositoryDateTimeValueBase < ApplicationRecord
inverse_of: :created_repository_date_time_values
belongs_to :last_modified_by, foreign_key: :last_modified_by_id, class_name: 'User', optional: true,
inverse_of: :modified_repository_date_time_values
has_one :repository_cell, as: :value, dependent: :destroy
has_one :repository_cell, as: :value, dependent: :destroy, touch: true
accepts_nested_attributes_for :repository_cell
before_save :reset_notification_sent, if: -> { data_changed? }
@ -19,10 +19,15 @@ class RepositoryDateTimeValueBase < ApplicationRecord
I18n.l(data, format: format)
end
def update_data!(new_data, user)
self.data = Time.zone.parse(new_data)
def update_data!(new_data, user, preview: false)
self.data = if new_data.is_a?(String)
Time.zone.parse(new_data)
else
new_data
end
self.last_modified_by = user
save!
preview ? validate : save!
end
def snapshot!(cell_snapshot)

View file

@ -56,10 +56,15 @@ class RepositoryDateValue < RepositoryDateTimeValueBase
value
end
def update_data!(new_data, user)
self.data = Date.parse(new_data)
def update_data!(new_data, user, preview: false)
self.data = if new_data.is_a?(String)
Date.parse(new_data)
else
new_data.to_time.utc
end
self.last_modified_by = user
save!
preview ? validate : save!
end
def self.import_from_text(text, attributes, options = {})

View file

@ -8,7 +8,7 @@ class RepositoryListValue < ApplicationRecord
belongs_to :last_modified_by,
foreign_key: :last_modified_by_id,
class_name: 'User'
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value, touch: true
accepts_nested_attributes_for :repository_cell
validates :repository_cell, presence: true
@ -57,10 +57,10 @@ class RepositoryListValue < ApplicationRecord
new_data.to_i != repository_list_item_id
end
def update_data!(new_data, user)
def update_data!(new_data, user, preview: false)
self.repository_list_item_id = new_data.to_i
self.last_modified_by = user
save!
preview ? validate : save!
end
def snapshot!(cell_snapshot)

View file

@ -5,7 +5,7 @@ class RepositoryNumberValue < ApplicationRecord
inverse_of: :created_repository_number_values
belongs_to :last_modified_by, foreign_key: :last_modified_by_id, class_name: 'User',
inverse_of: :modified_repository_number_values
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value, touch: true
accepts_nested_attributes_for :repository_cell
validates :repository_cell, :data, presence: true
@ -49,10 +49,10 @@ class RepositoryNumberValue < ApplicationRecord
BigDecimal(new_data.to_s) != data
end
def update_data!(new_data, user)
def update_data!(new_data, user, preview: false)
self.data = BigDecimal(new_data.to_s)
self.last_modified_by = user
save!
preview ? validate : save!
end
def snapshot!(cell_snapshot)

View file

@ -105,6 +105,8 @@ class RepositoryRow < ApplicationRecord
length: { maximum: Constants::NAME_MAX_LENGTH }
validates :created_by, presence: true
attr_accessor :import_status, :import_message
scope :active, -> { where(archived: false) }
scope :archived, -> { where(archived: true) }

View file

@ -6,7 +6,7 @@ class RepositoryStatusValue < ApplicationRecord
inverse_of: :created_repository_status_value
belongs_to :last_modified_by, foreign_key: 'last_modified_by_id', class_name: 'User', optional: true,
inverse_of: :modified_repository_status_value
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value, touch: true
accepts_nested_attributes_for :repository_cell
validates :repository_cell, :repository_status_item, presence: true
@ -48,10 +48,10 @@ class RepositoryStatusValue < ApplicationRecord
new_data.to_i != repository_status_item_id
end
def update_data!(new_data, user)
def update_data!(new_data, user, preview: false)
self.repository_status_item_id = new_data.to_i
self.last_modified_by = user
save!
preview ? validate : save!
end
def snapshot!(cell_snapshot)

View file

@ -9,7 +9,7 @@ class RepositoryStockValue < ApplicationRecord
belongs_to :repository_stock_unit_item, optional: true
belongs_to :created_by, class_name: 'User', optional: true, inverse_of: :created_repository_stock_values
belongs_to :last_modified_by, class_name: 'User', optional: true, inverse_of: :modified_repository_stock_values
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value
has_one :repository_cell, as: :value, dependent: :destroy, inverse_of: :value, touch: true
has_one :repository_row, through: :repository_cell
has_many :repository_ledger_records, dependent: :destroy
accepts_nested_attributes_for :repository_cell
@ -108,7 +108,7 @@ class RepositoryStockValue < ApplicationRecord
(new_data[:unit_item_id].present? && new_data[:unit_item_id] != repository_stock_unit_item.id)
end
def update_data!(new_data, user)
def update_data!(new_data, user, preview: false)
self.low_stock_threshold = new_data[:low_stock_threshold].presence if new_data[:low_stock_threshold]
self.repository_stock_unit_item = repository_cell
.repository_column
@ -118,16 +118,20 @@ class RepositoryStockValue < ApplicationRecord
new_amount = new_data[:amount].to_d
delta = new_amount - amount.to_d
self.comment = new_data[:comment].presence
repository_ledger_records.create!(
user: last_modified_by,
amount: delta,
balance: new_amount,
reference: repository_cell.repository_column.repository,
comment: comment,
unit: repository_stock_unit_item&.data
)
unless preview
repository_ledger_records.create!(
user: last_modified_by,
amount: delta,
balance: new_amount,
reference: repository_cell.repository_column.repository,
comment: comment,
unit: repository_stock_unit_item&.data
)
end
self.amount = new_amount
save!
preview ? validate : save!
end
def snapshot!(cell_snapshot)
@ -167,7 +171,7 @@ class RepositoryStockValue < ApplicationRecord
end
def self.import_from_text(text, attributes, _options = {})
digit, unit = text.match(/(^\d*\.?\d*)(\D*)/).captures
digit, unit = text.match(/(^-?\d*\.?\d*)(\D*)/).captures
digit.strip!
unit.strip!
return nil if digit.blank?

View file

@ -7,7 +7,7 @@ class RepositoryTextValue < ApplicationRecord
belongs_to :last_modified_by, foreign_key: :last_modified_by_id,
class_name: 'User',
inverse_of: :modified_repository_text_values
has_one :repository_cell, as: :value, dependent: :destroy
has_one :repository_cell, as: :value, dependent: :destroy, touch: true
accepts_nested_attributes_for :repository_cell
validates :repository_cell, presence: true
@ -36,10 +36,10 @@ class RepositoryTextValue < ApplicationRecord
new_data != data
end
def update_data!(new_data, user)
def update_data!(new_data, user, preview: false)
self.data = new_data
self.last_modified_by = user
save!
preview ? validate : save!
end
def snapshot!(cell_snapshot)
@ -61,7 +61,7 @@ class RepositoryTextValue < ApplicationRecord
def self.import_from_text(text, attributes, _options = {})
return nil if text.blank?
new(attributes.merge(data: text.truncate(Constants::TEXT_MAX_LENGTH)))
new(attributes.merge(data: text))
end
alias export_formatted formatted

View file

@ -5,6 +5,9 @@ class Result < ApplicationRecord
include SearchableModel
include SearchableByNameModel
include ViewableModel
include Discard::Model
default_scope -> { kept }
auto_strip_attributes :name, nullify: false
validates :name, length: { maximum: Constants::NAME_MAX_LENGTH }

View file

@ -25,7 +25,7 @@ class Step < ApplicationRecord
after_destroy :adjust_positions_after_destroy, unless: -> { skip_position_adjust }
# conditional touch excluding step completion updates
after_destroy :touch_protocol
after_destroy :touch_protocol, :remove_from_user_settings
after_save :touch_protocol
after_touch :touch_protocol
@ -167,6 +167,10 @@ class Step < ApplicationRecord
private
def remove_from_user_settings
CleanupUserSettingsJob.perform_later('task_step_states', id)
end
def touch_protocol
# if only step completion attributes were changed, do not touch protocol
return if saved_changes.keys.sort == %w(completed completed_on updated_at)

Some files were not shown because too many files have changed in this diff Show more