mirror of
				https://github.com/scinote-eln/scinote-web.git
				synced 2025-10-31 08:26:31 +08:00 
			
		
		
		
	Merge pull request #8225 from aignatov-bio/ai-sci-11535-allow-multiple-selecting-in-storage-locations
Enable multiple selection in storage locations grid [SCI-11535]
This commit is contained in:
		
						commit
						f74e65d311
					
				
					 5 changed files with 187 additions and 54 deletions
				
			
		|  | @ -19,22 +19,46 @@ class StorageLocationRepositoryRowsController < ApplicationController | |||
| 
 | ||||
|   def create | ||||
|     ActiveRecord::Base.transaction do | ||||
|       @storage_location_repository_row = StorageLocationRepositoryRow.new( | ||||
|         repository_row: @repository_row, | ||||
|         storage_location: @storage_location, | ||||
|         metadata: storage_location_repository_row_params[:metadata] || {}, | ||||
|         created_by: current_user | ||||
|       ) | ||||
|       storage_location_repository_rows = [] | ||||
| 
 | ||||
|       @storage_location_repository_row.with_lock do | ||||
|         if @storage_location_repository_row.save | ||||
|           log_activity(:storage_location_repository_row_created) | ||||
|           render json: @storage_location_repository_row, | ||||
|                  serializer: Lists::StorageLocationRepositoryRowSerializer | ||||
|         else | ||||
|           render json: { errors: @storage_location_repository_row.errors.full_messages }, status: :unprocessable_entity | ||||
|       if @storage_location.with_grid? | ||||
|         params[:positions].each do |position| | ||||
|           if position.dig(2, :occupied) | ||||
|             occupied_storage_location_repository_row = @storage_location.storage_location_repository_rows.find_by(id: position.dig(2, :id)) | ||||
|             raise ActiveRecord::RecordInvalid, occupied_row unless discard_storage_location_repository_rows(occupied_storage_location_repository_row) | ||||
|           end | ||||
|           storage_location_repository_row = StorageLocationRepositoryRow.new( | ||||
|             repository_row: @repository_row, | ||||
|             storage_location: @storage_location, | ||||
|             metadata: { position: position[0..1] }, | ||||
|             created_by: current_user | ||||
|           ) | ||||
|           storage_location_repository_row.with_lock do | ||||
|             storage_location_repository_row.save! | ||||
|             storage_location_repository_rows << storage_location_repository_row | ||||
|           end | ||||
|         end | ||||
|       else | ||||
|         storage_location_repository_row = StorageLocationRepositoryRow.new( | ||||
|           repository_row: @repository_row, | ||||
|           storage_location: @storage_location, | ||||
|           created_by: current_user | ||||
|         ) | ||||
|         storage_location_repository_row.with_lock do | ||||
|           storage_location_repository_row.save! | ||||
|           storage_location_repository_rows << storage_location_repository_row | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       log_activity(:storage_location_repository_row_created, { | ||||
|                      repository_row: @repository_row.id, | ||||
|                      position: storage_location_repository_rows.map(&:human_readable_position).join(', ') | ||||
|                    }) | ||||
| 
 | ||||
|       render json: storage_location_repository_rows, each_serializer: Lists::StorageLocationRepositoryRowSerializer | ||||
|     rescue ActiveRecord::RecordInvalid => e | ||||
|       render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity | ||||
|       raise ActiveRecord::Rollback | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|  | @ -43,7 +67,10 @@ class StorageLocationRepositoryRowsController < ApplicationController | |||
|       @storage_location_repository_row.update(storage_location_repository_row_params) | ||||
| 
 | ||||
|       if @storage_location_repository_row.save | ||||
|         log_activity(:storage_location_repository_row_moved) | ||||
|         log_activity(:storage_location_repository_row_moved, { | ||||
|                        repository_row: @storage_location_repository_row.repository_row_id, | ||||
|                        position: @storage_location_repository_row.human_readable_position | ||||
|                      }) | ||||
|         render json: @storage_location_repository_row, | ||||
|                serializer: Lists::StorageLocationRepositoryRowSerializer | ||||
|       else | ||||
|  | @ -58,17 +85,26 @@ class StorageLocationRepositoryRowsController < ApplicationController | |||
|       @original_position = @storage_location_repository_row.human_readable_position | ||||
| 
 | ||||
|       @storage_location_repository_row.discard | ||||
| 
 | ||||
|       metadata = if @storage_location.with_grid? | ||||
|                    { position: params[:positions][0][0..1] } # For now, we only support moving one row at a time | ||||
|                  else | ||||
|                    {} | ||||
|                  end | ||||
| 
 | ||||
|       @storage_location_repository_row = StorageLocationRepositoryRow.create!( | ||||
|         repository_row: @repository_row, | ||||
|         storage_location: @storage_location, | ||||
|         metadata: storage_location_repository_row_params[:metadata] || {}, | ||||
|         metadata: metadata, | ||||
|         created_by: current_user | ||||
|       ) | ||||
|       log_activity( | ||||
|         :storage_location_repository_row_moved, | ||||
|         { | ||||
|           storage_location_original: @original_storage_location.id, | ||||
|           position_original: @original_position | ||||
|           position_original: @original_position, | ||||
|           repository_row: @storage_location_repository_row.repository_row_id, | ||||
|           position: @storage_location_repository_row.human_readable_position | ||||
|         } | ||||
|       ) | ||||
|       render json: @storage_location_repository_row, | ||||
|  | @ -81,8 +117,7 @@ class StorageLocationRepositoryRowsController < ApplicationController | |||
| 
 | ||||
|   def destroy | ||||
|     ActiveRecord::Base.transaction do | ||||
|       if @storage_location_repository_row.discard | ||||
|         log_activity(:storage_location_repository_row_deleted) | ||||
|       if discard_storage_location_repository_rows(@storage_location_repository_row) | ||||
|         render json: {} | ||||
|       else | ||||
|         render json: { errors: @storage_location_repository_row.errors.full_messages }, status: :unprocessable_entity | ||||
|  | @ -101,6 +136,18 @@ class StorageLocationRepositoryRowsController < ApplicationController | |||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def discard_storage_location_repository_rows(storage_location_repository_row) | ||||
|     if storage_location_repository_row.discard | ||||
|       log_activity(:storage_location_repository_row_deleted, { | ||||
|                      repository_row: storage_location_repository_row.repository_row_id, | ||||
|                      position: storage_location_repository_row.human_readable_position | ||||
|                    }) | ||||
|       return true | ||||
|     end | ||||
| 
 | ||||
|     false | ||||
|   end | ||||
| 
 | ||||
|   def check_storage_locations_enabled | ||||
|     render_403 unless StorageLocation.storage_locations_enabled? | ||||
|   end | ||||
|  | @ -142,11 +189,9 @@ class StorageLocationRepositoryRowsController < ApplicationController | |||
|       .call(activity_type: type_of, | ||||
|             owner: current_user, | ||||
|             team: @storage_location.team, | ||||
|             subject: @storage_location_repository_row.storage_location, | ||||
|             subject: @storage_location, | ||||
|             message_items: { | ||||
|               storage_location: @storage_location_repository_row.storage_location_id, | ||||
|               repository_row: @storage_location_repository_row.repository_row_id, | ||||
|               position: @storage_location_repository_row.human_readable_position, | ||||
|               storage_location: @storage_location.id, | ||||
|               user: current_user.id | ||||
|             }.merge(message_items)) | ||||
|   end | ||||
|  |  | |||
|  | @ -5,7 +5,8 @@ | |||
|         :gridSize="gridSize" | ||||
|         :assignedItems="assignedItems" | ||||
|         :selectedItems="selectedItems" | ||||
|         @assign="assignRowToPosition" | ||||
|         :selectedEmptyCells="selectedEmptyCells" | ||||
|         @selectEmptyCell="selectEmptyCell" | ||||
|         @select="selectRow" | ||||
|       /> | ||||
|     </div> | ||||
|  | @ -32,11 +33,12 @@ | |||
|         v-if="openAssignModal" | ||||
|         :assignMode="assignMode" | ||||
|         :selectedContainer="assignToContainer" | ||||
|         :selectedPosition="assignToPosition" | ||||
|         :selectedPositions="assignToPositions" | ||||
|         :selectedRow="rowIdToMove" | ||||
|         :selectedRowName="rowNameToMove" | ||||
|         :cellId="cellIdToUnassign" | ||||
|         @close="openAssignModal = false; resetTableSearch(); this.reloadingTable = true" | ||||
|         @assign="assignCallback" | ||||
|         @close="openAssignModal = false" | ||||
|       ></AssignModal> | ||||
|       <ImportModal | ||||
|         v-if="openImportModal" | ||||
|  | @ -112,8 +114,8 @@ export default { | |||
|       moveToUrl: null, | ||||
|       assignedItems: [], | ||||
|       selectedItems: [], | ||||
|       selectedEmptyCells: [], | ||||
|       openAssignModal: false, | ||||
|       assignToPosition: null, | ||||
|       assignToContainer: null, | ||||
|       rowIdToMove: null, | ||||
|       rowNameToMove: null, | ||||
|  | @ -126,6 +128,14 @@ export default { | |||
|     paginationMode() { | ||||
|       return this.withGrid ? 'none' : 'pages'; | ||||
|     }, | ||||
|     assignToPositions() { | ||||
|       if (this.assignMode === 'assign' && this.withGrid) { | ||||
|         return this.selectedEmptyCells.map((cell) => [cell.row + 1, cell.column + 1, { occupied: false }]).concat( | ||||
|           this.selectedItems.map((item) => [item.position[0], item.position[1], { occupied: true, id: item.id }]) | ||||
|         ); | ||||
|       } | ||||
|       return []; | ||||
|     }, | ||||
|     tableId() { | ||||
|       return this.withGrid ? 'StorageLocationsContainerGrid' : 'StorageLocationsContainer'; | ||||
|     }, | ||||
|  | @ -218,32 +228,37 @@ export default { | |||
|       } | ||||
|       this.$refs.table.restoreSelection(); | ||||
|     }, | ||||
|     selectEmptyCell(cell) { | ||||
|       if (this.selectedEmptyCells.find((c) => c.row === cell.row && c.column === cell.column)) { | ||||
|         this.selectedEmptyCells = this.selectedEmptyCells.filter((c) => c.row !== cell.row || c.column !== cell.column); | ||||
|       } else { | ||||
|         this.selectedEmptyCells.push(cell); | ||||
|       } | ||||
|     }, | ||||
|     assignRow() { | ||||
|       this.openAssignModal = true; | ||||
|       this.rowIdToMove = null; | ||||
|       this.rowNameToMove = null; | ||||
|       this.assignToContainer = this.containerId; | ||||
|       this.assignToPosition = null; | ||||
|       this.assignToPositions = []; | ||||
|       this.cellIdToUnassign = null; | ||||
|       this.assignMode = 'assign'; | ||||
|     }, | ||||
|     assignRowToPosition(position) { | ||||
|       this.openAssignModal = true; | ||||
|       this.rowIdToMove = null; | ||||
|       this.rowNameToMove = null; | ||||
|       this.assignToContainer = this.containerId; | ||||
|       this.assignToPosition = position; | ||||
|       this.cellIdToUnassign = null; | ||||
|       this.assignMode = 'assign'; | ||||
|     assignCallback() { | ||||
|       this.openAssignModal = false; | ||||
|       this.resetTableSearch(); | ||||
|       this.reloadingTable = true; | ||||
|       this.selectedEmptyCells = []; | ||||
|     }, | ||||
|     moveRow(_event, data) { | ||||
|       this.assignMode = 'move'; | ||||
|       this.openAssignModal = true; | ||||
|       this.rowIdToMove = data[0].row_id; | ||||
|       this.rowNameToMove = data[0].row_name || this.i18n.t('storage_locations.show.hidden'); | ||||
|       this.assignToContainer = null; | ||||
|       this.assignToPosition = null; | ||||
|       this.cellIdToUnassign = data[0].id; | ||||
|       this.assignMode = 'move'; | ||||
| 
 | ||||
|     }, | ||||
|     async unassignRows(event, rows) { | ||||
|       this.storageLocationUnassignDescription = this.i18n.t( | ||||
|  | @ -255,7 +270,6 @@ export default { | |||
|         axios.post(event.path).then(() => { | ||||
|           this.resetTableSearch(); | ||||
|           this.reloadingTable = true; | ||||
| 
 | ||||
|         }).catch((error) => { | ||||
|           HelperModule.flashAlertMsg(error.response.data.error, 'danger'); | ||||
|         }); | ||||
|  |  | |||
|  | @ -3,13 +3,13 @@ | |||
|     <div class="z-10 bg-sn-super-light-grey"></div> | ||||
|     <div ref="columnsContainer" class="overflow-x-hidden"> | ||||
|       <div :style="{'width': `${columnsList.length * 54}px`}"> | ||||
|         <div v-for="column in columnsList" :key="column" class="uppercase float-left flex items-center justify-center w-[54px] "> | ||||
|         <div v-for="column in columnsList" :key="column" @click="selectColumn(column)" class=" cursor-pointer uppercase float-left flex items-center justify-center w-[54px] "> | ||||
|           <span>{{ column }}</span> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div ref="rowContainer" class="overflow-y-hidden max-h-[70vh]"> | ||||
|       <div v-for="row in rowsList" :key="row" class="uppercase flex items-center justify-center h-[54px]"> | ||||
|       <div v-for="row in rowsList" :key="row" @click="selectRow(row)" class="cursor-pointer uppercase flex items-center justify-center h-[54px]"> | ||||
|         <span>{{ row }}</span> | ||||
|       </div> | ||||
|     </div> | ||||
|  | @ -25,7 +25,7 @@ | |||
|           > | ||||
|             <div | ||||
|               class="h-full w-full rounded-full items-center flex justify-center" | ||||
|               @click="assignRow(cell)" | ||||
|               @click="selectPosition(cell)" | ||||
|               :class="{ | ||||
|                 'bg-sn-background-green': cellIsOccupied(cell), | ||||
|                 'bg-sn-grey-100': cellIsHidden(cell), | ||||
|  | @ -64,6 +64,10 @@ export default { | |||
|     selectedItems: { | ||||
|       type: Array, | ||||
|       default: () => [] | ||||
|     }, | ||||
|     selectedEmptyCells: { | ||||
|       type: Array, | ||||
|       default: () => [] | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|  | @ -108,12 +112,13 @@ export default { | |||
|       return this.cellObject(cell)?.hidden; | ||||
|     }, | ||||
|     cellIsSelected(cell) { | ||||
|       return this.selectedItems.some((item) => item.position[0] === cell.row + 1 && item.position[1] === cell.column + 1); | ||||
|       return this.selectedItems.some((item) => item.position[0] === cell.row + 1 && item.position[1] === cell.column + 1) | ||||
|         || this.selectedEmptyCells.some((selectedCell) => selectedCell.row === cell.row && selectedCell.column === cell.column); | ||||
|     }, | ||||
|     cellIsAvailable(cell) { | ||||
|       return !this.cellIsOccupied(cell) && !this.cellIsHidden(cell); | ||||
|     }, | ||||
|     assignRow(cell) { | ||||
|     selectPosition(cell) { | ||||
|       if (this.cellIsOccupied(cell)) { | ||||
|         this.$emit('select', this.cellObject(cell)); | ||||
|         return; | ||||
|  | @ -123,7 +128,23 @@ export default { | |||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       this.$emit('assign', [cell.row + 1, cell.column + 1]); | ||||
|       this.$emit('selectEmptyCell', cell); | ||||
|     }, | ||||
|     selectRow(row) { | ||||
|       this.columnsList.forEach((column) => { | ||||
|         const cell = { row: this.rowsList.indexOf(row), column: column - 1 }; | ||||
|         if (!this.cellIsSelected(cell) && !this.cellIsOccupied(cell)) { | ||||
|           this.$emit('selectEmptyCell', cell); | ||||
|         } | ||||
|       }); | ||||
|     }, | ||||
|     selectColumn(column) { | ||||
|       this.rowsList.forEach((row) => { | ||||
|         const cell = { row: this.rowsList.indexOf(row), column: column - 1 }; | ||||
|         if (!this.cellIsSelected(cell) && !this.cellIsOccupied(cell)) { | ||||
|           this.$emit('selectEmptyCell', cell); | ||||
|         } | ||||
|       }); | ||||
|     }, | ||||
|     handleScroll() { | ||||
|       this.$refs.columnsContainer.scrollLeft = this.$refs.cellsContainer.scrollLeft; | ||||
|  |  | |||
|  | @ -2,12 +2,36 @@ | |||
|   <div ref="modal" class="modal" tabindex="-1" role="dialog"> | ||||
|     <div class="modal-dialog" role="document"> | ||||
|       <form @submit.prevent="submit"> | ||||
|         <div class="modal-content"> | ||||
|         <div v-if="overrideWarning" 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 v-if="selectedPosition" class="modal-title truncate !block"> | ||||
|             <h4 class="modal-title truncate !block"> | ||||
|               {{ i18n.t(`storage_locations.show.assign_modal.override.title`) }} | ||||
|             </h4> | ||||
|           </div> | ||||
|           <div class="modal-body"> | ||||
|             <p v-html="i18n.t(`storage_locations.show.assign_modal.override.p_1_html`, {count: this.selectedPositions.filter((p) => p[2].occupied).length})"></p> | ||||
|             <p> | ||||
|               {{ i18n.t(`storage_locations.show.assign_modal.override.p_2`) }} | ||||
|             </p> | ||||
|           </div> | ||||
|           <div class="modal-footer"> | ||||
|             <button type="button" class="btn btn-secondary" @click="removeOverride = true; submit()"> | ||||
|               {{ i18n.t(`storage_locations.show.assign_modal.override.skip`) }} | ||||
|             </button> | ||||
|             <button class="btn btn-danger" @click="confirmOverride = true; submit()"> | ||||
|               {{ i18n.t(`storage_locations.show.assign_modal.override.cta`) }} | ||||
|             </button> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div v-else 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 v-if="selectedPositions.length > 0" class="modal-title truncate !block"> | ||||
|               {{ i18n.t(`storage_locations.show.assign_modal.selected_position_title`, { position: formattedPosition }) }} | ||||
|             </h4> | ||||
|             <h4 v-else-if="assignMode === 'assign' && selectedRow && selectedRowName" class="modal-title truncate !block"> | ||||
|  | @ -33,10 +57,10 @@ | |||
|             <RowSelector v-if="!selectedRow" @change="this.rowId = $event" class="mb-4"></RowSelector> | ||||
|             <ContainerSelector v-if="!selectedContainer" @change="this.containerId = $event"></ContainerSelector> | ||||
|             <PositionSelector | ||||
|               v-if="containerId && containerId > 0 && !selectedPosition" | ||||
|               v-if="containerId && containerId > 0 && !(selectedPositions.length > 0)" | ||||
|               :key="containerId" | ||||
|               :selectedContainerId="containerId" | ||||
|               @change="this.position = $event"></PositionSelector> | ||||
|               @change="this.positions = [$event]"></PositionSelector> | ||||
|           </div> | ||||
|           <div class="modal-footer"> | ||||
|             <button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button> | ||||
|  | @ -71,11 +95,17 @@ export default { | |||
|     selectedRowName: String, | ||||
|     selectedContainer: Number, | ||||
|     cellId: Number, | ||||
|     selectedPosition: Array, | ||||
|     selectedPositions: { | ||||
|       type: Array, | ||||
|       default: () => [] | ||||
|     }, | ||||
|     assignMode: String | ||||
|   }, | ||||
|   mixins: [modalMixin], | ||||
|   computed: { | ||||
|     showOverrideWarning() { | ||||
|       return this.selectedPositions.find((p) => p[2].occupied); | ||||
|     }, | ||||
|     validObject() { | ||||
|       return this.rowId && this.containerId && this.containerId > 0; | ||||
|     }, | ||||
|  | @ -85,8 +115,12 @@ export default { | |||
|       }); | ||||
|     }, | ||||
|     formattedPosition() { | ||||
|       if (this.selectedPosition) { | ||||
|         return String.fromCharCode(96 + parseInt(this.selectedPosition[0], 10)).toUpperCase() + this.selectedPosition[1]; | ||||
|       if (this.selectedPositions.length > 0) { | ||||
|         const pos = []; | ||||
|         this.selectedPositions.forEach((p) => { | ||||
|           pos.push(String.fromCharCode(96 + parseInt(p[0], 10)).toUpperCase() + p[1]); | ||||
|         }); | ||||
|         return pos.join(', '); | ||||
|       } | ||||
|       return ''; | ||||
|     }, | ||||
|  | @ -101,8 +135,11 @@ export default { | |||
|     return { | ||||
|       rowId: this.selectedRow, | ||||
|       containerId: this.selectedContainer, | ||||
|       position: this.selectedPosition, | ||||
|       saving: false | ||||
|       positions: this.selectedPositions, | ||||
|       saving: false, | ||||
|       overrideWarning: false, | ||||
|       confirmOverride: false, | ||||
|       removeOverride: false | ||||
|     }; | ||||
|   }, | ||||
|   components: { | ||||
|  | @ -112,16 +149,26 @@ export default { | |||
|   }, | ||||
|   methods: { | ||||
|     submit() { | ||||
|       if (this.showOverrideWarning && (!this.confirmOverride && !this.removeOverride)) { | ||||
|         this.overrideWarning = true; | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       if (this.saving) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       this.saving = true; | ||||
| 
 | ||||
|       if (this.removeOverride) { | ||||
|         this.positions = this.positions.filter((p) => !p[2].occupied); | ||||
|       } | ||||
| 
 | ||||
|       axios.post(this.actionUrl, { | ||||
|         repository_row_id: this.rowId, | ||||
|         metadata: { position: this.position?.map((pos) => parseInt(pos, 10)) } | ||||
|         positions: this.positions.map((p) => [parseInt(p[0], 10), parseInt(p[1], 10), p[2]]) | ||||
|       }).then(() => { | ||||
|         this.$emit('assign'); | ||||
|         this.$emit('close'); | ||||
|         this.saving = false; | ||||
|       }).catch((error) => { | ||||
|  |  | |||
|  | @ -2818,7 +2818,13 @@ en: | |||
|         description_single: 'Are you sure you want to remove item from this location?' | ||||
|         button: 'Unassign' | ||||
|       assign_modal: | ||||
|         selected_position_title: 'Assign to position %{position}' | ||||
|         override: | ||||
|           title: 'Override location' | ||||
|           p_1_html: "You are about to override <b>%{count}</b> occupied locations. This action can't be undone." | ||||
|           p_2: 'Are you sure you want to confirm this override?' | ||||
|           skip: 'Skip occupied locations' | ||||
|           cta: 'Override' | ||||
|         selected_position_title: 'Assign to positions %{position}' | ||||
|         selected_row_title: 'Assign new location' | ||||
|         assign_title: 'Assign position' | ||||
|         move_title: 'Move %{name}' | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue