diff --git a/app/api/chemotion/device_description_api.rb b/app/api/chemotion/device_description_api.rb index 256e1f6474..25afe0475c 100644 --- a/app/api/chemotion/device_description_api.rb +++ b/app/api/chemotion/device_description_api.rb @@ -204,6 +204,26 @@ def device_description_with_entity(device_description) end end + # split device description into sub device description + namespace :sub_device_descriptions do + params do + requires :ui_state, type: Hash, desc: 'Selected device descriptions from the UI' + end + post do + ui_state = params[:ui_state] + col_id = ui_state[:currentCollectionId] + element_params = ui_state[:device_description] + device_description_ids = + DeviceDescription.for_user(current_user.id) + .for_ui_state_with_collection(element_params, CollectionsDeviceDescription, col_id) + DeviceDescription.where(id: device_description_ids).each do |device_description| + device_description.create_sub_device_description(current_user, col_id) + end + + {} # JS layer does not use the reply + end + end + # return serialized device description by id params do requires :id, type: Integer diff --git a/app/models/device_description.rb b/app/models/device_description.rb index 198911772f..ea7e522c8d 100644 --- a/app/models/device_description.rb +++ b/app/models/device_description.rb @@ -44,13 +44,14 @@ # deleted_at :datetime # created_by :integer # ontologies :jsonb +# ancestry :string # # Indexes # # index_device_descriptions_on_device_id (device_id) # class DeviceDescription < ApplicationRecord - attr_accessor :collection_id + attr_accessor :collection_id, :is_split include ElementUIStateScopes # include PgSearch::Model @@ -73,6 +74,7 @@ class DeviceDescription < ApplicationRecord has_many :comments, as: :commentable, inverse_of: :commentable, dependent: :destroy has_one :container, as: :containable, inverse_of: :containable, dependent: :nullify + has_ancestry orphan_strategy: :adopt accepts_nested_attributes_for :collections_device_descriptions @@ -85,10 +87,43 @@ def analyses end def set_short_label + return if is_split == true + prefix = 'Dev' counter = creator.increment_counter 'device_descriptions' # rubocop:disable Rails/SkipsModelValidations user_label = creator.name_abbreviation update(short_label: "#{user_label}-#{prefix}#{counter}") end + + def counter_for_split_short_label + element_children = children.with_deleted.order('created_at') + last_child_label = element_children.where('short_label LIKE ?', "#{short_label}-%").last&.short_label + last_child_counter = (last_child_label&.match(/^#{short_label}-(\d+)/) && ::Regexp.last_match(1).to_i) || 0 + + [last_child_counter, element_children.count].max + end + + def all_collections(user, collection_ids) + Collection.where(id: collection_ids) | Collection.where(user_id: user, label: 'All', is_locked: true) + end + + def create_sub_device_description(user, collection_ids) + device_description = dup + segments = device_description.segments + + device_description.is_split = true + device_description.short_label = "#{short_label}-#{counter_for_split_short_label + 1}" + device_description.parent = self + device_description.created_by = user.id + device_description.container = Container.create_root_container + device_description.attachments = [] + device_description.segments = [] + device_description.collections << all_collections(user, collection_ids) + device_description.save! + + device_description.save_segments(segments: segments, current_user_id: user.id) if segments + + device_description.reload + end end diff --git a/app/packs/src/components/contextActions/CreateButton.js b/app/packs/src/components/contextActions/CreateButton.js index 161fdbb7c7..22e1fdf5b6 100644 --- a/app/packs/src/components/contextActions/CreateButton.js +++ b/app/packs/src/components/contextActions/CreateButton.js @@ -127,6 +127,28 @@ export default class CreateButton extends React.Component { ClipboardActions.fetchDeviceDescriptionsByUIState(params, 'copy_device_description'); } + noDeviceDescriptionSelected() { + const { device_description } = UIStore.getState() + return device_description.checkedIds.size == 0 && device_description.checkedAll == false + } + + splitSelectionAsSubDeviceDescription() { + const uiState = UIStore.getState() + let params = { + ui_state: { + device_description: { + all: uiState.device_description.checkedAll, + included_ids: uiState.device_description.checkedIds, + excluded_ids: uiState.device_description.uncheckedIds, + }, + currentCollectionId: uiState.currentCollection.id, + isSync: uiState.isSync, + } + } + + ElementActions.splitAsSubDeviceDescription(params); + } + createWellplateFromSamples() { let uiState = UIStore.getState(); let sampleFilter = this.filterParamsFromUIStateByElementType(uiState, "sample"); @@ -321,6 +343,10 @@ export default class CreateButton extends React.Component { > Split Wellplate + this.splitSelectionAsSubDeviceDescription()} + disabled={this.noDeviceDescriptionSelected() || this.isAllCollection()}> + Split Device Description + ) diff --git a/app/packs/src/fetchers/DeviceDescriptionFetcher.js b/app/packs/src/fetchers/DeviceDescriptionFetcher.js index 80f7007c95..e14b11d498 100644 --- a/app/packs/src/fetchers/DeviceDescriptionFetcher.js +++ b/app/packs/src/fetchers/DeviceDescriptionFetcher.js @@ -23,6 +23,17 @@ export default class DeviceDescriptionFetcher { .catch(errorMessage => console.log(errorMessage)); } + static splitAsSubDeviceDescription(params) { + return fetch('/api/v1/device_descriptions/sub_device_descriptions/', + { + ...this._httpOptions('POST'), + body: JSON.stringify(params) + } + ).then(response => response.json()) + .then((json) => json) + .catch(errorMessage => console.log(errorMessage)); + } + static fetchById(deviceDescriptionId) { return fetch( `/api/v1/device_descriptions/${deviceDescriptionId}`, diff --git a/app/packs/src/stores/alt/actions/ElementActions.js b/app/packs/src/stores/alt/actions/ElementActions.js index 8511e1c78d..1cfc0e844b 100644 --- a/app/packs/src/stores/alt/actions/ElementActions.js +++ b/app/packs/src/stores/alt/actions/ElementActions.js @@ -938,6 +938,17 @@ class ElementActions { return collection_id; } + splitAsSubDeviceDescription(ui_state) { + return (dispatch) => { + DeviceDescriptionFetcher.splitAsSubDeviceDescription(ui_state) + .then((result) => { + dispatch(ui_state.ui_state); + }).catch((errorMessage) => { + console.log(errorMessage); + }); + }; + } + // -- DataCite/Radar metadata -- fetchMetadata(collection_id) { diff --git a/app/packs/src/stores/alt/stores/ElementStore.js b/app/packs/src/stores/alt/stores/ElementStore.js index dfc23bab29..8290dac01d 100644 --- a/app/packs/src/stores/alt/stores/ElementStore.js +++ b/app/packs/src/stores/alt/stores/ElementStore.js @@ -258,6 +258,7 @@ class ElementStore { handleRemoveElementsCollection: ElementActions.removeElementsCollection, handleSplitAsSubsamples: ElementActions.splitAsSubsamples, handleSplitAsSubwellplates: ElementActions.splitAsSubwellplates, + handleSplitAsSubDeviceDescription: ElementActions.splitAsSubDeviceDescription, // formerly from DetailStore handleSelect: DetailActions.select, handleClose: DetailActions.close, @@ -967,6 +968,12 @@ class ElementStore { } } + handleSplitAsSubDeviceDescription(ui_state) { + ElementActions.fetchDeviceDescriptionsByCollectionId( + ui_state.currentCollectionId, {}, ui_state.isSync + ); + } + // -- Reactions -- handleFetchReactionById(result) { diff --git a/db/migrate/20240424181556_add_ancestry_to_device_descriptions.rb b/db/migrate/20240424181556_add_ancestry_to_device_descriptions.rb new file mode 100644 index 0000000000..428c2065de --- /dev/null +++ b/db/migrate/20240424181556_add_ancestry_to_device_descriptions.rb @@ -0,0 +1,6 @@ +class AddAncestryToDeviceDescriptions < ActiveRecord::Migration[6.1] + def change + add_column :device_descriptions, :ancestry, :string + add_index :device_descriptions, :ancestry + end +end diff --git a/db/schema.rb b/db/schema.rb index 4dc92b10c4..cc2aacd524 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2024_04_17_092033) do +ActiveRecord::Schema.define(version: 2024_04_24_181556) do # These are extensions that must be enabled in order to support this database enable_extension "hstore" @@ -470,6 +470,8 @@ t.datetime "deleted_at" t.integer "created_by" t.jsonb "ontologies" + t.string "ancestry" + t.index ["ancestry"], name: "index_device_descriptions_on_ancestry" t.index ["device_id"], name: "index_device_descriptions_on_device_id" end @@ -530,6 +532,18 @@ t.index ["name_abbreviation"], name: "index_devices_on_name_abbreviation", unique: true, where: "(name_abbreviation IS NOT NULL)" end + create_table "element_form_types", force: :cascade do |t| + t.string "name" + t.string "description" + t.string "element_type" + t.jsonb "structure", default: {} + t.boolean "enabled" + t.integer "enabled_for" + t.integer "creator_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + create_table "element_klasses", id: :serial, force: :cascade do |t| t.string "name" t.string "label" @@ -1214,7 +1228,9 @@ t.jsonb "solvent" t.boolean "inventory_sample", default: false t.boolean "dry_solvent", default: false + t.integer "element_form_type_id" t.index ["deleted_at"], name: "index_samples_on_deleted_at" + t.index ["element_form_type_id"], name: "index_samples_on_element_form_type_id" t.index ["identifier"], name: "index_samples_on_identifier" t.index ["inventory_sample"], name: "index_samples_on_inventory_sample" t.index ["molecule_id"], name: "index_samples_on_sample_id" diff --git a/spec/api/chemotion/device_description_api_spec.rb b/spec/api/chemotion/device_description_api_spec.rb index c841bd62ef..242ed2420b 100644 --- a/spec/api/chemotion/device_description_api_spec.rb +++ b/spec/api/chemotion/device_description_api_spec.rb @@ -8,6 +8,12 @@ let(:device_description) do create(:device_description, :with_ontologies, collection_id: collection.id, created_by: collection.user_id) end + let(:device_description2) do + create(:device_description, :with_ontologies, collection_id: collection.id, created_by: collection.user_id) + end + let(:device_description_collection) do + create(:collections_device_description, device_description: device_description, collection: collection) + end let(:segment_klass) { create(:segment_klass, :with_ontology_properties_template) } describe 'GET /api/v1/device_descriptions/' do @@ -27,12 +33,20 @@ end describe 'POST /api/v1/device_descriptions' do - let(:device_description_params) { attributes_for(:device_description) } + let(:device_description_params) { attributes_for(:device_description, collection_id: collection.id) } - it 'creates a device description' do - post '/api/v1/device_descriptions', params: device_description_params + context 'when creating a device description' do + it 'returns a device description' do + post '/api/v1/device_descriptions', params: device_description_params - expect(parsed_json_response['device_description']['short_label']).to include('Dev') + expect(parsed_json_response['device_description']['short_label']).to include('Dev') + end + + it 'has taggable_data' do + post '/api/v1/device_descriptions', params: device_description_params + + expect(parsed_json_response['device_description']['tag']['taggable_data'].size).to be(1) + end end end @@ -82,6 +96,56 @@ end end + describe 'POST /api/v1/device_descriptions/ui_state/' do + before do + device_description_collection + end + + let(:params) do + { + ui_state: { + all: false, + included_ids: [device_description.id, device_description2.id], + excluded_ids: [], + collection_id: collection.id, + }, + limit: 1, + } + end + + it 'fetches only one device description' do + post '/api/v1/device_descriptions/ui_state/', params: params, as: :json + + expect(parsed_json_response['device_descriptions'].size).to be(1) + end + end + + describe 'POST /api/v1/device_descriptions/sub_device_descriptions/' do + before do + device_description_collection + end + + let(:params) do + { + ui_state: { + currentCollectionId: collection.id, + device_description: { + all: false, + included_ids: [device_description.id], + excluded_ids: [], + }, + isSync: false, + }, + } + end + + it 'creates a split of selected device description' do + post '/api/v1/device_descriptions/sub_device_descriptions/', params: params, as: :json + + expect(device_description.reload.children.size).to be(1) + end + end + describe 'PUT /api/v1/device_descriptions/:id' do context 'when updating an device description' do let(:params) do diff --git a/spec/factories/collections_device_descriptions.rb b/spec/factories/collections_device_descriptions.rb new file mode 100644 index 0000000000..bd6547860a --- /dev/null +++ b/spec/factories/collections_device_descriptions.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :collections_device_description do + collection_id { 1 } + device_description_id { 1 } + end +end