Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions tcms/testplans/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
62 changes: 61 additions & 1 deletion tcms/testplans/static/testplans/js/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@
dataTableJsonRPC('TestPlan.filter', params, callbackF, preProcessData)
},
columns: [
{
data: null,
orderable: false,
render: function () { return '<input type="checkbox" class="row-select">' }
},
{
data: null,
defaultContent: '',
Expand Down Expand Up @@ -165,7 +170,22 @@
processing: '<div class="spinner spinner-lg"></div>',
zeroRecords: 'No records found'
},
order: [[1, 'asc']]
order: [[2, 'asc']]
})

// header “select all”
$('#resultsTable thead').on('change', '#select-all', function () {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not liking this in the header that much.

Can you move the "Select All" + the "Clone Selected" buttons into the button box above the table?

See how the rest of the buttons are defined. For icon, use fa-code-fork icon to match existing UI.

The order should be
[ ] <clone-selected> <excel> <.... the rest of the buttons>.

Note: (a follow up improvement can be made to the rest of these table buttons to operate only on the current selection if present.

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
Expand All @@ -189,9 +209,44 @@
return false // so we don't actually send the form
})

$('#btn_clone_bulk').click(function () {
const selectedTestPlans = getSelectedTestPlans()
if (selectedTestPlans.length === 0) {
alert('No test plans selected for cloning.')
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This string must be translatable. See how it's done for other strings on dynamic pages (.data('trans- selectors).

return false
}

window.location.assign(`/plan/clone/?p=${selectedTestPlans.join('&p=')}`)
Comment thread Fixed
})

$('#id_product').change(updateVersionSelectFromProduct)
}

function getSelectedTestPlans () {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm, this feels like a duplicate. Need to check if we don't have something more generic which already does this.

In testplans/static/testplans/js/get.js there is getSelectedTestCases() and IIRC there was another one in utils.js but let's leave this for later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main differences between getSelectedTestPlans() and getSelectedTestCases() is that getplans has the handle selecting child nested elements where getcases does not do this.

const inputs = $('#resultsTable tbody input.row-select:checked').closest('tr')
const tpIds = []

inputs.each(function (_, el) {
const id = $(el).closest('tr').find('td:nth-child(3)').text().trim()
if (id) {
tpIds.push(id)
}

// Check if the row has collapsed children and add their IDs
const row = $('#resultsTable').DataTable().row($(el).closest('tr'))
if (hiddenChildRows[id] && !row.child.isShown()) {
hiddenChildRows[id].forEach(function (childRow) {
const childId = $(childRow).find('td:nth-child(3)').text().trim()
if (childId) {
tpIds.push(childId)
}
})
}
})

return tpIds
}

function hideExpandedChildren (table, parentRow) {
const children = hiddenChildRows[parentRow.data().id]
children.forEach(
Expand All @@ -214,5 +269,10 @@
// 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()
}
77 changes: 77 additions & 0 deletions tcms/testplans/templates/testplans/multi_clone.html
Original file line number Diff line number Diff line change
@@ -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 %}
<div class="container-fluid container-cards-pf">
<form class="form-horizontal" method="post" action="{% url "plans-multi-clone" %}">
{% csrf_token %}

{% for plan in form.plan.field.queryset %}
<input type="hidden" name="plan" value="{{ plan.pk }}">
{% endfor %}

<div class="form-group">
<div class="col-md-1 col-lg-1">
<label for="id_product">{% trans "Product" %}</label>
<a href="{% url 'admin:management_product_add' %}?_popup" id="add_id_product" alt="{% trans 'add new Product' %}" title="{% trans 'add new Product' %}">+</a>
</div>
<div class="col-md-3 col-lg-3 {% if form.product.errors %}has-error{% endif %}">
<select id="id_product" name="product" class="form-control selectpicker">
{% for product in form.product.field.queryset %}
<option value="{{ product.pk }}" {% if product.pk|escape == form.product.value|escape %}selected{% endif %}>
{{ product.name }}
</option>
{% endfor %}
</select>
{{ form.product.errors }}
</div>

<div class="col-md-1 col-lg-1">
<label for="id_version">{% trans "Version" %}</label>
<a href="{% url 'admin:management_version_add' %}?_popup&product={{ form.product.value }}"
id="add_id_version" alt="{% trans 'add new Version' %}" title="{% trans 'add new Version' %}">
+
</a>
</div>
<div class="col-md-3 col-lg-3 {% if form.version.errors %}has-error{% endif %}">
<select id="id_version" name="version" class="form-control selectpicker">
{% for product_version in form.version.field.queryset %}
<option value="{{ product_version.pk }}" {% if product_version.pk|escape == form.version.value|escape %}selected{% endif %}>
{{ product_version.value }}
</option>
{% endfor %}
</select>
{{ form.version.errors }}
</div>
</div>

<div class="form-group">
<label class="col-md-1 col-lg-1" for="id_copy_testcases">{% trans "Clone TCs" %}</label>
<div class="col-md-3 col-lg-3">
<input class="bootstrap-switch" id="id_copy_testcases" name="copy_testcases"
type="checkbox" {% if form.copy_testcases.value %}checked{% endif %} data-on-text="{% trans 'Yes' %}" data-off-text="{% trans 'No' %}">
<p class="help-block">{% trans "Clone or link existing TCs into new TP" %}</p>
</div>


<label class="col-md-1 col-lg-1" for="id_set_parent">{% trans "Parent TP" %}</label>
<div class="col-md-3 col-lg-3">
<input class="bootstrap-switch" id="id_set_parent" name="set_parent"
type="checkbox" {% if form.set_parent.value %}checked{% endif %} data-on-text="{% trans 'Yes' %}" data-off-text="{% trans 'No' %}">
<p class="help-block">{% trans "Set the source TP as parent of new TP" %}</p>
</div>
</div>

<div class="form-group">
<div class="col-md-1 col-lg-1">
<button type="submit" class="btn btn-default btn-lg">{% trans "Clone" %}</button>
</div>
</div>
</form>

</div><!-- /container -->
{% endblock %}
4 changes: 4 additions & 0 deletions tcms/testplans/templates/testplans/search.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@
<div class="col-md-1 col-lg-1">
<button id="btn_search" type="submit" class="btn btn-default btn-lg">{% trans "Search" %}</button>
</div>
<div class="col-md-1 col-lg-1">
<button id="btn_clone_bulk" type="button" class="btn btn-default btn-lg">{% trans "Clone Selected" %}</button>
</div>
</div>
</form>

Expand All @@ -110,6 +113,7 @@
<table class="table table-striped table-bordered table-hover" id="resultsTable">
<thead>
<tr>
<th><input type="checkbox" id="select-all"></th>
<th></th>
<th>{% trans "ID" %}</th>
<th>{% trans "Test plan" %}</th>
Expand Down
1 change: 1 addition & 0 deletions tcms/testplans/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
),
re_path(r"^(?P<pk>\d+)/edit/$", views.Edit.as_view(), name="plan-edit"),
re_path(r"^(?P<pk>\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"),
]
64 changes: 64 additions & 0 deletions tcms/testplans/views.py
Original file line number Diff line number Diff line change
@@ -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 _
Expand All @@ -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,
Expand Down Expand Up @@ -226,3 +229,64 @@
return HttpResponseRedirect(
reverse("test_plan_url_short", args=[cloned_plan.pk])
)


@method_decorator(permission_required("testplans.add_testplan"), name="dispatch")
class MultiClone(FormView):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not liking this one very much b/c it adds unnecessary code.

IMO all could be done with the existing cloning code but needs more attention to the details.

Let's sort the GUI part first and then will circle back to this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could remove the original single clone classes and code and change it to redirect to multiclone checking if there is only one and if so letting you change its name.

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", "/"))

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium test

Untrusted URL redirection depends on a
user-provided value
.

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", "/"))

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium test

Untrusted URL redirection depends on a
user-provided value
.

# 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", "/"))

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium test

Untrusted URL redirection depends on a
user-provided value
.

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