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:
aignatov-bio 2025-02-11 13:39:27 +01:00 committed by GitHub
commit f74e65d311
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 187 additions and 54 deletions

View file

@ -19,22 +19,46 @@ class StorageLocationRepositoryRowsController < ApplicationController
def create
ActiveRecord::Base.transaction do
@storage_location_repository_row = StorageLocationRepositoryRow.new(
storage_location_repository_rows = []
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: storage_location_repository_row_params[:metadata] || {},
metadata: { position: position[0..1] },
created_by: current_user
)
@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
storage_location_repository_row.with_lock do
storage_location_repository_row.save!
storage_location_repository_rows << storage_location_repository_row
end
end
else
render json: { errors: @storage_location_repository_row.errors.full_messages }, status: :unprocessable_entity
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

View file

@ -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');
});

View file

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

View file

@ -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) => {

View file

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