diff --git a/app/components/samples/table_component.html.erb b/app/components/samples/table_component.html.erb index 466c638727..7d71b29bf5 100644 --- a/app/components/samples/table_component.html.erb +++ b/app/components/samples/table_component.html.erb @@ -185,10 +185,9 @@ <% if @row_actions[:destroy] %> <%= link_to( t(:"projects.samples.index.remove_button"), - project_sample_path(sample.project, sample), + new_namespace_project_samples_deletion_path(sample_id: sample.id, deletion_type: 'single'), data: { - turbo_method: :delete, - turbo_confirm: t(:"projects.samples.index.remove_button_confirmation"), + "turbo-prefetch": false, }, class: "font-medium text-blue-600 underline dark:text-blue-500 hover:no-underline cursor-pointer", diff --git a/app/controllers/projects/samples/attachments_controller.rb b/app/controllers/projects/samples/attachments_controller.rb index 7d3d32c22c..8b1d7fbfaa 100644 --- a/app/controllers/projects/samples/attachments_controller.rb +++ b/app/controllers/projects/samples/attachments_controller.rb @@ -5,6 +5,7 @@ module Samples # Controller actions for Project Samples Attachments class AttachmentsController < Projects::Samples::ApplicationController before_action :attachment, only: %i[destroy] + before_action :new_destroy_params, only: %i[new_destroy] def new authorize! @project, to: :update_sample? @@ -38,6 +39,15 @@ def create end end + def new_destroy + authorize! @sample, to: :destroy_attachment? + render turbo_stream: turbo_stream.update('sample_modal', + partial: 'delete_attachment_modal', + locals: { + open: true + }), status: :ok + end + def destroy # rubocop:disable Metrics/MethodLength authorize! @sample, to: :destroy_attachment? @@ -70,6 +80,11 @@ def attachment @attachment = @sample.attachments.find_by(id: params[:id]) || not_found end + def new_destroy_params + @attachment = Attachment.find_by(id: params[:attachment_id]) + @sample = @attachment.attachable + end + def destroy_status(attachment, count) return count == 2 ? :ok : :multi_status if attachment.associated_attachment diff --git a/app/controllers/projects/samples/deletions_controller.rb b/app/controllers/projects/samples/deletions_controller.rb new file mode 100644 index 0000000000..3844a64d13 --- /dev/null +++ b/app/controllers/projects/samples/deletions_controller.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Projects + module Samples + # Controller actions for Project Samples Deletions + class DeletionsController < Projects::ApplicationController + before_action :sample, only: %i[new destroy] + before_action :new_dialog_partial, only: :new + + def new + authorize! @project, to: :destroy_sample? + render turbo_stream: turbo_stream.update('samples_dialog', + partial: @partial, + locals: { + open: true + }), status: :ok + end + + def destroy # rubocop:disable Metrics/AbcSize,Metrics/MethodLength + ::Samples::DestroyService.new(@project, current_user, { sample: @sample }).execute + + respond_to do |format| + if @sample.deleted? + format.html do + flash[:success] = t('.success', sample_name: @sample.name, project_name: @project.namespace.human_name) + redirect_to namespace_project_samples_path(format: :html) + end + format.turbo_stream do + render status: :ok, locals: { type: 'success', + message: t('.success', sample_name: @sample.name, + project_name: @project.namespace.human_name) } + end + else + format.turbo_stream do + render status: :unprocessable_entity, + locals: { type: 'alert', message: @sample.errors.full_messages.first } + end + end + end + end + + def destroy_multiple + samples_to_delete_count = destroy_multiple_params['sample_ids'].count + + deleted_samples_count = ::Samples::DestroyService.new(@project, current_user, destroy_multiple_params).execute + + # No selected samples deleted + if deleted_samples_count.zero? + render status: :unprocessable_entity, locals: { type: :error, message: t('.no_deleted_samples') } + # Partial sample deletion + elsif deleted_samples_count.positive? && deleted_samples_count != samples_to_delete_count + render status: :multi_status, + locals: { messages: get_multi_status_destroy_multiple_message(deleted_samples_count, + samples_to_delete_count) } + # All samples deleted successfully + else + render status: :ok, locals: { type: :success, message: t('.success') } + end + end + + private + + def sample + # Necessary return for new when deletion_type = 'multiple', as has no params[:sample_id] defined + return if params[:deletion_type] == 'multiple' + + @sample = Sample.find_by(id: params[:id] || params[:sample_id], project_id: project.id) || not_found + end + + def new_dialog_partial + @partial = params['deletion_type'] == 'single' ? 'new_deletion_dialog' : 'new_multiple_deletions_dialog' + end + + def destroy_multiple_params + params.require(:multiple_deletion).permit(sample_ids: []) + end + + def get_multi_status_destroy_multiple_message(deleted_samples_count, samples_to_delete_count) + [ + { type: :success, + message: t('.partial_success', + deleted: "#{deleted_samples_count}/#{samples_to_delete_count}") }, + { type: :error, + message: t('.partial_error', + not_deleted: "#{samples_to_delete_count - deleted_samples_count}/#{samples_to_delete_count}") } + ] + end + end + end +end diff --git a/app/controllers/projects/samples_controller.rb b/app/controllers/projects/samples_controller.rb index 46731c25e0..8f033eebdf 100644 --- a/app/controllers/projects/samples_controller.rb +++ b/app/controllers/projects/samples_controller.rb @@ -5,9 +5,9 @@ module Projects class SamplesController < Projects::ApplicationController # rubocop:disable Metrics/ClassLength include Metadata - before_action :sample, only: %i[show edit update destroy view_history_version] + before_action :sample, only: %i[show edit update view_history_version] before_action :current_page - before_action :set_search_params, only: %i[index destroy] + before_action :set_search_params, only: %i[index] before_action :set_metadata_fields, only: :index def index @@ -78,37 +78,6 @@ def update end end - def destroy # rubocop:disable Metrics/AbcSize,Metrics/MethodLength - ::Samples::DestroyService.new(@sample, current_user).execute - @pagy, @samples = pagy(load_samples) - @q = load_samples.ransack(params[:q]) - - if @sample.deleted? - respond_to do |format| - format.html do - flash[:success] = t('.success', sample_name: @sample.name, project_name: @project.namespace.human_name) - redirect_to namespace_project_samples_path(format: :html) - end - format.turbo_stream do - fields_for_namespace( - namespace: @project.namespace, - show_fields: params[:q] && params[:q][:metadata].to_i == 1 - ) - render status: :ok, locals: { type: 'success', - message: t('.success', sample_name: @sample.name, - project_name: @project.namespace.human_name) } - end - end - else - respond_to do |format| - format.turbo_stream do - render status: :unprocessable_entity, - locals: { type: 'alert', message: @sample.errors.full_messages.first } - end - end - end - end - def select authorize! @project, to: :sample_listing? @samples = [] diff --git a/app/javascript/controllers/infinite_scroll_controller.js b/app/javascript/controllers/infinite_scroll_controller.js index f1045100a2..1dfa4f13bd 100644 --- a/app/javascript/controllers/infinite_scroll_controller.js +++ b/app/javascript/controllers/infinite_scroll_controller.js @@ -6,7 +6,9 @@ export default class extends Controller { static targets = ["all", "pageForm", "pageFormContent", "scrollable", "summary"]; static values = { fieldName: String, - pagedFieldName: String + pagedFieldName: String, + singular: String, + plural: String } #page = 1; @@ -27,12 +29,18 @@ export default class extends Controller { } } - #replaceCountPlaceholder(){ - const summary = this.summaryTarget; - summary.textContent = summary.textContent.replace( - "COUNT_PLACEHOLDER", - this.selectionOutlet.getNumSelected(), - ); + #replaceCountPlaceholder() { + const numSelected = this.selectionOutlet.getNumSelected() + let summary = this.summaryTarget; + + if (numSelected == 1) { + summary.textContent = this.singularValue + } else { + summary.textContent = this.pluralValue.replace( + "COUNT_PLACEHOLDER", + numSelected + ) + } } #makePagedHiddenInputs() { @@ -41,7 +49,7 @@ export default class extends Controller { const end = this.#page * itemsPerPage; const ids = this.allIds.slice(start, end); - if(ids && ids.length){ + if (ids && ids.length) { const fragment = document.createDocumentFragment(); for (const id of ids) { fragment.appendChild( @@ -69,7 +77,7 @@ export default class extends Controller { this.allTarget.appendChild(fragment); } - clear(){ + clear() { this.selectionOutlet.clear(); } } diff --git a/app/javascript/controllers/selection_controller.js b/app/javascript/controllers/selection_controller.js index 0914518a1a..68d90c19b2 100644 --- a/app/javascript/controllers/selection_controller.js +++ b/app/javascript/controllers/selection_controller.js @@ -45,7 +45,6 @@ export default class extends Controller { } remove({ params: { id } }) { - id = JSON.stringify(id).replaceAll(",", ", "); this.#addOrRemove(false, id); } diff --git a/app/services/samples/destroy_service.rb b/app/services/samples/destroy_service.rb index 4c4fa543b8..4ec50cf764 100644 --- a/app/services/samples/destroy_service.rb +++ b/app/services/samples/destroy_service.rb @@ -3,18 +3,43 @@ module Samples # Service used to Delete Samples class DestroyService < BaseService - attr_accessor :sample + attr_accessor :sample, :sample_ids, :project - def initialize(sample, user = nil, params = {}) - super(user, params.except(:sample, :id)) - @sample = sample + def initialize(project, user = nil, params = {}) + super(user, params) + @project = project + @sample = params[:sample] if params[:sample] + @sample_ids = params[:sample_ids] if params[:sample_ids] end def execute - authorize! sample.project, to: :destroy_sample? + authorize! project, to: :destroy_sample? + sample.nil? ? destroy_multiple : destroy_single + end + + private + + def destroy_single sample.destroy + update_metadata_summary(sample) + end + + def destroy_multiple + samples = Sample.where(id: sample_ids).where(project_id: project.id) + samples_to_delete_count = samples.count + + samples = samples.destroy_all + + samples.each do |sample| + update_metadata_summary(sample) + end + + samples_to_delete_count + end + + def update_metadata_summary(sample) sample.project.namespace.update_metadata_summary_by_sample_deletion(sample) if sample.deleted? end end diff --git a/app/views/projects/samples/attachments/_attachment.html.erb b/app/views/projects/samples/attachments/_attachment.html.erb index 69209b86b2..2f9ec239cd 100644 --- a/app/views/projects/samples/attachments/_attachment.html.erb +++ b/app/views/projects/samples/attachments/_attachment.html.erb @@ -101,22 +101,12 @@ <% if allowed_to?(:destroy_attachment?, @sample) %> <%= link_to( t(".delete"), - namespace_project_sample_attachment_path( + namespace_project_sample_attachment_new_destroy_path( sample_id: @sample.id, - id: attachment.id + attachment_id: attachment.id ), data: { - turbo_method: :delete, - action: "turbo:submit-end->selection#remove", - "selection-id-param": - ( - if attachment.associated_attachment - [attachment.id, attachment.associated_attachment.id] - else - attachment.id - end - ), - turbo_confirm: t(".delete_confirm") + "turbo-prefetch": false, }, class: "font-medium text-blue-600 underline dark:text-blue-500 hover:no-underline cursor-pointer" diff --git a/app/views/projects/samples/attachments/_delete_attachment_modal.html.erb b/app/views/projects/samples/attachments/_delete_attachment_modal.html.erb new file mode 100644 index 0000000000..dde35d0654 --- /dev/null +++ b/app/views/projects/samples/attachments/_delete_attachment_modal.html.erb @@ -0,0 +1,42 @@ +<%= viral_dialog(open: open, size: :large) do |dialog| %> + <%= dialog.with_header(title: t(".title")) %> + <%= dialog.with_section do %> + + <%= turbo_frame_tag("deletion-alert") %> + + <div + data-controller="selection" + data-selection-delete-id-param="<%= @attachment.id %>" + data-selection-storage-key-value='<%="files-#{@sample.id}" %>' + class="mb-4 font-normal text-slate-500 dark:text-slate-400 overflow-x-visible"> + <p class="mb-4"> + <%= t(".description") %> + </p> + <%= form_for(:deletion, url: namespace_project_sample_attachment_path(id: @attachment.id), method: :delete) do |form| %> + <%= form.submit t(".submit_button"), + class: " + button + text-sm + px-5 + py-2.5 + text-white + bg-red-700 + border-red-800 + focus:outline-none + hover:bg-red-800 + focus:ring-red-300 + dark:focus:ring-red-700 + dark:bg-red-600 + dark:text-white + dark:border-red-600 + dark:hover:bg-red-700", + data: { + turbo_frame: "_top", + action: "click->selection#remove", + "selection-id-param": @attachment.id + } %> + </div> + <% end %> + </div> + <% end %> +<% end %> diff --git a/app/views/projects/samples/attachments/destroy.turbo_stream.erb b/app/views/projects/samples/attachments/destroy.turbo_stream.erb index 30a7ba0257..fccffe1bb9 100644 --- a/app/views/projects/samples/attachments/destroy.turbo_stream.erb +++ b/app/views/projects/samples/attachments/destroy.turbo_stream.erb @@ -1,14 +1,27 @@ <% if destroyed_attachments %> + <%= turbo_stream.update "sample_modal", + partial: "delete_attachment_modal", + locals: { + open: false, + } %> + <% destroyed_attachments.each do |attachment| %> <%= turbo_stream.append "flashes" do %> <%= viral_flash( type: :success, - data: t(".success", filename: attachment.file.filename) + data: t(".success", filename: attachment.file.filename), ) %> <% end %> - - <%= turbo_stream.remove dom_id(attachment) %> <% end %> + + <%= turbo_stream.update( + "table-listing", + partial: "projects/samples/attachments/table", + locals: { + attachments: @sample.attachments, + }, + ) %> + <% else %> <%= turbo_stream.append "flashes" do %> <%= viral_flash(type: :error, data: message) %> diff --git a/app/views/projects/samples/clones/_dialog.html.erb b/app/views/projects/samples/clones/_dialog.html.erb index fe0ab86393..9ef96b0676 100644 --- a/app/views/projects/samples/clones/_dialog.html.erb +++ b/app/views/projects/samples/clones/_dialog.html.erb @@ -7,6 +7,8 @@ data-infinite-scroll-selection-outlet='#samples-table' data-infinite-scroll-field-name-value="clone[sample_ids][]" data-infinite-scroll-paged-field-name-value="sample_ids[]" + data-infinite-scroll-singular-value="<%= t(".description.singular") %>" + data-infinite-scroll-plural-value="<%= t(".description.plural") %>" > <%= form_with( url: list_namespace_project_samples_path, @@ -22,7 +24,7 @@ text-base leading-relaxed text-slate-500 dark:text-slate-400 " > - <%= t(".description") %> + <%= t(".description.zero") %> </p> <div> <div class="block mb-1 text-sm font-medium text-slate-900 dark:text-white"> diff --git a/app/views/projects/samples/deletions/_new_deletion_dialog.html.erb b/app/views/projects/samples/deletions/_new_deletion_dialog.html.erb new file mode 100644 index 0000000000..5a461edb33 --- /dev/null +++ b/app/views/projects/samples/deletions/_new_deletion_dialog.html.erb @@ -0,0 +1,40 @@ +<%= viral_dialog(open: open, size: :large) do |dialog| %> + <%= dialog.with_header(title: t(".title")) %> + <%= dialog.with_section do %> + + <%= turbo_frame_tag("deletion-alert") %> + + <div + data-controller="selection" + data-selection-delete-id-param="<%= @sample.id %>" + class="mb-4 font-normal text-slate-500 dark:text-slate-400 overflow-x-visible"> + <p class="mb-4"> + <%= t(".description", sample_name: @sample.name) %> + </p> + <%= form_for(:deletion, url: namespace_project_samples_deletion_path(sample_id: @sample.id), method: :delete) do |form| %> + <%= form.submit t(".submit_button"), + class: " + button + text-sm + px-5 + py-2.5 + text-white + bg-red-700 + border-red-800 + focus:outline-none + hover:bg-red-800 + focus:ring-red-300 + dark:focus:ring-red-700 + dark:bg-red-600 + dark:text-white + dark:border-red-600 + dark:hover:bg-red-700", + data: { + action: "click->selection#remove", + "selection-id-param": @sample.id + } %> + </div> + <% end %> + </div> + <% end %> +<% end %> diff --git a/app/views/projects/samples/deletions/_new_multiple_deletions_dialog.html.erb b/app/views/projects/samples/deletions/_new_multiple_deletions_dialog.html.erb new file mode 100644 index 0000000000..46bceef85a --- /dev/null +++ b/app/views/projects/samples/deletions/_new_multiple_deletions_dialog.html.erb @@ -0,0 +1,95 @@ +<%= viral_dialog(open: open) do |dialog| %> + <%= dialog.with_header(title: t(".title")) %> + <%= dialog.with_section do %> + <div + data-controller="infinite-scroll" + data-infinite-scroll-selection-outlet='#samples-table' + data-infinite-scroll-field-name-value="multiple_deletion[sample_ids][]" + data-infinite-scroll-paged-field-name-value="sample_ids[]" + data-infinite-scroll-singular-value="<%= t(".description.singular") %>" + data-infinite-scroll-plural-value="<%= t(".description.plural") %>" + > + <%= form_with( + url: list_namespace_project_samples_path, + data: { "infinite-scroll-target": "pageForm" } + ) do %> + <div data-infinite-scroll-target="pageFormContent"></div> + <% end %> + + <div class="grid gap-4"> + <p + data-infinite-scroll-target="summary" + class=" + text-base leading-relaxed text-slate-500 dark:text-slate-400 + " + ><%= t(".description.zero") %></p> + <div> + <div class="block mb-1 text-sm font-medium text-slate-900 dark:text-white"> + <%= t(".samples") %> + </div> + <div + class=" + overflow-y-auto max-h-[300px] border border-slate-300 rounded-md block w-full + p-2.5 dark:bg-slate-800 dark:border-slate-600 + " + data-action="scroll->infinite-scroll#scroll" + data-infinite-scroll-target="scrollable" + > + <%= turbo_frame_tag "list_select_samples" do %> + <%= render partial: "shared/loading/samples_list_skeleton" %> + <% end %> + </div> + </div> + + <%= form_for(:deletion, url: destroy_multiple_namespace_project_samples_deletion_path, method: :delete, + data: { + controller: "spinner", + action:"turbo:submit-start->spinner#submitStart turbo:submit-end->spinner#submitEnd" + } + ) do |form| %> + <div class="grid gap-4"> + <div class="hidden" data-infinite-scroll-target="all"></div> + <div> + <%= form.submit t(".submit_button"), + class: " + button + text-sm + px-5 + py-2.5 + text-white + bg-red-700 + border-red-800 + focus:outline-none + hover:bg-red-800 + focus:ring-red-300 + dark:focus:ring-red-700 + dark:bg-red-600 + dark:text-white + dark:border-red-600 + dark:hover:bg-red-700", + data: { + action: "click->infinite-scroll#clear", + "spinner-target": "submit", + "turbo-submits-with": t(:".loading"), + } %> + </div> + </div> + <% end %> + </div> + + <div + role="status" + id="spinner" + class=" + hidden backdrop-blur-sm absolute h-full w-full -translate-x-1/2 -translate-y-1/2 + top-2/4 left-1/2 grid place-items-center + " + > + <div class="grid place-items-center"> + <%= viral_icon(name: :loading, classes: "animate-spin text-primary-500") %> + <span class="text-black dark:text-white"><%= t(:".spinner") %>.</span> + </div> + </div> + + <% end %> +<% end %> diff --git a/app/views/projects/samples/deletions/destroy.turbo_stream.erb b/app/views/projects/samples/deletions/destroy.turbo_stream.erb new file mode 100644 index 0000000000..159da3cd9b --- /dev/null +++ b/app/views/projects/samples/deletions/destroy.turbo_stream.erb @@ -0,0 +1,18 @@ +<%= turbo_stream.append "flashes" do %> + <%= viral_flash(type: type, data: message) %> +<% end %> + +<% if @sample.deleted? %> + <%= turbo_stream.update( + "samples_dialog", + partial: "new_deletion_dialog", + locals: { + open: false, + }, + ) %> + + <%= turbo_stream.replace "project_samples_table" do %> + <%= turbo_frame_tag "project_samples_table", + src: project_samples_path(@project, format: :turbo_stream) %> + <% end %> +<% end %> diff --git a/app/views/projects/samples/deletions/destroy_multiple.turbo_stream.erb b/app/views/projects/samples/deletions/destroy_multiple.turbo_stream.erb new file mode 100644 index 0000000000..593731bd15 --- /dev/null +++ b/app/views/projects/samples/deletions/destroy_multiple.turbo_stream.erb @@ -0,0 +1,24 @@ +<%= turbo_stream.update( + "samples_dialog", + partial: "new_multiple_deletions_dialog", + locals: { + open: false, + }, +) %> + +<% if defined?(messages) %> + <% messages.each do |m| %> + <%= turbo_stream.append "flashes" do %> + <%= viral_flash(type: m[:type], data: m[:message]) %> + <% end %> + <% end %> +<% else %> + <%= turbo_stream.append "flashes" do %> + <%= viral_flash(type:, data: message) %> + <% end %> +<% end %> + +<%= turbo_stream.replace "project_samples_table" do %> + <%= turbo_frame_tag "project_samples_table", + src: project_samples_path(@project, format: :turbo_stream) %> +<% end %> diff --git a/app/views/projects/samples/destroy.turbo_stream.erb b/app/views/projects/samples/destroy.turbo_stream.erb deleted file mode 100644 index ab99afb691..0000000000 --- a/app/views/projects/samples/destroy.turbo_stream.erb +++ /dev/null @@ -1,13 +0,0 @@ -<%= turbo_stream.append "flashes" do %> - <%= viral_flash(type: type, data: message) %> -<% end %> - -<% if @sample.deleted? %> - <%= turbo_stream.update "project_samples_table", - partial: "table", - locals: { - project: @project, - pagy: @pagy, - samples: @samples, - } %> -<% end %> diff --git a/app/views/projects/samples/index.html.erb b/app/views/projects/samples/index.html.erb index fb89d80026..ec6bc94597 100644 --- a/app/views/projects/samples/index.html.erb +++ b/app/views/projects/samples/index.html.erb @@ -58,6 +58,16 @@ class: "button button--size-default button--state-primary", "aria-label": t(".actions.button_add_aria_label") %> <% end %> + <% if allowed_to?(:destroy_sample?, @project) %> + <%= link_to t(".delete_samples_button"), + new_namespace_project_samples_deletion_path(deletion_type: 'multiple'), + data: { + turbo_stream: true, + controller: "action-link", + action_link_required_value: 1, + }, + class: "button button--size-default button--state-destructive action-link" %> + <% end %> </div> <% end %> <% end %> diff --git a/app/views/projects/samples/show.html.erb b/app/views/projects/samples/show.html.erb index e066a36251..613fbd7996 100644 --- a/app/views/projects/samples/show.html.erb +++ b/app/views/projects/samples/show.html.erb @@ -15,7 +15,7 @@ <% if allowed_to?(:destroy?, @project) %> <%= link_to( t("projects.samples.show.remove_button"), - namespace_project_sample_path(id: @sample.id, format: :html), + namespace_project_samples_deletion_path(sample_id: @sample.id, format: :html), data: { turbo_method: :delete, turbo_confirm: t("projects.samples.show.remove_button_confirmation"), diff --git a/app/views/projects/samples/transfers/_dialog.html.erb b/app/views/projects/samples/transfers/_dialog.html.erb index 9b39dd3862..afb8bbfcc8 100644 --- a/app/views/projects/samples/transfers/_dialog.html.erb +++ b/app/views/projects/samples/transfers/_dialog.html.erb @@ -7,6 +7,8 @@ data-infinite-scroll-selection-outlet='#samples-table' data-infinite-scroll-field-name-value="transfer[sample_ids][]" data-infinite-scroll-paged-field-name-value="sample_ids[]" + data-infinite-scroll-singular-value="<%= t(".description.singular") %>" + data-infinite-scroll-plural-value="<%= t(".description.plural") %>" > <%= form_with( url: list_namespace_project_samples_path, @@ -22,7 +24,7 @@ text-base leading-relaxed text-slate-500 dark:text-slate-400 " > - <%= t(".description") %> + <%= t(".description.zero") %> </p> <div> <div class="block mb-1 text-sm font-medium text-slate-900 dark:text-white"> diff --git a/config/locales/en.yml b/config/locales/en.yml index 3f57edca81..5569cc1cec 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1156,6 +1156,7 @@ en: import_metadata_button: Import metadata clone_button: Clone samples create_export_button: Create Export + delete_samples_button: Delete Samples actions: button_add_aria_label: Add new sample button dropdown_aria_label: Dropdown options for sample %{sample_name} @@ -1174,9 +1175,6 @@ en: title: Name and Description submit_button: Update sample cancel_button: Cancel - destroy: - success: Sample %{sample_name} was successfully removed from project %{project_name}. - error: The sample does not exist within the project. shared: errors: ok_button: OK @@ -1185,7 +1183,10 @@ en: transfers: dialog: title: Transfer Samples - description: The following COUNT_PLACEHOLDER samples are about to be transferred, which will result in these samples no longer being accessible in this project. Maintainers can only transfer samples to another project within the same hierarchy. + description: + zero: No samples have been selected for transfer + singular: The following sample is about to be transferred, which will result in the sample no longer being accessible in this project. Maintainers can only transfer samples to another project within the same hierarchy. + plural: The following COUNT_PLACEHOLDER samples are about to be transferred, which will result in these samples no longer being accessible in this project. Maintainers can only transfer samples to another project within the same hierarchy. samples: Samples new_project_id: Project to transfer samples to submit_button: Submit @@ -1201,7 +1202,10 @@ en: no_samples_cloned_error: "Samples were not cloned for the following reasons:" dialog: title: Clone Samples - description: The following COUNT_PLACEHOLDER samples are about to be cloned to another project. + description: + zero: No samples have been selected for cloning + singular: The following sample is about to be cloned to another project. + plural: The following COUNT_PLACEHOLDER samples are about to be cloned to another project. samples: Samples new_project_id: Project to clone samples to submit_button: Submit @@ -1282,7 +1286,6 @@ en: error: "File %{filename} was not removed due to the following errors: %{errors}" attachment: delete: Delete - delete_confirm: Are you sure that you want to delete this file from the sample? create: success: File %{filename} was successfully uploaded. failure: "File %{filename} was not uploaded due to the following errors: %{errors}" @@ -1290,6 +1293,10 @@ en: success: File %{filename} was successfully removed. error: "File %{filename} was not removed due to the following errors: %{errors}" error: File %{filename} was not removed. + delete_attachment_modal: + description: Are you sure that you want to delete this file rom the sample? + submit_button: Confirm + title: Delete File table: description: The project samples table contains all samples related to this project selectAll: Select / Deselect Samples @@ -1308,6 +1315,31 @@ en: description: 'Paste a list of <kbd>,</kbd> seperated sample names or identifiers' apply: Apply filter remove_tag: Remove + deletions: + new_deletion_dialog: + title: Delete Sample + description: Are you sure you want to delete sample '%{sample_name}'? + submit_button: Remove + new_multiple_deletions_dialog: + description: + zero: No samples have been selected for deletion + singular: "The following sample has been selected for deletion:" + plural: "The following COUNT_PLACEHOLDER samples have been selected for deletion:" + id: ID + loading: Loading... + name: Name + samples: Samples + spinner: Deleting samples. This may take a while... + submit_button: Confirm + title: Delete Samples + destroy_multiple: + no_deleted_samples: Selected samples could not be deleted + partial_error: "%{not_deleted} samples could not be deleted" + partial_success: "%{deleted} samples were successfully deleted" + success: Samples were successfully deleted + destroy: + success: Sample %{sample_name} was successfully removed from project %{project_name}. + error: The sample does not exist within the project. workflow_executions: cancel: error: "Could not cancel workflow %{workflow_name}" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index a38e65c83d..451e0e3a3e 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1152,6 +1152,7 @@ fr: import_metadata_button: Import metadata clone_button: Clone samples create_export_button: Create Export + delete_samples_button: Delete Samples actions: button_add_aria_label: Add new sample button dropdown_aria_label: Dropdown options for sample %{sample_name} @@ -1168,9 +1169,6 @@ fr: title: Name and Description submit_button: Update sample cancel_button: Cancel - destroy: - success: Sample %{sample_name} was successfully removed from project %{project_name}. - error: The sample does not exist within the project. shared: errors: ok_button: OK @@ -1179,7 +1177,10 @@ fr: transfers: dialog: title: Transfer Samples - description: The following COUNT_PLACEHOLDER samples are about to be transferred, which will result in these samples no longer being accessible in this project. Maintainers can only transfer samples to another project within the same hierarchy. + description: + zero: No samples have been selected for transfer + singular: The following sample is about to be transferred, which will result in the sample no longer being accessible in this project. Maintainers can only transfer samples to another project within the same hierarchy. + plural: The following COUNT_PLACEHOLDER samples are about to be transferred, which will result in these samples no longer being accessible in this project. Maintainers can only transfer samples to another project within the same hierarchy. samples: Samples new_project_id: Project to transfer samples to submit_button: Submit @@ -1196,7 +1197,10 @@ fr: no_samples_cloned_error: "Samples were not cloned for the following reasons:" dialog: title: Clone Samples - description: The following COUNT_PLACEHOLDER samples are about to be cloned to another project. + description: + zero: No samples have been selected for cloning + singular: The following sample is about to be cloned to another project. + plural: The following COUNT_PLACEHOLDER samples are about to be cloned to another project. samples: Samples new_project_id: Project to clone samples to submit_button: Submit @@ -1277,7 +1281,6 @@ fr: error: "File %{filename} was not removed due to the following errors: %{errors}" attachment: delete: Delete - delete_confirm: Are you sure that you want to delete this file from the sample? create: success: File %{filename} was successfully uploaded. failure: "File %{filename} was not uploaded due to the following errors: %{errors}" @@ -1285,6 +1288,10 @@ fr: success: File %{filename} was successfully removed. error: "File %{filename} was not removed due to the following errors: %{errors}" error: File %{filename} was not removed. + delete_attachment_modal: + description: Are you sure that you want to delete this file rom the sample? + submit_button: Confirm + title: Delete File table: description: The project samples table contains all samples related to this project selectAll: Select / Deselect Samples @@ -1303,6 +1310,31 @@ fr: description: 'Paste a list of <kbd>,</kbd> seperated sample names or identifiers' apply: Apply filter remove_tag: Remove + deletions: + new_deletion_dialog: + title: Delete Sample + description: Are you sure you want to delete sample '%{sample_name}'? + submit_button: Remove + new_multiple_deletions_dialog: + description: + zero: No samples have been selected for deletion + singular: "The following sample has been selected for deletion:" + plural: "The following COUNT_PLACEHOLDER samples have been selected for deletion:" + id: ID + loading: Loading... + name: Name + samples: Samples + spinner: Deleting samples. This may take a while... + submit_button: Confirm + title: Delete Samples + destroy_multiple: + no_deleted_samples: Selected samples could not be deleted + partial_error: "%{not_deleted} samples could not be deleted" + partial_success: "%{deleted} samples were successfully deleted" + success: Samples were successfully deleted + destroy: + success: Sample %{sample_name} was successfully removed from project %{project_name}. + error: The sample does not exist within the project. workflow_executions: cancel: error: "Could not cancel workflow %{workflow_name}" diff --git a/config/routes/project.rb b/config/routes/project.rb index 09734e168b..f03de5bbcd 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -44,6 +44,9 @@ resource :clone, only: %i[create new] resource :transfer, only: %i[create new] resource :file_import, module: :metadata, only: %i[create new] + resource :deletion, only: %i[destroy new] do + delete :destroy_multiple + end end end collection do @@ -59,6 +62,7 @@ resource :deletion, only: %i[new destroy] end end + get :new_destroy end resource :metadata, module: :samples, only: %i[new edit destroy] do scope module: :metadata, as: :metadata do diff --git a/test/controllers/projects/samples/attachments_controller_test.rb b/test/controllers/projects/samples/attachments_controller_test.rb index f74c9c09fd..2eca3257ce 100644 --- a/test/controllers/projects/samples/attachments_controller_test.rb +++ b/test/controllers/projects/samples/attachments_controller_test.rb @@ -113,6 +113,21 @@ class AttachmentsControllerTest < ActionDispatch::IntegrationTest as: :turbo_stream end end + + test 'new_destroy with proper authorization' do + get namespace_project_sample_attachment_new_destroy_path(@namespace, @project, @sample1, @attachment1), + as: :turbo_stream + + assert_response :success + end + + test 'new_destroy without proper authorization' do + sign_in users(:ryan_doe) + get namespace_project_sample_attachment_new_destroy_path(@namespace, @project, @sample1, @attachment1), + as: :turbo_stream + + assert_response :unauthorized + end end end end diff --git a/test/controllers/projects/samples/deletions_controller_test.rb b/test/controllers/projects/samples/deletions_controller_test.rb new file mode 100644 index 0000000000..44b1780ef5 --- /dev/null +++ b/test/controllers/projects/samples/deletions_controller_test.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Projects + class SamplesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in users(:john_doe) + @sample1 = samples(:sample1) + @sample23 = samples(:sample23) + @project = projects(:project1) + @namespace = groups(:group_one) + end + + test 'should destroy sample' do + assert_difference('Sample.count', -1) do + delete namespace_project_samples_deletion_path(@namespace, @project), + params: { + sample_id: @sample1.id + }, as: :turbo_stream + end + end + + test 'should not destroy sample, if it does not belong to the project' do + assert_no_difference('Sample.count') do + delete namespace_project_samples_deletion_path(@namespace, @project), + params: { + sample_id: @sample23.id + }, as: :turbo_stream + end + assert_response :not_found + end + + test 'should not destroy sample, if the current user is not allowed to modify the project' do + sign_in users(:ryan_doe) + + assert_no_difference('Sample.count') do + delete namespace_project_samples_deletion_path(@namespace, @project), + params: { + deletion_type: 'single', + sample_id: @sample1.id + } + end + + assert_response :unauthorized + end + + test 'new destroy with single type with proper authorization' do + get new_namespace_project_samples_deletion_path(@namespace, @project), + params: { + deletion_type: 'single', + sample_id: @sample1.id + } + + assert_response :success + end + + test 'new destroy with single type without proper authorization' do + sign_in users(:jane_doe) + get new_namespace_project_samples_deletion_path(@namespace, @project), + params: { + deletion_type: 'single', + sample_id: @sample1.id + } + + assert_response :unauthorized + end + + test 'new destroy with multiple type with proper authorization' do + get new_namespace_project_samples_deletion_path(@namespace, @project), + params: { + deletion_type: 'multiple' + } + + assert_response :success + end + + test 'new destroy with multiple type without proper authorization' do + sign_in users(:jane_doe) + get new_namespace_project_samples_deletion_path(@namespace, @project), + params: { + deletion_type: 'multiple' + } + + assert_response :unauthorized + end + + test 'successfully deleting multiple samples' do + sample2 = samples(:sample2) + sample30 = samples(:sample30) + assert_difference('Sample.count', -3) do + delete destroy_multiple_namespace_project_samples_deletion_path(@namespace, @project), + params: { + multiple_deletion: { + sample_ids: [@sample1.id, sample2.id, sample30.id] + } + }, as: :turbo_stream + end + assert_response :success + end + + test 'partially deleting multiple samples' do + sample2 = samples(:sample2) + sample30 = samples(:sample30) + assert_difference('Sample.count', -3) do + delete destroy_multiple_namespace_project_samples_deletion_path(@namespace, @project), + params: { + multiple_deletion: { + sample_ids: [@sample1.id, sample2.id, sample30.id, 'invalid_sample_id'] + } + }, as: :turbo_stream + end + assert_response :multi_status + end + + test 'deleting no samples in destroy_multiple' do + assert_no_difference('Sample.count') do + delete destroy_multiple_namespace_project_samples_deletion_path(@namespace, @project), + params: { + multiple_deletion: { + sample_ids: %w[invalid_sample_id_1 invalid_sample_id_2 invalid_sample_id_3] + } + }, as: :turbo_stream + end + assert_response :unprocessable_entity + end + + test 'deleting no samples in destroy_multiple with valid sample ids but do not belong to project' do + sample4 = samples(:sample4) + sample5 = samples(:sample5) + sample6 = samples(:sample6) + + assert_no_difference('Sample.count') do + delete destroy_multiple_namespace_project_samples_deletion_path(@namespace, @project), + params: { + multiple_deletion: { + sample_ids: [sample4.id, sample5.id, sample6.id] + } + }, as: :turbo_stream + end + assert_response :unprocessable_entity + end + end +end diff --git a/test/controllers/projects/samples_controller_test.rb b/test/controllers/projects/samples_controller_test.rb index a426476932..fdab264b0d 100644 --- a/test/controllers/projects/samples_controller_test.rb +++ b/test/controllers/projects/samples_controller_test.rb @@ -115,52 +115,19 @@ class SamplesControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity end - test 'should destroy sample' do - assert_difference('Sample.count', -1) do - delete namespace_project_sample_url(@namespace, @project, @sample1), - as: :turbo_stream - end - end - - test 'should not destroy sample, if it does not belong to the project' do - delete namespace_project_sample_url(@namespace, @project, @sample23) - - assert_response :not_found - end - - test 'should not destroy sample, if the current user is not allowed to modify the project' do - sign_in users(:ryan_doe) - - assert_no_difference('Sample.count') do - delete namespace_project_sample_url(@namespace, @project, @sample1) - end - - assert_response :unauthorized - end - test 'show sample history listing' do - sign_in users(:john_doe) - namespace = groups(:group_one) - project = projects(:project1) - sample = samples(:sample1) - - sample.create_logidze_snapshot! + @sample1.create_logidze_snapshot! - get namespace_project_sample_path(namespace, project, sample, tab: 'history') + get namespace_project_sample_path(@namespace, @project, @sample1, tab: 'history') assert_response :success end test 'view sample history version' do - sign_in users(:john_doe) - namespace = groups(:group_one) - project = projects(:project1) - sample = samples(:sample1) - - sample.create_logidze_snapshot! + @sample1.create_logidze_snapshot! - get namespace_project_sample_view_history_version_path(namespace, project, sample, version: 1, - format: :turbo_stream) + get namespace_project_sample_view_history_version_path(@namespace, @project, @sample1, version: 1, + format: :turbo_stream) assert_response :success end diff --git a/test/services/samples/destroy_service_test.rb b/test/services/samples/destroy_service_test.rb index d06979b30d..956a226e50 100644 --- a/test/services/samples/destroy_service_test.rb +++ b/test/services/samples/destroy_service_test.rb @@ -6,12 +6,15 @@ module Samples class DestroyServiceTest < ActiveSupport::TestCase def setup @user = users(:john_doe) - @sample = samples(:sample23) + @sample1 = samples(:sample1) + @sample2 = samples(:sample2) + @sample30 = samples(:sample30) + @project = projects(:project1) end test 'destroy sample with correct permissions' do assert_difference -> { Sample.count } => -1 do - Samples::DestroyService.new(@sample, @user).execute + Samples::DestroyService.new(@project, @user, { sample: @sample1 }).execute end end @@ -19,51 +22,109 @@ def setup @user = users(:joan_doe) exception = assert_raises(ActionPolicy::Unauthorized) do - Samples::DestroyService.new(@sample, @user).execute + Samples::DestroyService.new(@project, @user, { sample: @sample1 }).execute end assert_equal ProjectPolicy, exception.policy assert_equal :destroy_sample?, exception.rule assert exception.result.reasons.is_a?(::ActionPolicy::Policy::FailureReasons) - assert_equal I18n.t(:'action_policy.policy.project.destroy_sample?', name: @sample.project.name), + assert_equal I18n.t(:'action_policy.policy.project.destroy_sample?', name: @project.name), exception.result.message end test 'valid authorization to destroy sample' do - assert_authorized_to(:destroy_sample?, @sample.project, with: ProjectPolicy, - context: { user: @user }) do - Samples::DestroyService.new(@sample, - @user).execute + assert_authorized_to(:destroy_sample?, @sample1.project, with: ProjectPolicy, + context: { user: @user }) do + Samples::DestroyService.new(@project, @user, { sample: @sample1 }).execute end end - test 'metadata summary updated after sample deletion' do + test 'metadata summary updated after single sample deletion' do # Reference group/projects descendants tree: # group12 < subgroup12b (project30 > sample 33) # | # ---- < subgroup12a (project29 > sample 32) < subgroup12aa (project31 > sample34 + 35) - @group12 = groups(:group_twelve) - @subgroup12a = groups(:subgroup_twelve_a) - @subgroup12b = groups(:subgroup_twelve_b) - @subgroup12aa = groups(:subgroup_twelve_a_a) - @project31 = projects(:project31) - @sample34 = samples(:sample34) - - assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, @project31.namespace.metadata_summary) - assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, @subgroup12aa.metadata_summary) - assert_equal({ 'metadatafield1' => 2, 'metadatafield2' => 2 }, @subgroup12a.metadata_summary) - assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, @subgroup12b.metadata_summary) - assert_equal({ 'metadatafield1' => 3, 'metadatafield2' => 3 }, @group12.metadata_summary) - - assert_no_changes -> { @subgroup12b.reload.metadata_summary } do - Samples::DestroyService.new(@sample34, @user).execute + group12 = groups(:group_twelve) + subgroup12a = groups(:subgroup_twelve_a) + subgroup12b = groups(:subgroup_twelve_b) + subgroup12aa = groups(:subgroup_twelve_a_a) + project31 = projects(:project31) + sample34 = samples(:sample34) + + assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, project31.namespace.metadata_summary) + assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, subgroup12aa.metadata_summary) + assert_equal({ 'metadatafield1' => 2, 'metadatafield2' => 2 }, subgroup12a.metadata_summary) + assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, subgroup12b.metadata_summary) + assert_equal({ 'metadatafield1' => 3, 'metadatafield2' => 3 }, group12.metadata_summary) + + assert_no_changes -> { subgroup12b.reload.metadata_summary } do + Samples::DestroyService.new(project31, @user, { sample: sample34 }).execute + end + + assert_equal({}, project31.namespace.reload.metadata_summary) + assert_equal({}, subgroup12aa.reload.metadata_summary) + assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, subgroup12a.reload.metadata_summary) + assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, subgroup12b.reload.metadata_summary) + assert_equal({ 'metadatafield1' => 2, 'metadatafield2' => 2 }, group12.reload.metadata_summary) + end + + test 'multiple destroy with multiple samples and correct permissions' do + assert_difference -> { Sample.count } => -3 do + Samples::DestroyService.new(@project, @user, { sample_ids: [@sample1.id, @sample2.id, @sample30.id] }).execute + end + end + + test 'destroy samples with incorrect permissions' do + user = users(:joan_doe) + + exception = assert_raises(ActionPolicy::Unauthorized) do + Samples::DestroyService.new(@project, user, { sample_ids: [@sample1.id, @sample2.id, @sample30.id] }).execute + end + + assert_equal ProjectPolicy, exception.policy + assert_equal :destroy_sample?, exception.rule + assert exception.result.reasons.is_a?(::ActionPolicy::Policy::FailureReasons) + assert_equal I18n.t(:'action_policy.policy.project.destroy_sample?', name: @project.name), + exception.result.message + end + + test 'metadata summary updated after multiple sample deletion' do + # Reference group/projects descendants tree: + # group12 < subgroup12b (project30 > sample 33) + # | + # ---- < subgroup12a (project29 > sample 32) < subgroup12aa (project31 > sample34 + 35) + group12 = groups(:group_twelve) + subgroup12a = groups(:subgroup_twelve_a) + subgroup12b = groups(:subgroup_twelve_b) + subgroup12aa = groups(:subgroup_twelve_a_a) + project30 = projects(:project30) + project31 = projects(:project31) + sample33 = samples(:sample33) + sample34 = samples(:sample34) + + assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, project31.namespace.metadata_summary) + assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, subgroup12aa.metadata_summary) + assert_equal({ 'metadatafield1' => 2, 'metadatafield2' => 2 }, subgroup12a.metadata_summary) + assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, subgroup12b.metadata_summary) + assert_equal({ 'metadatafield1' => 3, 'metadatafield2' => 3 }, group12.metadata_summary) + + Samples::TransferService.new(project30, @user).execute(project31.id, [sample33.id]) + + assert_equal( + { 'metadatafield1' => 2, 'metadatafield2' => 2 }, project31.reload.namespace.metadata_summary + ) + + assert_equal({ 'metadatafield1' => 2, 'metadatafield2' => 2 }, subgroup12aa.reload.metadata_summary) + + assert_no_changes -> { subgroup12b.reload.metadata_summary } do + Samples::DestroyService.new(project31, @user, { sample_ids: [sample33.id, sample34.id] }).execute end - assert_equal({}, @project31.namespace.reload.metadata_summary) - assert_equal({}, @subgroup12aa.reload.metadata_summary) - assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, @subgroup12a.reload.metadata_summary) - assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, @subgroup12b.reload.metadata_summary) - assert_equal({ 'metadatafield1' => 2, 'metadatafield2' => 2 }, @group12.reload.metadata_summary) + assert_equal({}, project31.namespace.reload.metadata_summary) + assert_equal({}, subgroup12aa.reload.metadata_summary) + assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, subgroup12a.reload.metadata_summary) + assert_equal({}, subgroup12b.reload.metadata_summary) + assert_equal({ 'metadatafield1' => 1, 'metadatafield2' => 1 }, group12.reload.metadata_summary) end end end diff --git a/test/system/projects/samples_test.rb b/test/system/projects/samples_test.rb index 9d9f9e02a5..72fc8d9882 100644 --- a/test/system/projects/samples_test.rb +++ b/test/system/projects/samples_test.rb @@ -122,8 +122,9 @@ class SamplesTest < ApplicationSystemTestCase click_on I18n.t('projects.samples.attachments.attachment.delete'), match: :first end - within('#turbo-confirm[open]') do - click_button I18n.t(:'components.confirmation.confirm') + within('dialog') do + assert_text I18n.t('projects.samples.attachments.delete_attachment_modal.description') + click_button I18n.t('projects.samples.attachments.delete_attachment_modal.submit_button') end assert_text I18n.t('projects.samples.attachments.destroy.success', filename: 'test_file.fastq') @@ -166,8 +167,8 @@ class SamplesTest < ApplicationSystemTestCase click_on I18n.t('projects.samples.attachments.attachment.delete'), match: :first end - within('#turbo-confirm[open]') do - click_button I18n.t(:'components.confirmation.confirm') + within('dialog') do + click_button I18n.t('projects.samples.attachments.delete_attachment_modal.submit_button') end assert_text I18n.t('projects.samples.attachments.destroy.success', filename: 'TestSample_S1_L001_R1_001.fastq') @@ -189,8 +190,8 @@ class SamplesTest < ApplicationSystemTestCase click_button I18n.t(:'components.confirmation.confirm') end - assert_text I18n.t('projects.samples.destroy.success', sample_name: @sample1.name, - project_name: @project.namespace.human_name) + assert_text I18n.t('projects.samples.deletions.destroy.success', sample_name: @sample1.name, + project_name: @project.namespace.human_name) assert_no_selector '#samples-table table tbody tr', text: @sample1.name assert_selector 'h1', text: I18n.t(:'projects.samples.index.title'), count: 1 @@ -210,12 +211,13 @@ class SamplesTest < ApplicationSystemTestCase click_link 'Remove' end - within('#turbo-confirm[open]') do - click_button I18n.t(:'components.confirmation.confirm') + within('dialog') do + assert_text I18n.t('projects.samples.deletions.new_deletion_dialog.description', sample_name: @sample1.name) + click_button I18n.t('projects.samples.deletions.new_deletion_dialog.submit_button') end - assert_text I18n.t('projects.samples.destroy.success', sample_name: @sample1.name, - project_name: @project.namespace.human_name) + assert_text I18n.t('projects.samples.deletions.destroy.success', sample_name: @sample1.name, + project_name: @project.namespace.human_name) assert_no_selector '#samples-table table tbody tr', text: @sample1.puid assert_no_selector '#samples-table table tbody tr', text: @sample1.name @@ -226,7 +228,7 @@ class SamplesTest < ApplicationSystemTestCase end end - test 'should transfer samples' do + test 'should transfer multiple samples' do project2 = projects(:project2) visit namespace_project_samples_url(@namespace, @project) assert_text 'Displaying 3 items' @@ -236,6 +238,7 @@ class SamplesTest < ApplicationSystemTestCase end click_link I18n.t('projects.samples.index.transfer_button'), match: :first within('span[data-controller-connected="true"] dialog') do + assert_text I18n.t('projects.samples.transfers.description.plural').gsub! 'COUNT_PLACEHOLDER', '3' within %(turbo-frame[id="list_select_samples"]) do samples = @project.samples.pluck(:puid, :name) samples.each do |sample| @@ -248,6 +251,25 @@ class SamplesTest < ApplicationSystemTestCase end end + test 'should transfer a single sample' do + project2 = projects(:project2) + visit namespace_project_samples_url(@namespace, @project) + assert_text 'Displaying 3 items' + within '#samples-table table tbody' do + assert_selector 'tr', count: 3 + all('input[type="checkbox"]')[0].click + end + click_link I18n.t('projects.samples.index.transfer_button'), match: :first + within('span[data-controller-connected="true"] dialog') do + assert_text I18n.t('projects.samples.transfers.dialog.description.singular') + within %(turbo-frame[id="list_select_samples"]) do + assert_text @sample1.name + end + select project2.full_path, from: I18n.t('projects.samples.transfers.dialog.new_project_id') + click_on I18n.t('projects.samples.transfers.dialog.submit_button') + end + end + test 'should not transfer samples with session storage cleared' do project2 = projects(:project2) visit namespace_project_samples_url(@namespace, @project) @@ -440,7 +462,7 @@ class SamplesTest < ApplicationSystemTestCase assert_selector 'a', text: I18n.t('projects.samples.index.upload_file'), count: 0 end - test 'visiting the index should not allow the current user only edit action' do + test 'visiting the index should not allow the current user only edit action' do user = users(:joan_doe) login_as user @@ -1893,7 +1915,7 @@ class SamplesTest < ApplicationSystemTestCase assert_selector 'a', text: I18n.t('projects.samples.index.clone_button'), count: 0 end - test 'should clone samples' do + test 'should clone multiple samples' do project2 = projects(:project2) visit namespace_project_samples_url(@namespace, @project) within '#samples-table table tbody' do @@ -1902,6 +1924,9 @@ class SamplesTest < ApplicationSystemTestCase end click_link I18n.t('projects.samples.index.clone_button'), match: :first within('span[data-controller-connected="true"] dialog') do + assert_text I18n.t( + 'projects.samples.clones.dialog.description.plural' + ).gsub! 'COUNT_PLACEHOLDER', '3' within %(turbo-frame[id="list_select_samples"]) do samples = @project.samples.pluck(:puid, :name) samples.each do |sample| @@ -1915,6 +1940,25 @@ class SamplesTest < ApplicationSystemTestCase assert_text I18n.t('projects.samples.clones.create.success') end + test 'should clone single sample' do + project2 = projects(:project2) + visit namespace_project_samples_url(@namespace, @project) + within '#samples-table table tbody' do + assert_selector 'tr', count: 3 + all('input[type="checkbox"]')[0].click + end + click_link I18n.t('projects.samples.index.clone_button'), match: :first + within('span[data-controller-connected="true"] dialog') do + assert_text I18n.t('projects.samples.clones.dialog.description.singular') + within %(turbo-frame[id="list_select_samples"]) do + assert_text @sample1.name + end + select project2.full_path, from: I18n.t('projects.samples.clones.dialog.new_project_id') + click_on I18n.t('projects.samples.clones.dialog.submit_button') + end + assert_text I18n.t('projects.samples.clones.create.success') + end + test 'should not clone samples with session storage cleared' do project2 = projects(:project2) visit namespace_project_samples_url(@namespace, @project) @@ -2098,5 +2142,161 @@ def retrieve_puids end puids end + + test 'delete multiple samples' do + visit namespace_project_samples_url(@namespace, @project) + within '#samples-table table tbody' do + assert_selector 'tr', count: 3 + assert_text @sample1.name + assert_text @sample2.name + assert_text @sample3.name + all('input[type=checkbox]').each { |checkbox| checkbox.click unless checkbox.checked? } + end + click_link I18n.t('projects.samples.index.delete_samples_button'), match: :first + within('span[data-controller-connected="true"] dialog') do + assert_text I18n.t('projects.samples.deletions.new_multiple_deletions_dialog.title') + assert_text I18n.t( + 'projects.samples.deletions.new_multiple_deletions_dialog.description.plural' + ).gsub! 'COUNT_PLACEHOLDER', '3' + assert_text @sample1.name + assert_text @sample1.puid + assert_text @sample2.name + assert_text @sample2.puid + assert_text @sample3.name + assert_text @sample3.puid + + click_on I18n.t('projects.samples.deletions.new_multiple_deletions_dialog.submit_button') + end + assert_text I18n.t('projects.samples.deletions.destroy_multiple.success') + + within '#project_samples_table' do + assert_no_selector 'tr' + assert_no_text @sample1.name + assert_no_text @sample2.name + assert_no_text @sample3.name + assert_text I18n.t('projects.samples.index.no_samples') + end + end + + test 'delete single sample with checkbox and delete samples button' do + visit namespace_project_samples_url(@namespace, @project) + within '#samples-table table tbody' do + assert_selector 'tr', count: 3 + assert_text @sample1.name + assert_text @sample2.name + assert_text @sample3.name + within 'table tbody tr:first-child' do + all('input[type="checkbox"]')[0].click + end + end + click_link I18n.t('projects.samples.index.delete_samples_button'), match: :first + within('span[data-controller-connected="true"] dialog') do + assert_text I18n.t('projects.samples.deletions.new_multiple_deletions_dialog.title') + assert_text I18n.t('projects.samples.deletions.new_multiple_deletions_dialog.description.singular', + sample_name: @sample1.name) + within %(turbo-frame[id="list_select_samples"]) do + assert_text @sample1.puid + end + + click_on I18n.t('projects.samples.deletions.new_multiple_deletions_dialog.submit_button') + end + + within '#samples-table table tbody' do + assert_selector 'tr', count: 2 + assert_no_text @sample1.name + assert_text @sample2.name + assert_text @sample3.name + end + + assert_text I18n.t('projects.samples.deletions.destroy_multiple.success') + end + + test 'delete single sample with remove link while all samples selected followed by multiple deletion' do + visit namespace_project_samples_url(@namespace, @project) + within '#samples-table table tbody' do + assert_selector 'tr', count: 3 + assert_text @sample1.name + assert_text @sample2.name + assert_text @sample3.name + all('input[type=checkbox]').each { |checkbox| checkbox.click unless checkbox.checked? } + end + + assert find('input#select-all').checked? + + within '#samples-table table tbody tr:first-child' do + click_link I18n.t('projects.samples.index.remove_button') + end + + within 'dialog' do + click_button I18n.t('projects.samples.deletions.new_deletion_dialog.submit_button') + end + + within '#samples-table table tbody' do + assert_selector 'tr', count: 2 + assert_no_text @sample1.name + assert all('input[type="checkbox"]')[0].checked? + assert all('input[type="checkbox"]')[1].checked? + end + + assert find('input#select-all').checked? + + click_link I18n.t('projects.samples.index.delete_samples_button'), match: :first + within('span[data-controller-connected="true"] dialog') do + assert_text I18n.t('projects.samples.deletions.new_multiple_deletions_dialog.title') + assert_text I18n.t( + 'projects.samples.deletions.new_multiple_deletions_dialog.description.plural' + ).gsub! 'COUNT_PLACEHOLDER', '2' + assert_text @sample2.name + assert_text @sample3.name + assert_no_text @sample1.name + click_on I18n.t('projects.samples.deletions.new_multiple_deletions_dialog.submit_button') + end + assert_text I18n.t('projects.samples.deletions.destroy_multiple.success') + + within '#project_samples_table' do + assert_no_selector 'tr' + assert_no_text @sample1.name + assert_no_text @sample2.name + assert_no_text @sample3.name + assert_text I18n.t('projects.samples.index.no_samples') + end + + assert_selector 'a.cursor-not-allowed.pointer-events-none', count: 5 + end + + test 'delete single attachment with remove link while all attachments selected followed by multiple deletion' do + visit namespace_project_sample_url(@namespace, @project, @sample1) + + within('#attachments-table-body') do + assert_link text: I18n.t('projects.samples.attachments.attachment.delete'), count: 2 + all('input[type=checkbox]').each { |checkbox| checkbox.click unless checkbox.checked? } + click_on I18n.t('projects.samples.attachments.attachment.delete'), match: :first + end + + within('dialog') do + assert_text I18n.t('projects.samples.attachments.delete_attachment_modal.description') + click_button I18n.t('projects.samples.attachments.delete_attachment_modal.submit_button') + end + + assert_text I18n.t('projects.samples.attachments.destroy.success', filename: 'test_file.fastq') + within('#table-listing') do + assert_no_text 'test_file.fastq' + assert_text 'test_file_A.fastq' + end + + click_link I18n.t('projects.samples.show.delete_files_button'), match: :first + + within('dialog') do + assert_text 'test_file_A.fastq' + assert_no_text 'test_file.fastq' + click_button I18n.t('projects.samples.attachments.deletions.modal.submit_button') + end + + assert_text I18n.t('projects.samples.attachments.deletions.destroy.success') + assert_no_text 'test_file_A.fastq' + assert_no_text 'test_file.fastq' + assert_text I18n.t('projects.samples.show.no_files') + assert_selector 'a.cursor-not-allowed.pointer-events-none', count: 2 + end end end