Merge branch 'features/repository-templates' into develop

This commit is contained in:
Martin Artnik 2025-04-25 14:20:37 +02:00
commit 4e5f9c738d
46 changed files with 961 additions and 88 deletions

View file

@ -117,7 +117,6 @@ var DasboardCurrentTasksWidget = (function() {
}
}
appendTasksList(result, '.current-tasks-list-wrapper');
PerfectSb().update_all();
InfiniteScroll.init('.current-tasks-list-wrapper', {
url: $currentTasksList.data('tasksListUrl'),

View file

@ -32,8 +32,6 @@ var DasboardRecentWorkWidget = (function() {
} else {
container.append($('#recent-work-no-results-template').html());
}
PerfectSb().update_all();
});
}

View file

@ -1,4 +1,4 @@
/* global PerfectScrollbar activePSB PerfectSb I18n */
/* global I18n */
/* eslint-disable no-unused-vars, no-use-before-define */
/*
@ -301,7 +301,6 @@ var dropdownSelector = (function() {
function generateDropdown(selector, config = {}) {
var selectElement = $(selector);
var optionContainer;
var perfectScroll;
var dropdownContainer;
var toggleElement;
@ -410,10 +409,6 @@ var dropdownSelector = (function() {
}
});
// Initialize scroll bar inside options container
perfectScroll = new PerfectScrollbar(dropdownContainer.find('.dropdown-container')[0]);
activePSB.push(perfectScroll);
// Select options container
optionContainer = dropdownContainer.find('.dropdown-container');
@ -450,7 +445,6 @@ var dropdownSelector = (function() {
if (dropdownContainer.hasClass('open')) {
// Each time we open option container we must scroll it
dropdownContainer.find('.dropdown-container').scrollTop(0);
PerfectSb().update_all();
// on Open we load new data
loadData(selectElement, dropdownContainer);
@ -652,9 +646,6 @@ var dropdownSelector = (function() {
$(`<div class="empty-dropdown">${I18n.t('dropdown_selector.nothing_found')}</div>`).appendTo(container.find('.dropdown-container'));
}
// Update scrollbar
PerfectSb().update_all();
// Check position of option dropdown
refreshDropdownSelection(selector, container);
@ -865,7 +856,6 @@ var dropdownSelector = (function() {
}].concat(optionsAjax);
}
loadData(selector, container, optionsAjax);
PerfectSb().update_all();
});
// For local options we convert options element from select to correct array
} else if (selector.data('select-by-group')) {

View file

@ -8,6 +8,7 @@
.recent-work-container {
height: 100%;
overflow-y: auto;
padding: 0 8px;
position: relative;

View file

@ -155,6 +155,7 @@
.activities-container {
height: 100%;
overflow-y: auto;
padding-top: 10px;
position: absolute;
width: 100%;
@ -284,6 +285,7 @@
.filters-container {
height: 100%;
margin-bottom: 60px;
overflow-y: auto;
padding: 15px 20px;
position: absolute;
}

View file

@ -135,7 +135,7 @@
bottom: calc(100% - 30px);
box-shadow: $flyout-shadow;
display: none;
overflow: hidden;
overflow-y: auto;
position: fixed;
transition: .2s;
transition-property: top, bottom, box-shadow;

View file

@ -40,6 +40,7 @@ class ProtocolsController < ApplicationController
unlink
unlink_modal
delete_steps
list_published_protocol_templates
)
before_action :check_manage_with_read_protocol_permissions, only: %i(
@ -798,6 +799,26 @@ class ProtocolsController < ApplicationController
}
end
def list_published_protocol_templates
protocols = Protocol.latest_available_versions_without_drafts(current_team)
.active
.with_granted_permissions(current_user, ProtocolPermissions::READ)
if params[:query].present?
protocols = protocols.where_attributes_like(
['protocols.name', Protocol::PREFIXED_ID_SQL],
params[:query]
)
end
protocols = protocols.order('LOWER(protocols.name) asc').page(params[:page])
render json: {
paginated: true,
next_page: protocols.next_page,
data: protocols.map { |protocol_template| [protocol_template.id, protocol_template.name] }
}
end
def protocol_status_bar
render json: {
html: render_to_string(partial: 'my_modules/protocols/protocol_status_bar', formats: :html)

View file

@ -151,17 +151,31 @@ class RepositoriesController < ApplicationController
end
def create
@repository = Repository.new(
team: current_team,
created_by: current_user
)
@repository.assign_attributes(repository_params)
Repository.transaction do
@repository = Repository.new(team: current_team, created_by: current_user)
@repository.assign_attributes(repository_params)
if @repository.save
@repository.save!
log_activity(:create_inventory)
repository_template = current_team.repository_templates.find_by(id: repository_params[:repository_template_id])
if repository_template.present?
repository_template.column_definitions&.each do |column_attributes|
service = RepositoryColumns::CreateColumnService
.call(user: current_user, repository: @repository, team: current_team,
column_type: column_attributes['column_type'],
params: column_attributes['params'].with_indifferent_access)
unless service.succeed?
render json: service.errors, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
end
end
render json: { message: t('repositories.index.modal_create.success_flash_html', name: @repository.name) }
else
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error e.message
render json: @repository.errors, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
end
@ -548,7 +562,7 @@ class RepositoriesController < ApplicationController
end
def repository_params
params.require(:repository).permit(:name)
params.require(:repository).permit(:name, :repository_template_id)
end
def import_params

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
class RepositoryTemplatesController < ApplicationController
before_action :check_read_permissions
before_action :load_repository_template, only: :list_repository_columns
def index
repository_templates = current_team.repository_templates.order(:id)
render json: {
data: repository_templates.map { |repository_template| [repository_template.id, repository_template.name] }
}
end
def list_repository_columns
render json: {
name: @repository_template.name,
columns: @repository_template.column_definitions&.map do |column|
[column.dig('params', 'name'), I18n.t("libraries.manange_modal_column.select.#{RepositoryColumn.data_types.key(column['column_type']).underscore}")]
end
}
end
private
def load_repository_template
@repository_template = current_team.repository_templates.find_by(id: params[:id])
render_404 unless @repository_template
end
def check_read_permissions
render_403 unless can_create_repositories?(current_team)
end
end

View file

@ -7,11 +7,11 @@ class StepsController < ApplicationController
before_action :load_vars, only: %i(update destroy show toggle_step_state update_view_state
update_asset_view_mode elements
attachments upload_attachment duplicate)
before_action :load_vars_nested, only: %i(create index reorder)
before_action :load_vars_nested, only: %i(create index reorder list_protocol_steps add_protocol_steps)
before_action :convert_table_contents_to_utf8, only: %i(create update)
before_action :check_protocol_manage_permissions, only: %i(reorder)
before_action :check_view_permissions, only: %i(show index attachments elements)
before_action :check_protocol_manage_permissions, only: %i(reorder add_protocol_steps)
before_action :check_view_permissions, only: %i(show index attachments elements list_protocol_steps)
before_action :check_create_permissions, only: %i(create)
before_action :check_manage_permissions, only: %i(update destroy
update_view_state update_asset_view_mode upload_attachment)
@ -267,6 +267,49 @@ class StepsController < ApplicationController
}
end
def list_protocol_steps
steps = @protocol.steps
if params[:query].present?
steps = steps.where_attributes_like(
['steps.name'],
params[:query]
)
end
steps = steps.order(position: :asc).page(params[:page])
render json: {
paginated: true,
next_page: steps.next_page,
data: steps.map { |step| [step.id, step.name] }
}
end
def add_protocol_steps
Protocol.transaction do
selected_protocol = Protocol.find_by(id: params[:selected_protocol])
render_403 unless selected_protocol.present? && can_read_protocol_in_repository?(selected_protocol)
steps = selected_protocol.steps.where(id: params[:steps]).order(position: :asc).map do |step|
step.duplicate(@protocol, current_user, original_protocol: selected_protocol)
end
message_items = {
protocol: selected_protocol.id,
count: steps.count
}
if @protocol.in_module?
message_items[:my_module] = @protocol.my_module.id
log_activity(:task_steps_loaded_from_template, @my_module.experiment.project, message_items)
else
log_activity(:protocol_steps_loaded_from_template, nil, message_items)
end
render json: steps, each_serializer: StepSerializer, user: current_user
end
end
private
def load_vars

View file

@ -81,18 +81,7 @@ class LoadFromRepositoryProtocolsDatatable < CustomDatatable
end
def get_raw_records_base
original_without_versions = @team.protocols
.left_outer_joins(:published_versions)
.where(protocol_type: Protocol.protocol_types[:in_repository_published_original])
.where(published_versions: { id: nil })
.select(:id)
published_versions = @team.protocols
.where(protocol_type: Protocol.protocol_types[:in_repository_published_version])
.order('parent_id, version_number DESC')
.select('DISTINCT ON (parent_id) id')
Protocol.where("protocols.id IN ((#{original_without_versions.to_sql}) UNION (#{published_versions.to_sql}))")
Protocol.latest_available_versions_without_drafts(@team)
.active
.with_granted_permissions(@user, ProtocolPermissions::READ)
end

View file

@ -46,7 +46,9 @@
<ProtocolOptions
v-if="protocol.attributes && protocol.attributes.urls"
:protocol="protocol"
:inRepository="inRepository"
@protocol:delete_steps="deleteSteps"
@protocol:add_protocol_steps="addSteps"
:canDeleteSteps="steps.length > 0 && urls.delete_steps_url !== null"
/>
<button class="btn btn-light icon-btn" data-toggle="modal" data-target="#print-protocol-modal" tabindex="0">
@ -128,7 +130,7 @@
</div>
<div :class="inRepository ? 'protocol-section protocol-steps-section protocol-information' : ''">
<div v-if="inRepository" id="protocol-steps" class="protocol-section-header">
<div class="protocol-steps-container">
<div class="protocol-steps-container w-full flex flex-row items-center justify-between">
<a class="protocol-section-caret" role="button" data-toggle="collapse" href="#protocol-steps-container" aria-expanded="false" aria-controls="protocol-steps-container">
<i class="sn-icon sn-icon-right"></i>
<span id="protocolStepsLabel" class="protocol-section-title" data-e2e="e2e-TX-protocol-templateSteps-title">
@ -137,6 +139,14 @@
</h2>
</span>
</a>
<ProtocolOptions
v-if="protocol.attributes && protocol.attributes.urls"
:protocol="protocol"
:inRepository="inRepository"
@protocol:delete_steps="deleteSteps"
@protocol:add_protocol_steps="addSteps"
:canDeleteSteps="steps.length > 0 && urls.delete_steps_url !== null"
/>
</div>
</div>
<div class="sci-divider my-4" v-if="!inRepository"></div>
@ -372,6 +382,9 @@ export default {
HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger');
});
},
addSteps(steps) {
this.steps.push(...steps);
},
refreshProtocolStatus() {
if (this.inRepository) return;
// legacy method from app/assets/javascripts/my_modules/protocols.js

View file

@ -0,0 +1,119 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<form @submit.prevent="submit">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate !block" >
{{ i18n.t('protocols.steps.modals.add_protocol_steps.title') }}
</h4>
</div>
<div class="modal-body">
<p class="mb-6">{{ i18n.t('protocols.steps.modals.add_protocol_steps.description')}}</p>
<div class="mb-6">
<label class="sci-label">{{ i18n.t('protocols.steps.modals.add_protocol_steps.protocol_label') }}</label>
<SelectDropdown
:placeholder="i18n.t('protocols.steps.modals.add_protocol_steps.protocol_placeholder')"
:optionsUrl="protocolsUrl"
:searchable="true"
:value="selectedProtocol"
@change="selectedProtocol = $event"
></SelectDropdown>
</div>
<div class="relative">
<label class="sci-label">{{ i18n.t('protocols.steps.modals.add_protocol_steps.protocol_steps_label') }}</label>
<SelectDropdown
:key="selectedProtocol"
:disabled="!selectedProtocol"
:optionsUrl="protocolStepsUrl"
:urlParams="{ selected_protocol_id: selectedProtocol }"
:placeholder="i18n.t('protocols.steps.modals.add_protocol_steps.protocol_steps_placeholder')"
:multiple="true"
:withCheckboxes="true"
:searchable="true"
:value="selectedSteps"
@change="selectedSteps= $event"
></SelectDropdown>
</div>
</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" :disabled="submitting || !validObject" type="submit">
{{ i18n.t('protocols.steps.modals.add_protocol_steps.confirm') }}
</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
/* global HelperModule */
import axios from '../../../packs/custom_axios.js';
import modalMixin from '../../shared/modal_mixin.js';
import SelectDropdown from '../../shared/select_dropdown.vue';
import {
list_published_protocol_templates_protocol_path,
list_protocol_steps_protocol_steps_path
} from '../../../routes.js';
export default {
name: 'AddStepsModal',
props: {
protocol: Object
},
components: {
SelectDropdown
},
mixins: [modalMixin],
data() {
return {
selectedProtocol: null,
selectedRow: null,
submitting: false,
selectedSteps: null
};
},
computed: {
validObject() {
return this.selectedProtocol && this.selectedSteps;
},
protocolsUrl() {
return list_published_protocol_templates_protocol_path(this.protocol.id);
},
protocolStepsUrl() {
if (!this.selectedProtocol) {
return null;
}
return list_protocol_steps_protocol_steps_path(this.selectedProtocol);
}
},
mounted() {
// move modal to body to avoid z-index issues
$('body').append($(this.$refs.modal));
},
methods: {
submit() {
this.submitting = true;
axios.post(this.protocol.attributes.urls.add_protocol_steps_url, {
selected_protocol: this.selectedProtocol,
steps: this.selectedSteps
}).then((data) => {
this.submitting = false;
this.$emit('confirm', data.data.data);
this.close();
HelperModule.flashAlertMsg(this.i18n.t('protocols.steps.modals.add_protocol_steps.success_flash', { count: data.data.data.length }), 'success');
}).catch(() => {
this.submitting = false;
HelperModule.flashAlertMsg(this.i18n.t('protocols.steps.modals.add_protocol_steps.error_flash'), 'danger');
});
}
}
};
</script>

View file

@ -17,7 +17,7 @@
class="dropdown-menu dropdown-menu-right rounded !p-2.5 sn-shadow-menu-sm"
aria-labelledby="dropdownProtocolOptions"
>
<li v-if="protocol.attributes.urls.load_from_repo_url">
<li v-if="protocol.attributes.urls.load_from_repo_url && !inRepository">
<a class="!px-3 !py-2.5 hover:!bg-sn-super-light-blue !text-sn-blue"
ref="loadProtocol"
data-action="load-from-repository"
@ -26,7 +26,17 @@
<span>{{ i18n.t("my_modules.protocol.options_dropdown.load_from_repo") }}</span>
</a>
</li>
<li>
<li v-if="protocol.attributes.urls.add_protocol_steps_url">
<a class="!px-3 !py-2.5 hover:!bg-sn-super-light-blue !text-sn-blue"
data-turbolinks="false"
@click.prevent="openAddStepsModal()"
>
<span>{{
i18n.t("my_modules.protocol.options_dropdown.add_protocol_steps")
}}</span>
</a>
</li>
<li v-if="!inRepository">
<a class="!px-3 !py-2.5 hover:!bg-sn-super-light-blue !text-sn-blue"
data-toggle="modal"
data-target="#newProtocolModal"
@ -38,7 +48,7 @@
}}</span>
</a>
</li>
<li>
<li v-if="!inRepository">
<a class="!px-3 !py-2.5 hover:!bg-sn-super-light-blue !text-sn-blue"
data-turbolinks="false"
:href="protocol.attributes.urls.export_url"
@ -49,7 +59,7 @@
}}</span>
</a>
</li>
<li v-if="protocol.attributes.urls.update_protocol_url">
<li v-if="protocol.attributes.urls.update_protocol_url && !inRepository">
<a class="!px-3 !py-2.5 hover:!bg-sn-super-light-blue !text-sn-blue"
ref="updateProtocol"
data-action="update-self"
@ -60,7 +70,7 @@
}}</span>
</a>
</li>
<li v-if="protocol.attributes.urls.unlink_url">
<li v-if="protocol.attributes.urls.unlink_url && !inRepository">
<a class="!px-3 !py-2.5 hover:!bg-sn-super-light-blue !text-sn-blue"
ref="unlinkProtocol"
data-action="unlink"
@ -71,7 +81,7 @@
}}</span>
</a>
</li>
<li v-if="protocol.attributes.urls.revert_protocol_url">
<li v-if="protocol.attributes.urls.revert_protocol_url && !inRepository">
<a class="!px-3 !py-2.5 hover:!bg-sn-super-light-blue !text-sn-blue"
ref="revertProtocol"
data-action="revert"
@ -95,19 +105,22 @@
</ul>
</div>
<DeleteStepsModals v-if="stepsDeleting" @confirm="deleteSteps" @close="closeStartStepsDeletingModal" />
<AddStepsModal v-if="stepsAdding" :protocol="protocol" @confirm="addSteps" @close="closeAddStepsModal" />
</div>
</template>
<script>
import DeleteStepsModals from './modals/delete_steps';
import AddStepsModal from './modals/add_protocol_steps';
export default {
name: 'ProtocolOptions',
components: { DeleteStepsModals },
components: { DeleteStepsModals, AddStepsModal },
data() {
return {
stepsDeleting: false
stepsDeleting: false,
stepsAdding: false
};
},
props: {
@ -118,12 +131,18 @@ export default {
canDeleteSteps: {
type: Boolean,
required: true
},
inRepository: {
type: Boolean,
required: true
}
},
mounted() {
// Legacy global functions from app/assets/javascripts/my_modules/protocols.js
initLoadFromRepository();
initLinkUpdate();
if (!this.inRepository) {
initLoadFromRepository();
initLinkUpdate();
}
},
methods: {
openStepsDeletingModal() {
@ -132,6 +151,12 @@ export default {
closeStartStepsDeletingModal() {
this.stepsDeleting = false;
},
openAddStepsModal() {
this.stepsAdding = true;
},
closeAddStepsModal() {
this.stepsAdding = false;
},
loadProtocol() {
$.get(
`${this.protocol.attributes.urls.load_from_repo_url}?type=recent`
@ -156,6 +181,9 @@ export default {
},
deleteSteps() {
this.$emit('protocol:delete_steps');
},
addSteps(steps) {
this.$emit('protocol:add_protocol_steps', steps);
}
}
};

View file

@ -22,10 +22,37 @@
:placeholder="i18n.t('repositories.index.modal_create.name_placeholder')" />
</div>
</div>
<div>
<label class="sci-label">{{ i18n.t("repositories.index.modal_create.repository_template_label") }}</label>
<SelectDropdown
:options="repositoryTemplates"
:value="repositoryTemplate"
:optionRenderer="repositoryTemplateOptionRenderer"
@change="repositoryTemplate = $event"
@close="showColumnInfo = false"
/>
<div v-if="showColumnInfo" class="absolute -right-64 w-60 bg-white border border-radius p-4 min-h-[10rem]">
<div v-if="loadingHoveredRow" class="flex absolute top-0 left-0 items-center justify-center w-full flex-grow h-full z-10">
<img src="/images/medium/loading.svg" alt="Loading" />
</div>
<template v-else>
<div class="flex gap-4 overflow-hidden items-centers">
<div class="truncate font-bold">{{ hoveredRow.name }}</div>
</div>
<template v-if="hoveredRow.columns">
<div class="flex items-center gap-0.5 overflow-hidden text-xs" v-for="column in hoveredRow.columns">
<span class="truncate shrink-c">{{ column[0] }}</span>
<span>-</span>
<span class="truncate shrink-0">{{ column[1] }}</span>
</div>
</template>
</template>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal" data-e2e="e2e-BT-newInventoryModal-cancel">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" type="submit" :disabled="submitting || !validName" data-e2e="e2e-BT-newInventoryModal-create">
<button class="btn btn-primary" type="submit" :disabled="submitting || !validName || !repositoryTemplate" data-e2e="e2e-BT-newInventoryModal-create">
{{ i18n.t('repositories.index.modal_create.submit') }}
</button>
</div>
@ -40,9 +67,18 @@
import axios from '../../../packs/custom_axios.js';
import modalMixin from '../../shared/modal_mixin';
import SelectDropdown from '../../shared/select_dropdown.vue';
import {
repository_templates_path,
list_repository_columns_repository_template_path
} from '../../../routes.js';
export default {
name: 'NewRepositoryModal',
components: {
SelectDropdown
},
props: {
createUrl: String
},
@ -51,21 +87,42 @@ export default {
return {
name: '',
error: null,
submitting: false
submitting: false,
repositoryTemplates: [],
repositoryTemplate: null,
showColumnInfo: false,
hoveredRow: {},
loadingHoveredRow: false
};
},
created() {
this.fetctRepositoryTemplates();
},
mounted() {
document.addEventListener('mouseover', this.loadColumnsInfo);
},
beforeDestroy() {
document.removeEventListener('mouseover', this.loadColumnsInfo);
},
computed: {
repositoryTemplateUrl() {
return repository_templates_path();
},
validName() {
return this.name.length >= GLOBAL_CONSTANTS.NAME_MIN_LENGTH;
}
},
methods: {
listRepositoryTemplateColumnsUrl(repositoryTemplateId) {
return list_repository_columns_repository_template_path(repositoryTemplateId);
},
submit() {
this.submitting = true;
axios.post(this.createUrl, {
repository: {
name: this.name
name: this.name,
repository_template_id: this.repositoryTemplate
}
}).then((response) => {
this.error = null;
@ -76,6 +133,45 @@ export default {
this.submitting = false;
this.error = error.response.data.name;
});
},
repositoryTemplateOptionRenderer(row) {
return `
<div class="flex items-center gap-4 w-full">
<div class="grow overflow-hidden">
<div class="truncate" >${row[1]}</div>
</div>
<i class="sn-icon sn-icon-info show-items-columns" title="" data-item-id="${row[0]}"></i>
</div>`;
},
fetctRepositoryTemplates() {
axios.get(this.repositoryTemplateUrl)
.then((response) => {
this.repositoryTemplates = response.data.data;
[this.repositoryTemplate] = this.repositoryTemplates[0];
});
},
loadColumnsInfo(e) {
if (!e.target.classList.contains('show-items-columns')) {
this.showColumnInfo = false;
this.hoveredRow = {};
return;
}
this.loadingHoveredRow = true;
this.showColumnInfo = true;
axios.get(this.listRepositoryTemplateColumnsUrl(e.target.dataset.itemId))
.then((response) => {
this.loadingHoveredRow = false;
this.hoveredRow = {
name: response.data.name,
columns: response.data.columns
};
});
e.stopPropagation();
e.preventDefault();
}
}
};

View file

@ -7,12 +7,13 @@
<template v-for="item in insertMenu">
<button v-if="!item.submenu" @click="$emit(item.emit)" class="btn btn-light">
<i :class="item.icon"></i>
{{ item.text }}
<span class="tw-hidden lg:inline">{{ item.text }}</span>
</button>
<MenuDropdown
:listItems="item.submenu"
:btnText="item.text"
:btnClasses="'btn btn-light'"
:smallScreenCollapse="true"
:position="'right'"
:caret="true"
:btnIcon="item.icon"

View file

@ -1,5 +1,5 @@
<template>
<div v-if="params.data.comments">
<div v-if="params.data.comments" class="w-9 flex justify-end">
<span v-if="!params.data.permissions.create_comments && params.data.comments.count === 0">0</span>
<a v-else
href="#"

View file

@ -2,7 +2,7 @@
<div class="relative" v-if="listItems.length > 0 || alwaysShow" v-click-outside="closeMenu" >
<button ref="field" :class="btnClasses" :title="title" @click="isOpen = !isOpen" :data-e2e="dataE2e">
<i v-if="btnIcon" :class="btnIcon"></i>
{{ btnText }}
<span :class="{'tw-hidden lg:inline': smallScreenCollapse}">{{ btnText }}</span>
<i v-if="caret && isOpen" class="sn-icon sn-icon-up"></i>
<i v-else-if="caret" class="sn-icon sn-icon-down"></i>
</button>
@ -84,7 +84,8 @@ export default {
caret: { type: Boolean, default: false },
alwaysShow: { type: Boolean, default: false },
title: { type: String, default: '' },
dataE2e: { type: String, default: '' }
dataE2e: { type: String, default: '' },
smallScreenCollapse: { type: Boolean, default: false }
},
data() {
return {

View file

@ -373,7 +373,7 @@ export default {
let request = {};
if (this.ajaxMethod === 'get') {
if (this.ajaxMethod.toLowerCase() === 'get') {
request = { method: 'get', url: this.optionsUrl, params };
} else {
request = { method: 'post', url: this.optionsUrl, data: params };

View file

@ -144,6 +144,7 @@ class Protocol < ApplicationRecord
dependent: :destroy
has_many :protocol_keywords, through: :protocol_protocol_keywords
has_many :steps, inverse_of: :protocol, dependent: :destroy
has_many :original_steps, class_name: 'Step', foreign_key: :original_protocol_id, inverse_of: :original_protocol, dependent: :nullify
has_many :users, through: :user_assignments
def self.search(user,
@ -218,6 +219,21 @@ class Protocol < ApplicationRecord
where('protocols.id IN ((?) UNION ALL (?) UNION ALL (?))', original_without_versions, published_versions, new_drafts)
end
def self.latest_available_versions_without_drafts(teams)
team_protocols = where(team: teams)
original_without_versions = team_protocols
.where.missing(:published_versions)
.where(protocol_type: Protocol.protocol_types[:in_repository_published_original])
.select(:id)
published_versions = team_protocols
.where(protocol_type: Protocol.protocol_types[:in_repository_published_version])
.order(:parent_id, version_number: :desc)
.select('DISTINCT ON (parent_id) id')
where('protocols.id IN ((?) UNION ALL (?))', original_without_versions, published_versions)
end
def self.viewable_by_user(user, teams, options = {})
if options[:fetch_latest_versions]
protocol_templates = latest_available_versions(teams)

View file

@ -22,6 +22,7 @@ class Repository < RepositoryBase
class_name: 'User',
inverse_of: :restored_repositories,
optional: true
belongs_to :repository_template, inverse_of: :repositories, optional: true
has_many :repository_snapshots,
class_name: 'RepositorySnapshot',
foreign_key: :parent_id,

View file

@ -0,0 +1,221 @@
# frozen_string_literal: true
class RepositoryTemplate < ApplicationRecord
belongs_to :team, inverse_of: :repository_templates
has_many :repositories, inverse_of: :repository_template, dependent: :destroy
def self.default
RepositoryTemplate.new(
name: I18n.t('repository_templates.default_template_name'),
column_definitions: [],
predefined: true
)
end
def self.cell_lines
RepositoryTemplate.new(
name: I18n.t('repository_templates.cell_lines_template_name'),
column_definitions: [
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.species') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.organ') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryListValue],
params: { name: I18n.t('repository_templates.template_columns.morphology'),
metadata: { delimiter: I18n.t('repository_templates.repository_list_value_delimiter') },
repository_list_items_attributes: [{ data: I18n.t('repository_templates.template_columns.repository_list_value.endothelial') },
{ data: I18n.t('repository_templates.template_columns.repository_list_value.epithelial') },
{ data: I18n.t('repository_templates.template_columns.repository_list_value.fibroblast') },
{ data: I18n.t('repository_templates.template_columns.repository_list_value.lymphoblast') }] }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryListValue],
params: { name: I18n.t('repository_templates.template_columns.culture_type'),
metadata: { delimiter: I18n.t('repository_templates.repository_list_value_delimiter') },
repository_list_items_attributes: [{ data: I18n.t('repository_templates.template_columns.repository_list_value.adherent') },
{ data: I18n.t('repository_templates.template_columns.repository_list_value.suspension') }] }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryStockValue],
params: { name: I18n.t('repository_templates.template_columns.stock'),
metadata: { decimals: 2 },
repository_stock_unit_items_attributes: RepositoryStockUnitItem::DEFAULT_UNITS.map { |unit| { data: unit } } +
[{ data: I18n.t('repository_templates.template_columns.stock_units.vials') }] }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryNumberValue],
params: { name: I18n.t('repository_templates.template_columns.passage_number') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.lot_number') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryDateValue],
params: { name: I18n.t('repository_templates.template_columns.freezing_date') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.operator') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.yield') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryStatusValue],
params: { name: I18n.t('repository_templates.template_columns.status'),
repository_status_items_attributes: [{ status: I18n.t('repository_templates.template_columns.repository_status_value.frozen'), icon: '❄️' },
{ status: I18n.t('repository_templates.template_columns.repository_status_value.in_subculturing'), icon: '🧫' },
{ status: I18n.t('repository_templates.template_columns.repository_status_value.out_of_tock'), icon: '❌' }] }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryAssetValue],
params: { name: I18n.t('repository_templates.template_columns.handling_procedure') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.notes') }
}
],
predefined: true
)
end
def self.equipment
RepositoryTemplate.new(
name: I18n.t('repository_templates.equipment_template_name'),
column_definitions: [
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryDateValue],
params: { name: I18n.t('repository_templates.template_columns.calibration_date'),
reminder_value: 1, reminder_unit: 2419200, reminder_message: I18n.t('repository_templates.template_columns.calibration_message') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryStatusValue],
params: { name: I18n.t('repository_templates.template_columns.availability_status'),
repository_status_items_attributes: [{ status: I18n.t('repository_templates.template_columns.repository_status_value.available_for_use'), icon: '🟢' },
{ status: I18n.t('repository_templates.template_columns.repository_status_value.in_use'), icon: '🟥' },
{ status: I18n.t('repository_templates.template_columns.repository_status_value.out_of_service'), icon: '❌' },
{ status: I18n.t('repository_templates.template_columns.repository_status_value.under_maintenance'), icon: '🔧' }] }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryAssetValue],
params: { name: I18n.t('repository_templates.template_columns.safety_handling_info') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryAssetValue],
params: { name: I18n.t('repository_templates.template_columns.training_records') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.contact_person') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.contact_phone') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.internal_id') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.manufacturer') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.serial_number') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.notes') }
}
],
predefined: true
)
end
def self.chemicals_and_reagents
RepositoryTemplate.new(
name: I18n.t('repository_templates.chemicals_and_reagents_template_name'),
column_definitions: [
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.concentration') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryStockValue],
params: { name: I18n.t('repository_templates.template_columns.stock'),
metadata: { decimals: 2 },
repository_stock_unit_items_attributes: RepositoryStockUnitItem::DEFAULT_UNITS.map { |unit| { data: unit } } }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryDateValue],
params: { name: I18n.t('repository_templates.template_columns.date_opened') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryDateValue],
params: { name: I18n.t('repository_templates.template_columns.expiration_date'),
reminder_value: 1, reminder_unit: 2419200, reminder_message: I18n.t('repository_templates.template_columns.expiration_date_message') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryListValue],
params: { name: I18n.t('repository_templates.template_columns.storage_conditions'),
metadata: { delimiter: I18n.t('repository_templates.repository_list_value_delimiter') },
repository_list_items_attributes: [{ data: I18n.t('repository_templates.template_columns.repository_list_value.minus_twenty_celsious') },
{ data: I18n.t('repository_templates.template_columns.repository_list_value.two_to_eigth_celsious') },
{ data: I18n.t('repository_templates.template_columns.repository_list_value.minus_eigthty') },
{ data: I18n.t('repository_templates.template_columns.repository_list_value.ambient') }] }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryListValue],
params: { name: I18n.t('repository_templates.template_columns.type'),
metadata: { delimiter: I18n.t('repository_templates.repository_list_value_delimiter') },
repository_list_items_attributes: [{ data: I18n.t('repository_templates.template_columns.repository_list_value.buffer') },
{ data: I18n.t('repository_templates.template_columns.repository_list_value.liquid') },
{ data: I18n.t('repository_templates.template_columns.repository_list_value.reagent') },
{ data: I18n.t('repository_templates.template_columns.repository_list_value.solid') }] }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.purity') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.cas_number') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryAssetValue],
params: { name: I18n.t('repository_templates.template_columns.safety_sheet') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryListValue],
params: { name: I18n.t('repository_templates.template_columns.vendor') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.catalog_number') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.lot') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.price') }
},
{
column_type: Extends::REPOSITORY_DATA_TYPES[:RepositoryTextValue],
params: { name: I18n.t('repository_templates.template_columns.molecular_weight') }
}
],
predefined: true
)
end
end

View file

@ -32,6 +32,7 @@ class Step < ApplicationRecord
belongs_to :user, inverse_of: :steps
belongs_to :last_modified_by, foreign_key: 'last_modified_by_id', class_name: 'User', optional: true
belongs_to :protocol, inverse_of: :steps
belongs_to :original_protocol, class_name: 'Protocol', optional: true, inverse_of: :original_steps
delegate :team, to: :protocol
has_many :step_orderable_elements, inverse_of: :step, dependent: :destroy
has_many :checklists, inverse_of: :step, dependent: :destroy
@ -118,7 +119,7 @@ class Step < ApplicationRecord
step_texts.order(created_at: :asc).first
end
def duplicate(protocol, user, step_position: nil, step_name: nil, include_file_versions: false)
def duplicate(protocol, user, step_position: nil, step_name: nil, include_file_versions: false, original_protocol: nil)
ActiveRecord::Base.transaction do
assets_to_clone = []
@ -126,7 +127,8 @@ class Step < ApplicationRecord
name: step_name || name,
position: step_position || protocol.steps.length,
completed: false,
user: user
user: user,
original_protocol: original_protocol
)
new_step.save!

View file

@ -14,6 +14,7 @@ class Team < ApplicationRecord
before_save -> { shareable_links.destroy_all }, if: -> { !shareable_links_enabled? }
after_create :generate_template_project
after_create :create_default_label_templates
after_create :create_default_repository_templates
scope :teams_select, -> { select(:id, :name).order(name: :asc) }
scope :ordered, -> { order('LOWER(name)') }
@ -44,6 +45,7 @@ class Team < ApplicationRecord
has_many :activities, inverse_of: :team, dependent: :destroy
has_many :assets, inverse_of: :team, dependent: :destroy
has_many :label_templates, dependent: :destroy
has_many :repository_templates, inverse_of: :team, dependent: :destroy
has_many :team_shared_objects, inverse_of: :team, dependent: :destroy
has_many :team_shared_repositories,
-> { where(shared_object_type: 'RepositoryBase') },
@ -210,4 +212,11 @@ class Team < ApplicationRecord
ZebraLabelTemplate.default_203dpi.update(team: self, default: false)
FluicsLabelTemplate.default.update(team: self, default: true)
end
def create_default_repository_templates
RepositoryTemplate.default.update(team: self)
RepositoryTemplate.cell_lines.update(team: self)
RepositoryTemplate.equipment.update(team: self)
RepositoryTemplate.chemicals_and_reagents.update(team: self)
end
end

View file

@ -98,7 +98,8 @@ class ProtocolSerializer < ActiveModel::Serializer
version_comment_url: version_comment_url,
print_protocol_url: print_protocol_url,
versions_modal: versions_modal_protocol_path(object.parent || object),
redirect_to_protocols: protocols_path
redirect_to_protocols: protocols_path,
add_protocol_steps_url: add_protocol_steps_url
}
end
@ -150,6 +151,12 @@ class ProtocolSerializer < ActiveModel::Serializer
export_protocols_path(protocol_ids: object.id, my_module_id: object.my_module.id)
end
def add_protocol_steps_url
return unless can_manage_protocol_in_module?(object) || can_manage_protocol_draft_in_repository?(object)
add_protocol_steps_protocol_steps_path(object)
end
def steps_url
return unless can_read_protocol_in_module?(object) || can_read_protocol_in_repository?(object)

View file

@ -76,7 +76,7 @@
</div>
<div class="widget-body">
<div class="current-tasks-list-wrapper perfect-scrollbar">
<div class="current-tasks-list-wrapper">
<div class="current-tasks-list "
data-tasks-list-url="<%= dashboard_current_tasks_path %>">
</div>

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 %>" data-e2e="e2e-CO-dashboard-recentWork"></div>
<div class="recent-work-container" data-url="<%= dashboard_recent_works_path %>" data-e2e="e2e-CO-dashboard-recentWork"></div>
</div>
</div>

View file

@ -7,7 +7,7 @@
</div>
<div class="ga-bottom">
<div class="ga-main">
<div class="activities-container perfect-scrollbar">
<div class="activities-container">
<div class="no-activities-message text-center <%= 'hidden' if @grouped_activities.keys.any? %>">
<p><%= t('activities.index.no_activities_message') %></p>
</div>
@ -24,7 +24,7 @@
</div>
</div>
<div class="ga-side">
<div class="filters-container perfect-scrollbar">
<div class="filters-container">
<%= render partial: "side_filters" %>
</div>
</div>

View file

@ -2,16 +2,21 @@
<button type="button" class="close self-start" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title">
<% if downstream %>
<% if downstream %>
<h4 class="modal-title !line-clamp-3" title="<%= t('my_modules.modals.assign_repository_record.title_downstream',
repository_name: repository.name) %>">
<%= t('my_modules.modals.assign_repository_record.title_downstream',
repository_name: repository.name) %>
<% else %>
repository_name: repository.name) %>
</h4>
<% else %>
<h4 class="modal-title !line-clamp-3" title="<%= t('my_modules.modals.assign_repository_record.title',
repository_name: repository.name,
my_module_name: my_module.name) %>">
<%= t('my_modules.modals.assign_repository_record.title',
repository_name: repository.name,
my_module_name: my_module.name) %>
<% end %>
</h4>
repository_name: repository.name,
my_module_name: my_module.name) %>
</h4>
<% end %>
</div>
<div class="modal-body">
<p>

View file

@ -6,16 +6,21 @@
<button type="button" class="close self-start" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title">
<% if downstream %>
<%= t('my_modules.modals.update_repository_record.title_downstream',
<% if downstream %>
<h4 class="modal-title !line-clamp-3" title="<%= t('my_modules.modals.update_repository_record.title_downstream',
repository_name: repository.name) %>">
<%= t('my_modules.modals.update_repository_record.title_downstream',
repository_name: repository.name) %>
<% else %>
</h4>
<% else %>
<h4 class="modal-title !line-clamp-3" title="<%= t('my_modules.modals.update_repository_record.title',
repository_name: repository.name,
my_module_name: my_module.name) %>">
<%= t('my_modules.modals.update_repository_record.title',
repository_name: repository.name,
my_module_name: my_module.name) %>
<% end %>
</h4>
</h4>
<% end %>
</div>
<div class="modal-body">
<% if downstream %>

View file

@ -1,5 +1,5 @@
<div class="dropdown asset-context-menu">
<button class="btn btn-light btn-xs dropdown-toggle icon-btn" type="button" id="dropdownAssetContextMenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
<button class="btn btn-secondary dropdown-toggle icon-btn" type="button" id="dropdownAssetContextMenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
<i class="sn-icon sn-icon-more-hori"></i>
</button>

View file

@ -38,10 +38,10 @@
<%= link_to shared_asset_download_path(@shareable_link.uuid, asset), class: 'btn btn-light icon-btn thumbnail-action-btn', data: { turbolinks: false } do %>
<span class="sn-icon sn-icon-export"></span>
<% end %>
<% if defined?(show_context) && show_context %>
<%= render partial: "shareable_links/my_modules/step_attachments/context_menu", locals: { asset: asset } %>
<% end %>
</div>
<% if defined?(show_context) && show_context %>
<%= render partial: "shareable_links/my_modules/step_attachments/context_menu", locals: { asset: asset } %>
<% end %>
</div>
</div>

View file

@ -556,7 +556,9 @@ class Extends
protocol_step_form_moved: 355,
form_block_duplicated: 356,
form_duplicated: 357,
form_unpublished: 358
form_unpublished: 358,
task_steps_loaded_from_template: 359,
protocol_steps_loaded_from_template: 360
}
ACTIVITY_GROUPS = {
@ -565,7 +567,7 @@ class Extends
task: [8, 58, 9, 59, *10..14, 35, 36, 37, 53, 54, *60..63, 138, 139, 140, 64, 66, 106, 126, 120, 132,
148, 166],
task_protocol: [15, 22, 16, 18, 19, 20, 21, 17, 38, 39, 100, 111, 45, 46, 47, 121, 124, 115, 118, 127, 130, 137,
184, 185, 188, 189, *192..203, 221, 222, 224, 225, 226, 236, *249..252, *274..278, 299, 302, 305, 327, *347..352],
184, 185, 188, 189, *192..203, 221, 222, 224, 225, 226, 236, *249..252, *274..278, 299, 302, 305, 327, *347..352, 359],
task_inventory: [55, 56, 146, 147, 183],
experiment: [*27..31, 57, 141, 165],
reports: [48, 50, 49, 163, 164],
@ -574,7 +576,7 @@ class Extends
protocol_repository: [80, 103, 89, 87, 79, 90, 91, 88, 85, 86, 84, 81, 82,
83, 101, 112, 123, 125, 117, 119, 129, 131, 187, 186,
190, 191, *204..215, 220, 223, 227, 228, 229, *230..235,
*237..240, *253..256, *279..283, 300, 304, 307, 330, *353..355],
*237..240, *253..256, *279..283, 300, 304, 307, 330, *353..355, 360],
team: [92, 94, 93, 97, 104, 244, 245],
label_templates: [*216..219],
storage_locations: [*309..315],

View file

@ -1323,6 +1323,76 @@ en:
unreachable: "Printer is offline"
search: "Checking printer status"
repository_templates:
default_template_name: 'Default template'
cell_lines_template_name: 'Cell lines template'
equipment_template_name: 'Equipment template'
chemicals_and_reagents_template_name: "Chemicals & reagents template"
repository_list_value_delimiter: 'return'
template_columns:
species: 'Species'
organ: 'Organ'
morphology: 'Morphology'
culture_type: 'Culture Type'
stock: 'Stock'
passage_number: 'Passage Number'
lot_number: 'Lot Number'
freezing_date: 'Freezing Date'
operator: 'Operator'
yield: 'Yield'
status: 'Status'
handling_procedure: 'Handling Procedure'
notes: 'Notes'
calibration_date: 'Calibration Date'
calibration_message: 'Consider recalibration.'
availability_status: 'Availability Status'
safety_handling_info: 'Safety & Handling Info'
training_records: 'Training Records'
contact_person: 'Contact Person'
contact_phone: 'Contact Phone'
internal_id: 'Internal ID'
manufacturer: 'Manufacturer'
serial_number: 'Serial Number'
concentration: 'Concentration'
date_opened: 'Date Opened'
expiration_date: 'Expiration Date'
expiration_date_message: 'Consider replacing the item.'
storage_conditions: 'Storage Conditions'
type: 'Type'
purity: 'Purity'
cas_number: 'CAS Number'
safety_sheet: 'Safety Sheet'
vendor: 'Vendor'
catalog_number: 'Catalog Number'
lot: 'Lot'
price: 'Price'
molecular_weight: 'Molecular Weight'
repository_list_value:
endothelial: 'Endothelial'
epithelial: 'Epithelial'
fibroblast: 'Fibroblast'
lymphoblast: 'Lymphoblast'
adherent: 'Adherent'
suspension: 'Suspension'
minus_twenty_celsious: "-20°C"
two_to_eigth_celsious: "2°C to 8°C"
minus_eigthty: "-80°C"
ambient: 'Ambient'
buffer: 'Buffer'
liquid: 'Liquid'
reagent: 'Reagent'
solid: 'Solid'
stock_units:
vials: 'Vial(s)'
repository_status_value:
frozen: 'Frozen'
in_subculturing: 'In subculturing'
out_of_tock: 'Out of stock'
available_for_use: 'Available for use'
in_use: 'In use'
out_of_service: 'Out of service'
under_maintenance: 'Under maintenance'
my_modules:
details:
title: "Details"
@ -1387,6 +1457,7 @@ en:
update_protocol: "New version update"
save_to_repo: "Save as new template"
delete_steps: "Delete all steps"
add_protocol_steps: "Add protocol steps"
description:
title: "Edit task %{module} description"
label: "Description"
@ -2178,6 +2249,7 @@ en:
name_placeholder: "My inventory"
submit: "Create"
success_flash_html: "Inventory <strong>%{name}</strong> successfully created."
repository_template_label: "Select inventory template"
modal_confirm_sharing:
title: "Inventory sharing changes"
description_1: "You will no longer share this inventory with some of the teams. All unshared inventory items assigned to tasks will be automatically removed and this action is irreversible. Any item relationship links (if they exist) will also be deleted."
@ -3999,6 +4071,18 @@ en:
name_label: 'Well plate name'
name_placeholder: 'Plate'
dimension_label: 'Dimensions (rows x columns)'
add_protocol_steps:
title: "Add steps from another template"
description: "Select one or more steps from a different protocol template to add them as new steps at the end of your current protocol."
confirm: "Add steps"
protocol_label: "Protocol"
protocol_placeholder: "Select protocol template"
protocol_steps_label: "Protocol steps"
protocol_steps_placeholder: "Select steps"
success_flash:
one: "%{count} step successfully added."
other: "%{count} steps successfully added."
error_flash: "There was an error importing steps. Please try again."
options:
up_arrow_title: "Move step up"
down_arrow_title: "Move step down"

View file

@ -372,6 +372,8 @@ en:
form_duplicated_html: "%{user} duplicated form %{form} from %{form_old} in Forms."
form_block_duplicated_html: "%{user} duplicated form block %{block_name} in form %{form} in Form templates."
form_unpublished_html: "%{user} unpublished form %{form}."
task_steps_loaded_from_template_html: "%{user} added %{count} steps from template %{protocol} to task %{my_module}."
protocol_steps_loaded_from_template_html: "%{user} added %{count} steps from template %{protocol}."
activity_name:
create_project: "Project created"
rename_project: "Project renamed"
@ -701,6 +703,8 @@ en:
form_duplicated: "Form duplicated"
form_block_duplicated: "Form block duplicated"
form_unpublished: "Form unpublished"
task_steps_loaded_from_template: "Task step loaded from template"
protocol_steps_loaded_from_template: "Step loaded from template"
activity_group:
projects: "Projects"
task_results: "Task results"

View file

@ -659,7 +659,11 @@ Rails.application.routes.draw do
resources :protocols, only: %i(index show edit create update) do
resources :steps, only: [:create] do
post :reorder, on: :collection
collection do
post :reorder
get :list_protocol_steps
post :add_protocol_steps
end
end
member do
post :publish
@ -697,6 +701,7 @@ Rails.application.routes.draw do
post 'delete_steps'
get :permissions
put :update_version_comment
get :list_published_protocol_templates
end
collection do
post 'archive', to: 'protocols#archive'
@ -841,6 +846,12 @@ Rails.application.routes.draw do
get :repositories
end
resources :repository_templates, only: %i(index) do
member do
get :list_repository_columns
end
end
resources :connected_devices, controller: 'users/connected_devices', only: %i(destroy)
resources :storage_locations, only: %i(index create destroy update show) do

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateRepositoryTemplate < ActiveRecord::Migration[7.0]
def change
create_table :repository_templates do |t|
t.string :name
t.jsonb :column_definitions
t.references :team, index: true, foreign_key: { to_table: :teams }
t.boolean :predefined, null: false, default: false
t.timestamps
end
add_reference :repositories, :repository_template, null: true, foreign_key: true
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class AddRepositoryTemplatesToTeams < ActiveRecord::Migration[7.0]
def up
Team.find_each do |team|
RepositoryTemplate.default.update(team: team)
RepositoryTemplate.cell_lines.update(team: team)
RepositoryTemplate.equipment.update(team: team)
RepositoryTemplate.chemicals_and_reagents.update(team: team)
end
end
def down
# rubocop:disable Rails/SkipsModelValidations
Repository.update_all(repository_template_id: nil)
# rubocop:enable Rails/SkipsModelValidations
RepositoryTemplate.destroy_all
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddOriginalProtocolToSteps < ActiveRecord::Migration[7.0]
def change
add_reference :steps, :original_protocol, null: true, foreign_key: { to_table: :protocols }
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2025_03_25_124848) do
ActiveRecord::Schema[7.0].define(version: 2025_04_02_092301) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gist"
enable_extension "pg_trgm"
@ -729,10 +729,12 @@ ActiveRecord::Schema[7.0].define(version: 2025_03_25_124848) do
t.bigint "restored_by_id"
t.string "external_id"
t.integer "repository_rows_count", default: 0, null: false
t.bigint "repository_template_id"
t.index ["archived"], name: "index_repositories_on_archived"
t.index ["archived_by_id"], name: "index_repositories_on_archived_by_id"
t.index ["discarded_at"], name: "index_repositories_on_discarded_at"
t.index ["my_module_id"], name: "index_repositories_on_my_module_id"
t.index ["repository_template_id"], name: "index_repositories_on_repository_template_id"
t.index ["restored_by_id"], name: "index_repositories_on_restored_by_id"
t.index ["team_id", "external_id"], name: "unique_index_repositories_on_external_id", unique: true
t.index ["team_id"], name: "index_repositories_on_team_id"
@ -1026,6 +1028,16 @@ ActiveRecord::Schema[7.0].define(version: 2025_03_25_124848) do
t.index ["user_id"], name: "index_repository_table_states_on_user_id"
end
create_table "repository_templates", force: :cascade do |t|
t.string "name"
t.jsonb "column_definitions"
t.bigint "team_id"
t.boolean "predefined", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["team_id"], name: "index_repository_templates_on_team_id"
end
create_table "repository_text_values", force: :cascade do |t|
t.string "data"
t.datetime "created_at", precision: nil
@ -1585,6 +1597,7 @@ ActiveRecord::Schema[7.0].define(version: 2025_03_25_124848) do
add_foreign_key "reports", "projects"
add_foreign_key "reports", "users"
add_foreign_key "reports", "users", column: "last_modified_by_id"
add_foreign_key "repositories", "repository_templates"
add_foreign_key "repositories", "users", column: "archived_by_id"
add_foreign_key "repositories", "users", column: "created_by_id"
add_foreign_key "repositories", "users", column: "restored_by_id"
@ -1630,6 +1643,7 @@ ActiveRecord::Schema[7.0].define(version: 2025_03_25_124848) do
add_foreign_key "repository_stock_values", "users", column: "created_by_id"
add_foreign_key "repository_stock_values", "users", column: "last_modified_by_id"
add_foreign_key "repository_table_filters", "users", column: "created_by_id"
add_foreign_key "repository_templates", "teams"
add_foreign_key "repository_text_values", "users", column: "created_by_id"
add_foreign_key "repository_text_values", "users", column: "last_modified_by_id"
add_foreign_key "result_assets", "assets"

View file

@ -8,6 +8,7 @@ describe RepositoriesController, type: :controller do
let!(:user) { controller.current_user }
let!(:team) { create :team, created_by: user }
let(:action) { post :create, params: params, format: :json }
let(:repository_template) { create :repository_template, team: team }
describe 'index' do
let(:repository) { create :repository, team: team, created_by: user }
@ -29,7 +30,7 @@ describe RepositoriesController, type: :controller do
end
describe 'POST create' do
let(:params) { { repository: { name: 'My Repository' } } }
let(:params) { { repository: { name: 'My Repository', repository_template_id: repository_template.id } } }
it 'calls create activity for creating inventory' do
expect(Activities::CreateActivityService)
@ -43,6 +44,13 @@ describe RepositoriesController, type: :controller do
expect { action }
.to(change { Activity.count })
end
it 'returns success response' do
expect { action }.to change(Repository, :count).by(1)
expect(response).to have_http_status(:success)
expect(response.media_type).to eq 'application/json'
expect(Repository.order(created_at: :desc).first.repository_template).to eq repository_template
end
end
describe 'DELETE destroy' do

View file

@ -0,0 +1,49 @@
# frozen_string_literal: true
require 'rails_helper'
describe RepositoryTemplatesController, type: :controller do
login_user
let!(:user) { controller.current_user }
let!(:team) { create :team, created_by: user }
describe 'index' do
let(:action) { get :index, format: :json }
it 'correct JSON format' do
action
expect(response).to have_http_status(:success)
expect(response.media_type).to eq 'application/json'
parsed_response = JSON.parse(response.body)
expect(parsed_response['data'].count).to eq(4)
end
end
describe 'list_repository_columns' do
let(:repository_template) { team.repository_templates.last }
let(:action) { get :list_repository_columns, params: { id: repository_template } ,format: :json }
let(:action_invalid) { get :list_repository_columns, params: { id: -1 } ,format: :json }
it 'correct JSON format' do
action
expect(response).to have_http_status(:success)
expect(response.media_type).to eq 'application/json'
parsed_response = JSON.parse(response.body)
expect(parsed_response['name']).to eq(repository_template.name)
expect(parsed_response['columns'].count).to eq(repository_template.column_definitions.count)
end
it 'invalid id' do
action_invalid
expect(response).to have_http_status(:not_found)
end
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
FactoryBot.define do
factory :repository_template do
association :team
end
end

View file

@ -24,6 +24,7 @@ describe Repository, type: :model do
describe 'Relations' do
it { should belong_to :team }
it { should belong_to(:created_by).class_name('User') }
it { should belong_to(:repository_template).optional }
it { should have_many :repository_rows }
it { should have_many :repository_table_states }
it { should have_many :report_elements }

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
require 'rails_helper'
describe RepositoryTemplate, type: :model do
let(:repository_template) { build :repository_template }
it 'is valid' do
expect(repository_template).to be_valid
end
it 'should be of class Repository Template' do
expect(subject.class).to eq RepositoryTemplate
end
describe 'Database table' do
it { should have_db_column :name }
it { should have_db_column :team_id }
it { should have_db_column :column_definitions }
it { should have_db_column :predefined }
it { should have_db_column :created_at }
it { should have_db_column :updated_at }
end
describe 'Relations' do
it { should belong_to :team }
it { should have_many(:repositories).dependent(:destroy) }
end
end

View file

@ -37,6 +37,7 @@ describe Team, type: :model do
it { should have_many(:team_shared_objects).dependent(:destroy) }
it { should have_many :shared_repositories }
it { should have_many(:shareable_links).dependent(:destroy) }
it { should have_many(:repository_templates).dependent(:destroy) }
end
describe 'Validations' do
@ -87,5 +88,12 @@ describe Team, type: :model do
expect(team.shareable_links.count).to eq(0)
end
end
context 'repository templates after team create' do
it 'create repository templates after team create' do
expect_any_instance_of(Team).to receive(:create_default_repository_templates)
team.save!
end
end
end
end