Merge branch 'develop' into features/inventory-items-relationships

This commit is contained in:
Oleksii Kriuchykhin 2023-12-14 11:28:04 +01:00
commit 372ef32f03
119 changed files with 1602 additions and 676 deletions

View file

@ -35,6 +35,10 @@
"template": 240,
"tabWidth": 2
}
],
"comma-dangle": [
"error",
"never"
]
},
"globals": {

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: 09b7b4910f59453970aab03d7b3ddb60b41db89a
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.4
1.29.5.1

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

@ -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

@ -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,14 +538,23 @@ class RepositoriesController < ApplicationController
end
def log_activity(type_of, message_items = {})
message_items = { repository: @repository.id }.merge(message_items)
if @repository.present?
message_items = { repository: @repository.id }.merge(message_items)
Activities::CreateActivityService
.call(activity_type: type_of,
owner: current_user,
subject: @repository,
team: @repository.team,
message_items: message_items)
Activities::CreateActivityService
.call(activity_type: type_of,
owner: current_user,
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

@ -460,6 +460,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,
title: sanitize_input(title),
message: sanitize_input(message)
def generate_annotation_notification(target_user, title, subject)
GeneralNotification.send_notifications(
{
type: :smart_annotation_added,
title: sanitize_input(title),
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)
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
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)
end
message = "#{I18n.t('search.index.team')} #{team.name}"
end
notification = Notification.create(
type_of: :assignment,
title: sanitize_input(title),
message: sanitize_input(message)
)
if target_user.assignments_notification
notification.create_user_notification(target_user)
end
GeneralNotification.send_notifications({
type: role ? :invite_user_to_team : :remove_user_from_team,
title: sanitize_input(title),
message: sanitize_input(message),
user: target_user
})
end
end

View file

@ -242,13 +242,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

@ -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,12 +40,13 @@ export default {
data() {
return {
notifications: [],
nextPage: 1,
nextPageUrl: null,
scrollBar: null,
loadingPage: false
}
},
created() {
this.nextPageUrl = this.notificationsUrl;
this.loadNotifications();
},
mounted() {
@ -64,23 +66,30 @@ export default {
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;
this.loadingPage = false;
this.$emit('update:unseenNotificationsCount');
});
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

@ -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

@ -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

@ -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

@ -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
@ -240,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

@ -80,9 +80,6 @@
valueLabel() {
let option = this.options.find((o) => o[0] === this.value);
return option && option[1];
},
focusElement() {
return this.$refs.focusElement || this.$parent.$refs.focusElement;
}
},
mounted() {
@ -98,7 +95,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

@ -16,6 +16,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>
@ -100,6 +101,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,
title: failed_notification_title,
message: failed_notification_message
DeliveryNotification.send_notifications(
{
title: failed_notification_title,
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),
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>"
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>",
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,
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))
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)),
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,
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))
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)),
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,18 +83,19 @@ class RepositoriesExportJob < ApplicationJob
end
def generate_notification
notification = Notification.create!(
type_of: :deliver,
title: I18n.t('zip_export.notification_title'),
message: "<a data-id='#{@zip_export.id}' " \
"data-turbolinks='false' " \
"href='#{Rails.application
.routes
.url_helpers
.zip_exports_download_export_all_path(@zip_export)}'>" \
"#{@zip_export.zip_file_name}</a>"
DeliveryNotification.send_notifications(
{
title: I18n.t('zip_export.notification_title'),
message: "<a data-id='#{@zip_export.id}' " \
"data-turbolinks='false' " \
"href='#{Rails.application
.routes
.url_helpers
.zip_exports_download_export_all_path(@zip_export)}'>" \
"#{@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,17 +34,18 @@ class ZipExportJob < ApplicationJob
end
def generate_notification!
notification = Notification.create!(
type_of: :deliver,
title: I18n.t('zip_export.notification_title'),
message: "<a data-id='#{@zip_export.id}' " \
"data-turbolinks='false' " \
"href='#{Rails.application
.routes
.url_helpers
.zip_exports_download_path(@zip_export)}'>" \
"#{@zip_export.zip_file_name}</a>"
DeliveryNotification.send_notifications(
{
title: I18n.t('zip_export.notification_title'),
message: "<a data-id='#{@zip_export.id}' " \
"data-turbolinks='false' " \
"href='#{Rails.application
.routes
.url_helpers
.zip_exports_download_path(@zip_export)}'>" \
"#{@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

@ -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

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class NonExistantRecord
attr_reader :name
def initialize(name)
@name = name
end
end

View file

@ -1,19 +1,13 @@
# frozen_string_literal: true
class Notification < ApplicationRecord
has_many :user_notifications, inverse_of: :notification, dependent: :destroy
has_many :users, through: :user_notifications
belongs_to :generator_user, class_name: 'User', optional: true
include Noticed::Model
enum type_of: Extends::NOTIFICATIONS_TYPES
belongs_to :recipient, polymorphic: true
def create_user_notification(user)
return if user == generator_user
return unless can_send_to_user?(user)
return unless user.enabled_notifications_for?(type_of.to_sym, :web)
user_notifications.create!(user: user)
end
scope :in_app, lambda {
where.not("notifications.params ? 'hide_in_app' AND notifications.params->'hide_in_app' = 'true'")
}
private

View file

@ -337,7 +337,7 @@ class Project < ApplicationRecord
def auto_assign_project_members
return if skip_user_assignments
UserAssignments::ProjectGroupAssignmentJob.perform_later(
UserAssignments::ProjectGroupAssignmentJob.perform_now(
team,
self,
last_modified_by&.id || created_by&.id

View file

@ -11,7 +11,7 @@ class Repository < RepositoryBase
ID_PREFIX = 'IN'
include PrefixedIdModel
enum permission_level: Extends::SHARED_INVENTORIES_PERMISSION_LEVELS
enum permission_level: Extends::SHARED_OBJECTS_PERMISSION_LEVELS
belongs_to :archived_by,
foreign_key: :archived_by_id,
@ -55,8 +55,8 @@ class Repository < RepositoryBase
.where(team: teams)
.or(accessible_repositories.where(team_shared_objects: { team: teams }))
.or(accessible_repositories
.where(permission_level: [Extends::SHARED_INVENTORIES_PERMISSION_LEVELS[:shared_read],
Extends::SHARED_INVENTORIES_PERMISSION_LEVELS[:shared_write]]))
.where(permission_level: [Extends::SHARED_OBJECTS_PERMISSION_LEVELS[:shared_read],
Extends::SHARED_OBJECTS_PERMISSION_LEVELS[:shared_write]]))
accessible_repositories.distinct
}
@ -112,6 +112,10 @@ class Repository < RepositoryBase
teams.blank? ? self : where(team: teams)
end
def shareable_write?
true
end
def permission_parent
team
end
@ -204,7 +208,7 @@ class Repository < RepositoryBase
new_repo = dup
new_repo.created_by = created_by
new_repo.name = name
new_repo.permission_level = Extends::SHARED_INVENTORIES_PERMISSION_LEVELS[:not_shared]
new_repo.permission_level = Extends::SHARED_OBJECTS_PERMISSION_LEVELS[:not_shared]
new_repo.save!
# Clone columns (only if new_repo was saved)

View file

@ -70,6 +70,7 @@ class RepositoryAssetValue < ApplicationRecord
asset.last_modified_by = user
self.last_modified_by = user
asset.save! && save!
asset.post_process_file(repository_cell.repository_column.repository.team)
end
def snapshot!(cell_snapshot)

View file

@ -9,6 +9,7 @@ class RepositoryDateTimeValueBase < ApplicationRecord
inverse_of: :modified_repository_date_time_values
has_one :repository_cell, as: :value, dependent: :destroy
accepts_nested_attributes_for :repository_cell
before_save :reset_notification_sent, if: -> { data_changed? }
validates :repository_cell, :data, :type, presence: true
@ -33,4 +34,10 @@ class RepositoryDateTimeValueBase < ApplicationRecord
)
value_snapshot.save!
end
private
def reset_notification_sent
self.notification_sent = false
end
end

View file

@ -45,7 +45,7 @@ class RepositorySnapshot < RepositoryBase
my_module: my_module,
created_by: created_by,
team: my_module.experiment.project.team,
permission_level: Extends::SHARED_INVENTORIES_PERMISSION_LEVELS[:not_shared])
permission_level: Extends::SHARED_OBJECTS_PERMISSION_LEVELS[:not_shared])
repository_snapshot.provisioning!
repository_snapshot.reload
end

View file

@ -18,6 +18,8 @@ class RepositoryStockValue < ApplicationRecord
validates :low_stock_threshold, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
before_save :update_consumption_stock_units, if: :repository_stock_unit_item_id_changed?
after_save :send_low_stock_notification, if: -> { status == :low }
after_create do
next if is_a?(RepositoryStockConsumptionValue)
@ -199,4 +201,18 @@ class RepositoryStockValue < ApplicationRecord
.my_module_repository_rows
.update_all(repository_stock_unit_item_id: repository_stock_unit_item_id)
end
def send_low_stock_notification
repository_row = repository_cell.repository_row
repository = repository_row.repository
return unless repository.class.name == 'Repository'
LowStockNotification.send_notifications({
repository_row_id: repository_cell.repository_row_id,
repository_row_name: repository_row.name,
repository_id: repository_row.repository_id,
repository_name: repository.name
})
end
end

View file

@ -41,7 +41,7 @@ class User < ApplicationRecord
recent: true,
recent_email: false,
system_message_email: false
}
}.merge(Extends::DEFAULT_USER_NOTIFICATION_SETTINGS)
}.freeze
DEFAULT_OTP_DRIFT_TIME_SECONDS = 10
@ -307,8 +307,7 @@ class User < ApplicationRecord
inverse_of: :created_by,
dependent: :destroy
has_many :user_notifications, inverse_of: :user
has_many :notifications, through: :user_notifications
has_many :notifications, as: :recipient, dependent: :destroy, inverse_of: :recipient
has_many :zip_exports, inverse_of: :user, dependent: :destroy
has_many :view_states, dependent: :destroy
@ -322,7 +321,6 @@ class User < ApplicationRecord
has_many :hidden_repository_cell_reminders, dependent: :destroy
before_validation :downcase_email!
before_destroy :destroy_notifications
def name
full_name
@ -514,40 +512,6 @@ class User < ApplicationRecord
user_identities.exists?(provider: provider)
end
# json friendly attributes
NOTIFICATIONS_TYPES = %w(assignments_notification recent_notification
assignments_email_notification
recent_email_notification)
# declare notifications getters
NOTIFICATIONS_TYPES.each do |name|
define_method(name) do
attr_name = name.gsub('_notification', '')
notifications_settings.fetch(attr_name.to_sym)
end
end
# declare notifications setters
NOTIFICATIONS_TYPES.each do |name|
define_method("#{name}=") do |value|
attr_name = name.gsub('_notification', '').to_sym
notifications_settings[attr_name] = value
end
end
def enabled_notifications_for?(notification_type, channel)
return true if %i(deliver deliver_error).include?(notification_type)
case channel
when :web
notification_type == :recent_changes && recent_notification ||
notification_type == :assignment && assignments_notification
when :email
notification_type == :recent_changes && recent_email_notification ||
notification_type == :assignment && assignments_email_notification
end
end
def increase_daily_exports_counter!
range = Time.now.utc.beginning_of_day.to_i..Time.now.utc.end_of_day.to_i
last_export = export_vars[:last_export_timestamp] || 0
@ -670,25 +634,6 @@ class User < ApplicationRecord
self.email = email.downcase
end
def destroy_notifications
# Find all notifications where user is the only reference
# on the notification, and destroy all such notifications
# (user_notifications are destroyed when notification is
# destroyed). We try to do this efficiently (hence in_groups_of).
nids_all = notifications.pluck(:id)
nids_all.in_groups_of(1000, false) do |nids|
Notification
.where(id: nids)
.joins(:user_notifications)
.group('notifications.id')
.having('count(notification_id) <= 1')
.destroy_all
end
# Now, simply destroy all user notification relations left
user_notifications.destroy_all
end
def clear_view_cache
Rails.cache.delete_matched(%r{^views\/users\/#{id}-})
end

View file

@ -1,22 +0,0 @@
# frozen_string_literal: true
class UserNotification < ApplicationRecord
include NotificationsHelper
belongs_to :user, optional: true
belongs_to :notification, optional: true
after_create :send_email
def self.unseen_notification_count(user)
where('user_id = ? AND checked = false', user.id).count
end
def self.seen_by_user(user)
where(user: user).where(checked: false).update_all(checked: true)
end
def send_email
send_email_notification(user, notification) if user.enabled_notifications_for?(notification.type_of.to_sym, :email)
end
end

View file

@ -0,0 +1,41 @@
# frozen_string_literal: true
class ActivityNotification < BaseNotification
include SearchHelper
include GlobalActivitiesHelper
include InputSanitizeHelper
include ActionView::Helpers::TextHelper
include ApplicationHelper
include ActiveRecord::Sanitization::ClassMethods
include Rails.application.routes.url_helpers
def message
params[:message] if params[:legacy]
end
def title
if params[:legacy]
params[:title]
else
generate_activity_content(activity)
end
end
def subject
activity.subject unless params[:legacy]
end
private
def current_team
@current_team ||= recipient.teams.find_by(id: recipient.current_team_id)
end
def current_user
recipient
end
def activity
@activity ||= Activity.find_by(id: params[:activity_id])
end
end

View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
class BaseNotification < Noticed::Base
deliver_by :database, if: :database_notification?
deliver_by :email, mailer: 'AppMailer', method: :general_notification, if: :email_notification?
def self.send_notifications(params, later: true)
recipients_class =
"Recipients::#{NotificationExtends::NOTIFICATIONS_TYPES[subtype || params[:type]][:recipients_module]}".constantize
recipients_class.new(params).recipients.each do |recipient|
if later
with(params).deliver_later(recipient)
else
with(params).deliver(recipient)
end
end
end
def self.subtype; end
def subtype
self.class.subtype || params[:type]
end
def subject; end
def message
params[:message]
end
def title
params[:title]
end
private
def database_notification?
# always save all notifications,
# but flag if they should display in app or not
params[:hide_in_app] = recipient.notifications_settings.dig(notification_subgroup.to_s, 'in_app') != true
true
end
def email_notification?
recipient.notifications_settings.dig(notification_subgroup.to_s, 'email')
end
def notification_subgroup
NotificationExtends::NOTIFICATIONS_GROUPS.values.reduce({}, :merge).find do |_sg, n|
n.include?(subtype.to_sym)
end[0]
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class DeliveryNotification < BaseNotification
def self.subtype
:delivery
end
def message
params[:message]
end
def title
params[:title]
end
def subject
return unless params[:subject_id] && params[:subject_class]
params[:subject_class].constantize.find(params[:subject_id])
rescue ActiveRecord::RecordNotFound
NonExistantRecord.new(params[:subject_name])
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class GeneralNotification < BaseNotification
def message
params[:message]
end
def title
params[:title]
end
def subtype
params[:type]
end
def subject
subject_class = params[:subject_class].constantize
subject_class.find(params[:subject_id])
rescue NameError, ActiveRecord::RecordNotFound
NonExistantRecord.new(params[:subject_name])
end
end

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
class LowStockNotification < BaseNotification
def self.subtype
:item_low_stock_reminder
end
def title
I18n.t(
'notifications.content.item_low_stock_reminder.message_html',
repository_row_name: subject.name,
repository: repository.name
)
end
def subject
RepositoryRow.find(params[:repository_row_id])
rescue ActiveRecord::RecordNotFound
NonExistantRecord.new(params[:repository_row_name])
end
def repository
Repository.find(params[:repository_id])
rescue ActiveRecord::RecordNotFound
NonExistantRecord.new(params[:repository_name])
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class Recipients::AssignedGroupRecipients
def initialize(params)
@params = params
end
def recipients
activity = Activity.find(@params[:activity_id])
project = activity.subject
project.team.users.where.not(id: project.user_assignments.where(assigned: 'manually').select(:user_id))
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class Recipients::AssignedRecipients
def initialize(params)
@params = params
end
def recipients
activity = Activity.find(@params[:activity_id])
User.where(id: activity.values.dig('message_items', 'user_target', 'id'))
.where.not(id: activity.owner_id)
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Recipients
class DesignateToMyModuleRecipients < MyModuleDesignatedRecipients
private
def activity_recipients
activity = Activity.find(@params[:activity_id])
user = User.find_by(id: activity.values.dig('message_items', 'user_target', 'id'))
return [] if user.id == activity.owner_id
[user]
end
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Recipients::DirectRecipient
def initialize(params)
@params = params
end
def recipients
[@params[:user]]
end
end

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
class Recipients::MyModuleDesignatedRecipients
def initialize(params)
@params = params
end
def recipients
if @params[:activity_id]
activity_recipients
else
MyModule.find(@params[:my_module_id]).designated_users
end
end
private
def activity_recipients
activity = Activity.find(@params[:activity_id])
case activity.subject_type
when 'MyModule'
users = activity.subject.designated_users
when 'Protocol', 'Result'
users = activity.subject.my_module.designated_users
when 'Step'
users = activity.subject.protocol.my_module.designated_users
end
users.where.not(id: activity.owner_id)
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class Recipients::RepositoryItemRecipients
def initialize(params)
@repository_row_id = params[:repository_row_id]
end
def recipients
repository_row = RepositoryRow.find(@repository_row_id)
repository_row.repository.team.users
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class Recipients::UserChangedRecipient
def initialize(params)
@params = params
end
def recipients
activity = Activity.find(@params[:activity_id])
User.where(id: activity.values.dig('message_items', 'user_changed', 'id'))
end
end

View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
class RepositoryItemDateNotification < BaseNotification
def self.subtype
:item_date_reminder
end
def title
unit = human_readable_unit(column.metadata['reminder_unit'], column.metadata['reminder_value'])
I18n.t(
'notifications.content.item_date_reminder.message_html',
repository_row_name: subject.name,
value: column.metadata['reminder_value'],
units: unit
)
end
def subject
RepositoryRow.find(params[:repository_row_id])
rescue ActiveRecord::RecordNotFound
NonExistantRecord.new(params[:repository_row_name])
end
def column
RepositoryColumn.find(params[:repository_column_id])
rescue ActiveRecord::RecordNotFound
NonExistantRecord.new(params[:repository_column_name])
end
after_deliver do
if params[:repository_date_time_value_id]
RepositoryDateTimeValue.find(params[:repository_date_time_value_id]).update(notification_sent: true)
elsif params[:repository_date_value_id]
RepositoryDateValue.find(params[:repository_date_value_id]).update(notification_sent: true)
end
end
private
def human_readable_unit(seconds, value)
return unless seconds
units_hash = {
'2419200' => 'month',
'604800' => 'week',
'86400' => 'day'
}
base_unit = units_hash.fetch(seconds) do
raise ArgumentError, "Unrecognized time unit for seconds value: #{seconds}"
end
value.to_i > 1 ? base_unit.pluralize : base_unit
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class TaskDueDateNotification < BaseNotification
def self.subtype
:my_module_due_date_reminder
end
def title
I18n.t(
'notifications.content.my_module_due_date_reminder.message_html',
my_module_name: subject.name
)
end
def subject
MyModule.find(params[:my_module_id])
end
after_deliver do
MyModule.find(params[:my_module_id]).update_column(:due_date_notification_sent, true)
end
end

View file

@ -59,7 +59,7 @@ Canaid::Permissions.register_for(Repository) do
# repository: share
can :share_repository do |user, repository|
can_manage_repository?(user, repository)
!repository.shared_with?(user.current_team) && repository.permission_granted?(user, RepositoryPermissions::SHARE)
end
# repository: make a snapshot with assigned rows

View file

@ -0,0 +1,91 @@
# frozen_string_literal: true
class NotificationSerializer < ActiveModel::Serializer
include Rails.application.routes.url_helpers
attributes :id, :title, :message, :created_at, :read_at, :type, :breadcrumbs, :checked, :today
def title
object.to_notification.title
end
def breadcrumbs
subject = object.to_notification.subject
generate_breadcrumbs(subject, []) if subject
end
def message
object.to_notification.message
end
def created_at
I18n.l(object.created_at, format: :full)
end
def today
object.created_at.today?
end
def checked
object.read_at.present?
end
private
def generate_breadcrumbs(subject, breadcrumbs)
return [] if subject.is_a?(NonExistantRecord)
case subject
when Project
parent = subject.team
url = project_path(subject)
when Experiment
parent = subject.project
url = my_modules_experiment_path(subject)
when MyModule
parent = subject.experiment
url = protocols_my_module_path(subject)
when Protocol
if subject.in_repository?
parent = subject.team
url = protocol_path(subject)
else
parent = subject.my_module
url = protocols_my_module_path(subject.my_module)
end
when Result
parent = subject.my_module
url = my_module_results_path(subject.my_module)
when ProjectFolder
parent = subject.team
url = project_folder_path(subject)
when RepositoryBase
parent = subject.team
url = repository_path(subject)
when RepositoryRow
parent = subject.team
url = repository_path(subject.repository)
when Report
parent = subject.team
url = reports_path(
preview_report_id: subject.id,
preview_type: object.params[:report_type],
team_id: subject.team.id
)
when LabelTemplate
parent = subject.team
url = label_template_path(subject)
when Team
parent = nil
url = projects_path(team: subject.id)
end
breadcrumbs << { name: subject.name, url: url } if subject.name.present?
if parent
generate_breadcrumbs(parent, breadcrumbs)
else
breadcrumbs.reverse
end
end
end

View file

@ -149,6 +149,19 @@ module WopiUtil
asset_name: { id: @asset.id, value_for: 'file_name' },
action: action
})
elsif @assoc.is_a?(RepositoryCell)
repository = @assoc.repository_row.repository
Activities::CreateActivityService
.call(activity_type: :edit_wopi_file_on_inventory_item,
owner: current_user,
subject: repository,
team: repository.team,
message_items: {
repository: repository.id,
repository_row: @assoc.repository_row.id,
asset_name: { id: @asset.id, value_for: 'file_name' },
action: action
})
end
end
end

View file

@ -1,4 +1,4 @@
<div id="taskSecondaryMenu" class="sticky-header-element bg-sn-white border-b border-solid border-0 border-sn-sleepy-grey rounded-t px-4 py-2 top-0 sticky flex items-center flex-wrap z-[106]">
<div id="taskSecondaryMenu" class="sticky-header-element bg-sn-white border-b border-solid border-0 border-sn-sleepy-grey rounded-t px-4 py-2 pb-[16px] top-0 sticky flex items-center flex-wrap z-[106]">
<div class="flex items-center gap-4 mr-auto">
<% if can_read_experiment?(@my_module.experiment) %>
<a class="p-3 border-b-4 border-transparent hover:no-underline uppercase text-bold capitalize <%= is_module_protocols? ? "text-sn-blue" : "text-sn-grey" %>"

View file

@ -37,7 +37,7 @@
<div class="module-tags">
<div class="tags-wrapper">
<span class="sn-icon block-icon sn-icon-users mr-2.5"></span>
<span class="hidden-xs hidden-sm"><%= t('my_modules.details.assigned_users') %></span>
<span class="hidden-xs hidden-sm mr-1"><%= t('my_modules.details.assigned_users') %></span>
<%= render partial: "user_my_modules/index", locals: { my_module: @my_module } %>
</div>
</div>

View file

@ -6,7 +6,7 @@
<share-task-container
shareable-link-url="<%= my_module_shareable_link_path(@my_module) %>"
:shared="<%= @my_module.shared? %>"
:disabled="<%= !can_share_my_module?(@my_module) %>" />
:can-share="<%= can_share_my_module?(@my_module) %>" />
</div>
<%= javascript_include_tag 'vue_share_task_container' %>

View file

@ -35,9 +35,6 @@
</div>
</th>
<th id="assigned" data-unmanageable="true"><%= t("repositories.table.assigned") %></th>
<% if @repository.is_a?(LinkedRepository) %>
<th id="row-external-id"><%= t('repositories.table.external_id') %></th>
<% end %>
<th id="row-id"><%= t("repositories.table.id") %></th>
<th id="row-name"><%= t("repositories.table.row_name") %></th>
<th id="relationship"><%= t("repositories.table.relationship") %></th>
@ -45,6 +42,9 @@
<th id="added-by" ><%= t("repositories.table.added_by") %></th>
<th id="archived-on"><%= t("repositories.table.archived_on") %></th>
<th id="archived-by"><%= t("repositories.table.archived_by") %></th>
<% if @repository.is_a?(LinkedRepository) %>
<th id="row-external-id"><%= t('repositories.table.external_id') %></th>
<% end %>
<% repository.repository_columns.order(:id).each do |column| %>
<th
class="repository-column <%= 'row-stock item-stock' if column.data_type == 'RepositoryStockValue' %>"

View file

@ -18,14 +18,17 @@
<div class="all-teams">
<span class="team-selector" title="<%= t("repositories.index.modal_share.all_teams_tooltip") %>">
<span class="sci-checkbox-container">
<%= check_box_tag 'select_all_teams', 0, @repository.shared_read? || @repository.shared_write?, { class: 'sci-checkbox' } %>
<%= check_box_tag 'select_all_teams', true, @repository.shared_read? || @repository.shared_write?, { class: 'sci-checkbox' } %>
<span class="sci-checkbox-label"></span>
</span>
<%= t("repositories.index.modal_share.all_teams") %>
</span>
<span class="permission-selector">
<span class="sci-toggle-checkbox-container">
<%= check_box_tag 'select_all_write_permission', 0, @repository.shared_write?, { class: 'hidden sci-toggle-checkbox' }%>
<%= check_box_tag 'select_all_write_permission', true, @repository.shared_write?, {
disabled: !@repository.shareable_write?,
class: 'hidden sci-toggle-checkbox'
} %>
<span class="sci-toggle-checkbox-label"></span>
</span>
</span>
@ -35,7 +38,7 @@
<div class="team-container">
<div class="team-selector">
<span class="sci-checkbox-container">
<%= check_box_tag 'share_team_ids[]', t.id, @repository.private_shared_with?(t), {id: "shared_#{t.id}", class: "sci-checkbox"} %>
<%= check_box_tag 'share_team_ids[]', t.id, @repository.private_shared_with?(t), { id: "shared_#{t.id}", class: "sci-checkbox" } %>
<span class="sci-checkbox-label"></span>
</span>
<%= t.name %>
@ -44,6 +47,7 @@
<span class="sci-toggle-checkbox-container">
<%= check_box_tag 'write_permissions[]', t.id, @repository.private_shared_with_write?(t), {
id: "editable_#{t.id}",
disabled: !@repository.shareable_write?,
class: (@repository.private_shared_with?(t) ? 'sci-toggle-checkbox' : 'sci-toggle-checkbox hidden')
}.compact %>
<span class="sci-toggle-checkbox-label"></span>

View file

@ -19,3 +19,5 @@
'data-view-mode': !can_manage_my_module_designated_users?(my_module)
} %>
</div>
<%= javascript_include_tag 'my_modules/assigned_users' %>

View file

@ -0,0 +1,8 @@
<p>Hello <%= @user.name %>,</p>
<p><%= I18n.t("notifications.email_title") %></p>
<p>Type: <%= @notification.title %></p>
<p>
<%= sanitize_input(prepend_server_url_to_links(@notification.message)) %>
</p>

View file

@ -1,106 +1,17 @@
<% provide(:head_title, t("users.settings.account.preferences.head_title")) %>
<% provide(:container_class, "no-second-nav-container") %>
<%= render partial: "users/settings/sidebar" %>
<div class="tab-content user-account-preferences">
<div class="tab-pane content-pane active" role="tabpanel">
<div class="row">
<div class="col-xs-12 col-sm-12">
<h1 class="preferences-title"><%= t('users.settings.account.preferences.title') %></h1>
<div class="time-zone-container">
<%= label_tag t("users.settings.account.preferences.edit.time_zone_label") %>
<div class="time-zone-selector-container">
<%= select_tag "time-zone-input-field",
options_for_select(
ActiveSupport::TimeZone.all.map{ |tz|
[tz.formatted_offset + " " + tz.name, tz.name]
},
@user.settings[:time_zone]
),{
'data-path-to-update': update_preferences_path(format: :json),
class: 'hidden'
}
%>
</div>
<small><%= t("users.settings.account.preferences.edit.time_zone_sublabel") %></small>
</div>
<div class="date-format-container">
<%= label_tag t("users.settings.account.preferences.edit.date_format_label") %>
<div class="date-format-selector-container">
<%= select_tag "date-format-input-field",
options_for_select(
Constants::SUPPORTED_DATE_FORMATS.map { |df|
["#{l(Time.new(2024, 4, 22), format: :full_date, date_format: df)}", df]
},
@user.settings[:date_format]
),{
'data-path-to-update': update_preferences_path(format: :json),
class: 'hidden'
}
%>
</div>
<small><%= t("users.settings.account.preferences.edit.date_format_sublabel") %></small>
</div>
<hr>
<%= form_for(@user,
url: update_togglable_settings_path(format: :json),
html: { method: :post, id: 'togglable-settings-panel' },
remote: true) do |f| %>
<div class="preferences-settings-container">
<h4><%= t('notifications.title') %></h4>
<div class="row">
<div class="col-sm-12">
<strong><%=t 'notifications.form.assignments' %></strong>
<p><%=t 'notifications.form.assignments_description' %></p>
<div class="row">
<div class="col-sm-4">
<%=t 'notifications.form.notification_scinote' %>
</div>
<div class="col-sm-8">
<%= check_box_tag :assignments_notification, @user.assignments_notification %>
</div>
</div>
<div class="row">
<div class="col-sm-4">
<%=t 'notifications.form.notification_email' %>
</div>
<div class="col-sm-8">
<%= check_box_tag :assignments_notification_email, @user.assignments_email_notification %>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<strong><%=t 'notifications.form.recent_notification' %></strong>
<p><%=t 'notifications.form.recent_notification_description' %></p>
<div class="row">
<div class="col-sm-4">
<%=t 'notifications.form.notification_scinote' %>
</div>
<div class="col-sm-8">
<%= check_box_tag :recent_notification, @user.recent_notification %>
</div>
</div>
<div class="row">
<div class="col-sm-4">
<%=t 'notifications.form.notification_email' %>
</div>
<div class="col-sm-8">
<%= check_box_tag :recent_notification_email, @user.recent_email_notification %>
</div>
</div>
</div>
</div>
</div>
<% end %>
<br>
</div>
</div>
</div>
<div class="tab-pane tab-pane-settings" role="tabpanel"></div>
<div id="user_preferences" class="contents">
<user-preferences
:update-url = "'<%= update_preferences_path(format: :json) %>'"
:user-settings = "<%= @user.settings.to_json %>"
:time-zones = "<%= ActiveSupport::TimeZone.all.map{ |tz|
[ tz.name, tz.formatted_offset + " " + tz.name]
}.to_json %>"
:date-formats = "<%= Constants::SUPPORTED_DATE_FORMATS.map { |df|
[df, "#{l(Time.new(2024, 4, 22), format: :full_date, date_format: df)}"]
}.to_json %>"
:notifications-groups = "<%= NotificationExtends::NOTIFICATIONS_GROUPS.to_json %>"
/>
</div>
<%= javascript_include_tag "users/settings/account/preferences/index" %>
<%= javascript_include_tag "vue_user_preferences" %>

View file

@ -55,7 +55,7 @@
</div>
<ul class="webhooks-list collapse" id="activityFilter<%= filter.id %>">
<li class="create-webhook-container hidden">
<%= form_with model: Webhook.new, url: users_settings_webhooks_path(filter_id: filter.id, sort: @current_sort), class: 'webhook-form' do |f| %>
<%= form_with model: Webhook.new, url: users_settings_webhooks_path(filter_id: filter.id, sort: @current_sort), class: 'webhook-form', data: { remote: true } do |f| %>
<%= render partial: 'webhook_form', locals: {f: f} %>
<% end %>
</li>

View file

@ -31,6 +31,7 @@ Rails.application.config.assets.precompile += %w(my_modules/status_flow.js)
Rails.application.config.assets.precompile += %w(my_modules/protocols/protocol_status_bar.js)
Rails.application.config.assets.precompile += %w(my_modules/results.js)
Rails.application.config.assets.precompile += %w(my_modules/stock.js)
Rails.application.config.assets.precompile += %w(my_modules/assigned_users.js)
Rails.application.config.assets.precompile += %w(my_modules/tags.js)
Rails.application.config.assets.precompile += %w(my_modules/archived.js)
Rails.application.config.assets.precompile += %w(my_modules/pwa_mobile_app.js)

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