diff --git a/tcms/testplans/forms.py b/tcms/testplans/forms.py index d3e817bc54..df69a3a74e 100644 --- a/tcms/testplans/forms.py +++ b/tcms/testplans/forms.py @@ -82,3 +82,33 @@ def populate(self, product_pk): ) else: self.fields["version"].queryset = Version.objects.none() + + +class CloneMultiPlanForm(forms.Form): # pylint: disable=must-inherit-from-model-form + plan = forms.ModelMultipleChoiceField( + queryset=TestPlan.objects.all(), + ) + + product = forms.ModelChoiceField( + queryset=Product.objects.all(), + empty_label=None, + required=False, + ) + version = forms.ModelChoiceField( + queryset=Version.objects.none(), + empty_label=None, + required=False, + ) + + copy_testcases = forms.BooleanField(required=False) + set_parent = forms.BooleanField(required=False) + + def populate(self, plans): + if plans: + product_pk = TestPlan.objects.filter(pk__in=plans[0])[0].product_id + self.fields["version"].queryset = Version.objects.filter( + product_id=product_pk + ) + self.fields["plan"].queryset = TestPlan.objects.filter(pk__in=plans) + else: + self.fields["version"].queryset = Version.objects.none() diff --git a/tcms/testplans/static/testplans/js/search.js b/tcms/testplans/static/testplans/js/search.js index 83a7eec96b..8a9ae37778 100644 --- a/tcms/testplans/static/testplans/js/search.js +++ b/tcms/testplans/static/testplans/js/search.js @@ -48,6 +48,20 @@ export function pageTestplansSearchReadyHandler () { initializeDateTimePicker('#id_before') initializeDateTimePicker('#id_after') + const multiCloneButton = { + text: '', + titleAttr: 'Clone Selected', + action: function (e, dt, node, config) { + const selectedTestPlans = getSelectedTestPlans() + if (selectedTestPlans.length === 0) { + alert($('#main-element').data('trans-no-testplans-selected')) + return false + } + + window.location.assign(`/plan/clone/?p=${selectedTestPlans.join('&p=')}`) + } + } + const rowsNotShownMessage = $('#main-element').data('trans-some-rows-not-shown') const table = $('#resultsTable').DataTable({ pageLength: $('#navbar').data('defaultpagesize'), @@ -93,6 +107,11 @@ export function pageTestplansSearchReadyHandler () { dataTableJsonRPC('TestPlan.filter', params, callbackF, preProcessData) }, columns: [ + { + data: null, + orderable: false, + render: function () { return '' } + }, { data: null, defaultContent: '', @@ -159,13 +178,37 @@ export function pageTestplansSearchReadyHandler () { }) }, dom: 'Bptp', - buttons: exportButtons, + buttons: [ + multiCloneButton, + ...exportButtons + ], language: { loadingRecords: '
', processing: '
', zeroRecords: 'No records found' }, - order: [[1, 'asc']] + order: [[2, 'asc']], + initComplete: function () { + const btnContainer = table.buttons().container() + $(btnContainer).prepend( + '' + ) + + // Hook the checkbox change event to “select all” + $('#onlyActive').on('change', function () { + const checked = this.checked + $('#resultsTable tbody input.row-select') + .prop('checked', checked) + .trigger('change') + }) + } + }) + + // row checkbox handler + $('#resultsTable tbody').on('change', 'input.row-select', function () { + const $tr = $(this).closest('tr') + if (this.checked) table.row($tr).select() + else table.row($tr).deselect() }) // Add event listener for opening and closing nested test plans @@ -192,6 +235,36 @@ export function pageTestplansSearchReadyHandler () { $('#id_product').change(updateVersionSelectFromProduct) } +function getSelectedTestPlans () { + const inputs = $('#resultsTable tbody input.row-select:checked').closest('tr:visible') + const tpIds = [] + + inputs.each(function (_, el) { + // Check if the row has collapsed children and add their IDs + tpIds.push(...getChildRows(el)) + }) + return tpIds +} + +function getChildRows (parentRowId) { + const tpIds = [] + const parentRow = $('#resultsTable').DataTable().row($(parentRowId).closest('tr')) + const id = $(parentRowId).closest('tr').find('td:nth-child(3)').text().trim() + const children = hiddenChildRows[id] + + if (id) { + tpIds.push(id) + } + + if (children && !parentRow.child.isShown()) { + children.forEach(function (childRow) { + tpIds.push(...getChildRows(childRow)) + }) + return tpIds + } + return tpIds +} + function hideExpandedChildren (table, parentRow) { const children = hiddenChildRows[parentRow.data().id] children.forEach( @@ -214,5 +287,10 @@ function renderChildrenOf (parentRow, data) { // this is an array of previously hidden rows const children = hiddenChildRows[data.id] $(children).find('td').css('border', '0').css('padding-left', `${childPadding}px`) + + if ($(parentRow).find('input.row-select').prop('checked')) { + $(children).find('input.row-select').prop('checked', true).trigger('change') + } + return $(children).show() } diff --git a/tcms/testplans/templates/testplans/multi_clone.html b/tcms/testplans/templates/testplans/multi_clone.html new file mode 100644 index 0000000000..322366225a --- /dev/null +++ b/tcms/testplans/templates/testplans/multi_clone.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} + +{% block title %}{% trans "Clone TestPlan" %} - {{ test_plan.name }}{% endblock %} +{% block page_id %}page-testplans-mutable{% endblock %} + +{% block contents %} +
+
+ {% csrf_token %} + + {% for plan in form.plan.field.queryset %} + + {% endfor %} + +
+
+ + + +
+
+ + {{ form.product.errors }} +
+ +
+ + + + + +
+
+ + {{ form.version.errors }} +
+
+ +
+ +
+ +

{% trans "Clone or link existing TCs into new TP" %}

+
+ + + +
+ +

{% trans "Set the source TP as parent of new TP" %}

+
+
+ +
+
+ +
+
+
+ +
+{% endblock %} diff --git a/tcms/testplans/templates/testplans/search.html b/tcms/testplans/templates/testplans/search.html index 6b23fb43e2..2264adbaca 100644 --- a/tcms/testplans/templates/testplans/search.html +++ b/tcms/testplans/templates/testplans/search.html @@ -10,6 +10,7 @@ class="container-fluid container-cards-pf" id="main-element" data-trans-some-rows-not-shown="{% trans 'Some child test plans do not match search criteria'%}" + data-trans-no-testplans-selected="{% trans 'No rows selected! Please select at least one!'%}" >
{% csrf_token %} @@ -110,6 +111,7 @@ + diff --git a/tcms/testplans/urls.py b/tcms/testplans/urls.py index a70c4bd225..99143b183f 100644 --- a/tcms/testplans/urls.py +++ b/tcms/testplans/urls.py @@ -18,6 +18,7 @@ ), re_path(r"^(?P\d+)/edit/$", views.Edit.as_view(), name="plan-edit"), re_path(r"^(?P\d+)/clone/$", views.Clone.as_view(), name="plans-clone"), + re_path(r"^clone/$", views.MultiClone.as_view(), name="plans-multi-clone"), re_path(r"^search/$", views.SearchTestPlanView.as_view(), name="plans-search"), re_path(r"^new/$", views.NewTestPlanView.as_view(), name="plans-new"), ] diff --git a/tcms/testplans/views.py b/tcms/testplans/views.py index 3cc217f5f9..ef5a96d98d 100644 --- a/tcms/testplans/views.py +++ b/tcms/testplans/views.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- +from django.contrib import messages from django.contrib.auth.decorators import permission_required from django.http import HttpResponsePermanentRedirect, HttpResponseRedirect +from django.shortcuts import render from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ @@ -15,6 +17,7 @@ from tcms.management.models import Priority from tcms.testcases.models import TestCaseStatus from tcms.testplans.forms import ( + CloneMultiPlanForm, ClonePlanForm, NewPlanForm, PlanNotifyFormSet, @@ -226,3 +229,64 @@ def form_valid(self, form): return HttpResponseRedirect( reverse("test_plan_url_short", args=[cloned_plan.pk]) ) + + +@method_decorator(permission_required("testplans.add_testplan"), name="dispatch") +class MultiClone(FormView): + template_name = "testplans/multi_clone.html" + form_class = CloneMultiPlanForm + + http_method_names = ["get", "post"] + + def get(self, request): # pylint: disable=W0221 + if not self._is_request_data_valid(request, "p"): + return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) + + get_params = request.GET.copy() + get_params.setlist("plan", request.GET.getlist("p")) + del get_params["p"] + + planids = get_params.getlist("plan") + + clone_form = CloneMultiPlanForm(get_params) + clone_form.populate(plans=planids) + + context = { + "form": clone_form, + } + return render(request, self.template_name, context) + + def post(self, request): # pylint: disable=W0221 + if not self._is_request_data_valid(request): + return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) + + # Do the clone action + clone_form = CloneMultiPlanForm(request.POST) + clone_form.populate(plans=request.POST.getlist("plan")) + + if clone_form.is_valid(): + + for tp_src in clone_form.cleaned_data["plan"]: + tp_src.clone(name=tp_src.name, **clone_form.cleaned_data) + + # Otherwise tell the user the clone action is successful + messages.add_message( + request, messages.SUCCESS, _("TestPlan cloning was successful") + ) + return HttpResponseRedirect(reverse("plans-search")) + + # invalid form + messages.add_message(request, messages.ERROR, clone_form.errors) + return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) + + @staticmethod + def _is_request_data_valid(request, field_name="plan"): + request_data = getattr(request, request.method) + + if field_name not in request_data: + messages.add_message( + request, messages.ERROR, _("At least one TestPlan is required") + ) + return False + + return True
{% trans "ID" %} {% trans "Test plan" %}