Merge branch 'develop' into features/bugfixes-and-improvements

This commit is contained in:
Oleksii Kriuchykhin 2023-11-23 11:11:46 +01:00
commit 9983d70eec
201 changed files with 3684 additions and 2768 deletions

View file

@ -21,6 +21,20 @@
{
"beforeLineComment": false
}
],
"max-len": [
"error",
{
"code": 120
}
],
"vue/max-len": [
"error",
{
"code": 120,
"template": 240,
"tabWidth": 2
}
]
},
"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

@ -89,12 +89,16 @@ tests-ci:
-e MAIL_FROM=MAIL_FROM \
-e MAIL_REPLYTO=MAIL_REPLYTO \
-e RAILS_ENV=test \
-e MAIL_SERVER_URL=localhost:3000 \
-e MAIL_SERVER_URL=http://localhost:3000 \
-e ENABLE_RECAPTCHA=false \
-e ENABLE_USER_CONFIRMATION=false \
-e ENABLE_USER_REGISTRATION=true \
-e CORE_API_RATE_LIMIT=1000000 \
--rm web bash -c "rake db:create && rake db:migrate && bundle exec rspec ./spec/requests/api/"
-e PROTOCOLS_IO_ACCESS_TOKEN=PROTOCOLS_IO_ACCESS_TOKEN \
-e ENABLE_WEBHOOKS=true \
--rm web bash -c "rake db:create && rake db:migrate && \
yarn install && yarn build && yarn build:css && rails tailwindcss:build && \
bundle exec rspec ./spec/"
console:
@$(MAKE) rails cmd="rails console"

View file

@ -1 +1 @@
1.29.2
1.29.3

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

@ -17,6 +17,7 @@ var DasboardRecentWorkWidget = (function() {
recentWorkItemType.attr('title', `${item.type} ID: ${item.code}`);
recentWorkItemType.tooltip();
}
recentWorkItemType.attr('data-e2e', `e2e-TL-dashRecentWork-${item.type}`);
});
}

View file

@ -166,7 +166,9 @@ var ExperimnetTable = {
});
});
window.initDateTimePickerComponent(`#calendarDueDateContainer${row.id}`);
if ($(`#calendarDueDateContainer${row.id}`).length > 0) {
window.initDateTimePickerComponent(`#calendarDueDateContainer${row.id}`);
}
});
},
initMyModuleActions: function() {

View file

@ -131,7 +131,8 @@ var MyModuleRepositories = (function() {
} else {
columnDefs.push({
targets: 2,
className: 'item-name'
className: 'item-name',
render: (data, type, row) => `<a href="${row.recordInfoUrl}" class="record-info-link">${data}</a>`,
});
}
@ -182,13 +183,7 @@ var MyModuleRepositories = (function() {
targets: 0,
className: 'item-name',
render: function(data, type, row) {
let recordName;
if (row.recordInfoUrl) {
recordName = `<a href="${row.recordInfoUrl}" class="record-info-link">${data}</a>`;
} else {
recordName = `<div class="inline-block my-2 mx-0">${data}</div>`;
}
let recordName = `<a href="${row.recordInfoUrl}" class="record-info-link">${data}</a>`;
if (row.hasActiveReminders) {
recordName = `<div class="dropdown row-reminders-dropdown"
@ -260,7 +255,7 @@ var MyModuleRepositories = (function() {
function addRepositorySearch() {
$(`<div id="inventorySearchComponent">
<repository_search_container/>
<repository-search-container/>
</div>`).appendTo('.filter-container');
initRepositorySearch();
}

View file

@ -1327,6 +1327,17 @@ function reportHandsonTableConverter() {
$.get($('#templateSelector').data('valuesEditorPath'), params, function(result) {
$('.report-template-values-container').removeClass('hidden');
$('.report-template-values-container').html(result.html);
$('.section').each(function() {
var section = $(this);
var collapseButton = section.find('.sn-icon-down');
var valuesContainer = section.find('.values-container');
if (valuesContainer.children().length === 0) {
collapseButton.hide();
}
});
$('.report-template-value-dropdown').each(function() {
dropdownSelector.init($(this), {
noEmptyOption: true

View file

@ -67,7 +67,7 @@
orderable: false,
render: function() {
return `<div class="sci-checkbox-container">
<input class='repository-row-selector sci-checkbox' type='checkbox'>
<input class='repository-row-selector sci-checkbox' type='checkbox' data-e2e="e2e-CB-inventory">
<span class='sci-checkbox-label'></span>
</div>`;
}
@ -75,7 +75,7 @@
targets: 1,
className: 'item-name',
render: function(value, type, row) {
return `<a href="${row.repositoryUrl}">${value}</a>`;
return `<a href="${row.repositoryUrl}" data-e2e="e2e-TL-inventories-Inventory-${row.DT_RowId}">${value}</a>`;
}
}, {
targets: 5,

View file

@ -210,17 +210,18 @@ $.fn.dataTable.render.RepositoryStockValue = function(data) {
if (data) {
if (data.value) {
if (data.stock_managable) {
return `<a class="manage-repository-stock-value-link stock-value-view-render stock-${data.stock_status}">
return `<a class="manage-repository-stock-value-link stock-value-view-render stock-${data.stock_status}"
data-manage-stock-url=${data.value.stock_url}>
${data.value.stock_formatted}
</a>`;
}
return `<span class="stock-value-view-render
${data.displayWarnings ? `stock-${data.stock_status}` : ''}">
return `<span class="stock-value-view-render data-manage-stock-url=${data.value.stock_url}
${data.displayWarnings ? `stock-${data.stock_status}` : ''}">
${data.value.stock_formatted}
</span>`;
}
if (data.stock_managable) {
return `<a class="manage-repository-stock-value-link not-assigned-stock">
return `<a class="manage-repository-stock-value-link not-assigned-stock" data-manage-stock-url=${data.stock_url}>
<i class="fas fa-box-open"></i>
${I18n.t('libraries.manange_modal_column.stock_type.add_stock')}
</a>`;

View file

@ -505,7 +505,7 @@ var RepositoryDatatable = (function(global) {
function addRepositorySearch() {
$(`<div id="inventorySearchComponent">
<repository_search_container/>
<repository-search-container/>
</div>`).appendTo('.repository-search-container');
initRepositorySearch();
}
@ -671,7 +671,7 @@ var RepositoryDatatable = (function(global) {
visible: true,
render: function(data, type, row) {
return "<a href='" + row.recordInfoUrl + "'"
+ "class='record-info-link'>" + data + '</a>';
+ "class='record-info-link' data-e2e='e2e-TL-invInventory-Item-" + row.DT_RowId + "'>" + data + '</a>';
}
}, {
// Added on column

View file

@ -25,12 +25,7 @@
function handleSuccessfulSubmit(data) {
$('#modal-import-records').modal('hide');
$(data.html).appendTo('body').promise().done(function() {
$('#parse-records-modal')
.modal('show')
.on('hidden.bs.modal', function() {
animateSpinner();
location.reload();
});
$('#parse-records-modal').modal('show');
repositoryRecordsImporter();
});
}
@ -42,19 +37,11 @@
}
function initParseRecordsModal() {
var modal = $('#parse-records-modal');
var form = $('#form-records-file');
var submitBtn = form.find('input[type="submit"]');
var closeBtn = modal.find('.close-button');
form.on('ajax:success', function(ev, data) {
$('#modal-import-records').modal('hide');
$(data.html).appendTo('body').promise().done(function() {
$('#parse-records-modal')
.modal('show')
.on('hidden.bs.modal', function() {
animateSpinner();
location.reload();
});
repositoryRecordsImporter();
});
}).on('ajax:error', function(ev, data) {
@ -80,8 +67,6 @@
contentType: false
});
});
closeBtn.on('click', pageReload);
}
function initImportRecordsModal() {
@ -89,9 +74,6 @@
$('#modal-import-records').modal('show');
initParseRecordsModal();
});
const closeBtn = $('#modal-import-records').find('.close-button');
closeBtn.on('click', pageReload);
}
$('.repository-title-name .inline-editing-container').on('inlineEditing::updated', function(e, value, viewValue) {

View file

@ -1,231 +0,0 @@
/* global dropdownSelector GLOBAL_CONSTANTS I18n SmartAnnotation formatDecimalValue Decimal */
var RepositoryStockValues = (function() {
const UNIT_SELECTOR = '#repository-stock-value-units';
function updateChangeAmount($element) {
if (!$element.val()) {
$('.stock-final-container .value').text('-');
return;
}
if (!($element.val() >= 0)) return;
let currentAmount = new Decimal($element.data('currentAmount') || 0);
let inputAmount = new Decimal($element.val());
let newAmount;
switch ($element.data('operator')) {
case 'set':
newAmount = inputAmount;
break;
case 'add':
newAmount = currentAmount.plus(inputAmount);
break;
case 'remove':
newAmount = currentAmount.minus(inputAmount);
break;
default:
newAmount = currentAmount;
break;
}
$('#change_amount').val(inputAmount);
$('#repository_stock_value_amount').val(newAmount);
$('.stock-final-container').toggleClass('negative', newAmount < 0);
$('.stock-final-container .value').text(
formatDecimalValue(String(newAmount), $('#stock-input-amount').data('decimals'))
);
}
function initManageAction() {
let amountChanged = false;
$('.repository-show').on('click', '.manage-repository-stock-value-link', function() {
let colIndex = this.parentNode.cellIndex;
let rowIndex = this.parentNode.parentNode.rowIndex;
$.ajax({
url: $(this).closest('tr').data('manage-stock-url'),
type: 'GET',
dataType: 'json',
success: (result) => {
var $manageModal = $('#manage-repository-stock-value-modal');
$manageModal.find('.modal-content').html(result.html);
dropdownSelector.init(UNIT_SELECTOR, {
singleSelect: true,
closeOnSelect: true,
noEmptyOption: true,
selectAppearance: 'simple',
onChange: function() {
let unit = '';
if (dropdownSelector.getValues(UNIT_SELECTOR) > 0) {
unit = dropdownSelector.getLabels(UNIT_SELECTOR);
}
$('.stock-final-container .units').text(unit);
$('.repository-stock-reminder-value .units').text(
I18n.t('repository_stock_values.manage_modal.units_remaining', {
unit: unit
})
);
}
});
$manageModal.find(`
.dropdown-selector-container .input-field,
.dropdown-selector-container .search-field
`).attr('tabindex', 2);
$manageModal.find('form').on('ajax:success', function(_, data) {
$manageModal.modal('hide');
let $cell = $('.dataTable').find(
`tr:nth-child(${rowIndex}) td:nth-child(${colIndex + 1})`
);
$cell.parent().data('manage-stock-url', data.manageStockUrl);
$cell.html(
$.fn.dataTable.render.RepositoryStockValue(data)
);
});
$('.stock-operator-option').click(function() {
var $stockInput = $('#stock-input-amount');
$('.stock-operator-option').removeClass('btn-primary').addClass('btn-secondary');
$(this).removeClass('btn-secondary').addClass('btn-primary');
$stockInput.data('operator', $(this).data('operator'));
dropdownSelector.selectValues(UNIT_SELECTOR, $('#initial_units').val());
$('#operator').val($(this).data('operator'));
switch ($(this).data('operator')) {
case 'set':
dropdownSelector.enableSelector(UNIT_SELECTOR);
if (!amountChanged) { $stockInput.val($stockInput.data('currentAmount')); }
break;
case 'add':
if (!amountChanged) { $stockInput.val(''); }
dropdownSelector.disableSelector(UNIT_SELECTOR);
break;
case 'remove':
if (!amountChanged) { $stockInput.val(''); }
dropdownSelector.disableSelector(UNIT_SELECTOR);
break;
default:
break;
}
updateChangeAmount($('#stock-input-amount'));
});
$('#stock-input-amount, #low_stock_threshold').on('input focus', function() {
let decimals = $(this).data('decimals');
this.value = formatDecimalValue(this.value, decimals);
});
SmartAnnotation.init($('#repository-stock-value-comment')[0], false);
$('#repository-stock-value-comment').on('input', function() {
$(this).closest('.sci-input-container').toggleClass(
'error',
this.value.length > GLOBAL_CONSTANTS.NAME_MAX_LENGTH
);
$('.update-repository-stock').toggleClass(
'disabled',
this.value.length > GLOBAL_CONSTANTS.NAME_MAX_LENGTH
);
});
$('#reminder-selector-checkbox').on('change', function() {
let valueContainer = $('.repository-stock-reminder-value');
valueContainer.toggleClass('hidden', !this.checked);
if (!this.checked) {
$(this).data('reminder-value', valueContainer.find('input').val());
valueContainer.find('input').val(null);
} else {
valueContainer.find('input').val($(this).data('reminder-value'));
valueContainer.find('input').focus();
}
});
$('.update-repository-stock').on('click', function() {
let reminderError = $('#reminder-selector-checkbox')[0].checked
&& $('.repository-stock-reminder-value').find('input').val() === '';
$('.repository-stock-reminder-value').find('.sci-input-container').toggleClass('error', reminderError);
});
$('#stock-input-amount').on('input', function() {
amountChanged = true;
updateChangeAmount($(this));
});
$manageModal.on('ajax:beforeSend', 'form', function() {
let status = true;
if (!(dropdownSelector.getValues(UNIT_SELECTOR) > 0)) {
dropdownSelector.showError(UNIT_SELECTOR, I18n.t('repository_stock_values.manage_modal.unit_error'));
status = false;
} else {
dropdownSelector.hideError(UNIT_SELECTOR);
}
let stockInput = $('#stock-input-amount');
if (stockInput.val().length && stockInput.val() >= 0) {
stockInput.parent().removeClass('error');
} else {
stockInput.parent().addClass('error');
if (stockInput.val().length === 0) {
stockInput.parent()
.attr(
'data-error-text',
I18n.t('repository_stock_values.manage_modal.amount_error')
);
} else {
stockInput.parent()
.attr(
'data-error-text',
I18n.t('repository_stock_values.manage_modal.negative_error')
);
}
status = false;
}
let reminderInput = $('.repository-stock-reminder-value input');
if ($('#reminder-selector-checkbox')[0].checked) {
if (reminderInput.val().length && reminderInput.val() >= 0) {
reminderInput.parent().removeClass('error');
} else {
reminderInput.parent().addClass('error');
if (reminderInput.val().length === 0) {
reminderInput.parent()
.attr(
'data-error-text',
I18n.t('repository_stock_values.manage_modal.amount_error')
);
} else {
reminderInput.parent()
.attr(
'data-error-text',
I18n.t('repository_stock_values.manage_modal.negative_error')
);
}
status = false;
}
}
return status;
});
$manageModal.modal('show');
amountChanged = false;
$('#stock-input-amount').focus();
$('#stock-input-amount')[0].selectionStart = $('#stock-input-amount')[0].value.length;
$('#stock-input-amount')[0].selectionEnd = $('#stock-input-amount')[0].value.length;
}
});
});
}
return {
init: () => {
initManageAction();
}
};
}());
RepositoryStockValues.init();

View file

@ -20,7 +20,7 @@
e.stopPropagation();
if (typeof PrintModalComponent !== 'undefined') {
PrintModalComponent.showModal = true;
PrintModalComponent.openModal();
if (selectedRows && selectedRows.length) {
$('#modal-info-repository-row').modal('hide');
PrintModalComponent.row_ids = selectedRows;
@ -69,4 +69,25 @@
$('#modal-info-repository-row').modal('hide');
}
});
$(document).on('click', '.manage-repository-stock-value-link', (e) => {
e.preventDefault();
window.initManageStockValueModalComponent();
if (window.manageStockModalComponent) {
const $link = $(e.target).parents('a')[0] ? $(e.target).parents('a') : $(e.target);
const stockValueUrl = $link.data('manage-stock-url');
let updateCallback;
if (stockValueUrl) {
updateCallback = (data) => {
if (!data?.value) return;
// reload dataTable
if ($('.dataTable')[0]) $('.dataTable').DataTable().ajax.reload(null, false);
// update item card stock column
window.manageStockCallback && window.manageStockCallback(data.value);
$link.data('manageStockUrl', data.value.stock_url)
};
window.manageStockModalComponent.showModal(stockValueUrl, updateCallback);
}
}
});
}());

View file

@ -7,11 +7,7 @@
margin-bottom: .8em;
.units {
margin: 1.25em 0 0 .5em;
margin: 1.5rem 0 0 .5rem;
}
}
.comments-container {
margin-bottom: 1em;
}
}

View file

@ -107,11 +107,6 @@ body.navigator-collapsed {
}
}
.resizable-r {
cursor: url("/images/icon_small/Resize.svg") 0 0, auto !important;
padding: 0 .8rem;
}
}
.w-98 {

View file

@ -1,10 +1,11 @@
.sci--layout-navigation-navigator {
.handle-mr {
cursor: url("/images/icon_small/resize_cursor.svg") 0 0, auto !important;
display: block !important;
height: 100%;
opacity: 0;
right: -2px;
top: 0;
width: 10px;
}

View file

@ -11,7 +11,6 @@
.stock-initial-container,
.stock-final-container {
align-items: center;
background: $color-concrete;
border-radius: $border-radius-modal;
display: flex;
flex-direction: column;

View file

@ -4,7 +4,6 @@
border-color: var(--sn-light-grey);
border-radius: $border-radius-default;
box-sizing: border-box;
cursor: pointer;
display: flex;
padding: .5rem .625rem .5rem 1rem;
position: relative;
@ -37,7 +36,6 @@
&.disabled {
background: var(--sn-sleepy-grey);
cursor: default;
pointer-events: none;
.caret {
@ -45,6 +43,10 @@
}
}
&.error {
border-color: var(--sn-delete-red);
}
.sn-select__options {
display: none;
}

View file

@ -1,3 +1,16 @@
/* Hide arrows on number type input field */
@layer utilities {
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
}
}
@layer components {
.sci-btn-group {
@apply inline-flex items-center gap-2 relative;

View file

@ -21,10 +21,10 @@ class MyModulesController < ApplicationController
update_protocol_description restore_group
save_table_state actions_toolbar)
before_action :check_update_state_permissions, only: :update_state
before_action :set_inline_name_editing, only: %i(protocols results activities archive)
before_action :load_experiment_my_modules, only: %i(protocols results activities archive)
before_action :set_breadcrumbs_items, only: %i(results protocols activities archive)
before_action :set_navigator, only: %i(protocols results activities archive)
before_action :set_inline_name_editing, only: %i(protocols activities archive)
before_action :load_experiment_my_modules, only: %i(protocols activities archive)
before_action :set_breadcrumbs_items, only: %i(protocols activities archive)
before_action :set_navigator, only: %i(protocols activities archive)
layout 'fluid'.freeze
@ -304,22 +304,6 @@ class MyModulesController < ApplicationController
render json: protocol.errors, status: :unprocessable_entity
end
def results
@results_order = params[:order] || 'new'
@results = @my_module.archived_branch? ? @my_module.results : @my_module.results.active
@results = @results.page(params[:page]).per(Constants::RESULTS_PER_PAGE_LIMIT)
@results = case @results_order
when 'old' then @results.order(created_at: :asc)
when 'old_updated' then @results.order(updated_at: :asc)
when 'new_updated' then @results.order(updated_at: :desc)
when 'atoz' then @results.order(name: :asc)
when 'ztoa' then @results.order(name: :desc)
else @results.order(created_at: :desc)
end
end
def archive
@archived_results = @my_module.archived_results
end

View file

@ -53,7 +53,7 @@ class ReportsController < ApplicationController
report = current_team.reports.new(project: @project)
end
if Rails.root.join('app', 'views', 'reports', 'templates', template, 'edit.html.erb').exist?
if lookup_context.any_templates?("reports/templates/#{template}/edit")
render json: {
html: render_to_string(
template: "reports/templates/#{template}/edit",

View file

@ -3,13 +3,16 @@ class RepositoryRowsController < ApplicationController
include ActionView::Helpers::TextHelper
include ApplicationHelper
include MyModulesHelper
include RepositoryDatatableHelper
MAX_PRINTABLE_ITEM_NAME_LENGTH = 64
before_action :load_repository, except: %i(print rows_to_print print_zpl
before_action :load_repository, except: %i(show print rows_to_print print_zpl
validate_label_template_columns actions_toolbar)
before_action :load_repository_row_print, only: %i(print rows_to_print print_zpl validate_label_template_columns)
before_action :load_repository_or_snapshot, only: %i(print rows_to_print print_zpl validate_label_template_columns)
before_action :load_repository_row, only: %i(update assigned_task_list active_reminder_repository_cells)
before_action :load_show_vars, only: %i(show)
before_action :load_repository_or_snapshot, only: %i(print rows_to_print print_zpl
validate_label_template_columns)
before_action :load_repository_row, only: %i(update update_cell assigned_task_list active_reminder_repository_cells)
before_action :check_read_permissions, except: %i(create update delete_records
copy_records reminder_repository_cells
delete_records archive_records restore_records
@ -17,7 +20,7 @@ class RepositoryRowsController < ApplicationController
before_action :check_snapshotting_status, only: %i(create update delete_records copy_records)
before_action :check_create_permissions, only: :create
before_action :check_delete_permissions, only: %i(delete_records archive_records restore_records)
before_action :check_manage_permissions, only: %i(update copy_records)
before_action :check_manage_permissions, only: %i(update update_cell copy_records)
def index
@draw = params[:draw].to_i
@ -43,9 +46,6 @@ class RepositoryRowsController < ApplicationController
end
def show
@repository_row = @repository.repository_rows.find_by(id: params[:id])
return render_404 unless @repository_row
respond_to do |format|
format.html do
redirect_to repository_path(@repository)
@ -83,7 +83,8 @@ class RepositoryRowsController < ApplicationController
if service.succeed?
repository_row = service.repository_row
log_activity(:create_item_inventory, repository_row)
log_activity(:create_item_inventory, repository_row, { repository_row: repository_row.id,
repository: @repository.id })
repository_row.repository_cells.where(value_type: 'RepositoryTextValue').each do |repository_cell|
record_annotation_notification(repository_row, repository_cell)
end
@ -166,7 +167,8 @@ class RepositoryRowsController < ApplicationController
if row_update.succeed?
if row_update.record_updated
log_activity(:edit_item_inventory, @repository_row)
log_activity(:edit_item_inventory, @repository_row, { repository_row: @repository_row.id,
repository: @repository.id })
@repository_row.repository_cells.where(value_type: 'RepositoryTextValue').each do |repository_cell|
record_annotation_notification(@repository_row, repository_cell)
end
@ -185,6 +187,45 @@ class RepositoryRowsController < ApplicationController
end
end
def update_cell
return render_422(t('.invalid_params')) if
update_params['repository_row'].present? && update_params['repository_cells'].present?
return render_422(t('.invalid_params')) if
update_params['repository_cells'] && update_params['repository_cells'].size != 1
row_cell_update =
RepositoryRows::UpdateRepositoryRowService.call(
repository_row: @repository_row, user: current_user, params: update_params
)
if row_cell_update.succeed?
if row_cell_update.record_updated
log_activity(:edit_item_field_inventory, @repository_row,
{ repository_row: @repository_row.id,
repository_column: update_params['repository_cells']&.keys&.first ||
I18n.t('repositories.table.row_name') })
end
@reminders_present = @repository_row.repository_cells.with_active_reminder(@current_user).any?
return render json: { name: @repository_row.name } if update_params['repository_row'].present?
column = row_cell_update.column
cell = row_cell_update.cell
data = { value_type: column.data_type, id: column.id, value: nil }
return render json: data if cell.blank?
data['hasActiveReminders'] = @reminders_present
data.merge! serialize_repository_cell_value(cell,
@repository.team,
@repository,
reminders_enabled: @reminders_present)
render json: data
else
render json: row_cell_update.errors, status: :bad_request
end
end
def delete_records
deleted_count = 0
if selected_params
@ -192,7 +233,7 @@ class RepositoryRowsController < ApplicationController
row = @repository.repository_rows.find_by(id: row_id)
next unless row && can_manage_repository_rows?(@repository)
log_activity(:delete_item_inventory, row)
log_activity(:delete_item_inventory, row, { repository_row: row.id, repository: @repository.id })
row.destroy && deleted_count += 1
end
if deleted_count.zero?
@ -333,6 +374,15 @@ class RepositoryRowsController < ApplicationController
render_404 unless @repository
end
def load_show_vars
@repository = Repository.accessible_by_teams(current_team).find_by(id: params[:repository_id])
@repository ||= RepositorySnapshot.find_by(id: params[:repository_id])
return render_404 unless @repository
@repository_row = @repository.repository_rows.eager_load(:repository_columns).find_by(id: params[:id])
render_404 unless @repository_row
end
def load_repository_row
@repository_row = @repository.repository_rows.eager_load(:repository_columns).find_by(id: params[:id])
render_404 unless @repository_row
@ -440,18 +490,15 @@ class RepositoryRowsController < ApplicationController
end
def update_params
params.permit(repository_row: {}, repository_cells: {}).to_h
params.permit(repository_row: :name, repository_cells: {}).to_h
end
def log_activity(type_of, repository_row)
def log_activity(type_of, repository_row, message_items = {})
Activities::CreateActivityService
.call(activity_type: type_of,
owner: current_user,
subject: repository_row,
team: @repository.team,
message_items: {
repository_row: repository_row.id,
repository: @repository.id
})
message_items: message_items)
end
end

View file

@ -6,33 +6,9 @@ class RepositoryStockValuesController < ApplicationController
before_action :load_vars
before_action :check_manage_permissions
def new
render json: {
html: render_to_string(
partial: 'repository_stock_values/manage_modal_content',
locals: {
repository_row: @repository_row,
repository_stock_column: @repository_column,
unit_items: @repository_column.repository_stock_unit_items,
repository_stock_value: RepositoryStockValue.new
}
)
}
end
def new; end
def edit
render json: {
html: render_to_string(
partial: 'repository_stock_values/manage_modal_content',
locals: {
repository_row: @repository_row,
repository_stock_column: @repository_column,
unit_items: @repository_column.repository_stock_unit_items,
repository_stock_value: @repository_stock_value
}
)
}
end
def edit; end
def create_or_update
ActiveRecord::Base.transaction do
@ -50,8 +26,12 @@ class RepositoryStockValuesController < ApplicationController
render json: {
stock_managable: true,
stock_status: @repository_stock_value.status,
manageStockUrl: edit_repository_stock_repository_repository_row_url(@repository, @repository_row)
}.merge(serialize_repository_cell_value(@repository_stock_value.repository_cell, current_team, @repository))
}.merge(
serialize_repository_cell_value(
@repository_stock_value.repository_cell, current_team, @repository,
reminders_enabled: Repository.reminders_enabled?
)
)
end
private

View file

@ -2,37 +2,10 @@ class ResultAssetsController < ApplicationController
include ResultsHelper
before_action :load_vars, only: %i(edit update)
before_action :load_vars_nested, only: %i(new create)
before_action :check_manage_permissions, only: %i(edit update)
before_action :check_create_permissions, only: %i(new create)
before_action :check_archive_permissions, only: [:update]
def new
@asset = Asset.new
@result = Result.new(
user: current_user,
my_module: @my_module,
asset: @asset
)
render json: {
html: render_to_string(partial: 'new')
}
end
def create
obj = create_multiple_results
if obj.fetch(:status)
flash[:success] = t('result_assets.create.success_flash',
module: @my_module.name)
redirect_to results_my_module_path(@my_module, page: params[:page], order: params[:order])
else
flash[:error] = t('result_assets.error_flash')
render json: {}, status: :bad_request
end
end
def edit
render json: {
html: render_to_string(partial: 'edit', formats: :html)
@ -131,15 +104,6 @@ class ResultAssetsController < ApplicationController
end
end
def load_vars_nested
@my_module = MyModule.find_by_id(params[:my_module_id])
render_404 unless @my_module
end
def check_create_permissions
render_403 unless can_create_results?(@my_module)
end
def check_manage_permissions
render_403 unless can_manage_result?(@result)
end

View file

@ -2,50 +2,11 @@ class ResultTablesController < ApplicationController
include ResultsHelper
before_action :load_vars, only: [:edit, :update, :download]
before_action :load_vars_nested, only: [:new, :create]
before_action :convert_contents_to_utf8, only: [:create, :update]
before_action :convert_contents_to_utf8, only: [:update]
before_action :check_manage_permissions, only: %i(edit update)
before_action :check_create_permissions, only: %i(new create)
before_action :check_archive_permissions, only: [:update]
before_action :check_view_permissions, except: %i(new create edit update)
def new
@table = Table.new
@result = Result.new(
user: current_user,
my_module: @my_module,
table: @table
)
render json: {
html: render_to_string({ partial: 'new', formats: :html })
}, status: :ok
end
def create
@table = Table.new(result_params[:table_attributes])
@table.metadata = JSON.parse(result_params[:table_attributes][:metadata])
@table.created_by = current_user
@table.team = current_team
@table.last_modified_by = current_user
@table.name = nil
@result = Result.new(
user: current_user,
my_module: @my_module,
name: result_params[:name],
table: @table
)
@result.last_modified_by = current_user
if @result.save && @table.save
log_activity(:add_result)
flash[:success] = t('result_tables.create.success_flash', module: @my_module.name)
redirect_to results_my_module_path(@my_module, page: params[:page], order: params[:order])
else
render json: @result.errors, status: :bad_request
end
end
before_action :check_view_permissions, except: %i(edit update)
def edit
render json: {
@ -122,14 +83,6 @@ class ResultTablesController < ApplicationController
end
end
def load_vars_nested
@my_module = MyModule.find_by_id(params[:my_module_id])
unless @my_module
render_404
end
end
def convert_contents_to_utf8
if params.include? :result and
params[:result].include? :table_attributes and
@ -139,10 +92,6 @@ class ResultTablesController < ApplicationController
end
end
def check_create_permissions
render_403 unless can_create_results?(@my_module)
end
def check_manage_permissions
render_403 unless can_manage_result?(@result)
end

View file

@ -6,47 +6,10 @@ class ResultTextsController < ApplicationController
include Rails.application.routes.url_helpers
before_action :load_vars, only: [:edit, :update, :download]
before_action :load_vars_nested, only: [:new, :create]
before_action :check_manage_permissions, only: %i(edit update)
before_action :check_create_permissions, only: %i(new create)
before_action :check_archive_permissions, only: [:update]
before_action :check_view_permissions, except: %i(new create edit update)
def new
@result = Result.new(
user: current_user,
my_module: @my_module
)
@result.build_result_text
render json: {
html: render_to_string({ partial: 'new', formats: :html })
}, status: :ok
end
def create
@result_text = ResultText.new(result_params[:result_text_attributes])
@result = Result.new(
user: current_user,
my_module: @my_module,
name: result_params[:name],
result_text: @result_text
)
@result.last_modified_by = current_user
if @result.save && @result_text.save
# link tiny_mce_assets to the text result
TinyMceAsset.update_images(@result_text, params[:tiny_mce_images], current_user)
result_annotation_notification
log_activity(:add_result)
flash[:success] = t('result_texts.create.success_flash', module: @my_module.name)
redirect_to results_my_module_path(@my_module, page: params[:page], order: params[:order])
else
render json: @result.errors, status: :bad_request
end
end
before_action :check_view_permissions, except: %i(edit update)
def edit
render json: {
@ -130,18 +93,6 @@ class ResultTextsController < ApplicationController
end
end
def load_vars_nested
@my_module = MyModule.find_by_id(params[:my_module_id])
unless @my_module
render_404
end
end
def check_create_permissions
render_403 unless can_create_results?(@my_module)
end
def check_manage_permissions
render_403 unless can_manage_result?(@result)
end

View file

@ -101,6 +101,6 @@ class TeamRepositoriesController < ApplicationController
message_items: { repository: team_shared_object.shared_repository.id,
team: team_shared_object.team.id,
permission_level:
Extends::SHARED_INVENTORIES_PL_MAPPINGS[team_repository.permission_level.to_sym] })
Extends::SHARED_INVENTORIES_PL_MAPPINGS[team_shared_object.permission_level.to_sym] })
end
end

View file

@ -30,6 +30,7 @@ module Users
email = auth.info.email
email ||= auth.dig(:extra, :raw_info, :id_token_claims, :emails)&.first
auth.uid ||= auth.dig(:extra, :raw_info, :sub)
user = User.from_omniauth(auth)
# User found in database so just signing in

View file

@ -29,7 +29,8 @@ module RepositoriesDatatableHelper
'data-copy-modal-url': team_repository_copy_modal_path(team, repository_id: repository),
'data-rename-modal-url': team_repository_rename_modal_path(team, repository_id: repository),
'data-shared': repository.shared_with?(team),
'data-i-shared': repository.i_shared?(team)
'data-i-shared': repository.i_shared?(team),
'data-e2e': "e2e-RT-inventories-tableItemRow-#{repository.id}"
}
)
end

View file

@ -2,6 +2,7 @@
module RepositoryDatatableHelper
include InputSanitizeHelper
include Rails.application.routes.url_helpers
def prepare_row_columns(repository_rows, repository, columns_mappings, team, options = {})
has_stock_management = repository.has_stock_management?
@ -16,7 +17,7 @@ module RepositoryDatatableHelper
row = public_send("#{repository.class.name.underscore}_default_columns", record)
row.merge!(
DT_RowId: record.id,
DT_RowAttr: { 'data-state': row_style(record) },
DT_RowAttr: { 'data-state': row_style(record), 'data-e2e': "e2e-RT-invInventory-row-#{record.id}" },
recordInfoUrl: Rails.application.routes.url_helpers.repository_repository_row_path(repository, record),
rowRemindersUrl:
Rails.application.routes.url_helpers
@ -47,20 +48,6 @@ module RepositoryDatatableHelper
end
if has_stock_management
row['manageStockUrl'] = if record.has_stock?
Rails.application.routes.url_helpers
.edit_repository_stock_repository_repository_row_url(
repository,
record
)
else
Rails.application.routes.url_helpers
.new_repository_stock_repository_repository_row_url(
repository,
record
)
end
stock_cell = record.repository_cells.find { |cell| cell.value_type == 'RepositoryStockValue' }
# always add stock cell, even if empty
@ -68,7 +55,7 @@ module RepositoryDatatableHelper
if stock_cell.present?
serialize_repository_cell_value(record.repository_stock_cell, team, repository)
else
{}
{ stock_url: new_repository_stock_repository_repository_row_url(repository, record) }
end
row['stock'][:stock_managable] = stock_managable
row['stock']['displayWarnings'] = display_stock_warnings?(repository)
@ -119,6 +106,7 @@ module RepositoryDatatableHelper
DT_RowId: record.id,
DT_RowAttr: { 'data-state': row_style(record) },
'0': escape_input(record.name),
recordInfoUrl: Rails.application.routes.url_helpers.repository_repository_row_path(record.repository, record),
rowRemindersUrl:
Rails.application.routes.url_helpers
.active_reminder_repository_cells_repository_repository_row_url(
@ -127,11 +115,6 @@ module RepositoryDatatableHelper
)
}
unless record.repository.is_a?(RepositorySnapshot)
row['recordInfoUrl'] = Rails.application.routes.url_helpers.repository_repository_row_path(record.repository,
record)
end
if reminders_enabled
row['hasActiveReminders'] = record.has_active_stock_reminders || record.has_active_datetime_reminders
end

View file

@ -1,7 +1,9 @@
function mountWithTurbolinks(app, target, callback = null) {
const originalHtml = document.querySelector(target).innerHTML;
const cacheDisabled = document.querySelector('#cache-directive');
const event = cacheDisabled ? 'turbolinks:before-render' : 'turbolinks:before-cache';
document.addEventListener('turbolinks:before-cache', () => {
document.addEventListener(event, () => {
app.unmount();
if (document.querySelector(target)) {
document.querySelector(target).innerHTML = originalHtml;

View file

@ -32,6 +32,8 @@ window.initDateTimePickerComponent = (id) => {
},
methods: {
formatDate(date) {
if (!(date instanceof Date)) return null;
if (this.$refs.input.dataset.simpleFormat) {
const y = date.getFullYear();
const m = date.getMonth() + 1;

View file

@ -0,0 +1,17 @@
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import ManageStockValueModal from '../../vue/repository_row/manage_stock_value_modal.vue';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
window.initManageStockValueModalComponent = () => {
if (window.manageStockModalComponent) return;
// eslint-disable-next-line no-undef
if (notTurbolinksPreview()) {
const app = createApp({});
app.component('ManageStockValueModal', ManageStockValueModal);
app.use(PerfectScrollbar);
app.config.globalProperties.i18n = window.I18n;
mountWithTurbolinks(app, '#manageStockValueModal');
}
};

View file

@ -68,6 +68,7 @@ window.initRepositoryFilter = () => {
{ id: 'archived_by', name: I18n.t('repositories.table.archived_by'), data_type: 'RepositoryUserValue' },
{ id: 'archived_on', name: I18n.t('repositories.table.archived_on'), data_type: 'RepositoryDateTimeValue' }
];
const defFilters = JSON.parse(JSON.stringify(DEFAULT_FILTERS));
const app = createApp({
data: () => ({
filters: [],
@ -124,12 +125,12 @@ window.initRepositoryFilter = () => {
this.reloadDataTable();
},
clearFilters() {
this.filters = this.filters
.map(filter => {
const newFilter = { ...filter };
newFilter.data["parameters"] = {};
return newFilter;
});
this.filters.forEach((filter, index) => {
const newFilter = { ...filter };
newFilter.data['parameters'] = {};
newFilter.data['operator'] = defFilters[index].data['operator'];
return newFilter;
});
this.filterName = null;
this.dataTableElement.removeAttr('data-repository-filter-json');
$('#modalSaveRepositoryTableFilter').data('repositoryTableFilterId', null);

View file

@ -1,15 +1,15 @@
/* global notTurbolinksPreview */
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import RepositoryItemSidebar from '../../vue/repository_item_sidebar/RepositoryItemSidebar.vue';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
import { vOnClickOutside } from '@vueuse/components'
function initRepositoryItemSidebar() {
const app = createApp({});
app.component('RepositoryItemSidebar', RepositoryItemSidebar);
app.config.globalProperties.i18n = window.I18n;
mountWithTurbolinks(app, '#repositoryItemSidebar');
}
initRepositoryItemSidebar();
const app = createApp({});
app.component('RepositoryItemSidebar', RepositoryItemSidebar);
app.use(RepositoryItemSidebar);
app.use(PerfectScrollbar);
app.directive('click-outside', vOnClickOutside)
app.config.globalProperties.i18n = window.I18n;
mountWithTurbolinks(app, '#repositoryItemSidebar');

View file

@ -25,12 +25,15 @@ function initPrintModalComponent() {
methods: {
closeModal() {
this.showModal = false;
},
openModal() {
this.showModal = true;
}
}
});
app.component('PrintModalContainer', PrintModalContainer);
app.config.globalProperties.i18n = window.I18n;
mountWithTurbolinks(app, '.print-label-modal-container');
window.PrintModalComponent = mountWithTurbolinks(app, '.print-label-modal-container');
}
}

View file

@ -2,13 +2,10 @@ import PerfectScrollbar from 'vue3-perfect-scrollbar';
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import 'vue3-perfect-scrollbar/dist/vue3-perfect-scrollbar.css';
import ShareLinkContainer from '../../vue/shareable_links/container.vue';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
function initShareTaskContainer() {
const app = createApp({});
app.component('ShareLinkContainer', ShareLinkContainer);
app.use(PerfectScrollbar);
app.config.globalProperties.i18n = window.I18n;
app.mount('.share-task-container');
}
initShareTaskContainer();
const app = createApp({});
app.component('ShareTaskContainer', ShareLinkContainer);
app.use(PerfectScrollbar);
app.config.globalProperties.i18n = window.I18n;
mountWithTurbolinks(app, '#share-task-container');

View file

@ -3,6 +3,7 @@
:initW="getNavigatorWidth()"
ref="vueResizable"
:minW="208"
v-model:w="width"
:disabledH="true"
:handles="['mr']"
:resizable="true"
@ -12,7 +13,7 @@
@resizing="onResizeMove"
@resize-end="onResizeEnd"
>
<div class="ml-4 h-full w-full border rounded bg-sn-white flex flex-col right-0 absolute navigator-container">
<div class="ml-4 h-full w-[calc(100%_-_1rem)] border rounded bg-sn-white flex flex-col right-0 absolute navigator-container">
<div class="px-3 py-2.5 flex items-center relative leading-4">
<i class="sn-icon sn-icon-navigator"></i>
<div class="font-bold text-base pl-3">
@ -20,7 +21,7 @@
</div>
<i @click="$emit('navigator:colapse')" class="sn-icon sn-icon-close ml-auto cursor-pointer absolute right-2.5 top-2.5"></i>
</div>
<perfect-scrollbar @ps-scroll-y="onScrollY" @ps-scroll-x="onScrollX" ref="scrollContainer" class="grow py-2 relative px-2 scroll-container">
<perfect-scrollbar @ps-scroll-y="onScrollY" @ps-scroll-x="onScrollX" ref="scrollContainer" class="grow py-2 relative px-2 scroll-container w-[calc(100%_-_.25rem)]">
<NavigatorItem v-for="item in sortedMenuItems"
:key="item.id"
:currentItemId="currentItemId"
@ -56,6 +57,7 @@ export default {
navigatorXScroll: 0,
currentItemId: null,
archived: null,
width: null
}
},
props: {
@ -141,7 +143,10 @@ export default {
$('.sci--layout').addClass('!transition-none');
},
onResizeEnd(event) {
if (event.w > 400) event.w = 400;
if (event.w > 400) {
event.w = 400;
this.width = 400;
}
document.body.style.cursor = 'default';
$('.sci--layout-navigation-navigator').removeClass('!transition-none');
$('.sci--layout').removeClass('!transition-none');

View file

@ -167,6 +167,7 @@
<i class="sn-icon sn-icon-new-task"></i>
</div>
<Step
ref="steps"
:step.sync="steps[index]"
@reorder="startStepReorder"
:inRepository="inRepository"
@ -451,7 +452,7 @@
this.activeDragStep = id;
},
uploadFilesToStep(file, stepId) {
this.$children.find(child => child.step?.id == stepId).uploadFiles(file);
this.$refs.steps.find(child => child.step?.id == stepId).uploadFiles(file);
},
firstObjectInViewport() {
let step = $('.step-container:not(.locked)').toArray().find(element => {

View file

@ -110,6 +110,7 @@
v-for="(element, index) in orderedElements"
:is="elements[index].attributes.orderable_type"
:key="element.id"
class="step-element"
:element.sync="elements[index]"
:inRepository="inRepository"
:reorderElementUrl="elements.length > 1 ? urls.reorder_elements_url : ''"
@ -189,7 +190,6 @@
required: false
},
activeDragStep: {
type: Number,
required: false
}
},
@ -507,9 +507,10 @@
HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger');
}).done(() => {
this.$parent.$nextTick(() => {
const children = this.$children
const lastChild = children[children.length - 1]
lastChild.$el.scrollIntoView(false)
const children = this.$refs.stepContainer.querySelectorAll(".step-element");
const lastChild = children[children.length - 1];
lastChild.scrollIntoView(false)
window.scrollBy({
top: 200,
behavior: 'smooth'

View file

@ -1,271 +1,242 @@
<template>
<div ref="wrapper"
class='items-sidebar-wrapper bg-white gap-2.5 self-stretch rounded-tl-4 rounded-bl-4 transition-transform ease-in-out transform shadow-lg'
:class="{ 'translate-x-0 w-[565px] h-full': isShowing, 'transition-transform ease-in-out duration-400 transform translate-x-0 translate-x-full w-0': !isShowing }">
<transition enter-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">
<div ref="wrapper" v-show="isShowing" id="repository-item-sidebar-wrapper"
class='items-sidebar-wrapper bg-white gap-2.5 self-stretch rounded-tl-4 rounded-bl-4 shadow-lg h-full w-[565px]'>
<div id="repository-item-sidebar" class="w-full h-full pl-6 bg-white flex flex-col">
<div id="repository-item-sidebar" class="w-full h-full pl-6 bg-white flex flex-col">
<div id="sticky-header-wrapper" 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">
<h4 class="item-name my-auto truncate text-xl" :title="repositoryRowName">
{{ repositoryRowName }}
</h4>
<i id="close-icon" @click="toggleShowHideSidebar(currentItemUrl)"
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>
</div>
<div ref="bodyWrapper" id="body-wrapper" class="overflow-y-auto overflow-x-hidden h-[calc(100%-78px)] pt-6 ">
<div v-if="dataLoading" class="h-full flex flex-grow-1">
<div class="sci-loader"></div>
<div ref="stickyHeaderRef" id="sticky-header-wrapper"
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)"
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>
</div>
<div v-else class="flex flex-1 flex-grow-1 justify-between" ref="scrollSpyContent">
<div ref="bodyWrapper" id="body-wrapper" class="overflow-y-auto overflow-x-hidden h-[calc(100%-78px)] pt-6 ">
<div v-if="dataLoading" class="h-full flex flex-grow-1">
<div class="sci-loader"></div>
</div>
<div id="left-col" class="flex flex-col gap-4">
<div v-else class="flex flex-1 flex-grow-1 justify-between" ref="scrollSpyContent" id="scrollSpyContent">
<!-- INFORMATION -->
<section id="information-section">
<div ref="information-label" id="information-label"
class="font-inter text-lg font-semibold leading-7 mb-4 transition-colors duration-300">{{
i18n.t('repositories.item_card.section.information') }}
</div>
<div v-if="defaultColumns">
<div class="flex flex-col gap-4">
<!-- REPOSITORY NAME -->
<div class="flex flex-col ">
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.repository_name') }}</span>
<span class="repository-name text-sn-dark-grey line-clamp-3" :title="repository?.name">
{{ repository?.name }}
</span>
</div>
<div id="left-col" class="flex flex-col gap-4">
<div class="sci-divider"></div>
<!-- CODE -->
<div class="flex flex-col ">
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.id')
}}</span>
<span class="inline-block text-sn-dark-grey line-clamp-3" :title="defaultColumns?.code">
{{ defaultColumns?.code }}
</span>
</div>
<div class="sci-divider"></div>
<!-- ADDED ON -->
<div class="flex flex-col ">
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.added_on')
}}</span>
<span class="inline-block text-sn-dark-grey" :title="defaultColumns?.added_on">
{{ defaultColumns?.added_on }}
</span>
</div>
<div class="sci-divider"></div>
<!-- ADDED BY -->
<div class="flex flex-col ">
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.added_by')
}}</span>
<span class="inline-block text-sn-dark-grey line-clamp-3" :title="defaultColumns?.added_by">
{{ defaultColumns?.added_by }}
</span>
</div>
<!-- ARCHIVED ON -->
<div v-if="defaultColumns.archived_on" class="flex flex-col ">
<div class="sci-divider pb-4"></div>
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.archived_on')
}}</span>
<span class="inline-block text-sn-dark-grey" :title="defaultColumns.archived_on">
{{ defaultColumns.archived_on }}
</span>
</div>
<!-- ARCHIVED BY -->
<div v-if="defaultColumns.archived_by" class="flex flex-col ">
<div class="sci-divider pb-4"></div>
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.archived_by')
}}</span>
<span class="inline-block text-sn-dark-grey" :title="defaultColumns.archived_by.full_name">
{{ defaultColumns.archived_by.full_name }}
</span>
</div>
<!-- INFORMATION -->
<section id="information-section">
<div ref="information-label" id="information-label"
class="font-inter text-lg font-semibold leading-7 mb-4 transition-colors duration-300">{{
i18n.t('repositories.item_card.section.information') }}
</div>
</div>
</section>
<div v-if="defaultColumns">
<div class="flex flex-col gap-4">
<!-- REPOSITORY NAME -->
<div class="flex flex-col ">
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.repository_name') }}</span>
<span class="repository-name text-sn-dark-grey line-clamp-3" :title="repository?.name">
{{ repository?.name }}
</span>
</div>
<div id="divider" class="w-500 bg-sn-light-grey flex items-center self-stretch h-px "></div>
<div class="sci-divider"></div>
<!-- CUSTOM COLUMNS, ASSIGNED, QR CODE -->
<div id="custom-col-assigned-qr-wrapper" class="flex flex-col gap-4">
<!-- CODE -->
<div class="flex flex-col ">
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.id')
}}</span>
<span class="inline-block text-sn-dark-grey line-clamp-3" :title="defaultColumns?.code">
{{ defaultColumns?.code }}
</span>
</div>
<!-- CUSTOM COLUMNS -->
<section id="custom-columns-section" class="flex flex-col min-h-[64px] h-auto">
<div ref="custom-columns-label" id="custom-columns-label"
class="font-inter text-lg font-semibold leading-7 pb-4 transition-colors duration-300">
{{ i18n.t('repositories.item_card.custom_columns_label') }}
</div>
<div v-if="customColumns?.length > 0" class="flex flex-col gap-4 w-[350px] h-auto">
<div v-for="(column, index) in customColumns" class="flex flex-col gap-4 w-[350px] h-auto relative">
<span class="absolute right-2 top-6" v-if="column?.value?.reminder === true">
<Reminder :value="column?.value" :valueType="column?.value_type" />
</span>
<div class="sci-divider"></div>
<component :is="column.data_type" :key="index" :data_type="column.data_type" :colId="column.id"
:colName="column.name" :colVal="column.value" :repositoryRowId="repositoryRowId"
:repositoryId="repository.id" :permissions="permissions" />
<!-- ADDED ON -->
<div class="flex flex-col ">
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.added_on')
}}</span>
<span class="inline-block text-sn-dark-grey" :title="defaultColumns?.added_on">
{{ defaultColumns?.added_on }}
</span>
</div>
<div class="sci-divider" :class="{ 'hidden': index === customColumns?.length - 1 }"></div>
<div class="sci-divider"></div>
<!-- ADDED BY -->
<div class="flex flex-col ">
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.added_by')
}}</span>
<span class="inline-block text-sn-dark-grey line-clamp-3" :title="defaultColumns?.added_by">
{{ defaultColumns?.added_by }}
</span>
</div>
<!-- ARCHIVED ON -->
<div v-if="defaultColumns.archived_on" class="flex flex-col ">
<div class="sci-divider pb-4"></div>
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.archived_on')
}}</span>
<span class="inline-block text-sn-dark-grey" :title="defaultColumns.archived_on">
{{ defaultColumns.archived_on }}
</span>
</div>
<!-- ARCHIVED BY -->
<div v-if="defaultColumns.archived_by" class="flex flex-col ">
<div class="sci-divider pb-4"></div>
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.archived_by')
}}</span>
<span class="inline-block text-sn-dark-grey" :title="defaultColumns.archived_by.full_name">
{{ defaultColumns.archived_by.full_name }}
</span>
</div>
</div>
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.no_custom_columns_label') }}
</div>
</section>
<div id="divider" class="w-500 bg-sn-light-grey flex px-8 items-center self-stretch h-px"></div>
<div id="divider" class="w-500 bg-sn-light-grey flex items-center self-stretch h-px "></div>
<!-- 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"
ref="assigned-label">
{{ i18n.t('repositories.item_card.section.assigned', {
count: assignedModules ?
assignedModules.total_assigned_size : 0
}) }}
<a v-if="!defaultColumns?.archived && (inRepository || actions?.assign_repository_row)"
class="btn-text-link font-normal" :class="{
'assign-inventory-button': actions?.assign_repository_row,
'disabled': actions?.assign_repository_row && actions.assign_repository_row.disabled
}"
:data-assign-url="actions?.assign_repository_row ? actions.assign_repository_row.assign_url : ''"
:data-repository-row-id="repositoryRowId" @click="showRepositoryAssignModal">
{{ i18n.t('repositories.item_card.assigned.assign') }}
</a>
</div>
<div v-if="assignedModules && assignedModules.total_assigned_size > 0" class="flex flex-col gap-4">
<div v-if="privateModuleSize() > 0" class="flex flex-col gap-4">
<div class="text-sn-dark-grey">{{ i18n.t('repositories.item_card.assigned.private',
{ count: privateModuleSize() }) }}
</div>
<div class="sci-divider" :class="{ 'hidden': assignedModules?.viewable_modules?.length == 0 }"></div>
<!-- CUSTOM COLUMNS, ASSIGNED, QR CODE -->
<div id="custom-col-assigned-qr-wrapper" class="flex flex-col gap-4">
<!-- CUSTOM COLUMNS -->
<section id="custom-columns-section" class="flex flex-col min-h-[64px] h-auto">
<div ref="custom-columns-label" id="custom-columns-label"
class="font-inter text-lg font-semibold leading-7 pb-4 transition-colors duration-300">
{{ i18n.t('repositories.item_card.custom_columns_label') }}
</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-2">
<div v-for="(item, index_assigned) in assigned" :key="`assigned_element_${index_assigned}`"
class="text-sm">
{{ 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">
{{ item.archived ? i18n.t('labels.archived') : '' }} {{ item.value }}
</a>
<CustomColumns :customColumns="customColumns" :repositoryRowId="repositoryRowId"
:repositoryId="repository?.id" :inArchivedRepositoryRow="defaultColumns?.archived"
:permissions="permissions" :updatePath="updatePath" :actions="actions" @update="update" />
</section>
<div id="divider" class="w-500 bg-sn-light-grey flex px-8 items-center self-stretch h-px"></div>
<!-- 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"
ref="assigned-label"
id="assigned-label"
>
{{ i18n.t('repositories.item_card.section.assigned', {
count: assignedModules ?
assignedModules.total_assigned_size : 0
}) }}
<a v-if="!defaultColumns?.archived && (inRepository || actions?.assign_repository_row)"
class="btn-text-link font-normal" :class="{
'assign-inventory-button': actions?.assign_repository_row,
'disabled': actions?.assign_repository_row && actions.assign_repository_row.disabled
}"
:data-assign-url="actions?.assign_repository_row ? actions.assign_repository_row.assign_url : ''"
:data-repository-row-id="repositoryRowId" @click="showRepositoryAssignModal">
{{ i18n.t('repositories.item_card.assigned.assign') }}
</a>
</div>
<div v-if="assignedModules && assignedModules.total_assigned_size > 0" class="flex flex-col gap-4">
<div v-if="privateModuleSize() > 0" class="flex flex-col gap-4">
<div class="text-sn-dark-grey">{{ i18n.t('repositories.item_card.assigned.private',
{ count: privateModuleSize() }) }}
</div>
<div class="sci-divider" :class="{ 'hidden': assignedModules?.viewable_modules?.length == 0 }">
</div>
</div>
<div class="sci-divider"
:class="{ 'hidden': index === assignedModules?.viewable_modules?.length - 1 }"></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 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">
{{ item.archived ? i18n.t('labels.archived') : '' }} {{ item.value }}
</a>
</div>
</div>
<div class="sci-divider"
:class="{ 'hidden': index === assignedModules?.viewable_modules?.length - 1 }"></div>
</div>
</div>
</div>
<div v-else class="text-sn-dark-grey">
{{ i18n.t('repositories.item_card.assigned.empty') }}
</div>
</section>
<div v-else class="text-sn-dark-grey">
{{ i18n.t('repositories.item_card.assigned.empty') }}
</div>
</section>
<div id="divider" class="w-500 bg-sn-light-grey flex px-8 items-center self-stretch h-px "></div>
<div id="divider" class="w-500 bg-sn-light-grey flex px-8 items-center self-stretch h-px "></div>
<!-- QR -->
<section id="qr-section" ref="QR-label">
<div class="font-inter text-base 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">
<canvas id="bar-code-canvas" class="hidden"></canvas>
<img :src="barCodeSrc" class="w-[90px]" />
</div>
</section>
<!-- 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">
{{ i18n.t('repositories.item_card.section.qr') }}
</div>
<div class="bar-code-container">
<canvas id="bar-code-canvas" class="hidden"></canvas>
<img :src="barCodeSrc" class="w-[90px]" />
</div>
</section>
</div>
</div>
<!-- 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] ">
<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' }
]" v-show="isShowing">
</scroll-spy>
</div>
</div>
<!-- NAVIGATION -->
<div ref="navigationRef" id="navigation"
class="flex item-end gap-x-4 min-w-[130px] min-h-[130px] h-fit sticky top-0 right-[24px] ">
<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' }
]" :stickyHeaderHeightPx="102" :cardTopPaddingPx="null" :targetAreaMargin="30" v-show="isShowing">
</scroll-spy>
<!-- BOTTOM -->
<div id="bottom" v-show="!dataLoading" class="h-[100px] flex flex-col justify-end mt-4 mr-6"
:class="{ 'pb-6': customColumns?.length }">
<div id="divider" class="w-500 bg-sn-light-grey flex px-8 items-center self-stretch h-px mb-6"></div>
<div id="bottom-button-wrapper" class="flex h-10 justify-end">
<button type="button" class="btn btn-primary print-label-button"
:data-rows="JSON.stringify([repositoryRowId])">
{{ i18n.t('repositories.item_card.print_label') }}
</button>
</div>
</div>
</div>
<!-- BOTTOM -->
<div id="bottom" v-show="!dataLoading" class="h-[100px] flex flex-col justify-end mt-4 mr-6"
:class="{ 'pb-6': customColumns?.length }">
<div id="divider" class="w-500 bg-sn-light-grey flex px-8 items-center self-stretch h-px mb-6"></div>
<div id="bottom-button-wrapper" class="flex h-10 justify-end">
<button type="button" class="btn btn-primary print-label-button"
:data-rows="JSON.stringify([repositoryRowId])">
{{ i18n.t('repositories.item_card.print_label') }}
</button>
</div>
</div>
</div>
</div>
</div>
</transition>
</template>
<script>
import RepositoryStockValue from './repository_values/RepositoryStockValue.vue';
import RepositoryTextValue from './repository_values/RepositoryTextValue.vue';
import RepositoryNumberValue from './repository_values/RepositoryNumberValue.vue';
import RepositoryAssetValue from './repository_values/RepositoryAssetValue.vue';
import RepositoryListValue from './repository_values/RepositoryListValue.vue';
import RepositoryChecklistValue from './repository_values/RepositoryChecklistValue.vue';
import RepositoryStatusValue from './repository_values/RepositoryStatusValue.vue';
import RepositoryDateTimeValue from './repository_values/RepositoryDateTimeValue.vue';
import RepositoryDateTimeRangeValue from './repository_values/RepositoryDateTimeRangeValue.vue';
import RepositoryDateValue from './repository_values/RepositoryDateValue.vue';
import RepositoryDateRangeValue from './repository_values/RepositoryDateRangeValue.vue';
import RepositoryTimeRangeValue from './repository_values/RepositoryTimeRangeValue.vue'
import RepositoryTimeValue from './repository_values/RepositoryTimeValue.vue'
import InlineEdit from '../shared/inline_edit.vue';
import ScrollSpy from './repository_values/ScrollSpy.vue';
import Reminder from './reminder.vue'
import CustomColumns from './customColumns.vue';
import RepositoryItemSidebarTitle from './Title.vue'
export default {
name: 'RepositoryItemSidebar',
components: {
Reminder,
RepositoryStockValue,
RepositoryTextValue,
RepositoryNumberValue,
RepositoryAssetValue,
RepositoryListValue,
RepositoryChecklistValue,
RepositoryStatusValue,
RepositoryDateTimeValue,
RepositoryDateTimeRangeValue,
RepositoryDateValue,
RepositoryDateRangeValue,
RepositoryTimeRangeValue,
RepositoryTimeValue,
'scroll-spy': ScrollSpy
CustomColumns,
'repository-item-sidebar-title': RepositoryItemSidebarTitle,
'inline-edit': InlineEdit,
'scroll-spy': ScrollSpy,
},
data() {
return {
currentItemUrl: null,
updatePath: null,
dataLoading: false,
repositoryRowId: null,
repository: null,
@ -291,22 +262,22 @@ export default {
},
mounted() {
// Add a click event listener to the document
document.addEventListener('click', this.handleOutsideClick);
document.addEventListener('mousedown', this.handleOutsideClick);
this.inRepository = $('.assign-items-to-task-modal-container').length > 0;
},
beforeUnmount() {
delete window.repositoryItemSidebarComponent;
document.removeEventListener('click', this.handleDocumentClick);
document.removeEventListener('mousedown', this.handleOutsideClick);
},
methods: {
handleOutsideClick(event) {
if (!this.isShowing) return
const sidebar = this.$refs.wrapper;
// Check if the clicked element is not within the sidebar and it's not another item link or belogs to modals
const selectors = ['a', '.modal', '.label-printing-progress-modal'];
const selectors = ['a', '.modal', '.label-printing-progress-modal', '.atwho-view'];
if (!sidebar.contains(event.target) && !selectors.some(selector => event.target.closest(selector))) {
if (!$(event.target).parents('#repository-item-sidebar-wrapper').length &&
!selectors.some(selector => event.target.closest(selector))) {
this.toggleShowHideSidebar(null);
}
},
@ -321,9 +292,7 @@ export default {
}
// click on the same item - should just open/close it
else if (this.currentItemUrl === repositoryRowUrl) {
this.isShowing = false;
this.currentItemUrl = null;
this.myModuleId = null;
this.isShowing = !this.isShowing;
return
}
// explicit close (from emit)
@ -333,8 +302,9 @@ export default {
this.myModuleId = null;
return
}
// click on a different item - should just fetch new data
// 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;
@ -351,6 +321,8 @@ export default {
success: (result) => {
this.repositoryRowId = result.id;
this.repository = result.repository;
this.optionsPath = result.options_path;
this.updatePath = result.update_path;
this.defaultColumns = result.default_columns;
this.customColumns = result.custom_columns;
this.assignedModules = result.assigned_modules;
@ -384,6 +356,22 @@ export default {
},
privateModuleSize() {
return this.assignedModules.total_assigned_size - this.assignedModules.viewable_modules.length;
},
update(params) {
$.ajax({
method: 'PUT',
url: this.updatePath,
dataType: 'json',
data: {
id: this.id,
...params,
},
}).done((response) => {
if (response) {
this.customColumns = this.customColumns.map(col => col.id === response.id ? { ...col, ...response } : col)
if ($('.dataTable')[0]) $('.dataTable').DataTable().ajax.reload(null, false);
}
});
}
}
}

View file

@ -0,0 +1,29 @@
<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"
: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>
</template>
<script>
import InlineEdit from "../shared/inline_edit.vue";
export default {
name: "RepositoryItemSidebarTitle",
components: {
"inline-edit": InlineEdit
},
props: {
editable: Boolean,
name: String,
},
methods: {
updateName(name) {
this.$emit('update', { 'repository_row': { name: name } });
},
},
};
</script>

View file

@ -0,0 +1,83 @@
<template>
<div v-if="permissions && customColumns?.length > 0" class="flex flex-col gap-4 w-[350px] h-auto">
<div v-for="(column, index) in customColumns" :key="column.id" class="flex flex-col gap-4 w-[350px] h-auto relative">
<component
:is="column.data_type"
:key="index"
:actions="actions"
:data_type="column.data_type"
:colId="column.id"
:colName="column.name"
:colVal="column.value"
:repositoryRowId="repositoryRowId"
:repositoryId="repositoryId"
:permissions="permissions"
:updatePath="updatePath"
:optionsPath="column.options_path"
:inArchivedRepositoryRow="inArchivedRepositoryRow"
:canEdit="permissions.can_manage && !inArchivedRepositoryRow"
:editingField="editingField"
@setEditingField="editingField = $event"
@update="update"
/>
<div class="sci-divider" :class="{ 'hidden': index === customColumns?.length - 1 }"></div>
</div>
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.no_custom_columns_label') }}
</div>
</template>
<script>
import RepositoryStockValue from './repository_values/RepositoryStockValue.vue';
import RepositoryTextValue from './repository_values/RepositoryTextValue.vue';
import RepositoryNumberValue from './repository_values/RepositoryNumberValue.vue';
import RepositoryAssetValue from './repository_values/RepositoryAssetValue.vue';
import RepositoryListValue from './repository_values/RepositoryListValue.vue';
import RepositoryChecklistValue from './repository_values/RepositoryChecklistValue.vue';
import RepositoryStatusValue from './repository_values/RepositoryStatusValue.vue';
import RepositoryDateTimeValue from './repository_values/RepositoryDateTimeValue.vue';
import RepositoryDateTimeRangeValue from './repository_values/RepositoryDateTimeRangeValue.vue';
import RepositoryDateValue from './repository_values/RepositoryDateValue.vue';
import RepositoryDateRangeValue from './repository_values/RepositoryDateRangeValue.vue';
import RepositoryTimeRangeValue from './repository_values/RepositoryTimeRangeValue.vue'
import RepositoryTimeValue from './repository_values/RepositoryTimeValue.vue'
export default {
name: 'CustomColumns',
components: {
RepositoryStockValue,
RepositoryTextValue,
RepositoryNumberValue,
RepositoryAssetValue,
RepositoryListValue,
RepositoryChecklistValue,
RepositoryStatusValue,
RepositoryDateTimeValue,
RepositoryDateTimeRangeValue,
RepositoryDateValue,
RepositoryDateRangeValue,
RepositoryTimeRangeValue,
RepositoryTimeValue
},
props: {
customColumns: { type: Array, default: () => [] },
permissions: { type: Object, default: () => {} },
updatePath: { type: String, default: '' },
repositoryRowId: { type: Number, default: null },
repositoryId: { type: Number, default: null },
inArchivedRepositoryRow: { type: Boolean, default: false },
actions: {type: Object, default: () => {}}
},
data() {
return {
editingField: null
}
},
methods: {
update(params) {
this.$emit('update', params);
}
}
}
</script>

View file

@ -0,0 +1,168 @@
export default {
data() {
return {
isSaving: null,
};
},
computed: {
borderColor() {
if (this.errorMessage) return 'border-sn-delete-red';
if (this.isEditing) return 'border-sn-science-blue';
return 'border-sn-light-grey hover:border-sn-sleepy-grey';
},
isEditable() {
return this.isEditing && this.editingField === this.dateType
}
},
methods: {
enableEdit() {
this.isEditing = true
this.$emit('setEditingField', this.dateType)
},
saveChange() {
if (!this.isEditing ||
this.isSaving ||
!this.params ||
(this.params && !Object.keys(this.params).includes(this.colId?.toString()))) {
Object.assign(this.$data, { isEditing: false, isSaving: false, errorMessage: null });
return;
};
// Don't submit unless values changed
switch (true) {
case ['date', 'dateTime', 'time'].includes(this.dateType):
if(this.equalDates(this.params[this.colId], this.initDate)) {
Object.assign(this.$data, { isEditing: false, isSaving: false });
return;
}
if(this.dateType === 'time' && this.equalTimes(this.params[this.colId], this.initDate)) {
Object.assign(this.$data, { isEditing: false, isSaving: false });
return;
}
case ['dateRange', 'dateTimeRange', 'timeRange'].includes(this.dateType):
if (this.equalDates(this.timeFrom?.datetime, this.initStartDate) &&
this.equalDates(this.timeTo?.datetime, this.initEndDate)) {
Object.assign(this.$data, { isEditing: false, isSaving: false });
return;
}
if (this.dateType === 'timeRange' &&
this.equalTimes(this.timeFrom?.datetime, this.initStartDate) &&
this.equalTimes(this.timeTo?.datetime, this.initEndDate)) {
Object.assign(this.$data, { isEditing: false, isSaving: false });
return;
}
default:
break;
}
Object.assign(this.$data, { isSaving: true, errorMessage: null });
const $this = this;
$.ajax({
method: 'PUT',
url: $this.cellUpdatePath,
dataType: 'json',
data: { repository_cells: $this.params },
success: (result) => {
const cellValue = result?.value;
switch (true) {
case ['date', 'dateTime', 'time'].includes(this.dateType):
this.values = cellValue
this.initDate = cellValue?.datetime
case ['dateRange', 'dateTimeRange', 'timeRange'].includes(this.dateType):
this.initStartDate = cellValue?.start_time?.datetime;
this.initEndDate = cellValue?.end_time?.datetime;
default:
break;
}
Object.assign($this.$data, { isEditing: false, isSaving: false, values: result?.value });
if ($('.dataTable')[0]) $('.dataTable').DataTable().ajax.reload(null, false);
}
});
},
setParams() {
let defaultParams = this.params ? this.params : { [this.colId]: {} };
switch (true) {
case ['date', 'dateTime', 'time'].includes(this.dateType):
defaultParams[this.colId] = this.values?.datetime;
break;
case ['dateRange', 'dateTimeRange', 'timeRange'].includes(this.dateType):
defaultParams[this.colId]['start_time'] = this.timeFrom?.datetime;
defaultParams[this.colId]['end_time'] = this.timeTo?.datetime;
break;
default:
break;
}
this.params = defaultParams;
},
formatDateTime(date, field = null) {
this.values = this.values ? this.values : { [this.colId]: {} }
let params = this.params && this.params[this.colId] ? this.params : { [this.colId]: {} };
let timeFrom = this.timeFrom || {};
let timeTo = this.timeTo || {};
switch (true) {
case ['date', 'dateTime', 'time'].includes(this.dateType):
params[this.colId] = date;
this.values['datetime'] = date;
break;
case ['dateRange', 'dateTimeRange', 'timeRange'].includes(this.dateType):
if (field === 'start_time') {
timeFrom['datetime'] = date;
}
if (field === 'end_time'){
timeTo['datetime'] = date;
}
params[this.colId][field] = date;
break;
default:
break;
}
this.timeFrom = timeFrom;
this.timeTo = timeTo;
this.params = params;
},
dateValue(date) {
if(date) return new Date(date)
return new Date()
},
hasMonthText(){
$('body').data('datetime-picker-format')?.match(/MMM/)
},
equalDates(date1, date2){
return new Date(date1).getTime() === new Date(date2).getTime();
},
equalTimes(date1, date2){
return new Date(date1).getHours() === new Date(date2).getHours() &&
new Date(date1).getMinutes() == new Date(date2).getMinutes()
},
validateAndSave() {
this.errorMessage = null;
switch (true) {
case ['date', 'dateTime', 'time'].includes(this.dateType):
// To do
break;
case ['dateRange', 'dateTimeRange', 'timeRange'].includes(this.dateType):
if(this.params && this.params[this.colId]) {
if((!this.params[this.colId].start_time && this.params[this.colId].end_time) ||
(this.params[this.colId].start_time && !this.params[this.colId].end_time)) {
this.errorMessage = I18n.t('repositories.item_card.date_time.errors.not_valid_range');
return;
}
if (this.params[this.colId].start_time && this.params[this.colId].end_time &&
this.params[this.colId].start_time.getTime() && this.params[this.colId].end_time.getTime()) {
if(this.params[this.colId].start_time.getTime() > this.params[this.colId].end_time.getTime()) {
this.errorMessage = I18n.t('repositories.item_card.date_time.errors.not_valid_range');
return
}
}
if (!this.params[this.colId].start_time && !this.params[this.colId].end_time) {
this.params = { [this.colId]: null }
}
}
break;
default:
break;
}
this.saveChange();
}
},
};

View file

@ -1,4 +1,4 @@
<template v-if="value.reminder === true">
<template v-if="value?.reminder === true">
<div class="inline-block float-right cursor-pointer relative" :title="reminderTitle"
tabindex='-1'>
<i class="sn-icon sn-icon-notifications row-reminders-icon"></i>
@ -10,19 +10,18 @@
export default {
name: 'Reminder',
props: {
valueType: null,
value: null
},
computed: {
reminderColor() {
if (this.value.reminder && (this.value.stock_amount > 0 || this.value.days_left > 0)) {
if (this.value?.reminder && (this.value?.stock_amount > 0 || this.value?.days_left > 0)) {
return 'bg-sn-alert-brittlebush';
}
return 'bg-sn-alert-passion';
},
reminderTitle() {
let title = this.value.reminder_text
if (this.value.reminder_message) title = `${title}\n${this.value.reminder_message}`;
let title = this.value?.reminder_text
if (this.value?.reminder_message) title = `${title}\n${this.value?.reminder_message}`;
return title;
}

View file

@ -0,0 +1,231 @@
<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

@ -3,20 +3,42 @@
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<div v-if="file_name" @mouseover="tooltipShowing = true" @mouseout="tooltipShowing = false"
class="w-full cursor-pointer relative">
<a class="w-full inline-block file-preview-link truncate hover:no-underline hover:text-sn-science-blue 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 }}
<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">
{{ i18n.t('repositories.item_card.repository_asset_value.add_asset') }}
</a>
<tooltip-preview v-if="tooltipShowing && medium_preview_url" :id="id" :url="url" :file_name="file_name"
:preview_url="preview_url" :icon_html="icon_html" :medium_preview_url="medium_preview_url">
</tooltip-preview>
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.repository_asset_value.no_asset') }}
<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"
data-id="true" data-status="asset-present" :data-preview-url=this?.preview_url :href=this?.url>
{{ file_name }}
</a>
<tooltip-preview v-if="tooltipShowing && medium_preview_url" :id="id" :url="url" :file_name="file_name"
: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"
:class="{ 'text-sn-dark-grey': !canEdit, 'text-sn-grey': canEdit }">
{{ i18n.t('repositories.item_card.repository_asset_value.no_asset') }}
</div>
</div>
<div v-else class="bg-sn-light-grey h-1 w-full rounded-sm">
<div class="h-full bg-sn-science-blue rounded-sm transition-all duration-1000 ease-sharp" :style="`width: ${progress}%`"></div>
</div>
<div v-if="error" class="text-sn-alert-passion font-inter text-sm">
{{ error }}
</div>
<input type="file" ref="fileInput" @change="handleFileChange" style="display: none" />
</div>
</template>
@ -37,13 +59,20 @@ export default {
file_name: null,
icon_html: null,
medium_preview_url: null,
uploading: false,
progress: 0,
error: false
}
},
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object
colVal: Object,
permissions: Object,
actions: Object,
updatePath: String,
canEdit: { type: Boolean, default: false }
},
created() {
if (!this.colVal) return
@ -60,5 +89,82 @@ export default {
return `modal_link${this.id}`
}
},
methods: {
openFileChooser() {
this.$refs.fileInput.click();
},
handleFileChange(event) {
if (event.target.files[0]) {
this.uploadFiles(event.target.files[0]);
}
},
clearFile() {
this.$refs.fileInput.value = '';
this.error = '';
this.updateCell(null);
},
uploadFiles(file) {
this.uploading = true;
this.progress = 0;
this.error = '';
if (file.size > GLOBAL_CONSTANTS.FILE_MAX_SIZE_MB * 1024 * 1024) {
this.error = I18n.t('repositories.item_card.repository_asset_value.errors.file_too_big',
{ file_size: GLOBAL_CONSTANTS.FILE_MAX_SIZE_MB });
this.uploading = false;
return;
}
const upload = new ActiveStorage.DirectUpload(file,
this.actions.direct_file_upload_path,
{
directUploadWillStoreFileWithXHR: (request) => {
request.upload.addEventListener('progress', (e) => {
this.progress = parseInt((e.loaded / e.total) * 100, 10);
});
}
});
upload.create((error, blob) => {
if (error) {
this.error = I18n.t('repositories.item_card.repository_asset_value.errors.upload_failed_general');
this.uploading = false;
} else {
this.updateCell(blob.signed_id);
}
});
},
updateCell(value) {
$.ajax({
type: 'PUT',
url: this.updatePath,
data: {
repository_cells: {
[this.colId]: value
}
},
success: (result) => {
let assetRepositoryCell = result?.value;
this.uploading = false;
if (assetRepositoryCell) {
this.id = assetRepositoryCell.id;
this.url = assetRepositoryCell.url;
this.preview_url = assetRepositoryCell.preview_url;
this.file_name = assetRepositoryCell.file_name;
this.icon_html = assetRepositoryCell.icon_html;
this.medium_preview_url = assetRepositoryCell.medium_preview_url;
} else {
this.file_name = '';
}
if ($('.dataTable')[0]) $('.dataTable').DataTable().ajax.reload(null, false);
},
error: () => {
this.error = I18n.t('repositories.item_card.repository_asset_value.errors.upload_failed_general');
this.uploading = false;
}
});
}
}
}
</script>

View file

@ -3,52 +3,96 @@
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<div v-if="checklistItems.length > 0">
<div v-if="isEditing"
class="text-sn-dark-grey font-inter text-sm font-normal leading-5 grid grid-rows-2 grid-cols-2 overflow-auto h-12">
<div v-for="(checklistItem, index) in checklistItems" :key="index">
<div class="sci-checkbox-container">
<input type="checkbox" class="sci-checkbox" :value="checklistItem?.value" v-model="selectedChecklistItems" />
<span class="sci-checkbox-label"></span>
</div>
{{ checklistItem?.label }}
</div>
</div>
<div v-else
class="text-sn-dark-grey font-inter text-sm font-normal leading-5 w-[370px] overflow-x-auto flex flex-wrap">
<span v-for="(checklistItem, index) in checklistItems" :key="index" :id="`checklist-item-${index}`"
class="flex w-fit break-words mr-1">
{{ index + 1 === checklistItems.length ? checklistItem?.label : `${checklistItem?.label} |` }}
</span>
</div>
<div v-if="canEdit">
<checklist-select
@change="changeSelected"
@update="update"
:initialSelectedValues="selectedValues"
:withButtons="true"
:withEditCursor="true"
ref="ChecklistSelector"
: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"
optionsClassName="max-h-[300px]"
></checklist-select>
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.repository_checklist_value.no_checklist') }}
<div v-else-if="selectedChecklistItems?.length > 0"
class="text-sn-dark-grey font-inter text-sm font-normal leading-5 w-[370px] overflow-x-auto flex flex-wrap gap-1">
<span v-for="(checklistItem, index) in selectedChecklistItems"
:key="index"
:id="`checklist-item-${index}`"
class="flex w-fit break-words mr-1">
{{
index + 1 === selectedChecklistItems.length
? checklistItem?.label
: `${checklistItem?.label} |`
}}
</span>
</div>
<div v-else
class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t("repositories.item_card.repository_checklist_value.no_checklist") }}
</div>
</div>
</template>
<script>
import ChecklistSelect from "../../shared/checklist_select.vue";
import repositoryValueMixin from "./mixins/repository_value.js";
export default {
name: 'RepositoryChecklistValue',
data() {
return {
isEditing: false,
id: null,
checklistItems: [],
selectedChecklistItems: []
}
name: "RepositoryChecklistValue",
mixins: [repositoryValueMixin],
components: {
"checklist-select": ChecklistSelect
},
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Array
colVal: Array,
optionsPath: String,
canEdit: Boolean
},
created() {
if (!this.colVal) return
data() {
return {
id: null,
isLoading: false,
checklistItems: [],
selectedChecklistItems: [],
selectedValues: []
};
},
mounted() {
this.fetchChecklistItems();
if(this.colVal) {
this.selectedChecklistItems = Array.isArray(this.colVal) ? this.colVal : [this.colVal];
this.selectedValues = this.selectedChecklistItems.map(item => item?.value);
}
},
methods: {
fetchChecklistItems() {
this.isLoading = true;
this.checklistItems = this.colVal
$.get(this.optionsPath, data => {
if (Array.isArray(data)) {
this.checklistItems = data.map(option => {
const { value, label } = option;
return { id: value, label: label };
});
return false;
}
this.checklistItems = [];
}).always(() => {
this.isLoading = false;
});
},
changeSelected(selectedChecklistItems) {
this.selectedChecklistItems = selectedChecklistItems;
}
}
}
};
</script>

View file

@ -3,36 +3,34 @@
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<div v-if="start_time?.formatted && end_time?.formatted"
class="text-sn-dark-grey font-inter text-sm font-normal leading-5 flex">
<div>{{ start_time?.formatted }} - {{ end_time?.formatted }}</div>
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.repository_date_range_value.no_date_range') }}
</div>
<DateTimeRange
dateType="dateRange"
:startTime="colVal?.start_time"
:endTime="colVal?.end_time"
:colVal="colVal"
:colId="colId"
:updatePath="updatePath"
:canEdit="canEdit"
:editingField="editingField"
@setEditingField="$emit('setEditingField', $event)"
/>
</div>
</template>
<script>
export default {
name: 'RepositoryDateRangeValue',
data() {
return {
start_time: null,
end_time: null
}
},
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object
},
created() {
if (!this.colVal) return
import DateTimeRange from './DateTimeRange.vue';
this.start_time = this.colVal.start_time
this.end_time = this.colVal.end_time
export default {
name: 'RepositoryDateRangeValue',
components: { DateTimeRange },
props: {
data_type: String,
colId: Number,
colName: String,
colVal: null,
updatePath: null,
editingField: null,
canEdit: { type: Boolean, default: false },
}
}
}
</script>

View file

@ -3,36 +3,34 @@
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<div v-if="start_time?.formatted && end_time?.formatted"
class="text-sn-dark-grey font-inter text-sm font-normal leading-5 flex">
<div>{{ start_time?.formatted }} - {{ end_time?.formatted }}</div>
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.repository_date_time_range_value.no_date_time_range') }}
</div>
<DateTimeRange
:editingField="editingField"
@setEditingField="$emit('setEditingField', $event)"
dateType="dateTimeRange"
:startTime="colVal?.start_time"
:endTime="colVal?.end_time"
:colVal="colVal"
:colId="colId"
:updatePath="updatePath"
:canEdit="canEdit"
/>
</div>
</template>
<script>
export default {
name: 'RepositoryDateTimeRangeValue',
data() {
return {
start_time: null,
end_time: null
}
},
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object
},
created() {
if (!this.colVal) return
import DateTimeRange from './DateTimeRange.vue';
this.start_time = this.colVal.start_time
this.end_time = this.colVal.end_time
export default {
name: 'RepositoryDateTimeRangeValue',
components: { DateTimeRange },
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object,
updatePath: null,
editingField: null,
canEdit: { type: Boolean, default: false }
}
}
}
</script>

View file

@ -3,39 +3,32 @@
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<div v-if="formatted" class="text-sn-dark-grey font-inter text-sm font-normal leading-5 flex">
<div>{{ formatted }}</div>
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.repository_date_time_value.no_date_time') }}
</div>
<DateTimeRange
:editingField="editingField"
@setEditingField="$emit('setEditingField', $event)"
dateType="dateTime"
:colVal="colVal"
:colId="colId"
:updatePath="updatePath"
:canEdit="canEdit"
/>
</div>
</template>
<script>
export default {
name: 'RepositoryDateTimeValue',
data() {
return {
formatted: null,
date_formatted: null,
time_formatted: null,
datetime: null
}
},
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object
},
created() {
if (!this.colVal) return
import DateTimeRange from './DateTimeRange.vue';
this.formatted = this.colVal.formatted
this.date_formatted = this.colVal.date_formatted
this.time_formatted = this.colVal.time_formatted
this.formatdatetimeted = this.colVal.datetime
export default {
name: 'RepositoryDateTimeValue',
components: { DateTimeRange },
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object,
updatePath: String,
editingField: null,
canEdit: { type: Boolean, default: false }
}
}
}
</script>

View file

@ -3,35 +3,33 @@
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<div v-if="formatted" class="text-sn-dark-grey font-inter text-sm font-normal leading-5 flex">
<div>{{ formatted }}</div>
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.repository_date_value.no_date') }}
</div>
<DateTimeRange
:editingField="editingField"
@setEditingField="$emit('setEditingField', $event)"
dateType="date"
:colVal="colVal"
:colId="colId"
:updatePath="updatePath"
:dataType="data_type"
:canEdit="canEdit"
/>
</div>
</template>
<script>
import DateTimeRange from './DateTimeRange.vue';
export default {
name: 'RepositoryDateValue',
data() {
return {
formatted: null,
datetime: null
}
},
components: { DateTimeRange },
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object
},
created() {
if (!this.colVal) return
this.formatted = this.colVal.formatted
this.datetime = this.colVal.datetime
colVal: null,
updatePath: null,
editingField: null,
canEdit: { type: Boolean, default: false }
}
}
</script>

View file

@ -3,35 +3,93 @@
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<div v-if="text" class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ text }}
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.repository_list_value.no_list') }}
<div>
<select-search
v-if="permissions?.can_manage && !inArchivedRepositoryRow"
ref="DropdownSelector"
@change="changeSelected"
@update="update"
:value="selected"
:withClearButton="true"
:withEditCursor="true"
:options="options"
:isLoading="isLoading"
: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"
optionsClassName="max-h-[300px]"
></select-search>
<div v-else-if="text"
class="text-sn-dark-grey font-inter text-sm font-normal leading-5"
>
{{ text }}
</div>
<div v-else
class="text-sn-dark-grey font-inter text-sm font-normal leading-5"
>
{{ i18n.t("repositories.item_card.repository_list_value.no_list") }}
</div>
</div>
</div>
</template>
<script>
import SelectSearch from "../../shared/select_search.vue";
import repositoryValueMixin from "./mixins/repository_value.js";
export default {
name: 'RepositoryListValue',
data() {
return {
id: null,
text: null
}
name: "RepositoryListValue",
components: {
"select-search": SelectSearch
},
mixins: [repositoryValueMixin],
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object
colVal: Object,
optionsPath: String,
permissions: null,
inArchivedRepositoryRow: Boolean,
},
data() {
return {
id: null,
text: null,
selected: null,
isLoading: true,
options: []
};
},
created() {
if (!this.colVal) return
this.id = this.colVal?.id;
this.text = this.colVal?.text;
},
mounted() {
this.isLoading = true;
this.id = this.colVal.id
this.text = this.colVal.text
$.get(this.optionsPath, data => {
if (Array.isArray(data)) {
this.options = data.map(option => {
const { value, label } = option;
return [value, label];
});
return false;
}
this.options = [];
}).always(() => {
this.isLoading = false;
this.selected = this.id;
});
},
methods: {
changeSelected(value) {
this.selected = value;
if (value) {
this.update(value);
}
}
}
}
};
</script>

View file

@ -1,48 +1,85 @@
<template>
<div id="repository-number-value-wrapper" class="flex flex-col min-min-h-[46px] h-auto gap-[6px]">
<div id="repository-number-value-wrapper"
class="flex flex-col min-h-[46px] h-auto gap-[6px]">
<div class="font-inter text-sm font-semibold leading-5 flex justify-between">
<div class="truncate" :class="{ 'w-4/5': expandable }" :title="colName">{{ colName }}</div>
<div @click="toggleExpandContent" v-show="expandable" class="font-normal leading-5 btn-text-link">
{{ this.contentExpanded ? i18n.t('repositories.item_card.repository_number_value.collapse') :
i18n.t('repositories.item_card.repository_number_value.expand') }}
<div @click="toggleCollapse"
v-show="expandable"
class="font-normal leading-5 btn-text-link">
{{
collapsed
? i18n.t("repositories.item_card.repository_number_value.expand")
: i18n.t("repositories.item_card.repository_number_value.collapse")
}}
</div>
</div>
<div v-if="colVal" ref="numberRef"
class="text-sn-dark-grey font-inter text-sm font-normal leading-5 min-h-[20px] overflow-y-auto"
:class="{ 'max-h-[60px]': !contentExpanded, 'max-h-[600px]': contentExpanded }">
<div v-if="canEdit" class="w-full">
<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')"
:decimals="decimals"
:isNumber="true"
:unEditableRef="`numberRef`"
:expandable="expandable"
:collapsed="collapsed"
@toggleExpandableState="toggleExpandableState"
@update="update"
className="px-3"/>
</div>
<div v-else-if="colVal"
ref="numberRef"
class="text-sn-dark-grey box-content font-inter text-sm font-normal leading-5 min-h-[20px] overflow-y-auto"
:class="{
'max-h-[4rem]': collapsed,
'max-h-[40rem]': !collapsed
}">
{{ colVal }}
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.repository_number_value.no_number') }}
<div v-else
class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t("repositories.item_card.repository_number_value.no_number") }}
</div>
</div>
</template>
<script>
import repositoryValueMixin from "./mixins/repository_value.js";
import Textarea from "../../shared/Textarea.vue";
export default {
name: 'RepositoryNumberValue',
name: "RepositoryNumberValue",
mixins: [repositoryValueMixin],
components: {
'text-area': Textarea,
},
data() {
return {
expandable: false,
collapsed: true,
numberValue: '',
};
},
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Number
colVal: Number,
permissions: null,
canEdit: { type: Boolean, defaul: false}
},
mounted() {
this.$nextTick(() => {
const textHeight = this.$refs.numberRef.scrollHeight
this.expandable = textHeight > 60 // 60px
})
},
data() {
return {
contentExpanded: false,
expandable: false,
}
created() {
// constants
this.decimals = Number(document.getElementById(`${this.colId}`).dataset['metadataDecimals']) || 0;
},
methods: {
toggleExpandContent() {
this.contentExpanded = !this.contentExpanded
toggleCollapse() {
if (!this.expandable) return;
this.collapsed = !this.collapsed;
},
},
}
toggleExpandableState(expandable) {
this.expandable = expandable;
},
}
};
</script>

View file

@ -3,47 +3,102 @@
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<div v-if="status && icon"
class="flex flex-row items-center text-sn-dark-grey font-inter text-sm font-normal leading-5 gap-1.5">
<div v-html="parseEmoji(icon)" class="flex h-6 w-6"></div>
{{ status }}
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.repository_status_value.no_status') }}
<div>
<select-search
v-if="permissions?.can_manage && !inArchivedRepositoryRow"
@change="changeSelected"
@update="update"
:value="selected"
:withClearButton="true"
:withEditCursor="true"
ref="DropdownSelector"
:options="options"
:isLoading="isLoading"
: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"
optionsClassName="max-h-[300px]"
></select-search>
<div v-else-if="status && icon"
class="flex flex-row items-center text-sn-dark-grey font-inter text-sm font-normal leading-5 gap-1.5">
<div v-html="parseEmoji(icon)" class="flex h-6 w-6"></div>
{{ status }}
</div>
<div
v-else
class="text-sn-dark-grey font-inter text-sm font-normal leading-5"
>
{{ i18n.t("repositories.item_card.repository_status_value.no_status") }}
</div>
</div>
</div>
</template>
<script>
import twemoji from 'twemoji';
import SelectSearch from "../../shared/select_search.vue";
import repositoryValueMixin from "./mixins/repository_value.js";
import twemoji from "twemoji";
export default {
name: 'RepositoryStatusValue',
name: "RepositoryStatusValue",
components: {
"select-search": SelectSearch
},
mixins: [repositoryValueMixin],
data() {
return {
id: null,
icon: null,
status: null
}
status: null,
selected: null,
isLoading: true,
options: []
};
},
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object
colVal: Object,
optionsPath: String,
permissions: null,
inArchivedRepositoryRow: Boolean,
},
created() {
if (!this.colVal) return
if (!this.colVal) return;
this.id = this.colVal.id
this.icon = this.colVal.icon
this.status = this.colVal.status
this.id = this.colVal.id;
this.icon = this.colVal.icon;
this.status = this.colVal.status;
},
mounted() {
this.isLoading = true;
$.get(this.optionsPath, data => {
if (Array.isArray(data)) {
this.options = data.map(option => {
const { value, label } = option;
return [value, label];
});
return false;
}
this.options = [];
}).always(() => {
this.isLoading = false;
this.selected = this.id;
});
},
methods: {
changeSelected(id) {
this.selected = id;
if (id) {
this.update(id);
}
},
parseEmoji(content) {
return twemoji.parse(content);
}
}
}
};
</script>

View file

@ -1,47 +1,88 @@
<template>
<div id="repository-stock-value-wrapper" class="flex flex-col min-min-h-[46px] h-auto gap-[6px]">
<div class="font-inter text-sm font-semibold leading-5 relative">
<div class="font-inter text-sm font-semibold leading-5 relative h-[20px]">
<span class="truncate w-full inline-block pr-[50px]" :title="colName">{{ colName }}</span>
<a style="text-decoration: none;" class="absolute right-0 btn-text-link font-normal export-consumption-button"
v-if="permissions?.can_export_repository_stock === true && colVal?.stock_formatted" :data-rows="JSON.stringify([repositoryRowId])"
v-if="permissions?.can_export_repository_stock === true && values?.stock_formatted" :data-rows="JSON.stringify([repositoryRowId])"
:data-object-id="repositoryId">
{{ i18n.t('repositories.item_card.stock_export') }}
</a>
</div>
<div v-if="colVal?.stock_formatted" class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ colVal?.stock_formatted }}
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.repository_stock_value.no_stock') }}
</div>
<a style="text-decoration: none;"
class="text-sn-dark-grey font-inter text-sm font-normal leading-5 w-full rounded relative block"
:class="editableClassName"
@click="enableEditing"
: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">
{{ 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 }">
{{ i18n.t(`repositories.item_card.repository_stock_value.${canEdit ? 'placeholder' : 'no_stock'}`) }}
</div>
<span class="absolute right-2 reminder" :class="{ 'top-1.5': canEdit, 'top-0': !canEdit, hidden: !values?.reminder }">
<Reminder :value="values" />
</span>
</a>
</div>
</template>
<script>
export default {
name: 'RepositoryStockValue',
data() {
return {
stock_formatted: null,
stock_amount: null,
low_stock_threshold: null
import Reminder from '../reminder.vue';
export default {
name: 'RepositoryStockValue',
components: {
Reminder
},
computed: {
editableClassName() {
const className = 'border-solid border-[1px] p-2 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 ''
}
},
data() {
return {
stock_formatted: null,
stock_amount: null,
low_stock_threshold: null,
isEditing: null,
values: null
}
},
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object,
repositoryId: Number,
repositoryRowId: null,
permissions: null,
canEdit: { type: Boolean, default: false },
actions: null
},
mounted() {
this.values = this.colVal || {};
this.values.stock_url = this.actions?.stock_value_url
window.manageStockCallback = this.submitCallback;
},
unmounted(){
delete window.manageStockCallback
},
methods: {
enableEditing(){
this.isEditing = true
const $this = this;
// disable edit
$('#manageStockValueModal').on('hide.bs.modal', function() {
$this.isEditing = false;
})
},
submitCallback(values) {
if (values) this.values = values;
}
}
},
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object,
repositoryId: Number,
repositoryRowId: null,
permissions: null
},
created() {
if (!this.colVal) return
this.stock_formatted = this.colVal.stock_formatted
this.stock_amount = this.colVal.stock_amount
this.low_stock_threshold = this.colVal.low_stock_threshold
}
}
</script>

View file

@ -1,56 +1,85 @@
<template>
<div id="repository-text-value-wrapper" class="flex flex-col min-min-h-[46px] h-auto gap-[6px]">
<div id="repository-text-value-wrapper"
class="flex flex-col min-h-[46px] h-auto gap-[6px]">
<div class="font-inter text-sm font-semibold leading-5 flex justify-between">
<div class="truncate" :class="{ 'w-4/5': expandable }" :title="colName">{{ colName }}</div>
<div @click="toggleExpandContent" v-show="expandable" class="font-normal leading-5 btn-text-link">
{{ this.contentExpanded ? i18n.t('repositories.item_card.repository_text_value.collapse') :
i18n.t('repositories.item_card.repository_text_value.expand') }}
<div @click="toggleCollapse"
v-show="expandable"
class="font-normal leading-5 btn-text-link">
{{
collapsed
? i18n.t("repositories.item_card.repository_text_value.expand")
: i18n.t("repositories.item_card.repository_text_value.collapse")
}}
</div>
</div>
<div v-if="view" v-html="view" ref="textRef" class="text-sn-dark-grey font-inter text-sm font-normal leading-5 overflow-y-auto"
:class="{ 'max-h-[60px]': !contentExpanded, 'max-h-[600px]': contentExpanded }">
<div v-if="canEdit">
<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')"
:unEditableRef="`textRef`"
:smartAnnotation="true"
:sa_value="colVal?.view"
:expandable="expandable"
:collapsed="collapsed"
@toggleExpandableState="toggleExpandableState"
@update="update"
className="px-3" />
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.repository_text_value.no_text') }}
<div v-else-if="colVal?.edit"
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)]]"
: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") }}
</div>
</div>
</template>
<script>
import repositoryValueMixin from "./mixins/repository_value.js";
import Textarea from "../../shared/Textarea.vue";
export default {
name: 'RepositoryTextValue',
name: "RepositoryTextValue",
mixins: [repositoryValueMixin],
components: {
'text-area': Textarea,
},
data() {
return {
edit: null,
view: null,
contentExpanded: false,
expandable: false
}
expandable: false,
collapsed: true,
textValue: '',
};
},
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object
},
methods: {
toggleExpandContent() {
this.contentExpanded = !this.contentExpanded
},
colVal: Object,
canEdit: { type: Boolean, default: false }
},
created() {
if (!this.colVal) return
// constants
this.noContentPlaceholder = this.i18n.t("repositories.item_card.repository_text_value.no_text");
},
methods: {
toggleCollapse() {
if (!this.expandable) return;
this.edit = this.colVal.edit
this.view = this.colVal.view
this.collapsed = !this.collapsed;
},
toggleExpandableState(expandable) {
this.expandable = expandable;
},
},
mounted() {
this.$nextTick(() => {
if (this.$refs.textRef) {
const textHeight = this.$refs.textRef.scrollHeight
this.expandable = textHeight > 60 // 60px
}
})
},
}
};
</script>

View file

@ -3,36 +3,33 @@
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<div v-if="start_time?.formatted && end_time?.formatted"
class="text-sn-dark-grey font-inter text-sm font-normal leading-5 flex">
<div>{{ start_time?.formatted }} - {{ end_time?.formatted }}</div>
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">{{
i18n.t('repositories.item_card.repository_time_range_value.no_time_range') }}
</div>
<DateTimeRange
:editingField="editingField"
@setEditingField="$emit('setEditingField', $event)"
dateType="timeRange"
:startTime="colVal?.start_time"
:endTime="colVal?.end_time"
:colVal="colVal"
:colId="colId"
:updatePath="updatePath"
:canEdit="canEdit"
/>
</div>
</template>
<script>
export default {
name: 'RepositoryTimeRangeValue',
data() {
return {
start_time: null,
end_time: null
import DateTimeRange from './DateTimeRange.vue';
export default {
name: 'RepositoryTimeRangeValue',
components: { DateTimeRange },
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object,
updatePath: null,
editingField: null,
canEdit: { type: Boolean, default: false }
}
},
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object
},
created() {
if (!this.colVal) return
this.start_time = this.colVal.start_time
this.end_time = this.colVal.end_time
}
}
</script>

View file

@ -3,37 +3,31 @@
<div class="font-inter text-sm font-semibold leading-5 truncate" :title="colName">
{{ colName }}
</div>
<div v-if="formatted" class="text-sn-dark-grey font-inter text-sm font-normal leading-5 flex">
<div>
{{ formatted }}
</div>
</div>
<div v-else class="text-sn-dark-grey font-inter text-sm font-normal leading-5">
{{ i18n.t('repositories.item_card.repository_time_value.no_time') }}
</div>
<DateTimeRange
:editingField="editingField"
@setEditingField="$emit('setEditingField', $event)"
dateType="time"
:colVal="colVal"
:colId="colId"
:updatePath="updatePath"
:canEdit="canEdit"
/>
</div>
</template>
<script>
export default {
name: 'RepositoryTimeValue',
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object
},
data() {
return {
formatted: null,
datetime: null
import DateTimeRange from './DateTimeRange.vue';
export default {
name: 'RepositoryTimeValue',
components: { DateTimeRange },
props: {
data_type: String,
colId: Number,
colName: String,
colVal: Object,
updatePath: null,
editingField: null,
canEdit: { type: Boolean, default: false }
}
},
created() {
if (!this.colVal) return
this.formatted = this.colVal.formatted
this.datetime = this.colVal.datetime
}
}
</script>

View file

@ -1,20 +1,20 @@
<template>
<div class="flex gap-3">
<div id="navigation-text">
<div class="flex flex-col py-2 px-0 gap-3 self-stretch w-[130px] h-[130px] justify-center items-center">
<div v-for="(itemObj, index) in itemsToCreate" :key="index"
class="flex flex-col w-[130px] h-[130px] justify-between text-right">
<div @click="handleSideNavClick" :id="itemObj?.textId" class="hover:cursor-pointer text-sn-grey"
:class="{ 'text-sn-science-blue': selectedNavText === itemObj?.textId }">{{
i18n.t(`repositories.highlight_component.${itemObj?.labelAlias}`) }}
</div>
</div>
<div id="navigation-text"
v-if="thresholds.length"
class="flex flex-col py-2 px-0 gap-3 self-stretch w-[130px] h-[130px] justify-center items-center">
<div v-for="(navigationItem, index) in itemsToCreate" :key="navigationItem.textId"
@click="navigateToSection(navigationItem)"
class="text-sn-grey nav-text-item flex flex-col w-[130px] h-[130px] justify-between text-right hover:cursor-pointer"
:class="{ 'text-sn-science-blue': navigationItemsStatus[index] }">
{{ i18n.t(`repositories.highlight_component.${navigationItem.labelAlias}`) }}
</div>
</div>
<div id="highlight-container" class="w-[1px] h-[130px] flex flex-col justify-evenly bg-sn-light-grey">
<div v-for="(itemObj, index) in itemsToCreate" :key="index">
<div :id="itemObj?.id" class="w-[5px] h-[28px] rounded-[11px]"
:class="{ 'bg-sn-science-blue relative left-[-2px]': itemObj?.id === selectedNavIndicator }"></div>
<div v-for="(navigationItem, index) in itemsToCreate" :key="navigationItem.id"
class="w-[5px] h-[28px] rounded-[11px]"
:class="{ 'bg-sn-science-blue relative left-[-2px]': navigationItemsStatus[index] }">
</div>
</div>
</div>
@ -23,188 +23,232 @@
<script>
export default {
name: 'ScrollSpy',
props: {
itemsToCreate: Array,
stickyHeaderHeightPx: Number || null,
cardTopPaddingPx: Number || null,
targetAreaMargin: Number || null
},
data() {
return {
bodyContainerEl: null,
selectedNavText: null,
selectedNavIndicator: null,
sections: [],
prevSection: null,
scrollTimer: null,
shouldRecalculateWhenStopped: false
}
sectionsWithHeight: [],
allSectionsCumulativeHeight: null,
thresholds: [],
navigationItemsStatus: [], // highlighted or not
scrollPosition: null,
centerOfScrollThumb: null,
};
},
mounted() {
this.bodyContainerEl = this.$parent.$refs.bodyWrapper
this.sections = this.$parent.$refs.scrollSpyContent.querySelectorAll('section[id]');
this.bodyContainerEl?.addEventListener('scroll', this.handleScroll)
this.highlightActiveSectionOnScroll()
},
methods: {
// If the user scrolls too fast to register movement, then we need to do something when the scrolling has stopped.
// When the scrolling has stopped and if we have permission to recalculate
// then we find the closest dom node relative to the target area and highlight it
scrollStopped() {
if (!this.shouldRecalculateWhenStopped) return
const bodyWrapperTargetAreaRectTop = this.bodyContainerEl.getBoundingClientRect().top;
const sectionRects = Array.from(this.sections).map((s) => {
const rect = s.getBoundingClientRect();
mounted() {
console.log('mounted');
window.addEventListener('resize', this.handleResize);
this.initializeComponent();
this.$nextTick(() => {
this.calculateAllSectionsCumulativeHeight()
this.calculateSectionsHeight();
this.constructThresholds()
this.handleScroll()
if (!this.initialSectionId) {
this.navigateToSection(this.itemsToCreate[0])
}
else {
const itemToNavigateTo = this.itemsToCreate.find((item) => item.sectionId === this.initialSectionId)
this.navigateToSection(itemToNavigateTo)
}
});
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize);
this.removeScrollListener();
},
methods: {
initializeComponent() {
const bodyWrapperEl = document.getElementById('body-wrapper')
const scrollSpyContentEl = document.getElementById('scrollSpyContent')
this.bodyContainerEl = bodyWrapperEl
this.sections = Array.from(scrollSpyContentEl.querySelectorAll('section[id]'));
this.navigationItemsStatus = Array(this.sections.length).fill(false);
this.navigationItemsStatus[0] = true;
this.addScrollListener();
},
addScrollListener() {
this.bodyContainerEl?.addEventListener('scroll', this.handleScroll);
},
removeScrollListener() {
this.bodyContainerEl?.removeEventListener('scroll', this.handleScroll);
},
calculateAllSectionsCumulativeHeight() {
let totalHeight = 0
this.itemsToCreate.forEach((item) => {
const sectionEl = document.getElementById(item.sectionId);
totalHeight += sectionEl.offsetHeight
})
this.allSectionsCumulativeHeight = totalHeight
},
calculateSectionsHeight() {
// Initialize an array to store the height data for each section
this.sectionsWithHeight = this.itemsToCreate.map(item => {
// Find the DOM element for the section
const sectionEl = document.getElementById(item.sectionId);
// Calculate the height of the section as a percentage of the total scrollable height
const heightPx = sectionEl.offsetHeight;
const percentHeight = Math.round((heightPx / this.allSectionsCumulativeHeight) * 100);
// Return an object containing the section ID and its percentage height
return {
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height,
id: s.getAttribute('id'),
sectionId: item.sectionId,
heightPx: heightPx,
percentHeight: percentHeight
};
});
const closestDomNodeToHighlight = this.findClosestDomNode(sectionRects, bodyWrapperTargetAreaRectTop)
// If user clicked on the navigation and not actually scrolled the scroll event still happened.
// However, in those cases top/bot values will be zero and we should not compute closestDomNode highlighting
if (closestDomNodeToHighlight.top !== 0 && closestDomNodeToHighlight.bottom !== 0) {
const id = closestDomNodeToHighlight.id
const foundMatchToHighlight = this.itemsToCreate.find((e) => e.sectionId === id)
this.selectedNavText = foundMatchToHighlight.textId
this.selectedNavIndicator = foundMatchToHighlight.id
}
},
// For finding the closest dom node (to highlight)
findClosestDomNode(arr, referenceValue) {
if (arr.length === 0) {
return null;
}
let closestObject = arr[0];
let minDifference = Math.abs(arr[0].top - referenceValue);
for (let i = 1; i < arr.length; i++) {
const difference = Math.abs(arr[i].top - referenceValue);
if (difference < minDifference) {
minDifference = difference;
closestObject = arr[i];
// Constructs and populates array of thresholds with threshold objects.
// Each object stores id, index, and threshold values (from/to) for a section, based
// on the % of vertical space of scrollable content that they occupy
constructThresholds() {
const scrollableArea = this.bodyContainerEl;
const deltaTravel = scrollableArea.scrollHeight - scrollableArea.clientHeight
const viewportHeight = scrollableArea.clientHeight;
const scrollableAreaHeight = scrollableArea.scrollHeight;
const scrollThumbHeight = Math.round(viewportHeight / scrollableAreaHeight * viewportHeight);
const scrollThumbCenter = Math.round(scrollThumbHeight / 2)
this.centerOfScrollThumb = scrollThumbCenter
this.scrollPosition = scrollThumbCenter
let prevThreshold = scrollThumbCenter
for (let i = 0; i < this.sectionsWithHeight.length; i++) {
// first section
if (i === 0) {
const from = prevThreshold
const to = Math.round(deltaTravel * this.sectionsWithHeight[i].percentHeight / 100) + prevThreshold
const id = this.sectionsWithHeight[i].sectionId
prevThreshold = to + 1
const threshold = {
id,
index: i,
from,
to
}
this.thresholds[i] = threshold
}
// last section
else if (i === this.sectionsWithHeight.length - 1) {
const from = prevThreshold
const to = scrollableArea.scrollHeight
const id = this.sectionsWithHeight[i].sectionId
const threshold = {
id,
index: i,
from,
to
}
this.thresholds[i] = threshold
}
else {
// other sections
const from = prevThreshold
const to = Math.round(deltaTravel * this.sectionsWithHeight[i].percentHeight / 100) + prevThreshold - 1
const id = this.sectionsWithHeight[i].sectionId
prevThreshold = to + 1
const threshold = {
id,
index: i,
from,
to
}
this.thresholds[i] = threshold
}
}
return closestObject;
},
// Handling scroll events
// Handling scroll event
handleScroll() {
this.shouldRecalculateWhenStopped = true
this.highlightActiveSectionOnScroll()
if (this.scrollTimer) {
clearTimeout(this.scrollTimer);
}
this.scrollTimer = setTimeout(this.scrollStopped, 200);
const scrollableArea = this.bodyContainerEl;
const scrollThumbsDistanceFromTop = scrollableArea.scrollTop + this.centerOfScrollThumb;
this.scrollPosition = scrollThumbsDistanceFromTop;
this.updateNavigationItemsStatusOnScroll();
},
// Highlighting active sections while scrolling
highlightActiveSectionOnScroll() {
// Navigating (scrolling into view) via click
navigateToSection(navigationItem) {
if (!this.bodyContainerEl) return;
this.removeScrollListener();
const bodyWrapperTargetAreaRect = this.bodyContainerEl.getBoundingClientRect();
const margin = this.targetAreaMargin;
const scrollableArea = this.bodyContainerEl;
const foundThreshold = this.thresholds.find((obj) => obj.id === navigationItem.sectionId)
const domElToScrollTo = document.getElementById(navigationItem.label)
// Far top position
if (this.bodyContainerEl.scrollTop === 0) {
this.shouldRecalculateWhenStopped = false
this.handleTopOrBotScrollPosition(this.sections[0]);
return;
if (foundThreshold.index === 0) {
// scroll to top
this.bodyContainerEl.scrollTo({
top: 0,
behavior: "auto"
});
}
// Far bottom position
if (this.bodyContainerEl.scrollTop + this.bodyContainerEl.clientHeight === this.bodyContainerEl.scrollHeight) {
this.shouldRecalculateWhenStopped = false
this.handleTopOrBotScrollPosition(this.sections[this.sections.length - 1])
return
else if (foundThreshold.index === this.thresholds.length - 1) {
// scroll to bottom
this.bodyContainerEl.scrollTo({
top: 99999,
behavior: "auto"
});
}
else {
// scroll to the start of a section's threshold, adjusted for the center thumb value (true center)
scrollableArea.scrollTop = foundThreshold.from - this.centerOfScrollThumb
}
this.flashTitleColor(domElToScrollTo);
// Checks when a section enters targetArea's boundary and highlights it
for (const section of this.sections) {
const sectionRect = section.getBoundingClientRect();
if (sectionRect === this.prevSection) continue;
this.updateNavigationItemsStatusOnClick(this.itemsToCreate.indexOf(navigationItem) || 0);
setTimeout(() => this.addScrollListener(), 1500);
},
if (this.isSectionInBounds(sectionRect, bodyWrapperTargetAreaRect, margin)) {
this.handleSectionHighlight(section);
flashTitleColor(domEl) {
if (!domEl) return
domEl.classList.add('text-sn-science-blue');
setTimeout(() => domEl.classList.remove('text-sn-science-blue'), 300);
},
handleResize() {
this.$nextTick(() => {
this.calculateAllSectionsCumulativeHeight()
this.calculateSectionsHeight();
this.constructThresholds()
});
},
updateNavigationItemsStatusOnScroll() {
this.thresholds.forEach((threshold, index) => {
this.navigationItemsStatus[index] = false;
if (threshold?.from <= this.scrollPosition && this.scrollPosition <= threshold?.to) {
this.navigationItemsStatus[index] = true;
}
}
});
},
// For handling top/bottom most positions
handleTopOrBotScrollPosition(section) {
const sectionId = section.getAttribute('id');
const foundObj = this.itemsToCreate.find((obj) => obj?.sectionId === sectionId);
updateNavigationItemsStatusOnClick(itemIndex) {
this.thresholds.forEach((_, index) => {
this.navigationItemsStatus[index] = false;
if (foundObj) {
this.selectedNavText = foundObj.textId;
this.selectedNavIndicator = foundObj.id;
}
if (index === itemIndex) {
this.navigationItemsStatus[index] = true;
}
});
},
// For checking if a section is within targetArea's boundaries
isSectionInBounds(sectionRect, targetAreaRect, margin) {
const upperBound = targetAreaRect.top - margin;
const lowerBound = targetAreaRect.top + margin;
return sectionRect.top >= upperBound && sectionRect.top <= lowerBound;
},
// For highlighting a section during scrolling
handleSectionHighlight(section) {
const sectionId = section.getAttribute('id');
const foundObj = this.itemsToCreate.find((obj) => obj?.sectionId === sectionId);
if (foundObj) {
this.selectedNavText = foundObj.textId;
this.selectedNavIndicator = foundObj.id;
this.prevSection = section.getBoundingClientRect();
}
},
// For handling clicks on the side navigation
handleSideNavClick(e) {
if (!this.bodyContainerEl) return
this.bodyContainerEl?.removeEventListener('scroll', this.handleScroll)
let refToScrollTo
const targetId = e.target.id
const foundObj = this.itemsToCreate.find((obj) => obj?.textId === targetId)
if (!foundObj) return
// Highlighting
refToScrollTo = foundObj.label
this.selectedNavText = foundObj.textId
this.selectedNavIndicator = foundObj.id
const sectionLabels = this.itemsToCreate.map((obj) => obj.label)
const labelsToUnhighlight = sectionLabels.filter((i) => i !== refToScrollTo)
// Scrolling to desired section
const domElToScrollTo = this.$parent.$refs[refToScrollTo]
const top = domElToScrollTo.offsetTop - this?.stickyHeaderHeightPx - this?.cardTopPaddingPx;
this.bodyContainerEl.scrollTo({
top: top,
behavior: "auto"
})
// flashing the title color to blue and back over 300ms
domElToScrollTo?.classList.add('text-sn-science-blue')
labelsToUnhighlight.forEach(id => document.getElementById(id)?.classList.remove('text-sn-science-blue'))
setTimeout(() => {
domElToScrollTo?.classList.remove('text-sn-science-blue')
}, 300)
setTimeout(() => {
this.bodyContainerEl?.addEventListener('scroll', this.handleScroll)
}, 100)
}
},
}
}
</script>

View file

@ -0,0 +1,19 @@
export default {
props: {
colId: Number,
colVal: String,
inArchivedRepository: Boolean,
},
data() {
return {
editing: false,
};
},
methods: {
update(newValue) {
const repositoryCells = {};
repositoryCells[this.colId] = newValue;
this.$emit('update', { repository_cells: repositoryCells });
},
},
};

View file

@ -0,0 +1,286 @@
<template>
<div
ref="modal"
class="modal fade"
tabindex="-1"
role="dialog"
aria-labelledby="manage-stock-value"
>
<div class="modal-dialog" role="document" v-if="stockValue">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close self-start" data-dismiss="modal" :aria-label="i18n.t('general.close')">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title">
<template v-if="stockValue?.id">
{{ i18n.t('repository_stock_values.manage_modal.edit_title', { item: repositoryRowName }) }}
</template>
<template v-else>
{{ i18n.t('repository_stock_values.manage_modal.title', { item: repositoryRowName }) }}
</template>
</h4>
</div>
<div class="modal-body">
<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">
<div class="flex flex-col w-40">
<label class="text-sn-grey text-sm font-normal" for="operations">{{ i18n.t('repository_stock_values.manage_modal.operation') }}</label>
<Select
:disabled="!stockValue?.id"
:value="operation"
:options="operations"
@change="setOperation"
></Select>
</div>
<div class="flex flex-col w-40">
<Input
@update="value => amount = value"
name="stock_amount"
id="stock-amount"
:inputClass="`sci-input-container-v2 ${errors.amount ? 'error' : ''}`"
:labelClass="`text-sm font-normal ${errors.amount ? 'text-sn-delete-red' : 'text-sn-grey'}`"
type="number"
:value="amount"
:decimals="stockValue.decimals"
:placeholder="i18n.t('repository_stock_values.manage_modal.amount_placeholder_new')"
required
:label="i18n.t('repository_stock_values.manage_modal.amount')"
showLabel
autoFocus
:error="errors.amount"
/>
</div>
<div class="flex flex-col w-40">
<label :class="`text-sm font-normal ${errors.unit ? 'text-sn-delete-red' : 'text-sn-grey'}`" for="stock-unit">
{{ i18n.t('repository_stock_values.manage_modal.unit') }}
</label>
<Select
:disabled="[2, 3].includes(operation)"
:value="unit"
:options="units"
:placeholder="i18n.t('repository_stock_values.manage_modal.unit_prompt')"
@change="unit = $event"
:className="`${errors.unit ? 'error' : ''}`"
></Select>
<div class="text-sn-delete-red text-xs" :class="{ visible: errors.unit, invisible: !errors.unit }">
{{ errors.unit }}
</div>
</div>
</fieldset>
<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-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>
</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-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>
</div>
</div>
</template>
<div class="repository-stock-reminder-selector">
<div class="sci-checkbox-container">
<input type="checkbox" name="reminder-enabled" tabindex="4" class="sci-checkbox" id="reminder-selector-checkbox" :checked="reminderEnabled" @change="reminderEnabled = $event.target.checked"/>
<span class="sci-checkbox-label"></span>
</div>
<span class="ml-2">{{ i18n.t('repository_stock_values.manage_modal.create_reminder') }}</span>
</div>
<div v-if="reminderEnabled" class="stock-reminder-value flex gap-2 items-center">
<Input
@update="value => lowStockTreshold = value"
name="treshold_amount"
id="treshold-amount"
fieldClass="flex gap-2"
inputClass="sci-input-container-v2 w-40"
labelClass="text-sm font-normal flex items-center"
type="number"
:value="lowStockTreshold"
:decimals="stockValue.decimals"
:placeholder="i18n.t('repository_stock_values.manage_modal.amount_placeholder_new')"
required
:label="i18n.t('repository_stock_values.manage_modal.reminder_at')"
showLabel
:error="errors.tresholdAmount"
/>
<span class="text-sm font-normal">
{{ unitLabel }}
</span>
</div>
<div class="sci-input-container flex flex-col" :data-error-text="i18n.t('repository_stock_values.manage_modal.comment_limit')">
<label class="text-sn-grey text-sm font-normal" for="stock-value-comment">{{ i18n.t('repository_stock_values.manage_modal.comment') }}</label>
<input class="sci-input-field"
@input="event => comment = event.target.value"
type="text"
name="comment"
id="stock-value-comment"
:placeholder="i18n.t('repository_stock_values.manage_modal.comment_placeholder')"
/>
</div>
</form>
</div>
<div class="modal-footer">
<button type='button' class='btn btn-secondary' data-dismiss='modal'>
{{ i18n.t('general.cancel') }}
</button>
<button class="btn btn-primary" @click="validateAndsaveStockValue">
{{ i18n.t('repository_stock_values.manage_modal.save_stock') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import Select from './../shared/select.vue';
import Input from './../shared/input.vue';
import Decimal from 'decimal.js';
export default {
name: 'ManageStockValueModal',
components: {
Select,
Input
},
data() {
return {
operation: null,
operations: [],
stockValue: null,
amount: '',
repositoryRowName: null,
stockUrl: null,
units: null,
unit: null,
reminderEnabled: false,
lowStockTreshold: null,
comment: null,
errors: {}
}
},
computed: {
unitLabel: function() {
const currentUnit = this.units?.find(option => option[0] === this.unit);
return currentUnit ? currentUnit[1] : ''
},
initUnitLabel: function() {
const unit = this.units?.find(option => option[0] === this.stockValue?.unit);
return unit ? unit[1] : ''
},
newAmount: function() {
const currentAmount = new Decimal(this.stockValue?.amount || 0);
const amount = new Decimal(this.amount || 0)
let value;
switch (this.operation) {
case 2:
value = currentAmount.plus(amount);
break;
case 3:
value = currentAmount.minus(amount);
break;
default:
value = amount;
break;
}
return Number(value)
}
},
created() {
window.manageStockModalComponent = this;
},
beforeDestroy() {
delete window.manageStockModalComponent;
},
mounted() {
// Focus stock amount input field
$(this.$refs.modal).on('show.bs.modal', function() {
setTimeout(() => {
$('#stock-amount')[0]?.focus()
}, 500)
});
},
methods: {
setOperation($event) {
this.operation = $event;
if ([2, 3].includes($event)) {
this.unit = this.stockValue.unit;
}
},
fetchStockValueData(stockValueUrl) {
if (!stockValueUrl) return;
$.ajax({
method: 'GET',
url: stockValueUrl,
dataType: 'json',
success: (result) => {
this.repositoryRowName = result.repository_row_name
this.stockValue = result.stock_value
this.amount = Number(new Decimal(result.stock_value.amount || 0))
this.units = result.stock_value.units
this.unit = result.stock_value.unit
this.reminderEnabled = result.stock_value.reminder_enabled
this.lowStockTreshold = result.stock_value.low_stock_treshold
this.operation = 1;
this.stockUrl = result.stock_url;
this.operations = [[1, 'set'], [2, 'add'], [3, 'remove']];
this.errors = {};
}
});
},
closeModal() {
$(this.$refs.modal).modal('hide');
},
showModal(stockValueUrl, closeCallback) {
$(this.$refs.modal).modal('show');
this.fetchStockValueData(stockValueUrl);
this.closeCallback = closeCallback;
},
validateAndsaveStockValue() {
let newErrors = {};
this.errors = newErrors;
if (!this.unit)
newErrors['unit'] = I18n.t('repository_stock_values.manage_modal.unit_error');
if (!this.amount)
newErrors['amount'] = I18n.t('repository_stock_values.manage_modal.amount_error');
if (this.amount && this.amount < 0)
newErrors['amount'] = I18n.t('repository_stock_values.manage_modal.negative_error');
if (this.reminderEnabled && !this.lowStockTreshold)
newErrors['tresholdAmount'] = I18n.t('repository_stock_values.manage_modal.amount_error');
this.errors = newErrors;
if (!$.isEmptyObject(newErrors)) return;
const $this = this
$.ajax({
method: 'POST',
url: this.stockUrl,
dataType: 'json',
data: {
repository_stock_value: {
unit_item_id: this.unit,
amount: this.newAmount,
comment: this.comment,
low_stock_threshold: this.reminderEnabled ? this.lowStockTreshold : null
},
operator: this.operations.find(operation => operation[0] == this.operation)?.[1],
change_amount: Math.abs(this.amount),
},
success: function(result) {
$this.stockValue = null;
$this.closeModal();
$this.closeCallback && $this.closeCallback(result);
}
})
}
}
}
</script>

View file

@ -39,6 +39,9 @@ import { vOnClickOutside } from '@vueuse/components'
export default {
name: 'RepositorySearchContainer',
directives: {
'click-outside': vOnClickOutside
},
data() {
return {
barcodeSearchOpened: false,

View file

@ -103,6 +103,7 @@
<div v-for="(element, index) in orderedElements" :key="element.id">
<component
:is="elements[index].attributes.orderable_type"
class="result-element"
:element.sync="elements[index]"
:inRepository="false"
:reorderElementUrl="elements.length > 1 ? urls.reorder_elements_url : ''"
@ -433,8 +434,8 @@
HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger');
}).done(() => {
this.$parent.$nextTick(() => {
const children = this.$children
const lastChild = children[children.length - 1]
const children = this.$refs.stepContainer.querySelectorAll(".result-element");
const lastChild = children[children.length - 1];
lastChild.$el.scrollIntoView(false)
window.scrollBy({
top: 200,

View file

@ -18,6 +18,7 @@
/>
<div class="results-list">
<Result v-for="result in results" :key="result.id"
ref="results"
:result="result"
:resultToReload="resultToReload"
:activeDragResult="activeDragResult"
@ -149,7 +150,7 @@
this.activeDragResult = id;
},
uploadFilesToResult(file, resultId) {
this.$children.find(child => child.result?.id == resultId).uploadFiles(file);
this.$refs.results.find(child => child.result?.id == resultId).uploadFiles(file);
},
firstObjectInViewport() {
let result = $('.result-wrapper:not(.locked)').toArray().find(element => {

View file

@ -34,7 +34,7 @@
</li>
</ul>
</div>
<div class="result-toolbar__right flex items-center" @click="$emit('expandAll')">
<div class="result-toolbar__right flex items-center">
<button class="btn btn-secondary mr-3" @click="collapseResults" tabindex="0">
{{ i18n.t('my_modules.results.collapse_label') }}
</button>

View file

@ -0,0 +1,157 @@
<template>
<textarea v-if="editing"
ref="textareaRef"
class="leading-5 inline-block outline-none border-solid font-normal border-[1px] box-content
overflow-x-hidden overflow-y-auto resize-none rounded py-2 w-[calc(100%-1.5rem)]
border-sn-science-blue"
:class="{
'max-h-[4rem]': collapsed,
'max-h-[40rem]': !collapsed,
[className]: true
}"
:placeholder="placeholder"
v-model="value"
@keydown="handleKeydown"
@blur="handleBlur"></textarea>
<div v-else
:ref="unEditableRef"
class="grid box-content sci-cursor-edit font-normal border-solid py-2 border-sn-light-grey rounded
leading-5 border outline-none hover:border-sn-sleepy-grey overflow-y-auto whitespace-pre-line"
:class="{ 'max-h-[4rem]': collapsed,
'max-h-[40rem]': !collapsed,
[className]: true,
'text-sn-dark-grey': value, 'text-sn-grey': !value
}"
@click="enableEdit">
<span v-if="smartAnnotation"
v-html="sa_value || noContentPlaceholder"
class="[&>p]:mb-0"></span>
<span v-else>{{ value || noContentPlaceholder }}</span>
</div>
</template>
<script>
export default {
name: "Textarea",
data() {
return {
value: '',
editing: false,
};
},
props: {
expandable: { type: Boolean, required: true },
collapsed: { type: Boolean, required: true },
initialValue: String,
noContentPlaceholder: String,
placeholder: String,
decimals: { type: Number, default: 0 },
isNumber: { type: Boolean, default: false },
unEditableRef: { type: String, required: true },
smartAnnotation: { type: Boolean, default: false },
sa_value: { type: String },
className: { type: String, default: false }
},
mounted() {
this.value = this.initialValue;
this.$nextTick(() => {
this.toggleExpandableState();
});
},
beforeUpdate() {
if (!this.$refs.textareaRef) return;
if (this.isNumber) this.enforceNumberInput();
},
watch: {
initialValue: {
handler() {
this.value = this.initialValue || '';
this.toggleExpandableState();
},
deep: true,
},
value() {
this.refreshTextareaHeight();
},
editing() {
if (this.editing) {
this.setCaretAtEnd();
this.refreshTextareaHeight();
return;
}
this.toggleExpandableState();
},
},
computed: {
canEdit() {
return this.permissions?.can_manage && !this.inArchivedRepositoryRow;
}
},
methods: {
handleKeydown(event) {
if (event.key === 'Enter') {
event.preventDefault();
this.$refs.textareaRef.blur();
}
},
handleBlur() {
if ($('.atwho-view:visible').length) return;
if (this.smartAnnotation) {
this.value = this.$refs.textareaRef.value.trim() // Fix for smart annotation
}
this.editing = false;
this.toggleExpandableState();
this.$emit('update', this.value);
},
toggleExpandableState() {
this.$nextTick(() => {
if (!this.$refs[this.unEditableRef]) return;
const maxCollapsedHeight = '80';
const scrollHeight = this.$refs[this.unEditableRef].scrollHeight;
const expandable = scrollHeight > maxCollapsedHeight;
this.$emit('toggleExpandableState', expandable);
});
},
enableEdit(e) {
if (e && $(e.target).hasClass('atwho-user-popover')) return;
if (e && $(e.target).hasClass('sa-link')) return;
if (e && $(e.target).parent().hasClass('atwho-inserted')) return;
this.editing = true;
this.$nextTick(() => {
if (this.smartAnnotation) {
SmartAnnotation.init($(this.$refs.textareaRef), false);
}
});
},
refreshTextareaHeight() {
this.$nextTick(() => {
if (!this.editing) return;
const textarea = this.$refs.textareaRef;
textarea.style.height = '0px';
// 16px is the height of the textarea's line
textarea.style.height = textarea.scrollHeight - 16 + 'px';
});
},
setCaretAtEnd() {
this.$nextTick(() => {
if (!this.editing) return;
this.$refs.textareaRef.focus();
});
},
enforceNumberInput() {
const regexp = this.decimals === 0 ? /[^0-9]/g : /[^0-9.]/g;
const decimalsRegex = new RegExp(`^\\d*(\\.\\d{0,${this.decimals}})?`);
let value = this.value;
value = value.replace(regexp, '');
value = value.match(decimalsRegex)[0];
this.value = value;
}
},
};
</script>

View file

@ -0,0 +1,146 @@
<template>
<div class="w-full relative" ref="container" v-click-outside="closeDropdown">
<button ref="focusElement"
class="btn flex justify-between items-center w-full outline-none border-[1px] bg-white rounded p-2
font-inter text-sm font-normal leading-5"
:class="{
'sci-cursor-edit': !isOpen && withEditCursor,
'border-sn-light-grey hover:border-sn-sleepy-grey': !isOpen,
'border-sn-science-blue': isOpen,
'text-sn-grey': !valueLabel,
[className]: true
}"
:disabled="disabled"
@click="toggle">
<span>{{ 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 class="btn btn-light !text-xs h-[30px] px-0 active:bg-sn-super-light-blue"
@click="selectedValues = []"
:class="{
'disabled cursor-default': !selectedValues.length,
'cursor-pointer': selectedValues.length
}"
>{{ i18n.t('general.clear') }}</div>
<div class="btn btn-light !text-xs h-[30px] px-0 active:bg-sn-super-light-blue"
@click="selectedValues = options.map(option => option.id)"
:class="{
'disabled cursor-default': options.length === selectedValues.length,
'cursor-pointer': options.length !== selectedValues.length}">
{{ i18n.t('general.select_all') }}
</div>
</div>
</div>
<perfect-scrollbar ref="optionsContainer"
class="relative scroll-container px-2.5 pt-0"
:class="{
'block': isOpen,
[optionsClassName]: true
}">
<div v-if="options.length" class="flex flex-col gap-[1px]">
<div v-for="option in options"
:key="option.id"
class="px-3 py-2 rounded hover:bg-sn-super-light-grey cursor-pointer flex gap-1 justify-start items-center">
<div class="sci-checkbox-container">
<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>
</div>
</div>
<template v-else>
<div
class="sn-select__no-options"
>
{{ this.noOptionsPlaceholder }}
</div>
</template>
</perfect-scrollbar>
</div>
</div>
</template>
<script>
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import { vOnClickOutside } from '@vueuse/components'
export default {
name: 'ChecklistSelect',
comments: { PerfectScrollbar },
props: {
withButtons: { type: Boolean, default: false },
withEditCursor: { type: Boolean, default: false },
initialSelectedValues: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
placeholder: { type: String },
noOptionsPlaceholder: { type: String },
disabled: { type: Boolean, default: false },
className: { type: String, default: '' },
optionsClassName: { type: String, default: '' }
},
directives: {
'click-outside': vOnClickOutside
},
data() {
return {
selectedValues: [],
isOpen: false,
optionPositionStyle: ''
}
},
mounted() {
this.selectedValues = this.initialSelectedValues;
},
computed: {
valueLabel() {
if (!this.selectedValues.length) return
if (this.selectedValues.length === 1) return this.options.find(({id}) => id === this.selectedValues[0])?.label
return `${this.selectedValues.length} ${this.i18n.t('general.selected')}`;
}
},
watch: {
initialSelectedValues: {
handler: function (newVal) {
this.selectedValues = newVal;
},
deep: true
}
},
methods: {
toggle() {
this.isOpen = !this.isOpen;
if (this.isOpen) {
this.updateOptionPosition();
} else {
this.closeDropdown();
}
},
updateOptionPosition() {
const container = $(this.$refs.container);
const rect = container.get(0).getBoundingClientRect();
let width = rect.width;
let height = rect.height;
this.optionPositionStyle = `position: absolute; top: ${height}px; left: 0px; width: ${width}px`
},
toggleOption(id) {
if (this.selectedValues.includes(id)) {
this.selectedValues = this.selectedValues.filter((value) => value !== id);
} else {
this.selectedValues.push(id);
}
},
closeDropdown() {
if (!this.isOpen) return;
this.isOpen = false;
this.$emit('update', this.selectedValues.length ? this.selectedValues : null);
},
isSelected(id) {
return this.selectedValues.includes(id);
}
}
}
</script>

View file

@ -109,6 +109,10 @@ export default {
}, (result) => {
fileObject.id = result.data.id;
fileObject.attributes = result.data.attributes;
this.attachments.splice(filePosition, 1);
setTimeout(() => {
this.attachments.push(fileObject);
}, 0);
}).fail(() => {
fileObject.error = I18n.t('attachments.new.general_error');
this.attachments.splice(filePosition, 1);
@ -137,8 +141,8 @@ export default {
changeAttachmentsViewMode(viewMode) {
this.attachmentsParent.attributes.assets_view_mode = viewMode;
this.attachments.forEach((attachment) => {
this.$set(attachment.attributes, 'view_mode', viewMode);
this.$set(attachment.attributes, 'asset_order', this.viewModeOrder[viewMode]);
attachment.attributes['view_mode'] = viewMode;
attachment.attributes['asset_order'] = this.viewModeOrder[viewMode];
});
$.post(this.attachmentsParent.attributes.urls.update_asset_view_mode_url, {
assets_view_mode: viewMode
@ -146,8 +150,8 @@ export default {
},
updateAttachmentViewMode(id, viewMode) {
const attachment = this.attachments.find(e => e.id === id);
this.$set(attachment.attributes, 'view_mode', viewMode);
this.$set(attachment.attributes, 'asset_order', this.viewModeOrder[viewMode]);
attachment.attributes['view_mode'] = viewMode;
attachment.attributes['asset_order'] = this.viewModeOrder[viewMode];
}
}
};

View file

@ -48,7 +48,7 @@
@editingDisabled="disableEditMode"
@editingEnabled="enableEditMode"
/>
<div class="view-text-element" v-else-if="element.attributes.orderable.text_view" v-html="element.attributes.orderable.text_view"></div>
<div class="view-text-element" v-else-if="element.attributes.orderable.text_view" v-html="wrapTables"></div>
<div v-else class="text-sn-grey">
{{ i18n.t("protocols.steps.text.empty_text") }}
</div>
@ -112,6 +112,16 @@
})
},
computed: {
wrapTables() {
const container = $(`<span>${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(`
<div class="table-wrapper" style="overflow: auto; width: 100%"></div>
`);
});
return container.prop('outerHTML');
},
actionMenu() {
let menu = [];
if (this.element.attributes.orderable.urls.update_url) {

View file

@ -1,12 +1,12 @@
<template>
<div class="date-time-picker grow">
<VueDatePicker
ref="datetimePicker"
:class="{
'only-time': mode == 'time',
}"
v-model="compDatetime"
:teleport="teleport"
:text-input="true"
:no-today="true"
:clearable="clearable"
:format="format"
@ -15,6 +15,7 @@
:auto-apply="true"
:partial-flow="true"
:markers="markers"
week-start="0"
:enable-time-picker="mode == 'datetime'"
:time-picker="mode == 'time'"
:placeholder="placeholder" >
@ -56,7 +57,11 @@
clearable: { type: Boolean, default: false },
teleport: { type: Boolean, default: true },
defaultValue: { type: Date, required: false },
placeholder: { type: String }
placeholder: { type: String },
standAlone: { type: Boolean, default: false, required: false },
dateClassName: { type: String, default: '' },
timeClassName: { type: String, default: '' },
disabled: { type: Boolean, default: false }
},
data() {
return {
@ -86,18 +91,25 @@
watch: {
defaultValue: function () {
this.datetime = this.defaultValue;
this.time = {
hours: this.defaultValue ? this.defaultValue.getHours() : 0,
minutes: this.defaultValue ? this.defaultValue.getMinutes() : 0
if (this.defaultValue) {
this.time = {
hours: this.defaultValue.getHours(),
minutes: this.defaultValue.getMinutes()
}
}
},
datetime: function () {
if (this.mode == 'time') {
this.time = null;
if (this.datetime) {
this.time = {
hours: this.datetime ? this.datetime.getHours() : 0,
minutes: this.datetime ? this.datetime.getMinutes() : 0
hours: this.datetime.getHours(),
minutes: this.datetime.getMinutes()
}
}
return
}
@ -129,7 +141,10 @@
newDate.setHours(this.time.hours);
newDate.setMinutes(this.time.minutes);
} else {
newDate = null;
newDate = {
hours: null,
minutes: null
};
this.$emit('cleared');
}
@ -160,6 +175,17 @@
if (this.mode == 'date') return document.body.dataset.datetimePickerFormatVue
return `${document.body.dataset.datetimePickerFormatVue} HH:mm`
}
},
mounted() {
window.addEventListener('resize', this.close);
},
unmounted() {
window.removeEventListener('resize', this.close);
},
methods: {
close() {
this.$refs.datetimePicker.closeMenu();
},
}
}
</script>

View file

@ -9,7 +9,7 @@
<i class="sn-icon sn-icon-close"></i>
</button>
</div>
<div class="sci-flyout-body">
<div class="sci-flyout-body max-h-[400px] overflow-y-auto perfect-scrollbar relative w-[calc(100%_+_1.125rem)] pr-5">
<div v-for="filter in filters" :key="filter.key + resetFilters" class="">
<Component :is="`${filter.type}Filter`" :filter="filter" :value="filterValues[filter.key]" @update="updateFilter" />
</div>

View file

@ -1,19 +1,26 @@
<template>
<div class="mb-6">
<label class="sci-label">{{ filter.label }}</label>
<div class="flex items-center gap-5">
<DateTimePicker
@change="updateDateFrom"
:placeholder="i18n.t('From')"
:dateOnly="true"
:selectorId="`DatePicker${filter.key}`"
/>
<DateTimePicker
@change="updateDateTo"
:placeholder="i18n.t('To')"
:dateOnly="true"
:selectorId="`DatePickerTo${filter.key}`"
/>
<div class="flex items-center gap-6 flex-col">
<div class="w-full">
<label class="sci-label">{{ filter.label }} ({{ i18n.t('general.from') }})</label>
<DateTimePicker
class="w-full"
@change="updateDateFrom"
:placeholder="i18n.t('From')"
:dateOnly="true"
:selectorId="`DatePicker${filter.key}`"
/>
</div>
<div class="w-full">
<label class="sci-label">{{ filter.label }} ({{ i18n.t('general.to') }})</label>
<DateTimePicker
class="w-full"
@change="updateDateTo"
:placeholder="i18n.t('To')"
:dateOnly="true"
:selectorId="`DatePickerTo${filter.key}`"
/>
</div>
</div>
</div>
</template>

View file

@ -4,8 +4,9 @@
<input type="text"
v-if="singleLine"
ref="input"
class="inline-block leading-5 outline-none pl-0 py-1 border-0 border-solid border-y w-full border-t-transparent"
class="inline-block leading-5 outline-none pl-0 border-0 border-solid border-y w-full border-t-transparent"
:class="{
'py-1': !singleLine,
'inline-edit-placeholder text-sn-grey caret-black': isBlank,
'border-b-sn-delete-red': error,
'border-b-sn-science-blue': !error,
@ -33,8 +34,8 @@
<div
v-else
ref="view"
class="grid sci-cursor-edit leading-5 border-0 py-1 outline-none border-solid border-y border-transparent"
:class="{ 'text-sn-grey font-normal': isBlank, 'whitespace-pre-line': !singleLine }"
class="grid sci-cursor-edit leading-5 border-0 outline-none border-solid border-y border-transparent"
:class="{ 'text-sn-grey font-normal': isBlank, 'whitespace-pre-line py-1': !singleLine }"
@click="enableEdit($event)"
>
<span :class="{'truncate': singleLine }" v-if="smartAnnotation" v-html="sa_value || placeholder" ></span>
@ -92,6 +93,11 @@
}
},
watch: {
value(newVal, oldVal) {
if (newVal !== oldVal) {
this.newValue = newVal
}
},
editing() {
this.refreshTexareaHeight()
},
@ -107,7 +113,10 @@
},
computed: {
isBlank() {
return this.newValue.length === 0;
if (typeof this.newValue === 'string') {
return this.newValue.trim().length === 0;
}
return true; // treat as blank for non-string values
},
isContentDefault() {
return this.newValue === this.defaultValue;
@ -152,8 +161,11 @@
this.$emit('blur');
if (this.allowBlank || !this.isBlank) {
this.$nextTick(this.update);
} else {
this.$emit('delete');
} else if (this.isBlank) {
this.newValue = this.value || '';
}
else {
this.$emit('delete')
}
},
focus() {

View file

@ -0,0 +1,75 @@
<template>
<div class="relative" :class="fieldClass">
<label v-if="showLabel" :class="labelClass" :for="id">{{ label }}</label>
<div :class="inputClass">
<input ref="input"
:id="id"
:name="name"
:value="inputValue"
:class="`${error ? 'error' : ''}`"
:placeholder="placeholder"
:required="required"
@input="updateValue"
/>
<div
class="mt-2 text-sn-delete-red whitespace-nowrap truncate text-xs font-normal absolute bottom-[-1rem] w-full"
:title="error"
:class="{ visible: error, invisible: !error}"
>
{{ error }}
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Input',
props: {
id: { type: String, required: false },
fieldClass: { type: String, default: '' },
inputClass: { type: String, default: '' },
labelClass: { type: String, default: '' },
type: { type: String, default: 'text' },
name: { type: String, required: true },
value: { type: [String, Number], required: false },
decimals: { type: [Number, String], default: 0 },
placeholder: { type: String, default: '' },
required: { type: Boolean, default: false },
showLabel: { type: Boolean, default: false },
label: { type: String, required: false },
autoFocus: { type: Boolean, default: false },
error: { type: String, required: false }
},
computed: {
inputValue() {
if (this.type === 'text') return this.value;
return isNaN(this.value) ? '' : this.value;
}
},
methods: {
updateValue($event) {
switch (this.type) {
case 'text':
this.$emit('update', $event.target.value);
break;
case 'number':
const newValue = this.formatDecimalValue($event.target.value);
this.$refs.input.value = newValue;
if (!isNaN(newValue)) this.$emit('update', newValue);
break
default:
break;
}
},
formatDecimalValue(value) {
let decimalValue = value.replace(/[^-0-9.]/g, '');
if (this.decimals === '0') {
return decimalValue.split('.')[0];
}
return decimalValue.match(new RegExp(`^-?\\d*(\\.\\d{0,${this.decimals}})?`))[0];
},
}
}
</script>

View file

@ -1,5 +1,5 @@
<template>
<div class="relative" v-if="listItems.length > 0" >
<div class="relative" v-if="listItems.length > 0" v-click-outside="closeMenu">
<button ref="openBtn" :class="btnClasses" @click="showMenu = !showMenu">
<i v-if="btnIcon" :class="btnIcon"></i>
{{ btnText }}
@ -15,7 +15,7 @@
'!mb-0': !openUp,
}"
v-if="showMenu"
v-click-outside="closeMenu">
>
<span v-for="(item, i) in listItems" :key="i" class="contents">
<div v-if="item.dividerBefore" class="border-0 border-t border-solid border-sn-light-grey"></div>
<a :href="item.url" v-if="!item.submenu"

View file

@ -22,7 +22,7 @@
<div class="step-element-grip step-element-grip--draggable">
<i class="sn-icon sn-icon-drag"></i>
</div>
<div class="step-element-name text-center">
<div class="step-element-name text-center flex items-center gap-2">
<strong v-if="includeNumbers" class="step-element-number">{{ index + 1 }}</strong>
<i v-if="element.attributes.icon" class="fas" :class="element.attributes.icon"></i>
<span :title="nameWithFallbacks(element)" v-if="nameWithFallbacks(element)">{{ nameWithFallbacks(element) }}</span>

View file

@ -6,34 +6,46 @@
</button>
<i class="sn-icon" :class="{ 'sn-icon-down': !isOpen, 'sn-icon-up': isOpen}"></i>
</slot>
<perfect-scrollbar
ref="optionsContainer"
:style="optionPositionStyle"
class="sn-select__options scroll-container p-2.5 block"
>
<div v-if="options.length" class="flex flex-col gap-[1px]">
<div
v-for="option in options"
:key="option[0]"
@mousedown.prevent.stop="setValue(option[0])"
class="sn-select__option p-3 rounded"
:title="option[1]"
:class="{
'select__option-placeholder': option[2],
'!bg-sn-super-light-blue': option[0] == value,
}"
>
{{ option[1] }}
<div :style="optionPositionStyle" class="py-2.5 bg-white z-10 shadow-sn-menu-sm" :class="{ 'hidden': !isOpen }">
<div v-if="withClearButton" class="px-2 pb-2.5">
<div @mousedown.prevent.stop="setValue(null)"
class="btn btn-light !text-xs active:bg-sn-super-light-blue"
:class="{
'disabled cursor-default': !value,
'cursor-pointer': value,
}">
{{ i18n.t('general.clear') }}
</div>
</div>
<template v-else>
<div
class="sn-select__no-options"
>
{{ this.noOptionsPlaceholder }}
<perfect-scrollbar ref="optionsContainer"
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"
:key="option[0]"
@mousedown.prevent.stop="setValue(option[0])"
class="sn-select__option p-3 rounded shadow-none"
:title="option[1]"
:class="{
'select__option-placeholder': option[2],
'!bg-sn-super-light-blue': option[0] == value,
}"
>
{{ option[1] }}
</div>
</div>
</template>
</perfect-scrollbar>
<template v-else>
<div
class="sn-select__no-options"
>
{{ this.noOptionsPlaceholder }}
</div>
</template>
</perfect-scrollbar>
</div>
</div>
</template>
<script>
@ -42,11 +54,15 @@
export default {
name: 'Select',
props: {
withClearButton: { type: Boolean, default: false },
withEditCursor: { type: Boolean, default: false },
value: { type: [String, Number] },
options: { type: Array, default: () => [] },
initialValue: { type: [String, Number] },
placeholder: { type: String },
noOptionsPlaceholder: { type: String },
className: { type: String, default: '' },
optionsClassName: { type: String, default: '' },
disabled: { type: Boolean, default: false }
},
directives: {
@ -100,6 +116,7 @@
const container = $(this.$refs.container);
const rect = container.get(0).getBoundingClientRect();
let width = rect.width;
let height = rect.height;
let top = rect.top + rect.height;
let left = rect.left;
@ -109,9 +126,11 @@
const modalRect = modal.get(0).getBoundingClientRect();
top -= modalRect.top;
left -= modalRect.left;
this.optionPositionStyle = `position: fixed; top: ${top}px; left: ${left}px; width: ${width}px`
} else {
container.addClass('relative');
this.optionPositionStyle = `position: absolute; top: ${height}px; left: 0px; width: ${width}px`
}
this.optionPositionStyle = `position: fixed; top: ${top}px; left: ${left}px; width: ${width}px`
}
}
}

View file

@ -1,6 +1,10 @@
<template>
<Select
class="sn-select--search"
class="sn-select sn-select--search"
:className="className"
:optionsClassName="optionsClassName"
:withEditCursor="withEditCursor"
:withClearButton="withClearButton"
:value="value"
:options="currentOptions"
:placeholder="placeholder"
@ -23,6 +27,8 @@
export default {
name: 'SelectSearch',
props: {
withClearButton: { type: Boolean, default: false },
withEditCursor: { type: Boolean, default: false },
value: { type: [String, Number] },
options: { type: Array, default: () => [] },
optionsUrl: { type: String },
@ -30,7 +36,9 @@
searchPlaceholder: { type: String },
noOptionsPlaceholder: { type: String },
disabled: { type: Boolean },
isLoading: { type: Boolean, default: false }
isLoading: { type: Boolean, default: false },
className: { type: String, default: '' },
optionsClassName: { type: String, default: '' }
},
components: { Select },
data() {

View file

@ -222,7 +222,11 @@ module Reports
merged_file
end
def prepend_title_page
def prepend_title_page(file, template, report, renderer)
unless File.exist?(Rails.root.join('app', 'views', 'reports', 'templates', template, 'cover.html.erb'))
return file
end
total_pages = 0
IO.popen(['pdfinfo', @file.path], 'r+') do |f|

View file

@ -23,6 +23,10 @@ module PrefixedIdModel
self::PREFIXED_ID_SQL = "('#{self::ID_PREFIX}' || #{table_name}.id)".freeze
def self.code(id)
"#{self::ID_PREFIX}#{id}"
end
def code
"#{self.class::ID_PREFIX}#{id}"
end

View file

@ -63,7 +63,13 @@ class MyModuleRepositoryRow < ApplicationRecord
amount: delta,
balance: stock_value.amount,
comment: comment,
unit: stock_value.repository_stock_unit_item&.data
unit: stock_value.repository_stock_unit_item&.data,
my_module_references: {
my_module_id: my_module.id,
experiment_id: my_module.experiment.id,
project_id: my_module.experiment.project.id,
team_id: my_module.experiment.project.team.id
}
)
stock_value.save!
save!

View file

@ -17,4 +17,18 @@ class RepositoryLedgerRecord < ApplicationRecord
end),
optional: true, foreign_key: :reference_id, inverse_of: :repository_ledger_records
has_one :repository_row, through: :repository_stock_value
validate :my_module_references_present?, if: -> { reference.is_a?(MyModuleRepositoryRow) }
private
def my_module_references_present?
return if my_module_references.present? &&
my_module_references['my_module_id'].is_a?(Integer) &&
my_module_references['experiment_id'].is_a?(Integer) &&
my_module_references['project_id'].is_a?(Integer) &&
my_module_references['team_id'].is_a?(Integer)
errors.add(:base, I18n.t('repository_ledger_records.errors.my_module_references_missing'))
end
end

View file

@ -3,7 +3,7 @@
class UserAssignment < ApplicationRecord
attr_accessor :assign
before_validation -> { self.team ||= (assignable.is_a?(Team) ? assignable : assignable.team) }
before_validation :set_assignable_team
after_create :assign_team_child_objects, if: -> { assignable.is_a?(Team) }
after_update :update_team_children_assignments, if: -> { assignable.is_a?(Team) && saved_change_to_user_role_id? }
before_destroy :unassign_team_child_objects, if: -> { assignable.is_a?(Team) }
@ -41,6 +41,10 @@ class UserAssignment < ApplicationRecord
private
def set_assignable_team
self.team ||= (assignable.is_a?(Team) ? assignable : assignable.team)
end
def call_user_assignment_changed_hook
assignable.__send__(:after_user_assignment_changed, self)
end

View file

@ -9,6 +9,14 @@ Canaid::Permissions.register_for(RepositoryBase) do
user.teams.include?(repository.team) || repository.shared_with?(user.current_team)
end
end
can :export_repository_stock do |user, repository|
if repository.is_a?(Repository)
can_read_repository?(user, repository) && repository.has_stock_management?
else
false
end
end
end
Canaid::Permissions.register_for(Repository) do
@ -98,8 +106,4 @@ Canaid::Permissions.register_for(Repository) do
can :manage_repository_stock do |user, repository|
RepositoryBase.stock_management_enabled? && can_manage_repository_rows?(user, repository)
end
can :export_repository_stock do |user, repository|
can_read_repository?(user, repository) && repository.has_stock_management?
end
end

View file

@ -3,27 +3,37 @@
module RepositoryDatatable
class RepositoryStockValueSerializer < RepositoryBaseValueSerializer
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
def value
data = {
stock_formatted: value_object.formatted,
stock_amount: value_object.data,
low_stock_threshold: value_object.low_stock_threshold
low_stock_threshold: value_object.low_stock_threshold,
stock_url: edit_repository_stock_repository_repository_row_url(scope[:repository],
value_object.repository_row)
}
data.merge(reminder_values)
end
private
def reminder_values
data = {}
if scope.dig(:options, :reminders_enabled) &&
!scope[:repository].is_a?(RepositorySnapshot) &&
value_object.data.present? &&
value_object.low_stock_threshold.present?
data[:reminder] = value_object.low_stock_threshold > value_object.data
if data[:reminder] && (data[:stock_amount]).positive?
if data[:reminder] && value_object.data&.positive?
data[:reminder_text] =
I18n.t('repositories.item_card.reminders.stock_low', stock_formated: data[:stock_formatted])
I18n.t('repositories.item_card.reminders.stock_low', stock_formated: value_object.formatted)
elsif data[:reminder]
data[:reminder_text] = I18n.t('repositories.item_card.reminders.stock_empty')
end
end
if data[:stock_amount] <= 0
if value_object.data && value_object.data <= 0
data[:reminder] = true
data[:reminder_text] = I18n.t('repositories.item_card.reminders.stock_empty')
end

View file

@ -50,7 +50,7 @@ module Activities
k = k.to_s.sub('tiny_mce_asset', 'asset').to_sym if k.to_s.include? 'tiny_mce_asset'
if const
if const && (v.is_a?(Hash) || v.to_i != 0 || v.nil?)
if v.is_a?(Hash) # Value is Hash, so you have getter specified
id = v[:id]
getter_method = v[:value_for]

View file

@ -8,9 +8,9 @@ module Experiments
attr_reader :errors
def initialize(experiment_id:, project_id:, user_id:)
@exp = Experiment.find experiment_id
@project = Project.find project_id
@user = User.find user_id
@exp = Experiment.find_by(id: experiment_id)
@project = Project.find_by(id: project_id)
@user = User.find_by(id: user_id)
@original_project = @exp&.project
@errors = {}
end

View file

@ -70,24 +70,26 @@ module Reports::Docx::DrawMyModule
draw_step(step)
end
@docx.h4 I18n.t('Results') if my_module.results.any? && (%w(file_results table_results text_results).any? { |k| @settings.dig('task', k) })
order_results_for_report(my_module.results, @settings.dig('task', 'result_order')).each do |result|
@docx.p do
text result.name.presence || I18n.t('projects.reports.unnamed'), italic: true
text " #{I18n.t('search.index.archived')} ", bold: true if result.archived?
text I18n.t('projects.reports.elements.result.user_time',
timestamp: I18n.l(result.created_at, format: :full),
user: result.user.full_name), color: color[:gray]
end
draw_result_asset(result, @settings)
result.result_orderable_elements.each do |element|
if element.orderable_type == "ResultTable"
draw_result_table(element)
elsif element.orderable_type == "ResultText"
draw_result_text(element)
if my_module.results.any? && (%w(file_results table_results text_results).any? { |k| @settings.dig('task', k) })
@docx.h4 I18n.t('Results')
order_results_for_report(my_module.results, @settings.dig('task', 'result_order')).each do |result|
@docx.p do
text result.name.presence || I18n.t('projects.reports.unnamed'), italic: true
text " #{I18n.t('search.index.archived')} ", bold: true if result.archived?
text I18n.t('projects.reports.elements.result.user_time',
timestamp: I18n.l(result.created_at, format: :full),
user: result.user.full_name), color: color[:gray]
end
draw_result_asset(result, @settings) if @settings.dig('task', 'file_results')
result.result_orderable_elements.each do |element|
if @settings.dig('task', 'table_results') && element.orderable_type == 'ResultTable'
draw_result_table(element)
elsif @settings.dig('task', 'text_results') && element.orderable_type == 'ResultText'
draw_result_text(element)
end
end
draw_result_comments(result) if @settings.dig('task', 'result_comments')
end
draw_result_comments(result) if @settings.dig('task', 'result_comments')
end
@docx.p

View file

@ -168,6 +168,8 @@ class RepositoryDatatableService
end
def build_row_id_filter_condition(repository_rows, filter_element_params)
return repository_rows if filter_element_params.dig(:parameters, :text).blank?
case filter_element_params[:operator]
when 'contains'
repository_rows
@ -183,6 +185,8 @@ class RepositoryDatatableService
end
def build_name_filter_condition(repository_rows, filter_element_params)
return repository_rows if filter_element_params.dig(:parameters, :text).blank?
case filter_element_params[:operator]
when 'contains'
repository_rows.where('repository_rows.name ILIKE ?',
@ -351,14 +355,16 @@ class RepositoryDatatableService
end
def build_assigned_filter_condition(repository_rows, filter_element_params)
return repository_rows if filter_element_params.dig(:parameters, :my_module_ids).blank?
case filter_element_params[:operator]
when 'any_of'
repository_rows.joins(:my_modules)
.where(my_modules: { id: filter_element_params.dig(:parameters, :my_module_ids) })
when 'none_of'
repository_rows.where('NOT EXISTS (SELECT NULL FROM my_module_repository_rows
WHERE my_module_repository_rows.repository_row_id = repository_rows.id AND
my_module_repository_rows.my_module_id IN (?))', filter_element_params.dig(:parameters, :my_module_ids))
WHERE my_module_repository_rows.repository_row_id = repository_rows.id AND
my_module_repository_rows.my_module_id IN (?))', filter_element_params.dig(:parameters, :my_module_ids))
when 'all_of'
repository_rows
.joins(:my_modules)

View file

@ -4,7 +4,7 @@ module RepositoryRows
class UpdateRepositoryRowService
extend Service
attr_reader :repository_row, :params, :errors, :record_updated
attr_reader :repository_row, :params, :errors, :record_updated, :cell, :column
def initialize(repository_row:, user:, params:)
@repository_row = repository_row
@ -20,31 +20,31 @@ module RepositoryRows
@repository_row.with_lock do
# Update invetory row's cells
params[:repository_cells]&.each do |column_id, value|
column = @repository_row.repository.repository_columns.find_by(id: column_id)
next unless column
@column = @repository_row.repository.repository_columns.find_by(id: column_id)
next unless @column
cell = @repository_row.repository_cells.find_by(repository_column_id: column.id)
@cell = @repository_row.repository_cells.find_by(repository_column_id: @column.id)
if cell.present? && value.blank?
cell.destroy!
if @cell.present? && value.blank?
@cell.destroy!
@record_updated = true
next
elsif cell.blank? && value.present?
RepositoryCell.create_with_value!(@repository_row, column, value, @user)
elsif @cell.blank? && value.present?
RepositoryCell.create_with_value!(@repository_row, @column, value, @user)
@record_updated = true
next
elsif cell.blank? && value.blank?
elsif @cell.blank? && value.blank?
next
end
if cell.value.data_different?(value)
cell.value.update_data!(value, @user)
if @cell.value.data_different?(value)
@cell.value.update_data!(value, @user)
@record_updated = true
end
end
# Update invetory rows
@repository_row.attributes = params[:repository_row]
@repository_row.name = params[:repository_row][:name] if params.dig(:repository_row, :name).present?
if @repository_row.changed?
@repository_row.last_modified_by = @user

View file

@ -17,6 +17,8 @@ module RepositoryStockLedgerZipExport
project
experiment
task
project_id
experiment_id
task_id
stock_amount_balance
stock_balance_unit
@ -51,15 +53,13 @@ module RepositoryStockLedgerZipExport
if (consumption_type == 'Task' && record.amount.positive?) ||
(consumption_type == 'Inventory' && record.amount.negative?)
consumed_amount = record.amount.abs.to_d
consumed_amount = record.amount.abs
consumed_amount_unit = record.unit
else
added_amount = record.amount.abs.to_d
added_amount = record.amount.abs
added_amount_unit = record.unit
end
breadcrumbs_data = Array.new(4, '')
row_data = [
consumption_type,
record.repository_row.name,
@ -70,23 +70,45 @@ module RepositoryStockLedgerZipExport
added_amount_unit,
record.user.full_name,
I18n.l(record.created_at, format: :full),
record.balance.to_d,
record.balance,
record.unit
]
breadcrumbs_data = Array.new(5)
if consumption_type == 'Task'
my_module = record.my_module_repository_row&.my_module
breadcrumbs_data = [
my_module&.experiment&.project&.team&.name,
my_module&.experiment&.project&.name,
my_module&.experiment&.name,
my_module&.name,
my_module&.code
]
end
breadcrumbs_data =
if consumption_type == 'Task'
build_breadcrumbs(record)
else
Array.new(7)
end
row_data.insert(9, *breadcrumbs_data)
row_data
end
def build_breadcrumbs(record)
if record.my_module_repository_row.present?
my_module = record.my_module_repository_row.my_module
[
my_module.experiment.project.team.name,
my_module.experiment.project.name,
my_module.experiment.name,
my_module.name,
my_module.experiment.project.code,
my_module.experiment.code,
my_module.code
]
elsif record.my_module_references.present?
[
Team.find_by(id: record.my_module_references['team_id'])&.name,
nil,
nil,
nil,
Project.code(record.my_module_references['project_id']),
Experiment.code(record.my_module_references['experiment_id']),
MyModule.code(record.my_module_references['my_module_id'])
]
else
Array.new(7)
end
end
end
end

View file

@ -175,6 +175,8 @@ class TeamImporter
i += 1 while experiment_names.include?("#{exp_name} (#{i})")
experiment_json['experiment']['name'] = "#{exp_name} (#{i})"
end
experiment = nil
ActiveRecord::Base.transaction do
ActiveRecord::Base.no_touching do
experiment = create_experiment(experiment_json, project, user_id)
@ -193,9 +195,9 @@ class TeamImporter
UserAssignments::GenerateUserAssignmentsJob.perform_now(my_module, user.id)
end
puts "Imported experiment: #{experiment.id}"
return experiment
end
end
experiment
ensure
# Reset callbacks
MyModule.set_callback(:create, :before, :create_blank_protocol)

View file

@ -19,7 +19,7 @@ module ProtocolImporters
published_on: protocol_hash[:published_on],
version: protocol_hash[:version_id],
source_id: protocol_hash[:id],
name: unescape(protocol_hash[:title]),
name: protocol_hash[:title] ? unescape(protocol_hash[:title]) : nil,
description: {
body: protocol_hash[:description],
image: protocol_hash[:image][:source],

View file

@ -1,4 +1,4 @@
<div class="quick-start-buttons">
<div class="quick-start-buttons" data-e2e="e2e-CO-dashboard-quickStartButtons" >
<div class="new-task btn btn-secondary"><i class="sn-icon sn-icon-new-task"></i><%= t("dashboard.quick_start.new_task") %></div>
<% if can_create_protocols_in_repository?(current_team) %>
<button data-toggle="modal" data-target="#newProtocolModal" class="btn btn-secondary">

View file

@ -12,7 +12,7 @@
</div>
</div>
<div class="widget-body">
<div class="recent-work-container perfect-scrollbar" data-url="<%= dashboard_recent_works_path %>"></div>
<div class="recent-work-container perfect-scrollbar" data-url="<%= dashboard_recent_works_path %>" data-e2e="e2e-CO-dashboard-recentWork"></div>
</div>
</div>

View file

@ -1,7 +1,7 @@
<% provide :head_title, t('nav.label.dashboard') %>
<% if current_team %>
<div class="dashboard-view">
<div class="dashboard-view" data-e2e="e2e-CO-dashboard">
<div class="dashboard-background"></div>
<div class="dashboard-header">
<%= render partial: 'quick_start' %>

View file

@ -11,7 +11,7 @@
</span>
<% if due_date_editable %>
<div class="datetime-picker-container vue-date-time-picker h-full" id="calendarDueDateContainer<%= my_module.id %>">
<input ref="input" type="hidden" data-simple-format="true" v-model="date" id="calendarDueDate<%= my_module.id %>" data-default="<%= my_module.due_date %>" />
<input ref="input" type="hidden" data-simple-format="true" v-model="date" id="calendarDueDate<%= my_module.id %>" data-default="<%= l(my_module.due_date, format: :default) if my_module.due_date %>" />
<date-time-picker class="opacity-0" ref="vueDateTime" @change="updateDate" mode="datetime" placeholder="<%= t('my_modules.details.no_due_date_placeholder') %>"></date-time-picker>
</div>
<div class="sn-icon sn-icon-close clear-date <%= 'tw-hidden' if !my_module.due_date %>"

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