Merge branch 'develop' into e2e

This commit is contained in:
Oleksii Kriuchykhin 2023-12-15 15:11:30 +01:00
commit 430daa4c8e
185 changed files with 4066 additions and 1186 deletions

View file

@ -21,6 +21,24 @@
{
"beforeLineComment": false
}
],
"max-len": [
"error",
{
"code": 120
}
],
"vue/max-len": [
"error",
{
"code": 120,
"template": 240,
"tabWidth": 2
}
],
"comma-dangle": [
"error",
"never"
]
},
"globals": {

View file

@ -4,7 +4,8 @@ ruby:
eslint:
enabled: true
config_file: app/assets/.eslintrc.json
version: 8.1.0
config_file: .eslintrc.json
scss:
config_file: .scss-lint.yml

View file

@ -57,6 +57,7 @@ gem 'jbuilder' # JSON structures via a Builder-style DSL
gem 'logging', '~> 2.0.0'
gem 'nested_form_fields'
gem 'nokogiri', '~> 1.14.3' # HTML/XML parser
gem 'noticed'
gem 'rails_autolink', '~> 1.1', '>= 1.1.6'
gem 'rgl' # Graph framework for project diagram calculations
gem 'roo', '~> 2.10.0' # Spreadsheet parser

View file

@ -50,7 +50,7 @@ GIT
GIT
remote: https://github.com/scinote-eln/yomu
revision: 020ab670b2919f3b436e926a890d1dad23d75676
revision: fb518a5fbab82f692dea4ae1fdf30eae5df62590
branch: master
specs:
yomu (0.2.4)
@ -301,6 +301,8 @@ GEM
discard (1.2.1)
activerecord (>= 4.2, < 8)
docile (1.4.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.6.6)
railties (>= 5)
down (5.4.1)
@ -324,6 +326,9 @@ GEM
faraday-net_http (3.0.2)
fastimage (2.2.7)
ffi (1.15.5)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
figaro (1.2.0)
thor (>= 0.14.0, < 2)
fugit (1.8.1)
@ -338,6 +343,14 @@ GEM
nokogiri (~> 1.0)
hashdiff (1.0.1)
hashie (5.0.0)
http (5.1.1)
addressable (~> 2.8)
http-cookie (~> 1.0)
http-form_data (~> 2.2)
llhttp-ffi (~> 0.4.0)
http-cookie (1.0.5)
domain_name (~> 0.5)
http-form_data (2.3.0)
httparty (0.21.0)
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
@ -387,6 +400,9 @@ GEM
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
little-plugger (1.1.4)
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
logging (2.0.0)
little-plugger (~> 1.1)
multi_json (~> 1.10)
@ -434,6 +450,9 @@ GEM
racc (~> 1.4)
nokogiri (1.14.5-x86_64-linux)
racc (~> 1.4)
noticed (1.6.3)
http (>= 4.0.0)
rails (>= 5.2.0)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
@ -661,6 +680,9 @@ GEM
uglifier (4.2.0)
execjs (>= 0.3.0, < 3)
underscore-rails (1.8.3)
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (2.4.2)
uniform_notifier (1.16.0)
version_gem (1.1.3)
@ -748,6 +770,7 @@ DEPENDENCIES
nested_form_fields
newrelic_rpm
nokogiri (~> 1.14.3)
noticed
omniauth (~> 2.1)
omniauth-azure-activedirectory-v2
omniauth-linkedin-oauth2

View file

@ -1 +1 @@
1.29.3
1.29.5.1

View file

@ -1,25 +0,0 @@
{
"env": {
"browser": true,
"jquery": true
},
"extends": ["airbnb"],
"rules": {
"space-before-function-paren": ["error", "never"],
"func-names": ["error", "never"],
"spaced-comment": [
"error",
"always",
{
"markers": ["="]
}
],
"lines-around-comment": [
"warn",
{
"beforeLineComment": false
}
],
"max-len": ["error", { "code": 120 }]
}
}

View file

@ -615,6 +615,7 @@ var ExperimnetTable = {
this.appendRows(result.data);
this.initDueDatePicker(result.data);
this.handleNoResults();
this.initProvisioningStatusPolling();
}, 100);
InfiniteScroll.init(this.table, {

View file

@ -0,0 +1,81 @@
/* global I18n dropdownSelector HelperModule animateSpinner */
/* eslint-disable no-use-before-define */
(function() {
function initAssignedUsersSelector() {
var myModuleUserSelector = '#module-assigned-users-selector';
dropdownSelector.init(myModuleUserSelector, {
closeOnSelect: true,
labelHTML: true,
tagClass: 'my-module-user-tags',
tagLabel: (data) => {
return `<img class="img-responsive block-inline" src="${data.params.avatar_url}" alt="${data.label}"/>
<span class="user-full-name block-inline">${data.label}</span>`;
},
customDropdownIcon: () => {
return '';
},
optionLabel: (data) => {
if (data.params.avatar_url) {
return `<span class="global-avatar-container" style="margin-top: 10px">
<img src="${data.params.avatar_url}" alt="${data.label}"/></span>
<span style="margin-left: 10px">${data.label}</span>`;
}
return data.label;
},
onSelect: function() {
var selectElement = $(myModuleUserSelector);
var lastUser = selectElement.next().find('.ds-tags').last();
var lastUserId = lastUser.find('.tag-label').data('ds-tag-id');
var newUser;
if (lastUserId > 0) {
newUser = {
user_my_module: {
user_id: lastUserId
}
};
} else {
newUser = {
user_my_module: {
user_id: selectElement.val()
}
};
}
$.post(selectElement.data('users-create-url'), newUser, function(result) {
dropdownSelector.removeValue(myModuleUserSelector, 0, '', true);
dropdownSelector.addValue(myModuleUserSelector, {
value: result.user.id,
label: result.user.full_name,
params: {
avatar_url: result.user.avatar_url,
user_module_id: result.user.user_module_id
}
}, true);
}).fail(function() {
dropdownSelector.removeValue(myModuleUserSelector, lastUserId, '', true);
});
},
onUnSelect: (id) => {
var umID = $(myModuleUserSelector).find(`option[value="${id}"]`).data('params').user_module_id;
$.ajax({
url: `${$(myModuleUserSelector).data('update-module-users-url')}/${umID}`,
type: 'DELETE',
success: () => {
dropdownSelector.closeDropdown(myModuleUserSelector);
},
error: (r) => {
if (r.status === 403) {
HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger');
}
}
});
}
}).getContainer(myModuleUserSelector).addClass('my-module-users-container');
}
initAssignedUsersSelector();
}());

View file

@ -57,6 +57,10 @@ var RepositoryDatatable = (function(global) {
}
function restoreColumnSizes() {
const scrollBody = $('.dataTables_scrollBody');
if (scrollBody[0].offsetWidth > scrollBody[0].clientWidth) {
scrollBody.css('width', `calc(100% + ${scrollBody[0].offsetWidth - scrollBody[0].clientWidth}px)`);
}
TABLE.colResize.restore();
}

View file

@ -1,7 +1,7 @@
/* global HelperModule PerfectScrollbar */
// eslint-disable-next-line no-unused-vars
var ShareModal = (function() {
const ShareModal = (function() {
function init() {
var form = $('.share-repo-modal').find('form');
var sharedCBs = form.find("input[name='share_team_ids[]']");
@ -13,21 +13,18 @@ var ShareModal = (function() {
form.find('.teams-list').find('input.sci-checkbox, .permission-selector')
.toggleClass('hidden', selectAllCheckbox.is(':checked'));
form.find('.all-teams .sci-toggle-checkbox')
.toggleClass('hidden', !selectAllCheckbox.is(':checked'))
.attr('disabled', !selectAllCheckbox.is(':checked'));
.toggleClass('hidden', !selectAllCheckbox.is(':checked'));
selectAllCheckbox.change(function() {
form.find('.teams-list').find('input.sci-checkbox, .permission-selector')
.toggleClass('hidden', this.checked);
form.find('.all-teams .sci-toggle-checkbox').toggleClass('hidden', !this.checked)
.attr('disabled', !this.checked);
form.find('.all-teams .sci-toggle-checkbox').toggleClass('hidden', !this.checked);
});
sharedCBs.change(function() {
var selectedTeams = form.find('.teams-list .sci-checkbox:checked').length;
form.find('#select_all_teams').prop('indeterminate', selectedTeams > 0);
$('#editable_' + this.value).toggleClass('hidden', !this.checked)
.attr('disabled', !this.checked);
$('#editable_' + this.value).toggleClass('hidden', !this.checked);
});
if (form.find('.teams-list').length) new PerfectScrollbar(form.find('.teams-list')[0]);

View file

@ -262,6 +262,10 @@ var RepositoryColumns = (function() {
if (!_.isEmpty(searchText)) {
TABLE.search(searchText).draw();
}
const scrollBody = $('.dataTables_scrollBody');
if (scrollBody[0].offsetWidth > scrollBody[0].clientWidth) {
scrollBody.css('width', `calc(100% + ${scrollBody[0].offsetWidth - scrollBody[0].clientWidth}px)`);
}
});
}

View file

@ -84,8 +84,6 @@
.sci-navigation--notificaitons-flyout-notification {
border-bottom: $border-tertiary;
display: grid;
grid-template-columns: max-content auto;
padding: 1rem 0;
.sci-navigation--notificaitons-flyout-notification-icon {
@ -94,7 +92,7 @@
border-radius: 50%;
color: $color-white;
display: flex;
grid-row: 1 / 4;
grid-row: 1 / 5;
height: 2rem;
justify-content: center;
margin-right: .75rem;

View file

@ -137,7 +137,7 @@
--dp-secondary-color: var(--sn-grey);
--dp-border-color: var(--sn-light-grey);
--dp-menu-border-color: var(--sn-light-grey);
--dp-border-color-hover: var(--sn-light-grey);
--dp-border-color-hover: var(--sn-sleepy-grey);
--dp-disabled-color: var(--sn-super-light-grey);
--dp-scroll-bar-background: var(--sn-white);
--dp-scroll-bar-color: var(--sn-grey);

View file

@ -141,7 +141,7 @@
transition: .2s;
transition-property: top, bottom, box-shadow;
width: 100%;
z-index: 100;
z-index: 999;
.empty-dropdown {
opacity: .6;

View file

@ -52,16 +52,14 @@ input[type="checkbox"].sci-toggle-checkbox {
}
}
&:focus + .sci-toggle-checkbox-label {
box-shadow: 0 0 0 4px var(--sn-science-blue-hover);
outline: 2px solid transparent;
outline-offset: 2px;
}
&.hidden + .sci-toggle-checkbox-label {
display: none;
}
&:disabled {
cursor: not-allowed;
}
&:checked + .sci-toggle-checkbox-label {
border-color: var(--sn-blue);

View file

@ -98,6 +98,7 @@
.step-elements {
padding-left: 2.5rem;
padding-right: 2.5rem;
.step-timestamp {
position: relative;

View file

@ -71,6 +71,7 @@ module AccessPermissions
log_activity(:assign_user_to_project, { user_target: user_assignment.user.id,
role: user_assignment.user_role.name })
created_count += 1
propagate_job(user_assignment)
end
@ -99,7 +100,14 @@ module AccessPermissions
raise ActiveRecord::RecordInvalid
end
propagate_job(user_assignment, destroy: true)
UserAssignments::PropagateAssignmentJob.perform_now(
@project,
user_assignment.user.id,
user_assignment.user_role,
current_user.id,
destroy: true
)
log_activity(:unassign_user_from_project, { user_target: user_assignment.user.id,
role: user_assignment.user_role.name })

View file

@ -88,7 +88,10 @@ module Api
metadata_cells = metadata[:cells]
data = contents['data']
if data.present? && data[0].present? && (data.size * data[0].size) < metadata_cells.size
if data.present? && data[0].present?
data_size = (data[0].is_a?(Array) ? data.size * data[0].size : data.size)
if data_size < metadata_cells.size
error_message = I18n.t('api.core.errors.table.metadata.detail_too_many_cells')
raise ActionController::BadRequest, error_message
end
@ -97,3 +100,4 @@ module Api
end
end
end
end

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Api
module V2
class BaseController < Api::V1::BaseController
private
def load_result(key = :result_id)
@result = @task.results.find(params.require(key))
raise PermissionError.new(Result, :read) unless can_read_result?(@result)
end
def load_result_text(key = :result_text_id)
@result_text = @result.result_texts.find(params.require(key))
raise PermissionError.new(Result, :read) unless can_read_result?(@result)
end
def load_result_table(key = :table_id)
@table = @result.tables.find(params.require(key))
raise PermissionError.new(Result, :read) unless can_read_result?(@result)
end
end
end
end

View file

@ -0,0 +1,74 @@
# frozen_string_literal: true
module Api
module V2
class ResultAssetsController < BaseController
before_action :load_team, :load_project, :load_experiment, :load_task, :load_result
before_action :check_manage_permission, only: %i(create destroy)
before_action :load_asset, only: %i(show destroy)
before_action :check_upload_type, only: :create
def index
result_assets =
timestamps_filter(@result.result_assets).page(params.dig(:page, :number))
.per(params.dig(:page, :size))
render jsonapi: result_assets, each_serializer: ResultAssetSerializer
end
def show
render jsonapi: @asset.result_asset, serializer: ResultAssetSerializer
end
def create
asset = if @form_multipart_upload
@result.assets.new(asset_params.merge({ team_id: @team.id }))
else
blob = ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(Base64.decode64(asset_params[:file_data])),
filename: asset_params[:file_name],
content_type: asset_params[:file_type]
)
@result.assets.new(file: blob, team: @team)
end
asset.save!(context: :on_api_upload)
asset.post_process_file
render jsonapi: asset.result_asset,
serializer: ResultAssetSerializer,
status: :created
end
def destroy
@asset.destroy!
render body: nil
end
private
def asset_params
raise TypeError unless params.require(:data).require(:type) == 'attachments'
return params.require(:data).require(:attributes).permit(:file) if @form_multipart_upload
attr_list = %i(file_data file_type file_name)
params.require(:data).require(:attributes).require(attr_list)
params.require(:data).require(:attributes).permit(attr_list)
end
def load_asset
@asset = @result.assets.find(params.require(:id))
raise PermissionError.new(Result, :read) unless can_read_result?(@result)
end
def check_upload_type
@form_multipart_upload = true if params.dig(:data, :attributes, :file)
end
def check_manage_permission
raise PermissionError.new(Result, :manage) unless can_manage_result?(@result)
end
end
end
end

View file

@ -0,0 +1,102 @@
# frozen_string_literal: true
module Api
module V2
class ResultTablesController < BaseController
before_action :load_team, :load_project, :load_experiment, :load_task, :load_result
before_action only: %i(show update destroy) do
load_result_table(:id)
end
before_action :check_manage_permission, only: %i(create update destroy)
def index
result_tables = timestamps_filter(@result.result_tables).page(params.dig(:page, :number))
.per(params.dig(:page, :size))
render jsonapi: result_tables, each_serializer: ResultTableSerializer
end
def show
render jsonapi: @table.result_table, serializer: ResultTableSerializer
end
def create
table = @result.tables.new(table_params.merge!(team: @team, created_by: current_user))
@result.with_lock do
@result.result_orderable_elements.create!(
position: @result.result_orderable_elements.size,
orderable: table.result_table
)
table.save!
end
render jsonapi: table.result_table, serializer: ResultTableSerializer, status: :created
end
def update
@table.assign_attributes(table_params)
if @table.changed? && @table.save!
render jsonapi: @table.result_table, serializer: ResultTableSerializer
else
render body: nil, status: :no_content
end
end
def destroy
@table.destroy!
render body: nil
end
private
def check_manage_permission
raise PermissionError.new(Result, :manage) unless can_manage_result?(@result)
end
def convert_plate_template(metadata_params)
if metadata_params.present? && metadata_params['plateTemplate']
metadata_params['plateTemplate'] = ActiveRecord::Type::Boolean.new.cast(metadata_params['plateTemplate'])
end
end
def table_params
raise TypeError unless params.require(:data).require(:type) == 'tables'
attributes_params = params.require(:data).require(:attributes).permit(
:name,
:contents,
metadata: [
:plateTemplate,
{ cells: %i(col row className) }
]
)
convert_plate_template(attributes_params[:metadata])
validate_metadata_params(attributes_params)
attributes_params
end
def validate_metadata_params(attributes_params)
metadata = attributes_params[:metadata]
contents = JSON.parse(attributes_params[:contents] || '{}')
if metadata.present? && metadata[:cells].present? && contents.present?
metadata_cells = metadata[:cells]
data = contents['data']
if data.present? && data[0].present?
data_size = (data[0].is_a?(Array) ? data.size * data[0].size : data.size)
if data_size < metadata_cells.size
error_message = I18n.t('api.core.errors.table.metadata.detail_too_many_cells')
raise ActionController::BadRequest, error_message
end
end
end
end
end
end
end

View file

@ -0,0 +1,66 @@
# frozen_string_literal: true
module Api
module V2
class ResultTextsController < BaseController
before_action :load_team, :load_project, :load_experiment, :load_task, :load_result
before_action only: %i(show update destroy) do
load_result_text(:id)
end
before_action :check_manage_permission, only: %i(create update destroy)
def index
result_texts = timestamps_filter(@result.result_texts).page(params.dig(:page, :number))
.per(params.dig(:page, :size))
render jsonapi: result_texts, each_serializer: ResultTextSerializer
end
def show
render jsonapi: @result_text, serializer: ResultTextSerializer
end
def create
result_text = @result.result_texts.new(result_text_params)
@result.with_lock do
@result.result_orderable_elements.create!(
position: @result.result_orderable_elements.size,
orderable: result_text
)
result_text.save!
end
render jsonapi: result_text, serializer: ResultTextSerializer, status: :created
end
def update
@result_text.assign_attributes(result_text_params)
if @result_text.changed? && @result_text.save!
render jsonapi: @result_text, serializer: ResultTextSerializer, status: :ok
else
render body: nil, status: :no_content
end
end
def destroy
@result_text.destroy!
render body: nil
end
private
def check_manage_permission
raise PermissionError.new(Result, :manage) unless can_manage_result?(@result)
end
def result_text_params
raise TypeError unless params.require(:data).require(:type) == 'result_texts'
params.require(:data).require(:attributes).permit(:text, :name)
end
end
end
end

View file

@ -0,0 +1,76 @@
# frozen_string_literal: true
module Api
module V2
class ResultsController < BaseController
before_action :load_team, :load_project, :load_experiment, :load_task
before_action only: %i(show update destroy) do
load_result(:id)
end
before_action :check_create_permissions, only: :create
before_action :check_delete_permissions, only: :destroy
before_action :check_update_permissions, only: :update
def index
results = timestamps_filter(@task.results).page(params.dig(:page, :number))
.per(params.dig(:page, :size))
render jsonapi: results, each_serializer: ResultSerializer,
include: include_params
end
def show
render jsonapi: @result, serializer: ResultSerializer,
include: include_params
end
def create
@result = Result.create!(
user: current_user,
my_module: @task,
name: result_params[:name]
)
render jsonapi: @result, serializer: ResultSerializer
end
def update
@result.assign_attributes(result_params)
if @result.changed? && @result.save!
render jsonapi: @result, serializer: ResultSerializer
else
render body: nil, status: :no_content
end
end
def destroy
@result.destroy!
render body: nil
end
private
def check_create_permissions
raise PermissionError.new(MyModule, :manage) unless can_manage_my_module?(@task)
end
def check_delete_permissions
raise PermissionError.new(Result, :delete) unless can_delete_result?(@result)
end
def check_update_permissions
raise PermissionError.new(Result, :manage) unless can_manage_result?(@result)
end
def permitted_includes
%w(comments result_texts tables assets)
end
def result_params
raise TypeError unless params.require(:data).require(:type) == 'results'
params.require(:data).require(:attributes).require(:name)
params.require(:data).permit(attributes: %i(name archived))[:attributes]
end
end
end
end

View file

@ -264,11 +264,7 @@ class AssetsController < ApplicationController
end
# Return edit url and asset info
render json: {
attributes: AssetSerializer.new(asset, scope: { user: current_user }).as_json,
success: true,
edit_url: edit_url
}, status: :ok
render json: asset, scope: { user: current_user }
end
def destroy

View file

@ -48,6 +48,19 @@ module AssetsActions
asset_name: { id: asset.id, value_for: 'file_name' },
action: action
})
elsif asset.repository_cell.present?
repository = asset.repository_cell.repository_row.repository
Activities::CreateActivityService
.call(activity_type: :edit_image_on_inventory_item,
owner: current_user,
subject: repository,
team: repository.team,
message_items: {
repository: repository.id,
repository_row: asset.repository_cell.repository_row.id,
asset_name: { id: asset.id, value_for: 'file_name' },
action: action
})
end
end
end

View file

@ -48,6 +48,7 @@ module StepsActions
smart_annotation_notification(
old_text: old_text,
new_text: checklist_item.text,
subject: step.protocol,
title: t('notifications.checklist_title',
user: current_user.full_name,
step: step.name),
@ -59,6 +60,7 @@ module StepsActions
smart_annotation_notification(
old_text: old_text,
new_text: step_text.text,
subject: step.protocol,
title: t('notifications.step_text_title',
user: current_user.full_name,
step: step.name),
@ -70,6 +72,7 @@ module StepsActions
smart_annotation_notification(
old_text: old_text,
new_text: checklist.name,
subject: step.protocol,
title: t('notifications.checklist_title',
user: current_user.full_name,
step: step.name),
@ -81,6 +84,7 @@ module StepsActions
smart_annotation_notification(
old_text: old_text,
new_text: step.description,
subject: step.protocol,
title: t('notifications.step_description_title',
user: current_user.full_name,
step: step.name),

View file

@ -602,6 +602,7 @@ class ExperimentsController < ApplicationController
smart_annotation_notification(
old_text: old_text,
new_text: @experiment.description,
subject: @experiment,
title: t('notifications.experiment_annotation_title',
experiment: @experiment.name,
user: current_user.full_name),

View file

@ -264,6 +264,7 @@ class MyModuleRepositoriesController < ApplicationController
smart_annotation_notification(
old_text: nil,
new_text: comment,
subject: module_repository_row.repository_row,
title: t('notifications.my_module_consumption_comment_annotation_title',
repository_item: module_repository_row.repository_row.name,
repository: @repository.name,

View file

@ -8,6 +8,7 @@ class MyModuleShareableLinksController < ApplicationController
results_show)
before_action :check_view_permissions, only: :show
before_action :check_manage_permissions, except: %i(protocol_show
show
repository_index_dt
repository_snapshot_index_dt
download_asset

View file

@ -67,6 +67,7 @@ class MyModulesController < ApplicationController
subject: @my_module,
message_items: { my_module: @my_module.id }
)
log_user_designation_activity
redirect_to canvas_experiment_path(@experiment) if params[:my_module][:view_mode] == 'canvas'
rescue ActiveRecord::RecordInvalid
render json: @my_module.errors, status: :unprocessable_entity
@ -529,6 +530,14 @@ class MyModulesController < ApplicationController
log_activity(type_of, @my_module, message_items)
end
def log_user_designation_activity
users = User.where.not(id: current_user.id).where(id: params[:my_module][:user_ids])
users.each do |user|
log_activity(:designate_user_to_my_module, @my_module, { user_target: user.id })
end
end
def log_activity(type_of, my_module = nil, message_items = {})
my_module ||= @my_module
message_items = { my_module: my_module.id }.merge(message_items)
@ -552,6 +561,7 @@ class MyModulesController < ApplicationController
smart_annotation_notification(
old_text: old_text,
new_text: @my_module.description,
subject: @my_module,
title: t('notifications.my_module_description_annotation_title',
my_module: @my_module.name,
user: current_user.full_name),
@ -566,6 +576,7 @@ class MyModulesController < ApplicationController
smart_annotation_notification(
old_text: old_text,
new_text: @my_module.protocol.description,
subject: @my_module,
title: t('notifications.my_module_protocol_annotation_title',
my_module: @my_module.name,
user: current_user.full_name),

View file

@ -1079,6 +1079,7 @@ class ProtocolsController < ApplicationController
smart_annotation_notification(
old_text: old_text,
new_text: @protocol.description,
subject: @protocol,
title: t('notifications.protocol_description_annotation_title',
user: current_user.full_name,
protocol: @protocol.name),

View file

@ -328,6 +328,7 @@ class RepositoriesController < ApplicationController
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') }
)
@ -357,6 +358,7 @@ class RepositoriesController < ApplicationController
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)
log_activity(:export_inventories, inventories: repositories.pluck(:name).join(', '))
render json: { message: t('zip_export.export_request_success') }
else
render json: { message: t('zip_export.export_error') }, status: :unprocessable_entity
@ -364,14 +366,18 @@ class RepositoriesController < ApplicationController
end
def export_repository_stock_items
row_ids = @repository.repository_rows.where(id: params[:row_ids]).pluck(:id)
if row_ids.any?
repository_rows = @repository.repository_rows.where(id: params[:row_ids]).pluck(:id, :name)
if repository_rows.any?
RepositoryStockZipExportJob.perform_later(
user_id: current_user.id,
params: {
repository_row_ids: row_ids
repository_row_ids: repository_rows.map { |row| row[0] }
}
)
log_activity(
:export_inventory_stock_consumption,
inventory_items: repository_rows.map { |row| row[1] }.join(', ')
)
render json: { message: t('zip_export.export_request_success') }
else
render json: { message: t('zip_export.export_error') }, status: :unprocessable_entity
@ -532,6 +538,7 @@ class RepositoriesController < ApplicationController
end
def log_activity(type_of, message_items = {})
if @repository.present?
message_items = { repository: @repository.id }.merge(message_items)
Activities::CreateActivityService
@ -540,6 +547,14 @@ class RepositoriesController < ApplicationController
subject: @repository,
team: @repository.team,
message_items: message_items)
else
Activities::CreateActivityService
.call(activity_type: type_of,
owner: current_user,
subject: @current_team,
team: @current_team,
message_items: message_items)
end
end
def set_breadcrumbs_items

View file

@ -210,7 +210,7 @@ class RepositoryRowsController < ApplicationController
return render json: { name: @repository_row.name } if update_params['repository_row'].present?
column = row_cell_update.column
cell = row_cell_update.cell
cell = row_cell_update.cell&.reload || row_cell_update.cell
data = { value_type: column.data_type, id: column.id, value: nil }
return render json: data if cell.blank?
@ -452,6 +452,7 @@ class RepositoryRowsController < ApplicationController
smart_annotation_notification(
old_text: old_text,
new_text: cell.value.data,
subject: cell.repository_column.repository,
title: t('notifications.repository_annotation_title',
user: current_user.full_name,
column: cell.repository_column.name,

View file

@ -100,6 +100,7 @@ module ResultElements
smart_annotation_notification(
old_text: (old_text if old_text),
new_text: @result_text.text,
subject: @result,
title: t('notifications.result_annotation_title',
result: @result.name,
user: current_user.full_name),

View file

@ -121,6 +121,7 @@ class ResultTextsController < ApplicationController
smart_annotation_notification(
old_text: (old_text if old_text),
new_text: @result_text.text,
subject: @result,
title: t('notifications.result_annotation_title',
result: @result.name,
user: current_user.full_name),

View file

@ -60,6 +60,7 @@ class TeamRepositoriesController < ApplicationController
def check_sharing_permissions
render_403 unless can_share_repository?(@repository)
render_403 if !@repository.shareable_write? && update_params[:write_permissions].present?
end
def teams_to_share

View file

@ -4,22 +4,17 @@ class UserNotificationsController < ApplicationController
prepend_before_action -> { request.env['devise.skip_trackable'] = true }, only: :unseen_counter
def index
page = (params[:page] || 1).to_i
notifications = load_notifications.page(page).per(Constants::INFINITE_SCROLL_LIMIT).without_count
page = (params.dig(:page, :number) || 1).to_i
notifications = load_notifications.page(page).per(Constants::INFINITE_SCROLL_LIMIT)
render json: {
notifications: notification_serializer(notifications),
next_page: notifications.next_page
}
render json: notifications, each_serializer: NotificationSerializer
UserNotification.where(
notification_id: notifications.except(:select).where.not(type_of: 2).select(:id)
).seen_by_user(current_user)
notifications.mark_as_read!
end
def unseen_counter
render json: {
unseen: load_notifications.where('user_notifications.checked = ?', false).size
unseen: load_notifications.where(read_at: nil).size
}
end
@ -27,21 +22,8 @@ class UserNotificationsController < ApplicationController
def load_notifications
current_user.notifications
.select(:id, :type_of, :title, :message, :created_at, 'user_notifications.checked')
.in_app
.order(created_at: :desc)
end
def notification_serializer(notifications)
notifications.map do |notification|
{
id: notification.id,
type_of: notification.type_of,
title: notification.title,
message: notification.message,
created_at: I18n.l(notification.created_at, format: :full),
today: notification.created_at.today?,
checked: notification.checked
}
end
end
end

View file

@ -4,9 +4,9 @@ module Users
class PreferencesController < ApplicationController
before_action :load_user, only: [
:index,
:update,
:update_togglable_settings
:update
]
before_action :set_breadcrumbs_items, only: %i(index)
layout 'fluid'
def index
@ -20,30 +20,6 @@ module Users
end
end
def update_togglable_settings
read_from_params(:assignments_notification) do |val|
@user.assignments_notification = val
end
read_from_params(:recent_notification) do |val|
@user.recent_notification = val
end
read_from_params(:recent_notification_email) do |val|
@user.recent_email_notification = val
end
read_from_params(:assignments_notification_email) do |val|
@user.assignments_email_notification = val
end
if @user.save
render json: {
status: :ok
}
else
render json: {
status: :unprocessable_entity
}
end
end
private
def load_user
@ -51,12 +27,21 @@ module Users
end
def update_params
params.require(:user).permit(:time_zone, :date_format)
params.require(:user).permit(:time_zone, :date_format, notifications_settings: {})
end
def read_from_params(name)
yield(params.include?(name) ? true : false)
end
def set_breadcrumbs_items
@breadcrumbs_items = [{
label: t('notifications.breadcrumb'),
url: preferences_path
}]
@breadcrumbs_items
end
end
end
end

View file

@ -71,6 +71,7 @@ module ApplicationHelper
message = options.fetch(:message) { :message_must_be_present }
old_text = options[:old_text] || ''
new_text = options[:new_text]
subject = options[:subject]
return if new_text.blank?
sa_user = /\[\@(.*?)~([0-9a-zA-Z]+)\]/
@ -96,17 +97,21 @@ module ApplicationHelper
target_user = User.find_by_id(user_id)
next unless target_user
generate_annotation_notification(target_user, title, message)
generate_annotation_notification(target_user, title, subject)
end
end
def generate_annotation_notification(target_user, title, message)
notification = Notification.create(
type_of: :assignment,
def generate_annotation_notification(target_user, title, subject)
GeneralNotification.send_notifications(
{
type: :smart_annotation_added,
title: sanitize_input(title),
message: sanitize_input(message)
subject_id: subject.id,
subject_class: subject.class.name,
subject_name: subject.respond_to?(:name) && subject.name,
user: target_user
}
)
UserNotification.create(notification: notification, user: target_user) if target_user.assignments_notification
end
def custom_link_open_new_tab(text)

View file

@ -125,6 +125,7 @@ module CommentHelper
smart_annotation_notification(
old_text: old_text,
new_text: comment.message,
subject: result,
title: t('notifications.result_comment_annotation_title',
result: result.name,
user: current_user.full_name),
@ -147,6 +148,7 @@ module CommentHelper
smart_annotation_notification(
old_text: old_text,
new_text: comment.message,
subject: project,
title: t('notifications.project_comment_annotation_title',
project: project.name,
user: current_user.full_name),
@ -160,6 +162,7 @@ module CommentHelper
smart_annotation_notification(
old_text: old_text,
new_text: comment.message,
subject: step.protocol,
title: t('notifications.step_comment_annotation_title',
step: step.name,
user: current_user.full_name),
@ -184,6 +187,7 @@ module CommentHelper
smart_annotation_notification(
old_text: old_text,
new_text: comment.message,
subject: my_module,
title: t('notifications.my_module_comment_annotation_title',
my_module: my_module.name,
user: current_user.full_name),

View file

@ -10,22 +10,21 @@ module NotificationsHelper
unassigned_user: target_user.name,
team: team.name,
unassigned_by_user: user.name)
if role
title = I18n.t('notifications.assign_user_to_team',
assigned_user: target_user.name,
role: role,
team: team.name,
assigned_by_user: user.name) if role
assigned_by_user: user.name)
end
message = "#{I18n.t('search.index.team')} #{team.name}"
end
notification = Notification.create(
type_of: :assignment,
GeneralNotification.send_notifications({
type: role ? :invite_user_to_team : :remove_user_from_team,
title: sanitize_input(title),
message: sanitize_input(message)
)
if target_user.assignments_notification
notification.create_user_notification(target_user)
end
message: sanitize_input(message),
user: target_user
})
end
end

View file

@ -236,13 +236,13 @@ module RepositoryDatatableHelper
def linked_repository_default_columns(record)
{
'1': assigned_row(record),
'2': escape_input(record.external_id),
'3': record.code,
'4': escape_input(record.name),
'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)
'2': record.code,
'3': escape_input(record.name),
'4': I18n.l(record.created_at, format: :full),
'5': escape_input(record.created_by.full_name),
'6': (record.archived_on ? I18n.l(record.archived_on, format: :full) : ''),
'7': escape_input(record.archived_by&.full_name),
'8': escape_input(record.external_id)
}
end

View file

@ -12,6 +12,8 @@ window.initManageStockValueModalComponent = () => {
app.component('ManageStockValueModal', ManageStockValueModal);
app.use(PerfectScrollbar);
app.config.globalProperties.i18n = window.I18n;
mountWithTurbolinks(app, '#manageStockValueModal');
mountWithTurbolinks(app, '#manageStockValueModal', () => {
window.manageStockModalComponent = null;
});
}
};

View file

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

View file

@ -98,8 +98,8 @@ export default {
},
reloadCurrentLevel: function() {
if (this.reloadCurrentLevel && (
this.currentItemId.length == 0 ||
this.menuItems.filter(item => item.id == this.currentItemId)
this.currentItemId?.length === 0
|| this.menuItems.filter((item) => item.id === this.currentItemId)
)) {
this.loadTree();
}

View file

@ -1,15 +1,20 @@
<template>
<div class="sci-navigation--notificaitons-flyout-notification">
<div class="sci-navigation--notificaitons-flyout-notification-icon" :class="notification.type_of">
<i :class="icon"></i>
</div>
<div class="sci-navigation--notificaitons-flyout-notification-date">
{{ notification.created_at }}
{{ notification.attributes.created_at }}
</div>
<div class="sci-navigation--notificaitons-flyout-notification-title"
v-html="notification.title"
:data-seen="notification.checked"></div>
<div v-html="notification.message" class="sci-navigation--notificaitons-flyout-notification-message"></div>
v-html="notification.attributes.title"
:data-seen="notification.attributes.checked"></div>
<div v-html="notification.attributes.message" class="sci-navigation--notificaitons-flyout-notification-message"></div>
<div v-if="notification.attributes.breadcrumbs" class="flex items-center flex-wrap gap-0.5">
<template v-for="(breadcrumb, index) in notification.attributes.breadcrumbs" :key="index">
<div class="flex items-center gap-0.5">
<i v-if="index > 0" class="sn-icon sn-icon-right"></i>
<a :href="breadcrumb.url" :title="breadcrumb.name" class="truncate max-w-[20ch] inline-block">{{ breadcrumb.name }}</a>
</div>
</template>
</div>
</div>
</template>
@ -21,7 +26,7 @@ export default {
},
computed: {
icon() {
switch(this.notification.type_of) {
switch(this.notification.attributes.type_of) {
case 'deliver':
return 'fas fa-truck';
case 'assignment':

View file

@ -26,6 +26,7 @@
<script>
import NotificationItem from './notification_item.vue'
import axios from '../../../packs/custom_axios.js';
export default {
name: 'NotificationsFlyout',
@ -39,17 +40,17 @@ export default {
data() {
return {
notifications: [],
nextPage: 1,
nextPageUrl: null,
scrollBar: null,
loadingPage: false
}
},
created() {
this.nextPageUrl = this.notificationsUrl;
this.loadNotifications();
},
mounted() {
let container = this.$refs.scrollContainer.$el
document.body.style.overflow = 'hidden'
container.addEventListener('ps-scroll-y', (e) => {
if (e.target.scrollTop + e.target.clientHeight >= e.target.scrollHeight - 20) {
@ -57,31 +58,38 @@ export default {
}
})
},
destroyed() {
document.body.style.overflow = 'scroll'
beforeUnmount() {
document.body.style.overflow = 'scroll';
},
computed: {
filteredNotifications() {
this.loadNotifications();
},
todayNotifications() {
return this.notifications.filter(n => n.today);
return this.notifications.filter(n => n.attributes.today);
},
olderNotifications() {
return this.notifications.filter(n => !n.today);
return this.notifications.filter(n => !n.attributes.today);
}
},
methods: {
loadNotifications() {
if (this.nextPage == null || this.loadingPage) return;
if (this.nextPageUrl == null || this.loadingPage) return;
this.loadingPage = true;
$.getJSON(this.notificationsUrl, { page: this.nextPage }, (result) => {
this.notifications = this.notifications.concat(result.notifications);
this.nextPage = result.next_page;
axios.get(this.nextPageUrl)
.then(response => {
this.notifications = this.notifications.concat(response.data.data);
this.nextPageUrl = response.data.links.next;
this.loadingPage = false;
this.$emit('update:unseenNotificationsCount');
})
.catch(error => {
this.loadingPage = false;
});
}
}
}

View file

@ -122,6 +122,15 @@
)
}
},
watch: {
notificationsOpened(newVal) {
if (newVal === true) {
document.body.style.overflow = 'hidden';
} else if (newVal === false) {
document.body.style.overflow = 'scroll';
}
}
},
methods: {
fetchData() {
$.get(this.url, (result) => {

View file

@ -58,6 +58,7 @@
alwaysAllowSave: true,
menuFilter: this.menuFilter,
beforeReadOnlyChange: this.readOnlyHandler,
showCircularity: true,
ToolBarProps: {
toolList: [
'saveTool',

View file

@ -5,7 +5,7 @@
<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%_-_4rem)] min-w-[5rem] cursor-pointer">
<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>
@ -25,7 +25,7 @@
</div>
</div>
<div class="actions-block">
<div class="protocol-buttons-group">
<div class="protocol-buttons-group shrink-0">
<a v-if="urls.add_step_url"
class="btn btn-secondary"
:title="i18n.t('protocols.steps.new_step_title')"

View file

@ -1,5 +1,5 @@
<template>
<transition enter-class="translate-x-full w-0"
<transition enter-from-class="translate-x-full w-0"
enter-active-class="transition-all ease-sharp duration-[588ms]"
leave-active-class="transition-all ease-sharp duration-[588ms]"
leave-to-class="translate-x-full w-0">
@ -12,9 +12,12 @@
class="sticky top-0 right-0 bg-white flex z-50 flex-col h-[78px] pt-6">
<div class="header flex w-full h-[30px] pr-6">
<repository-item-sidebar-title v-if="defaultColumns"
:editable="permissions?.can_manage && !defaultColumns?.archived" :name="defaultColumns.name"
@update="update"></repository-item-sidebar-title>
<i id="close-icon" @click="toggleShowHideSidebar(currentItemUrl)"
:editable="permissions?.can_manage && !defaultColumns?.archived"
:name="defaultColumns.name"
:archived="defaultColumns.archived"
@update="update">
</repository-item-sidebar-title>
<i id="close-icon" @click="toggleShowHideSidebar(null)"
class="sn-icon sn-icon-close ml-auto cursor-pointer my-auto mx-0"></i>
</div>
<div id="divider" class="w-500 bg-sn-light-grey flex items-center self-stretch h-px mt-6 mr-6"></div>
@ -27,7 +30,7 @@
<div v-else class="flex flex-1 flex-grow-1 justify-between" ref="scrollSpyContent" id="scrollSpyContent">
<div id="left-col" class="flex flex-col gap-4">
<div id="left-col" class="flex flex-col gap-4 max-w-[350px]">
<!-- INFORMATION -->
<section id="information-section">
@ -128,7 +131,7 @@
<!-- ASSIGNED -->
<section id="assigned-section" class="flex flex-col" ref="assignedSectionRef">
<div
class="flex flex-row text-base font-semibold w-[350px] pb-4 leading-7 items-center justify-between transition-colors duration-300"
class="flex flex-row text-lg font-semibold w-[350px] pb-4 leading-7 items-center justify-between transition-colors duration-300"
ref="assigned-label"
id="assigned-label"
>
@ -156,7 +159,7 @@
</div>
<div v-for="(assigned, index) in assignedModules.viewable_modules" :key="`assigned_module_${index}`"
class="flex flex-col w-[350px] h-auto gap-4">
<div class="flex flex-col gap-3.5">
<div class="flex flex-col gap-2">
<div v-for="(item, index_assigned) in assigned" :key="`assigned_element_${index_assigned}`">
{{ i18n.t(`repositories.item_card.assigned.labels.${item.type}`) }}
<a :href="item.url" class="text-sn-science-blue hover:text-sn-science-blue hover:no-underline">
@ -177,7 +180,7 @@
<!-- QR -->
<section id="qr-section" ref="QR-label">
<div id="QR-label" class="font-inter text-base font-semibold leading-7 mb-4 mt-0 transition-colors duration-300">
<div id="QR-label" class="font-inter text-lg font-semibold leading-7 mb-4 mt-0 transition-colors duration-300">
{{ i18n.t('repositories.item_card.section.qr') }}
</div>
<div class="bar-code-container">
@ -190,12 +193,36 @@
<!-- NAVIGATION -->
<div v-if="isShowing && !dataLoading" ref="navigationRef" id="navigation"
class="flex item-end gap-x-4 min-w-[130px] min-h-[130px] h-fit sticky top-0 right-[4px] ">
class="flex item-end gap-x-4 min-w-[130px] min-h-[130px] h-fit sticky top-0 pr-6 [scrollbar-gutter:stable_both-edges] ">
<scroll-spy :itemsToCreate="[
{ id: 'highlight-item-1', textId: 'text-item-1', labelAlias: 'information_label', label: 'information-label', sectionId: 'information-section' },
{ id: 'highlight-item-2', textId: 'text-item-2', labelAlias: 'custom_columns_label', label: 'custom-columns-label', sectionId: 'custom-columns-section' },
{ id: 'highlight-item-3', textId: 'text-item-3', labelAlias: 'assigned_label', label: 'assigned-label', sectionId: 'assigned-section' },
{ id: 'highlight-item-4', textId: 'text-item-4', labelAlias: 'QR_label', label: 'QR-label', sectionId: 'qr-section' }
{
id: 'highlight-item-1',
textId: 'text-item-1',
labelAlias: 'information_label',
label: 'information-label',
sectionId: 'information-section'
},
{
id: 'highlight-item-2',
textId: 'text-item-2',
labelAlias: 'custom_columns_label',
label: 'custom-columns-label',
sectionId: 'custom-columns-section'
},
{
id: 'highlight-item-3',
textId: 'text-item-3',
labelAlias: 'assigned_label',
label: 'assigned-label',
sectionId: 'assigned-section'
},
{
id: 'highlight-item-4',
textId: 'text-item-4',
labelAlias: 'QR_label',
label: 'QR-label',
sectionId: 'qr-section'
}
]" v-show="isShowing">
</scroll-spy>
</div>
@ -252,6 +279,11 @@ export default {
inRepository: false
}
},
provide() {
return {
reloadRepoItemSidebar: this.reload,
}
},
created() {
window.repositoryItemSidebarComponent = this;
},
@ -288,28 +320,27 @@ export default {
this.isShowing = true;
this.loadRepositoryRow(repositoryRowUrl);
this.currentItemUrl = repositoryRowUrl;
return
return;
}
// click on the same item - should just open/close it
else if (this.currentItemUrl === repositoryRowUrl) {
this.isShowing = !this.isShowing;
return
// same item click
if (repositoryRowUrl === this.currentItemUrl) {
if (this.isShowing) {
this.toggleShowHideSidebar(null);
}
return;
}
// explicit close (from emit)
else if (repositoryRowUrl === null) {
if (repositoryRowUrl === null) {
this.isShowing = false;
this.currentItemUrl = null;
this.myModuleId = null;
return
return;
}
// click on a different item - if the item card is already showing should just fetch new data
else {
this.isShowing = true;
this.myModuleId = myModuleId;
this.loadRepositoryRow(repositoryRowUrl);
this.currentItemUrl = repositoryRowUrl;
return
}
},
loadRepositoryRow(repositoryRowUrl) {
this.dataLoading = true

View file

@ -1,10 +1,11 @@
<template>
<inline-edit v-if="editable" class="item-name my-auto text-xl font-semibold" :value="name" :characterLimit="255"
:characterMinLimit="0" :allowBlank="false" :smartAnnotation="false"
:preventLeavingUntilFilled="true"
:attributeName="`${i18n.t('repositories.item_card.header_title')}`" :singleLine="true"
@editingEnabled="editingName = true" @editingDisabled="editingName = false" @update="updateName" @delete="handleDelete"></inline-edit>
<h4 v-else class="item-name my-auto truncate text-xl" :title="name">
{{ name }}
<h4 v-else class="item-name my-auto truncate text-xl" :title="computedName">
{{ computedName }}
</h4>
</template>
@ -16,9 +17,16 @@ export default {
components: {
"inline-edit": InlineEdit
},
emits: ['update'],
props: {
editable: Boolean,
name: String,
archived: Boolean,
},
computed: {
computedName() {
return this.archived ? `(A) ${this.name}` : this.name;
},
},
methods: {
updateName(name) {

View file

@ -15,6 +15,7 @@
:updatePath="updatePath"
:optionsPath="column.options_path"
:inArchivedRepositoryRow="inArchivedRepositoryRow"
:decimals="column.decimals"
:canEdit="permissions.can_manage && !inArchivedRepositoryRow"
:editingField="editingField"
@setEditingField="editingField = $event"

View file

@ -91,7 +91,6 @@ export default {
default:
break;
}
this.params = defaultParams;
},
formatDateTime(date, field = null) {

View file

@ -1,231 +0,0 @@
<template>
<div>
<div
@click="enableEdit"
v-click-outside="validateAndSave"
class="text-sn-dark-grey font-inter text-sm font-normal leading-5 w-full rounded relative"
:class="editableClassName"
>
<div v-if="dateType === 'date'">
<div v-if="isEditing || values?.datetime" ref="edit">
<DateTimePicker
:disabled="!canEdit"
@change="formatDateTime($event)"
:selectorId="`DatePicker${colId}`"
:dateOnly="true"
:defaultValue="dateValue(values?.datetime)"
:standAlone="true"
/>
</div>
<div v-else ref="view" :class="{ 'text-sn-dark-grey': !canEdit, 'text-sn-grey': canEdit }" >
{{ i18n.t(`repositories.item_card.repository_date_value.${canEdit ? 'placeholder' : 'no_date'}`) }}
</div>
</div>
<div v-else-if="dateType === 'dateRange'">
<div v-if="isEditing || (timeFrom?.datetime && timeTo?.datetime)" ref="edit" class="w-full flex align-center">
<div>
<DateTimePicker
:disabled="!canEdit"
@change="formatDateTime($event, 'start_time')"
:selectorId="`DatePickerStart${colId}`"
:dateOnly="true"
:defaultValue="dateValue(timeFrom?.datetime)"
:standAlone="true"
:dateClassName="hasMonthText() ? 'w-[135px]' : 'w-[90px]'"
/>
</div>
<span class="mr-3">-</span>
<div>
<DateTimePicker
:disabled="!canEdit"
@change="formatDateTime($event, 'end_time')"
:selectorId="`DatePickerEnd${colId}`"
:dateOnly="true"
:defaultValue="dateValue(timeTo?.datetime)"
:standAlone="true"
:dateClassName="hasMonthText() ? 'w-[135px]' : 'ml-2 w-[90px]'"
/>
</div>
</div>
<div v-else ref="view" :class="{ 'text-sn-dark-grey': !canEdit, 'text-sn-grey': canEdit }" >
{{ i18n.t(`repositories.item_card.repository_date_range_value.${canEdit ? 'placeholder' : 'no_date_range'}`) }}
</div>
</div>
<div v-if="dateType === 'dateTime'">
<div v-if="isEditing || values?.datetime" ref="edit" class="w-full">
<DateTimePicker
:disabled="!canEdit"
@change="formatDateTime"
:selectorId="`DatePicker${colId}`"
:defaultValue="dateValue(values?.datetime)"
:standAlone="true"
:dateClassName="hasMonthText() ? 'w-[135px]' : 'w-[90px]'"
timeClassName="w-11"
/>
</div>
<div v-else ref="view" :class="{ 'text-sn-dark-grey': !canEdit, 'text-sn-grey': canEdit }" >
{{ i18n.t(`repositories.item_card.repository_date_time_value.${canEdit ? 'placeholder' : 'no_date_time'}`) }}
</div>
</div>
<div v-else-if="dateType === 'dateTimeRange'">
<div v-if="isEditing || (timeFrom?.datetime && timeTo?.datetime)" ref="edit" class="w-full flex">
<div>
<DateTimePicker
:disabled="!canEdit"
@change="formatDateTime($event, 'start_time')"
:selectorId="`DatePickerStart${colId}`"
:defaultValue="dateValue(timeFrom?.datetime)"
:timeOnly="false"
:dateOnly="false"
:standAlone="true"
:dateClassName="hasMonthText() ? 'w-[135px]' : 'w-[90px]'"
timeClassName="w-11"
/>
</div>
<span class="mx-1">-</span>
<div>
<DateTimePicker
:disabled="!canEdit"
@change="formatDateTime($event, 'end_time')"
:selectorId="`DatePickerEnd${colId}`"
:defaultValue="dateValue(timeTo?.datetime)"
:timeOnly="false"
:dateOnly="false"
:standAlone="true"
:dateClassName="hasMonthText() ? 'w-[135px]' : 'ml-2 w-[90px]'"
timeClassName="w-11"
/>
</div>
</div>
<div v-else ref="view" :class="{ 'text-sn-dark-grey': !canEdit, 'text-sn-grey': canEdit }" >
{{ i18n.t(`repositories.item_card.repository_date_time_range_value.${canEdit ? 'placeholder' : 'no_date_time_range'}`) }}
</div>
</div>
<div v-else-if="dateType === 'time'">
<div v-if="isEditing || values?.datetime" ref="edit">
<DateTimePicker
:disabled="!canEdit"
@change="formatDateTime"
:selectorId="`DatePicker${colId}`"
:timeOnly="true"
:defaultValue="dateValue(values?.datetime)"
:standAlone="true"
timeClassName="w-11"
/>
</div>
<div v-else ref="view" :class="{ 'text-sn-dark-grey': !canEdit, 'text-sn-grey': canEdit }">
{{ i18n.t(`repositories.item_card.repository_time_value.${ canEdit ? 'placeholder' : 'no_time'}`) }}
</div>
</div>
<div v-else-if="dateType === 'timeRange'">
<div v-if="isEditing || (timeFrom?.datetime && timeTo?.datetime)" ref="edit" class="w-full flex">
<div>
<DateTimePicker
:disabled="!canEdit"
@change="formatDateTime($event, 'start_time')"
:selectorId="`DatePickerStart${colId}`"
:timeOnly="true"
:defaultValue="dateValue(timeFrom?.datetime)"
:standAlone="true"
timeClassName="w-11"
/>
</div>
<span class="mx-1">-</span>
<div>
<DateTimePicker
:disabled="!canEdit"
@change="formatDateTime($event, 'end_time')"
:selectorId="`DatePickerEnd${colId}`"
:timeOnly="true"
:defaultValue="dateValue(timeTo?.datetime)"
:standAlone="true"
timeClassName="ml-2 w-11"
/>
</div>
</div>
<div v-else ref="view" :class="{ 'text-sn-dark-grey': !canEdit, 'text-sn-grey': canEdit }">
{{ i18n.t(`repositories.item_card.repository_time_range_value.${canEdit ? 'placeholder' : 'no_time_range'}`) }}
</div>
</div>
<span class="absolute right-2 top-1.5" v-if="values?.reminder">
<Reminder :value="values" />
</span>
</div>
<div class="text-sn-delete-red text-xs w-full " :class="{ visible: errorMessage, invisible: !errorMessage }">
{{ errorMessage }}
</div>
</div>
</template>
<script>
import { vOnClickOutside } from '@vueuse/components'
import date_time_range from './../mixins/date_time_range';
import DateTimePicker from '../../shared/date_time_picker.vue';
import Reminder from './../reminder.vue';
export default {
name: 'DateTimeRange',
mixins: [date_time_range],
components: {
DateTimePicker,
Reminder
},
directives: {
'click-outside': vOnClickOutside
},
data() {
return {
values: {},
errorMessage: null,
params: null,
cellUpdatePath: null,
timeFrom: null,
timeTo: null,
isEditing: false,
initValue: null,
initStartDate: null,
initEndDate: null
}
},
props: {
dateType: String,
colVal: null,
colId: null,
updatePath: null,
startTime: null,
endTime: null,
editingField: false,
canEdit: { type: Boolean, default: false }
},
computed: {
editableClassName() {
const className = 'border-solid border-[1px] py-2 px-3 sci-cursor-edit'
if (this.canEdit && this.errorMessage) return `${className} border-sn-delete-red`;
if (this.canEdit && this.isEditing) return `${className} border-sn-science-blue`;
if (this.canEdit) return `${className} border-sn-light-grey hover:border-sn-sleepy-grey`;
return ''
}
},
mounted() {
this.cellUpdatePath = this.updatePath;
this.values = this.colVal || {};
this.timeFrom = this.startTime
this.timeTo = this.endTime
this.errorMessage = null;
this.setParams();
this.initDate = this.colVal?.datetime;
this.initStartDate = this.startTime?.datetime;
this.initEndDate = this.endTime?.datetime;
},
watch: {
isEditing(newValue) {
if (!newValue) return;
// Focus input field to open date picker
this.$nextTick(() => {
$(this.$refs.edit)?.find('input')[0]?.focus();
})
}
}
}
</script>

View file

@ -1,19 +1,28 @@
<template>
<div id="repository-asset-value-wrapper" class="flex flex-col min-min-h-[46px] h-auto gap-[6px]">
<div class="flex flex-row justify-between">
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<div class="w-fit absolute right-0 top-7">
<a v-if="!file_name && (!uploading || error) && canEdit"
class="btn-text-link font-normal" @click="openFileChooser">
class="btn-text-link font-normal min-w-fit pl-4" @click="openFileChooser">
{{ i18n.t('repositories.item_card.repository_asset_value.add_asset') }}
</a>
<div v-if="file_name && !uploading && canEdit" class="flex whitespace-nowrap gap-4 min-w-fit pl-4">
<a class="btn-text-link font-normal" @click="openFileChooser">
{{ i18n.t('general.replace') }}
</a>
<a class="btn-text-link font-normal" @click="clearFile">
{{ i18n.t('general.delete') }}
</a>
</div>
</div>
<div v-if="!uploading">
<div v-if="file_name">
<div class="flex flex-row justify-between">
<div class="w-full cursor-pointer text-sn-science-blue relative" @mouseover="tooltipShowing = true" @mouseout="tooltipShowing = false">
<a class="w-full inline-block file-preview-link truncate" :id="modalPreviewLinkId" data-no-turbolink="true"
<div class="w-full cursor-pointer relative" @mouseover="tooltipShowing = true" @mouseout="tooltipShowing = false">
<a class="w-full inline-block file-preview-link truncate text-sn-science-blue" :id="modalPreviewLinkId" data-no-turbolink="true"
data-id="true" data-status="asset-present" :data-preview-url=this?.preview_url :href=this?.url>
{{ file_name }}
</a>
@ -21,10 +30,6 @@
:preview_url="preview_url" :icon_html="icon_html" :medium_preview_url="medium_preview_url">
</tooltip-preview>
</div>
<div v-if="canEdit" class="flex whitespace-nowrap gap-4 pl-4">
<a class="btn-text-link font-normal" @click="openFileChooser"> {{ i18n.t('general.replace') }} </a>
<a class="btn-text-link font-normal" @click="clearFile"> {{ i18n.t('general.delete') }} </a>
</div>
</div>
</div>
<div v-else-if="!error" class="flex flex-row items-center font-inter text-sm font-normal leading-5 justify-between"

View file

@ -14,7 +14,7 @@
:options="checklistItems"
:placeholder="i18n.t('repositories.item_card.dropdown_placeholder')"
:no-options-placeholder="i18n.t('repositories.item_card.dropdown_placeholder')"
className="h-[38px] !pl-3"
className="h-[38px] pl-3"
optionsClassName="max-h-[300px]"
></checklist-select>
</div>

View file

@ -1,35 +1,30 @@
<template>
<div id="repository-date-range-value-wrapper" class="flex flex-col min-min-h-[46px] h-auto gap-[6px]">
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<DateTimeRange
dateType="dateRange"
:startTime="colVal?.start_time"
:endTime="colVal?.end_time"
<div class="flex flex-col gap-2">
<DateTimeComponent
mode="date"
:range="true"
:colVal="colVal"
:colId="colId"
:colName="colName"
:updatePath="updatePath"
:canEdit="canEdit"
:editingField="editingField"
@setEditingField="$emit('setEditingField', $event)"
/>
</div>
</template>
<script>
import DateTimeRange from './DateTimeRange.vue';
import DateTimeComponent from './date_time_component.vue';
export default {
name: 'RepositoryDateRangeValue',
components: { DateTimeRange },
components: { DateTimeComponent },
props: {
data_type: String,
colId: Number,
colName: String,
colVal: null,
updatePath: null,
editingField: null,
colVal: Object,
updatePath: String,
canEdit: { type: Boolean, default: false },
}
}

View file

@ -1,16 +1,11 @@
<template>
<div id="repository-date-time-range-value-wrapper" class="flex flex-col min-min-h-[46px] h-auto gap-[6px]">
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<DateTimeRange
:editingField="editingField"
@setEditingField="$emit('setEditingField', $event)"
dateType="dateTimeRange"
:startTime="colVal?.start_time"
:endTime="colVal?.end_time"
<div class="flex flex-col gap-2">
<DateTimeComponent
mode="datetime"
:range="true"
:colVal="colVal"
:colId="colId"
:colName="colName"
:updatePath="updatePath"
:canEdit="canEdit"
/>
@ -18,18 +13,17 @@
</template>
<script>
import DateTimeRange from './DateTimeRange.vue';
import DateTimeComponent from './date_time_component.vue';
export default {
name: 'RepositoryDateTimeRangeValue',
components: { DateTimeRange },
components: { DateTimeComponent },
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object,
updatePath: null,
editingField: null,
updatePath: String,
canEdit: { type: Boolean, default: false }
}
}

View file

@ -1,14 +1,10 @@
<template>
<div id="repository-date-time-value-wrapper" class="flex flex-col min-min-h-[46px] h-auto gap-[6px]">
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<DateTimeRange
:editingField="editingField"
@setEditingField="$emit('setEditingField', $event)"
dateType="dateTime"
<div class="flex flex-col gap-2">
<DateTimeComponent
mode="datetime"
:colVal="colVal"
:colId="colId"
:colName="colName"
:updatePath="updatePath"
:canEdit="canEdit"
/>
@ -16,18 +12,17 @@
</template>
<script>
import DateTimeRange from './DateTimeRange.vue';
import DateTimeComponent from './date_time_component.vue';
export default {
name: 'RepositoryDateTimeValue',
components: { DateTimeRange },
components: { DateTimeComponent },
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object,
updatePath: String,
editingField: null,
canEdit: { type: Boolean, default: false }
}
}

View file

@ -1,27 +1,22 @@
<template>
<div id="repository-date-value-wrapper" class="flex flex-col min-min-h-[46px] h-auto gap-[6px]">
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<DateTimeRange
:editingField="editingField"
@setEditingField="$emit('setEditingField', $event)"
dateType="date"
<div class="flex flex-col gap2">
<DateTimeComponent
mode="date"
:colVal="colVal"
:colId="colId"
:colName="colName"
:updatePath="updatePath"
:dataType="data_type"
:canEdit="canEdit"
/>
</div>
</template>
<script>
import DateTimeRange from './DateTimeRange.vue';
import DateTimeComponent from './date_time_component.vue';
export default {
name: 'RepositoryDateValue',
components: { DateTimeRange },
components: { DateTimeComponent },
props: {
data_type: String,
colId: Number,

View file

@ -17,7 +17,7 @@
:placeholder="i18n.t('repositories.item_card.dropdown_placeholder')"
:no-options-placeholder="i18n.t('repositories.item_card.dropdown_placeholder')"
:searchPlaceholder="i18n.t('repositories.item_card.dropdown_placeholder')"
className="h-[38px] !pl-3"
customClass="!h-[38px] !pl-3 sci-cursor-edit"
optionsClassName="max-h-[300px]"
></select-search>
<div v-else-if="text"

View file

@ -13,7 +13,7 @@
}}
</div>
</div>
<div v-if="canEdit" class="w-full">
<div v-if="canEdit" class="w-full contents">
<text-area :initialValue="(colVal)?.toLocaleString('fullwide', {useGrouping:false}) || ''"
:noContentPlaceholder="i18n.t('repositories.item_card.repository_number_value.placeholder')"
:placeholder="i18n.t('repositories.item_card.repository_number_value.placeholder')"
@ -65,11 +65,8 @@ export default {
colName: String,
colVal: Number,
permissions: null,
canEdit: { type: Boolean, defaul: false}
},
created() {
// constants
this.decimals = Number(document.getElementById(`${this.colId}`).dataset['metadataDecimals']) || 0;
decimals: { type: Number, default: 0 },
canEdit: { type: Boolean, default: false },
},
methods: {
toggleCollapse() {

View file

@ -1,5 +1,5 @@
<template>
<div id="repository-status-value-wrapper" class="flex flex-col min-min-h-[46px] h-auto gap-[6px]">
<div ref="container" id="repository-status-value-wrapper" class="flex flex-col min-min-h-[46px] h-auto gap-[6px]">
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
@ -17,7 +17,7 @@
:placeholder="i18n.t('repositories.item_card.dropdown_placeholder')"
:no-options-placeholder="i18n.t('repositories.item_card.dropdown_placeholder')"
:searchPlaceholder="i18n.t('repositories.item_card.dropdown_placeholder')"
className="h-[38px] !pl-3"
customClass="!h-[38px] !pl-2 sci-cursor-edit"
optionsClassName="max-h-[300px]"
></select-search>
<div v-else-if="status && icon"
@ -88,16 +88,25 @@ export default {
this.isLoading = false;
this.selected = this.id;
});
this.replaceEmojiesInDropdown();
},
methods: {
changeSelected(id) {
this.selected = id;
if (id) {
if (id || id === null) {
this.update(id);
this.replaceEmojiesInDropdown();
}
},
parseEmoji(content) {
return twemoji.parse(content);
},
replaceEmojiesInDropdown() {
setTimeout(() => {
twemoji.size = "24x24";
twemoji.base = '/images/twemoji/';
twemoji.parse(this.$refs.container);
}, 300);
}
}
};

View file

@ -15,7 +15,8 @@
:data-manage-stock-url="values?.stock_url"
:data-repository-row-id="repositoryId"
>
<div v-if="values?.stock_formatted" :data-manage-stock-url="values?.stock_url" class="text-sn-dark-grey font-inter text-sm font-normal leading-5 stock-value">
<div v-if="values?.stock_formatted" :data-manage-stock-url="values?.stock_url"
class="text-sn-dark-grey font-inter text-sm font-normal leading-5 stock-value overflow-hidden text-ellipsis whitespace-nowrap">
{{ values.stock_formatted }}
</div>
<div v-else class="font-inter text-sm font-normal leading-5" :class="{ 'text-sn-dark-grey': !canEdit, 'text-sn-grey': canEdit }">
@ -37,7 +38,7 @@
},
computed: {
editableClassName() {
const className = 'border-solid border-[1px] p-2 manage-repository-stock-value-link sci-cursor-edit'
const className = 'border-solid border-[1px] p-2 pl-3 manage-repository-stock-value-link sci-cursor-edit'
if (this.canEdit && this.isEditing) return `${className} border-sn-science-blue`;
if (this.canEdit) return `${className} border-sn-light-grey hover:border-sn-sleepy-grey`;
return ''
@ -48,7 +49,7 @@
stock_formatted: null,
stock_amount: null,
low_stock_threshold: null,
isEditing: null,
isEditing: false,
values: null
}
},

View file

@ -14,7 +14,7 @@
</div>
</div>
<div v-if="canEdit">
<div v-if="canEdit" class="w-full contents">
<text-area :initialValue="colVal?.edit"
:noContentPlaceholder="i18n.t('repositories.item_card.repository_text_value.placeholder')"
:placeholder="i18n.t('repositories.item_card.repository_text_value.placeholder')"
@ -27,15 +27,16 @@
@update="update"
className="px-3" />
</div>
<div v-else-if="colVal?.edit"
<div v-else-if="colVal?.view"
ref="textRef"
class="text-sn-dark-grey box-content text-sm font-normal leading-5 overflow-y-auto pr-3 py-2 rounded w-[calc(100%-2rem)]]"
v-html="colVal?.view"
class="text-sn-dark-grey box-content text-sm font-normal leading-5
overflow-y-auto pr-3 rounded w-[calc(100%-2rem)]]"
:class="{
'max-h-[4rem]': collapsed,
'max-h-[40rem]': !collapsed
}"
>
{{ colVal?.edit }}
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5 pr-3 py-2 w-[calc(100%-2rem)]]">
{{ i18n.t("repositories.item_card.repository_text_value.no_text") }}

View file

@ -1,16 +1,11 @@
<template>
<div id="repository-time-range-value-wrapper" class="flex flex-col min-min-h-[46px] h-auto gap-[6px]">
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<DateTimeRange
:editingField="editingField"
@setEditingField="$emit('setEditingField', $event)"
dateType="timeRange"
:startTime="colVal?.start_time"
:endTime="colVal?.end_time"
<div class="flex flex-col gap-2">
<DateTimeComponent
mode="time"
:range="true"
:colVal="colVal"
:colId="colId"
:colName="colName"
:updatePath="updatePath"
:canEdit="canEdit"
/>
@ -18,10 +13,10 @@
</template>
<script>
import DateTimeRange from './DateTimeRange.vue';
import DateTimeComponent from './date_time_component.vue';
export default {
name: 'RepositoryTimeRangeValue',
components: { DateTimeRange },
components: { DateTimeComponent },
props: {
data_type: String,
colId: Number,

View file

@ -1,14 +1,10 @@
<template>
<div id="repository-time-value-wrapper" class="flex flex-col min-min-h-[46px] h-auto gap-[6px]">
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<DateTimeRange
:editingField="editingField"
@setEditingField="$emit('setEditingField', $event)"
dateType="time"
<div class="flex flex-col gap-2">
<DateTimeComponent
mode="time"
:colVal="colVal"
:colId="colId"
:colName="colName"
:updatePath="updatePath"
:canEdit="canEdit"
/>
@ -16,10 +12,10 @@
</template>
<script>
import DateTimeRange from './DateTimeRange.vue';
import DateTimeComponent from './date_time_component.vue';
export default {
name: 'RepositoryTimeValue',
components: { DateTimeRange },
components: { DateTimeComponent },
props: {
data_type: String,
colId: Number,

View file

@ -42,7 +42,6 @@ export default {
},
mounted() {
console.log('mounted');
window.addEventListener('resize', this.handleResize);
this.initializeComponent();
this.$nextTick(() => {

View file

@ -0,0 +1,194 @@
<template>
<div class="flex gap-1">
<div class="text-sm font-bold truncate" :title="colName">
{{ colName }}
</div>
<div v-if="colVal.reminder" class="bg-sn-alert-passion w-1.5 h-1.5 rounded" :title="colVal.reminder_text"></div>
</div>
<div class="flex flex-col gap-2">
<template v-if="!canEdit">
<span v-if="range">
<template v-if="colVal.start_time && colVal.end_time">
{{ colVal.start_time.formatted }} - {{ colVal.end_time.formatted }}
</template>
<template v-else>
{{ viewPlaceholder }}
</template>
</span>
<span v-else >
<template v-if="colVal.formatted">
{{ colVal.formatted }}
</template>
<template v-else>
{{ viewPlaceholder }}
</template>
</span>
</template>
<template v-else>
<div>
<span class="text-xs capitalize" v-if="range">{{ i18n.t('general.from') }}</span>
<DateTimePicker :defaultValue="defaultStartDate" @closed="update" @change="updateStartDate" :mode="mode" :placeholder="placeholder" :clearable="true"/>
</div>
<div>
<span class="text-xs capitalize" v-if="range">{{ i18n.t('general.to') }}</span>
<DateTimePicker :defaultValue="defaultEndDate" @closed="update" v-if="range" @change="updateEndDate" :placeholder="placeholder" :mode="mode" :clearable="true"/>
</div>
<div class="text-xs text-sn-delete-red" v-if="error">{{ error }}</div>
</template>
</div>
</template>
<script>
import DateTimePicker from '../../shared/date_time_picker.vue';
import Reminder from '../reminder.vue';
export default {
name: 'DateTimeComponent',
components: {
DateTimePicker,
Reminder
},
data() {
return {
startDate: null,
endDate: null,
error: null,
defaultStartDate: null,
defaultEndDate: null,
}
},
inject: ['reloadRepoItemSidebar'],
props: {
mode: String,
range: { type: Boolean, default: false },
colVal: { type: Object, default: {} },
colId: Number,
updatePath: String,
canEdit: { type: Boolean, default: false },
colName: String,
},
created() {
if (this.range) {
if (this.colVal.start_time?.datetime) this.startDate = new Date(this.colVal.start_time.datetime)
if (this.colVal.end_time?.datetime) this.endDate = new Date(this.colVal.end_time.datetime)
} else {
if (this.colVal.datetime) this.startDate = new Date(this.colVal.datetime)
}
this.defaultStartDate = this.startDate;
this.defaultEndDate = this.endDate;
},
computed: {
value: {
get () {
if (this.range) {
if (!(this.startDate instanceof Date) && !(this.endDate instanceof Date)) return null;
return {
start_time: this.formatDate(this.startDate),
end_time: this.formatDate(this.endDate)
};
} else {
if (!(this.startDate instanceof Date)) return null;
return this.formatDate(this.startDate);
}
},
},
placeholder() {
switch (this.mode) {
case 'date':
return this.i18n.t('repositories.item_card.repository_date_value.placeholder');
case 'time':
return this.i18n.t('repositories.item_card.repository_time_value.placeholder');
case 'datetime':
return this.i18n.t('repositories.item_card.repository_date_time_value.placeholder');
}
},
viewPlaceholder() {
switch (this.mode) {
case 'date':
if (this.range) {
return this.i18n.t('repositories.item_card.repository_date_range_value.no_date_range');
}
return this.i18n.t('repositories.item_card.repository_date_value.no_date');
case 'time':
if (this.range) {
return this.i18n.t('repositories.item_card.repository_time_range_value.no_time_range');
}
return this.i18n.t('repositories.item_card.repository_time_value.no_time');
case 'datetime':
if (this.range) {
return this.i18n.t('repositories.item_card.repository_date_time_range_value.no_date_time_range');
}
return this.i18n.t('repositories.item_card.repository_date_time_value.no_date_time');
}
}
},
methods: {
updateStartDate(date) {
this.startDate = date;
if (!(this.startDate instanceof Date)) this.update();
},
updateEndDate(date) {
this.endDate = date;
if (!(this.endDate instanceof Date)) this.update();
},
validateValue() {
this.error = null;
// Date is not changed
if (this.defaultStartDate == this.startDate && this.defaultEndDate == this.endDate) return false;
if (this.range) {
// Both empty
if (!(this.startDate instanceof Date) && !(this.endDate instanceof Date)) return true;
// One empty
if (!(this.startDate instanceof Date) || !(this.endDate instanceof Date)) {
this.error = this.i18n.t('repositories.item_card.date_time.errors.not_valid_range')
return false;
}
// Start date is after end date
if (this.startDate > this.endDate) {
this.error = this.i18n.t('repositories.item_card.date_time.errors.not_valid_range')
return false;
}
}
return true
},
update() {
const params = {}
if (!this.validateValue()) return;
params[this.colId] = this.value
$.ajax({
method: 'PUT',
url: this.updatePath,
dataType: 'json',
data: { repository_cells: params },
success: () => {
this.defaultStartDate = this.startDate;
this.defaultEndDate = this.endDate;
if ($('.dataTable')[0]) {
$('.dataTable').DataTable().ajax.reload(null, false);
this.reloadRepoItemSidebar();
}
}
});
},
formatDate(date) {
if (!(date instanceof Date)) return null;
const y = date.getFullYear();
const m = date.getMonth() + 1;
const d = date.getDate();
const hours = date.getHours();
const mins = date.getMinutes();
return `${y}/${m}/${d} ${hours}:${mins}`;
},
}
}
</script>

View file

@ -21,7 +21,7 @@
</template>
</h4>
</div>
<div class="modal-body">
<div class="modal-body !pt-[6px]">
<p class="text-sm pb-6"> {{ i18n.t('repository_stock_values.manage_modal.enter_amount') }}</p>
<form class="flex flex-col gap-6" @submit.prevent novalidate>
<fieldset class="w-full flex justify-between">
@ -72,17 +72,17 @@
<template v-if="stockValue?.id">
<div class="flex justify-between w-full items-center">
<div class="flex flex-col w-[220px] h-24 border-rounded bg-sn-super-light-grey justify-between text-center">
<span class="text-sm text-sn-grey leading-5">{{ i18n.t('repository_stock_values.manage_modal.current_stock') }}</span>
<span class="text-sm text-sn-grey leading-5 pt-2">{{ i18n.t('repository_stock_values.manage_modal.current_stock') }}</span>
<span class="text-2xl text-sn-black font-semibold leading-8" :class="{ 'text-sn-delete-red': stockValue.amount < 0 }">{{ stockValue.amount }}</span>
<span class="text-sm text0sn-black leading-5">{{ initUnitLabel }}</span>
<span class="text-sm text0sn-black leading-5 pb-2">{{ initUnitLabel }}</span>
</div>
<i class="sn-icon sn-icon-arrow-right"></i>
<div class="flex flex-col w-[220px] h-24 border-rounded bg-sn-super-light-grey justify-between text-center">
<span class="text-sm text-sn-grey leading-5">{{ i18n.t('repository_stock_values.manage_modal.new_stock') }}</span>
<span class="text-sm text-sn-grey leading-5 pt-2">{{ i18n.t('repository_stock_values.manage_modal.new_stock') }}</span>
<span class="text-2xl text-sn-black font-semibold leading-8" :class="{ 'text-sn-delete-red': newAmount < 0 }">
{{ (newAmount || newAmount === 0) ? newAmount : '-' }}
</span>
<span class="text-sm text0sn-black leading-5">{{ unitLabel }}</span>
<span class="text-sm text0sn-black leading-5 pb-2">{{ unitLabel }}</span>
</div>
</div>
</template>
@ -209,6 +209,9 @@
},
methods: {
setOperation($event) {
if ($event !== this.operation) {
this.amount = null;
}
this.operation = $event;
if ([2, 3].includes($event)) {
this.unit = this.stockValue.unit;

View file

@ -132,7 +132,7 @@
}
).then(
(response) => {
this.results = [response.data.data, ...this.results];
this.results = [{ newResult: true, ...response.data.data }, ...this.results];
window.scrollTo(0, 0);
}
);

View file

@ -32,6 +32,7 @@
v-model="sharedEnabled"
id="checkbox"
class="sci-toggle-checkbox"
:disabled="!canShare"
tabindex="0"
@change="checkboxChange"
@keyup.enter="handleCheckboxEnter"/>
@ -46,7 +47,7 @@
:class="{ 'error': error }"
v-model="description"
:placeholder="i18n.t('shareable_links.modal.description_placeholder')"
:disabled="!sharedEnabled"
:disabled="!sharedEnabled || !canShare"
@focus="editing = true">
</textarea>
</div>
@ -110,6 +111,10 @@
characterLimit: {
type: Number,
default: null
},
canShare: {
type: Boolean,
default: false
}
},
data() {

View file

@ -5,7 +5,6 @@
type="button"
:class="shareClass"
:title="shareValue"
:disabled="disabled"
@click="openModal">
<span class="sn-icon sn-icon-shared"></span>
<span class="text-sm">
@ -19,6 +18,7 @@
:characterLimit="255"
@enable="enableShare"
@disable="disableShare"
:canShare="canShare"
@close="closeModal"/>
</div>
</div>
@ -38,7 +38,7 @@
type: String,
required: true
},
disabled: {
canShare: {
type: Boolean,
default: false
}

View file

@ -12,12 +12,12 @@
}"
:disabled="disabled"
@click="toggle">
<span>{{ valueLabel || this.placeholder || this.i18n.t('general.select') }}</span>
<span class="overflow-hidden text-ellipsis">{{ valueLabel || this.placeholder || this.i18n.t('general.select') }}</span>
<i class="sn-icon" :class="{ 'sn-icon-down': !isOpen, 'sn-icon-up': isOpen}"></i>
</button>
<div :style="optionPositionStyle" class="py-2.5 z-10 bg-white rounded border-[1px] border-sn-light-grey shadow-sn-menu-sm" :class="{ 'hidden': !isOpen }">
<div v-if="withButtons" class="px-2.5">
<div class="flex gap-2 pl-2 pb-2.5 justify-start items-center w-[calc(100%-10px)]">
<div v-if="withButtons" class="px-2.5 pb-[1px]">
<div class="flex gap-2 pl-2 justify-start items-center w-[calc(100%-10px)]">
<div class="btn btn-light !text-xs h-[30px] px-0 active:bg-sn-super-light-blue"
@click="selectedValues = []"
:class="{
@ -48,7 +48,7 @@
<input v-model="selectedValues" :value="option.id" :id="option.id" type="checkbox" class="sci-checkbox project-card-selector">
<label :for="option.id" class="sci-checkbox-label"></label>
</div>
<span class="text-ellipsis overflow-hidden max-h-[4rem] ml-1">{{ option.label }}</span>
<span :title="option.label" class="text-ellipsis overflow-hidden max-h-[4rem] ml-1 whitespace-normal line-clamp-3">{{ option.label }}</span>
</div>
</div>
<template v-else>

View file

@ -157,7 +157,6 @@
deleteAttachment(id) {
this.$emit('attachment:deleted', id)
},
initMarvinJS() {
// legacy logic from app/assets/javascripts/sitewide/marvinjs_editor.js
MarvinJsEditor.initNewButton(
@ -165,16 +164,6 @@
() => this.$emit('attachment:uploaded')
);
},
openWopiFileModal() {
this.initWopiFileModal(this.parent, (_e, data, status) => {
if (status === 'success') {
this.$emit('attachment:uploaded', data);
} else {
HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger');
}
});
},
handleDropdownPosition() {
this.$refs.actionsDropdownButton.classList.toggle("dropup", !this.isInViewport(this.$refs.actionsDropdown));
},

View file

@ -40,7 +40,7 @@
@update="updateText"
@delete="removeItem()"
@keypress="keyPressHandler"
@blur="editingText = false"
@blur="onBlurHandler"
/>
<span v-if="!editingText && (!checklistItem.attributes.urls || deleteUrl)" class="absolute right-0 top-0.5 leading-6 tw-hidden group-hover/checklist-item-header:inline-block !text-sn-blue cursor-pointer" @click="showDeleteModal" tabindex="0">
<i class="sn-icon sn-icon-delete"></i>
@ -139,6 +139,11 @@
this.checklistItem.attributes.checked = this.$refs.checkbox.checked;
this.$emit('toggle', this.checklistItem);
},
onBlurHandler() {
this.$nextTick(() => {
this.editingText = false;
});
},
updateText(text, withKey) {
if (text.length === 0) {
this.disableTextEdit();

View file

@ -36,9 +36,10 @@ export default {
button.click();
},
openWopiFileModal() {
this.initWopiFileModal(this.attachmentsParent, (_e, data, status) => {
this.initWopiFileModal(this.attachmentsParent, (_e, attachmentData, status) => {
if (status === 'success') {
this.addAttachment(data)
const attachment = attachmentData.data;
this.addAttachment(attachment);
} else {
HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger');
}

View file

@ -35,8 +35,8 @@
@keyup.enter="!editingTable && enableTableEdit()">
<div ref="hotTable" class="hot-table-container" @click="!editingTable && enableTableEdit()">
</div>
<div v-if="editingTable" class="text-xs pt-3 pb-2 text-sn-grey">
{{ i18n.t('protocols.steps.table.edit_message') }}
<div class="text-xs pt-3 pb-2 text-sn-grey h-1">
<span v-if="editingTable">{{ i18n.t('protocols.steps.table.edit_message') }}</span>
</div>
</div>
<deleteElementModal v-if="confirmingDelete" @confirm="deleteElement" @cancel="closeDeleteModal"/>
@ -163,8 +163,9 @@
return;
}
const { row = 0, col = 0 } = this.selectedCell || {};
this.editingTable = true;
this.$nextTick(() => this.tableObject.selectCell(0,0));
this.$nextTick(() => this.tableObject.selectCell(row,col));
},
disableTableEdit() {
this.editingTable = false;
@ -268,8 +269,12 @@
preventOverflow: 'horizontal',
readOnly: !this.editingTable,
afterUnlisten: () => {
this.updatingTableData = true;
this.updateTable();
this.editingTable = false;
},
afterSelection: (r, c, r2, c2) => {
if (r === r2 && c === c2) {
this.selectedCell = { row: r, col: c };
}
},
afterChange: () => {
if (this.editingTable == false) return;

View file

@ -113,7 +113,7 @@
},
computed: {
wrapTables() {
const container = $(`<span>${this.element.attributes.orderable.text_view}</span>`);
const container = $(`<span class="text-base">${this.element.attributes.orderable.text_view}</span>`);
container.find('table').toArray().forEach((table) => {
if ($(table).parent().hasClass('table-wrapper')) return;
$(table).css('float', 'none').wrapAll(`

View file

@ -5,6 +5,8 @@
:class="{
'only-time': mode == 'time',
}"
@closed="closedHandler"
@cleared="clearedHandler"
v-model="compDatetime"
:teleport="teleport"
:no-today="true"
@ -91,7 +93,7 @@
watch: {
defaultValue: function () {
this.datetime = this.defaultValue;
if (this.defaultValue) {
if (this.defaultValue instanceof Date) {
this.time = {
hours: this.defaultValue.getHours(),
minutes: this.defaultValue.getMinutes()
@ -103,7 +105,7 @@
this.time = null;
if (this.datetime) {
if (this.datetime instanceof Date) {
this.time = {
hours: this.datetime.getHours(),
minutes: this.datetime.getMinutes()
@ -186,6 +188,12 @@
close() {
this.$refs.datetimePicker.closeMenu();
},
closedHandler() {
this.$emit('closed');
},
clearedHandler() {
this.$emit('cleared');
}
}
}
</script>

View file

@ -14,7 +14,7 @@
v-model="newValue"
@keydown="handleKeypress"
@blur="handleBlur"
@keyup.escape="cancelEdit"
@keyup.escape="cancelEdit && this.atWhoOpened"
@focus="setCaretAtEnd"/>
<textarea v-else
ref="input"
@ -28,7 +28,7 @@
v-model="newValue"
@keydown="handleKeypress"
@blur="handleBlur"
@keyup.escape="cancelEdit"
@keyup.escape="cancelEdit && this.atWhoOpened"
@focus="setCaretAtEnd"/>
</template>
<div
@ -73,7 +73,8 @@
smartAnnotation: { type: Boolean, default: false },
editOnload: { type: Boolean, default: false },
defaultValue: { type: String, default: '' },
singleLine: { type: Boolean, default: true }
singleLine: { type: Boolean, default: true },
preventLeavingUntilFilled: { type: Boolean, default: false }
},
data() {
return {
@ -123,6 +124,10 @@
},
error() {
if (!this.allowBlank && this.isBlank) {
if (this.preventLeavingUntilFilled) {
this.addPreventFromLeaving(document.body);
}
return this.i18n.t('inline_edit.errors.blank', { attribute: this.attributeName })
}
if(this.characterLimit && this.newValue.length > this.characterLimit) {
@ -146,10 +151,25 @@
)
}
this.removePreventFromLeaving(document.body);
return false
}
},
methods: {
removePreventFromLeaving(domEl) {
domEl.removeEventListener('click', this.preventClicks, true);
domEl.removeEventListener('mousedown', this.preventClicks, true);
domEl.removeEventListener('mouseup', this.preventClicks, true);
},
addPreventFromLeaving(domEl) {
domEl.addEventListener('click', this.preventClicks, true);
domEl.addEventListener('mousedown', this.preventClicks, true);
domEl.addEventListener('mouseup', this.preventClicks, true);
},
preventClicks(event) {
event.stopPropagation();
event.preventDefault();
},
handleAutofocus() {
if (this.autofocus || !this.placeholder && this.isBlank || this.editOnload && this.isBlank) {
this.enableEdit();
@ -220,6 +240,9 @@
sel.collapse(sel.anchorNode, offset);
},
handleKeypress(e) {
this.atWhoOpened = $('.atwho-view:visible').length > 0
if (this.atWhoOpened) return;
if (e.key == 'Escape') {
this.cancelEdit();
} else if (e.key == 'Enter' && this.saveOnEnter && e.shiftKey == false) {

View file

@ -21,7 +21,6 @@
class="sn-select__options !relative !top-0 !left-[-1px] !shadow-none scroll-container px-2.5 pt-0 block"
:class="{ [optionsClassName]: true }"
>
<div v-if="options.length" class="flex flex-col gap-[1px]">
<div
v-for="option in options"
@ -78,9 +77,6 @@
valueLabel() {
let option = this.options.find((o) => o[0] === this.value);
return option && option[1];
},
focusElement() {
return this.$refs.focusElement || this.$scopedSlots.default()[0].context.$refs.focusElement;
}
},
mounted() {
@ -96,7 +92,8 @@
if (this.isOpen) {
this.$emit('open');
this.$nextTick(() => {
this.focusElement.focus();
this.$emit('focus');
this.$refs.focusElement?.focus();
});
this.$refs.optionsContainer.scrollTop = 0;
this.updateOptionPosition();

View file

@ -1,6 +1,7 @@
<template>
<Select
class="sn-select sn-select--search"
class="sn-select sn-select--search hover:border-sn-sleepy-grey"
:class="customClass"
:className="className"
:optionsClassName="optionsClassName"
:withEditCursor="withEditCursor"
@ -14,6 +15,7 @@
@blur="blur"
@open="open"
@close="close"
@focus="focus"
>
<input ref="focusElement" v-model="query" type="text" class="sn-select__search-input" :placeholder="searchPlaceholder" />
<span class="sn-select__value">{{ valueLabel || (placeholder || i18n.t('general.select')) }}</span>
@ -38,7 +40,8 @@
disabled: { type: Boolean },
isLoading: { type: Boolean, default: false },
className: { type: String, default: '' },
optionsClassName: { type: String, default: '' }
optionsClassName: { type: String, default: '' },
customClass: { type: String, default: '' }
},
components: { Select },
data() {
@ -75,6 +78,9 @@
}
},
methods: {
focus() {
this.$refs.focusElement.focus();
},
blur() {
this.isOpen = false;
this.$emit('blur');

View file

@ -0,0 +1,142 @@
<template>
<div class="content-pane flexible with-grey-background">
<div class="content-header">
<div class="title-row">
<h1 class="mt-0">
{{ i18n.t('users.settings.account.preferences.title') }}
</h1>
</div>
</div>
<div class="p-4 mb-4 bg-sn-white rounded">
<div>
<h2 class="mt-0">{{ i18n.t("users.settings.account.preferences.edit.time_zone_label") }}</h2>
<div class="text-sn-dark-grey mb-4">
<p>{{ i18n.t("users.settings.account.preferences.edit.time_zone_sublabel") }}</p>
</div>
<SelectSearch
class="max-w-[40ch]"
:value="selectedTimeZone"
@change="setTimeZone"
:options="timeZones"
/>
</div>
<div class="sci-divider my-6 inline-block"></div>
<div>
<h2 class="mt-0">{{ i18n.t("users.settings.account.preferences.edit.date_format_label") }}</h2>
<div class="text-sn-dark-grey mb-4">
<p>{{ i18n.t("users.settings.account.preferences.edit.date_format_sublabel") }}</p>
</div>
<SelectSearch
class="max-w-[40ch]"
:value="selectedDateFormat"
@change="setDateFormat"
:options="dateFormats"
/>
</div>
</div>
<div class="p-4 mb-4 bg-sn-white rounded">
<h2 class="mt-0">{{ i18n.t('notifications.title') }}</h2>
<div class="text-sn-dark-grey">
<p>{{ i18n.t('notifications.sub_title') }}</p>
</div>
<table v-if="notificationsSettings">
<template v-for="(_subGroups, group) in notificationsGroups" :key="group">
<div class="contents">
<tr>
<td colspan=3 class="pt-6"><h3>{{ i18n.t(`notifications.groups.${group}`) }}</h3></td>
</tr>
<tr>
<td></td>
<td class="p-2.5 text-base w-32">{{ i18n.t('notifications.in_app') }}</td>
<td class="p-2.5 text-base w-32">{{ i18n.t('notifications.email') }}</td>
</tr>
</div>
<template v-for="(_notifications, subGroup, i) in notificationsGroups[group]" :key="subGroup">
<tr v-if="subGroup !== 'always_on'"
class="text-base border-transparent border-b-sn-super-light-grey border-solid"
:class="{'border-t-sn-super-light-grey': i == 0}"
>
<td class="p-2.5 pr-10">{{ i18n.t(`notifications.sub_groups.${subGroup}`) }}</td>
<td class="p-2.5">
<div class="sci-toggle-checkbox-container">
<input v-model="notificationsSettings[subGroup]['in_app']" type="checkbox" class="sci-toggle-checkbox" @change="setNotificationsSettings"/>
<label class="sci-toggle-checkbox-label"></label>
</div>
</td>
<td class="p-2.5">
<div class="sci-toggle-checkbox-container">
<input v-model="notificationsSettings[subGroup]['email']" type="checkbox" class="sci-toggle-checkbox" @change="setNotificationsSettings"/>
<label class="sci-toggle-checkbox-label"></label>
</div>
</td>
</tr>
</template>
</template>
</table>
</div>
</div>
</template>
<script>
import SelectSearch from "../shared/select_search.vue";
import axios from '../../packs/custom_axios.js';
export default {
name: "UserPreferences",
props: {
userSettings: Object,
timeZones: Array,
dateFormats: Array,
updateUrl: String,
notificationsGroups: Object
},
data: function() {
return {
selectedTimeZone: null,
selectedDateFormat: null,
notificationsSettings: null
};
},
created() {
this.selectedTimeZone = this.userSettings.time_zone;
this.selectedDateFormat = this.userSettings.date_format;
this.notificationsSettings = {...this.emptySettings, ...this.userSettings.notifications_settings};
},
computed: {
emptySettings() {
let settings = {};
for (const group in this.notificationsGroups) {
for (const subGroup in this.notificationsGroups[group]) {
settings[subGroup] = { in_app: false, email: false };
}
}
return settings;
}
},
components: {
SelectSearch,
PerfectScrollbar
},
methods: {
setTimeZone(value) {
this.selectedTimeZone = value;
axios.put(this.updateUrl, {
user: { time_zone: value }
})
},
setDateFormat(value) {
this.selectedDateFormat = value;
axios.put(this.updateUrl, {
user: { date_format: value }
})
},
setNotificationsSettings() {
axios.put(this.updateUrl, {
user: { notifications_settings: this.notificationsSettings }
})
}
},
}
</script>

View file

@ -24,12 +24,14 @@ module FailedDeliveryNotifiableJob
@user = User.find_by(id: arguments.last[:user_id])
return if @user.blank?
notification = Notification.create!(
type_of: :deliver_error,
DeliveryNotification.send_notifications(
{
title: failed_notification_title,
message: failed_notification_message
message: failed_notification_message,
error: true,
user: @user
}
)
notification.create_user_notification(@user)
end
def failed_notification_title

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
module MyModules
class DueDateReminderJob < ApplicationJob
def perform
my_modules = MyModule.uncomplete.approaching_due_dates
my_modules.each do |task|
TaskDueDateNotification.send_notifications({ my_module_id: task.id })
end
end
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class NotificationCleanupJob < ApplicationJob
def perform
Notification.where('created_at < ?', 3.months.ago).delete_all
end
end

View file

@ -136,15 +136,17 @@ module Protocols
"href='#{Rails.application.routes.url_helpers.rails_blob_path(@tmp_files.take.file)}'>" \
"#{@tmp_files.take.file.filename}</a>"
notification = Notification.create!(
type_of: :deliver,
title: I18n.t('protocols.import_export.import_protocol_notification.title', link: original_file_download_link),
DeliveryNotification.send_notifications(
{
title:
I18n.t('protocols.import_export.import_protocol_notification.title', link: original_file_download_link),
message: "#{I18n.t('protocols.import_export.import_protocol_notification.message')} " \
"<a data-id='#{@protocol.id}' data-turbolinks='false' " \
"href='#{Rails.application.routes.url_helpers.protocol_path(@protocol)}'>" \
"#{@protocol.name}</a>"
"#{@protocol.name}</a>",
user: @user
}
)
notification.create_user_notification(@user)
end
# Overrides method from FailedDeliveryNotifiableJob concern

View file

@ -21,16 +21,22 @@ module Reports
report.docx_ready!
report_path = Rails.application.routes.url_helpers
.reports_path(team: report.team.id, preview_report_id: report.id, preview_type: :docx)
notification = Notification.create(
type_of: :deliver,
DeliveryNotification.send_notifications(
{
title: I18n.t('projects.reports.index.generation.completed_docx_notification_title'),
message: I18n.t('projects.reports.index.generation.completed_notification_message',
report_link: "<a href='#{report_path}'>#{escape_input(report.name)}</a>",
team_name: escape_input(report.team.name))
team_name: escape_input(report.team.name)),
subject_id: report_id,
subject_class: 'Report',
subject_name: report.name,
report_type: 'docx',
user: user
}
)
Reports::DocxPreviewJob.perform_now(report.id)
notification.create_user_notification(user)
ensure
I18n.backend.date_format = nil
file.close

View file

@ -162,14 +162,19 @@ module Reports
def create_notification_for_user
report_path = Rails.application.routes.url_helpers
.reports_path(team: @report.team.id, preview_report_id: @report.id, preview_type: :pdf)
notification = Notification.create(
type_of: :deliver,
DeliveryNotification.send_notifications(
{
title: I18n.t('projects.reports.index.generation.completed_pdf_notification_title'),
message: I18n.t('projects.reports.index.generation.completed_notification_message',
report_link: "<a href='#{report_path}'>#{escape_input(@report.name)}</a>",
team_name: escape_input(@report.team.name))
team_name: escape_input(@report.team.name)),
subject_id: @report.id,
subject_class: 'Report',
subject_name: @report.name,
report_type: 'pdf',
user: @user
}
)
notification.create_user_notification(@user)
end
def append_result_asset_previews
@ -222,9 +227,9 @@ module Reports
merged_file
end
def prepend_title_page(file, template, report, renderer)
unless File.exist?(Rails.root.join('app', 'views', 'reports', 'templates', template, 'cover.html.erb'))
return file
def prepend_title_page
unless File.exist?(Rails.root.join('app', 'views', 'reports', 'templates', @template, 'cover.html.erb'))
return @file
end
total_pages = 0

View file

@ -83,8 +83,8 @@ class RepositoriesExportJob < ApplicationJob
end
def generate_notification
notification = Notification.create!(
type_of: :deliver,
DeliveryNotification.send_notifications(
{
title: I18n.t('zip_export.notification_title'),
message: "<a data-id='#{@zip_export.id}' " \
"data-turbolinks='false' " \
@ -92,9 +92,10 @@ class RepositoriesExportJob < ApplicationJob
.routes
.url_helpers
.zip_exports_download_export_all_path(@zip_export)}'>" \
"#{@zip_export.zip_file_name}</a>"
"#{@zip_export.zip_file_name}</a>",
user: @user
}
)
notification.create_user_notification(@user)
end
# Overrides method from FailedDeliveryNotifiableJob concern

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
class RepositoryItemDateReminderJob < ApplicationJob
queue_as :default
def perform
process_repository_values(RepositoryDateTimeValue, DateTime.current)
process_repository_values(RepositoryDateValue, Date.current)
end
private
def process_repository_values(model, comparison_value)
model
.joins(repository_cell: { repository_column: :repository })
.where(notification_sent: false, repositories: { type: 'Repository' })
.where('repository_date_time_values.updated_at >= ?', 2.days.ago)
.where( # date(time) values that are within the reminder range
"data <= " \
"(?::timestamp + CAST(((repository_columns.metadata->>'reminder_unit')::int * " \
"(repository_columns.metadata->>'reminder_value')::int) || ' seconds' AS Interval))",
comparison_value
).find_each do |value|
repository_row = RepositoryRow.find(value.repository_cell.repository_row_id)
repository_column = RepositoryColumn.find(value.repository_cell.repository_column_id)
RepositoryItemDateNotification
.send_notifications({
"#{value.class.name.underscore}_id": value.id,
repository_row_id: repository_row.id,
repository_row_name: repository_row.name,
repository_column_id: repository_column.id,
repository_column_name: repository_column.name,
reminder_unit: repository_column.metadata['reminder_unit'],
reminder_value: repository_column.metadata['reminder_value']
})
end
end
end

View file

@ -35,7 +35,7 @@ class RepositoryZipExportJob < ZipExportJob
repository,
nil,
params[:my_module_id].present?)
File.binwrite("#{dir}/export.csv", data)
File.binwrite("#{dir}/export.csv", data.encode('UTF-8', invalid: :replace, undef: :replace))
end
def failed_notification_title

View file

@ -34,8 +34,8 @@ class ZipExportJob < ApplicationJob
end
def generate_notification!
notification = Notification.create!(
type_of: :deliver,
DeliveryNotification.send_notifications(
{
title: I18n.t('zip_export.notification_title'),
message: "<a data-id='#{@zip_export.id}' " \
"data-turbolinks='false' " \
@ -43,8 +43,9 @@ class ZipExportJob < ApplicationJob
.routes
.url_helpers
.zip_exports_download_path(@zip_export)}'>" \
"#{@zip_export.zip_file_name}</a>"
"#{@zip_export.zip_file_name}</a>",
user: @user
}
)
notification.create_user_notification(@user)
end
end

View file

@ -22,4 +22,16 @@ class AppMailer < Devise::Mailer
}.merge(opts)
mail(headers)
end
def general_notification(opts = {})
@user = params[:recipient]
@notification = params[:record].to_notification
mail(
{
to: @user.email,
subject: I18n.t('notifications.email_title')
}.merge(opts)
)
end
end

View file

@ -10,7 +10,7 @@ module Cloneable
last_clone_number =
parent.public_send(self.class.table_name)
.select("substring(#{self.class.table_name}.name, '(?:^#{clone_label} )(\\d+)')::int AS clone_number")
.where('name ~ ?', "^#{clone_label} \\d+ - #{name}$")
.where('name ~ ?', "^#{clone_label} \\d+ - #{Regexp.escape(name)}$")
.order(clone_number: :asc)
.last&.clone_number

View file

@ -9,104 +9,14 @@ module GenerateNotificationModel
end
def generate_notification_from_activity
return if notification_recipients.none?
message = generate_activity_content(self, no_links: true, no_custom_links: true)
description = generate_notification_description_elements(subject).reverse.join(' | ')
notification = Notification.create(
type_of: notification_type,
title: sanitize_input(message),
message: sanitize_input(description),
generator_user_id: owner.id
)
notification_recipients.each do |user|
notification.create_user_notification(user)
end
params = { activity_id: id, type: "#{type_of}_activity".to_sym }
ActivityNotification.send_notifications(params, later: true)
end
protected
def notification_recipients
users = []
case subject
when Project
users = subject.users
when Experiment
users = subject.users
when MyModule
users = subject.designated_users
# Also send to the user that was unassigned,
# and is therefore no longer present on the module.
if type_of == 'undesignate_user_from_my_module'
users += User.where(id: values.dig('message_items', 'user_target', 'id'))
end
when Protocol
users = subject.in_repository? ? [] : subject.my_module.designated_users
when Result
users = subject.my_module.designated_users
when Repository
users = subject.team.users
when Team
users = subject.users
when Report
users = subject.team.users
when ProjectFolder
users = subject.team.users
end
users - [owner]
end
# This method returns unsanitized elements. They must be sanitized before saving to DB
def generate_notification_description_elements(object, elements = [])
case object
when Project
path = Rails.application.routes.url_helpers.project_path(object)
elements << "#{I18n.t('search.index.project')} <a href='#{path}'>#{object.name}</a>"
when Experiment
path = Rails.application.routes.url_helpers.my_modules_experiment_path(object)
elements << "#{I18n.t('search.index.experiment')} <a href='#{path}'>#{object.name}</a>"
generate_notification_description_elements(object.project, elements)
when MyModule
path = if object.archived?
Rails.application.routes.url_helpers.my_modules_experiment_path(object.experiment, view_mode: :archived)
else
Rails.application.routes.url_helpers.protocols_my_module_path(object)
end
elements << "#{I18n.t('search.index.module')} <a href='#{path}'>#{object.name}</a>"
generate_notification_description_elements(object.experiment, elements)
when Protocol
if object.in_repository?
path = Rails.application.routes.url_helpers.protocols_path(team: object.team.id)
elements << "#{I18n.t('search.index.protocol')} <a href='#{path}'>#{object.name}</a>"
generate_notification_description_elements(object.team, elements)
else
generate_notification_description_elements(object.my_module, elements)
end
when Result
generate_notification_description_elements(object.my_module, elements)
when Repository
path = Rails.application.routes.url_helpers.repository_path(object, team: object.team.id)
elements << "#{I18n.t('search.index.repository')} <a href='#{path}'>#{object.name}</a>"
generate_notification_description_elements(object.team, elements)
when Team
path = Rails.application.routes.url_helpers.projects_path(team: object.id)
elements << "#{I18n.t('search.index.team')} <a href='#{path}'>#{object.name}</a>"
when Report
path = Rails.application.routes.url_helpers.reports_path(team: object.team.id)
elements << "#{I18n.t('search.index.report')} <a href='#{path}'>#{object.name}</a>"
generate_notification_description_elements(object.team, elements)
when ProjectFolder
generate_notification_description_elements(object.team, elements)
end
elements
end
def notifiable?
type_of.in? ::Extends::NOTIFIABLE_ACTIVITIES
NotificationExtends::NOTIFICATIONS_TYPES.key?("#{type_of}_activity".to_sym)
end
private
@ -114,14 +24,4 @@ module GenerateNotificationModel
def generate_notification
CreateNotificationFromActivityJob.perform_later(self) if notifiable?
end
def notification_type
return :recent_changes unless instance_of?(Activity)
if type_of.in? Activity::ASSIGNMENT_TYPES
:assignment
else
:recent_changes
end
end
end

View file

@ -1,6 +1,12 @@
# frozen_string_literal: true
class LinkedRepository < Repository
enum permission_level: Extends::SHARED_OBJECTS_PERMISSION_LEVELS.except(:shared_write)
def shareable_write?
false
end
def default_table_state
state = Constants::REPOSITORY_TABLE_DEFAULT_STATE.deep_dup
state['order'] = [[3, 'asc']]
@ -12,13 +18,13 @@ class LinkedRepository < Repository
def default_sortable_columns
[
'assigned',
'repository_rows.external_id',
'repository_rows.id',
'repository_rows.name',
'repository_rows.created_at',
'users.full_name',
'repository_rows.archived_on',
'archived_bies_repository_rows.full_name'
'archived_bies_repository_rows.full_name',
'repository_rows.external_id'
]
end

View file

@ -20,6 +20,7 @@ class MyModule < ApplicationRecord
before_validation :archiving_and_restoring_extras, on: :update, if: :archived_changed?
before_save -> { report_elements.destroy_all }, if: -> { !new_record? && experiment_id_changed? }
before_save :reset_due_date_notification_sent, if: -> { due_date_changed? }
around_save :exec_status_consequences, if: :my_module_status_id_changed?
before_create :create_blank_protocol
before_create :assign_default_status_flow
@ -139,6 +140,11 @@ class MyModule < ApplicationRecord
joins(experiment: :project).where(experiment: { projects: { team: teams } })
end
def self.approaching_due_dates
where(due_date_notification_sent: false)
.where('due_date > ? AND due_date <= ?', DateTime.current, DateTime.current + 1.day)
end
def parent
experiment
end
@ -529,6 +535,10 @@ class MyModule < ApplicationRecord
end
end
def reset_due_date_notification_sent
self.due_date_notification_sent = false
end
def archiving_and_restoring_extras
if archived?
# Removes connections with other modules

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