diff --git a/tcms/static/js/utils.js b/tcms/static/js/utils.js index 8e912edad7..27c5b66891 100644 --- a/tcms/static/js/utils.js +++ b/tcms/static/js/utils.js @@ -560,6 +560,186 @@ export function showOrHideMultipleRows (rootSelector, rows) { } } +/* + Reusable duplicate-check logic for "new" forms. + + config = { + rpcMethod: 'TestCase.filter', // JSON-RPC method + fieldName: 'summary', // model field name + inputSelector: '#id_summary', // input element + groupSelector: '#summary-group', // form-group with data-trans-* attrs + warningSelector:'#duplicate-summary-warning', + autocompleteSelector: '#summary-autocomplete', + modalSelector: '#duplicate-tc-modal', + prefix: 'TC', // display prefix, e.g. TC, TR, TP + detailUrlBase: '/case/', // URL path for detail view + modalRows: function(g, item) {}, // returns array of [label, value] + descriptionField: 'text' // field name for markdown description, or null + } +*/ +export function initDuplicateCheck (config) { + const duplicateWarning = $(config.warningSelector) + if (!duplicateWarning.length) { + return + } + + const group = $(config.groupSelector) + const input = $(config.inputSelector) + const autocomplete = $(config.autocompleteSelector) + const modal = $(config.modalSelector) + const maxItems = 10 + let debounceTimer = null + let duplicateMatches = [] + + function showModal (item) { + const g = group.data.bind(group) + $('#duplicate-modal-title').text( + g('trans-duplicate-modal-title') + ' - ' + config.prefix + '-' + item.id + ) + + $('#duplicate-modal-view-btn').attr('href', config.detailUrlBase + item.id + '/') + $('#duplicate-modal-view-label').text( + g('trans-duplicate-modal-view') + ' ' + config.prefix + '-' + item.id + ) + + const body = $('#duplicate-modal-body') + body.empty() + + const table = $('', { class: 'table table-striped table-condensed' }) + const rows = config.modalRows(g, item) + rows.forEach(function (row) { + $('') + .append($('') + .append($('
', { text: row[0], css: { width: '150px' } })) + .append($('', { text: row[1] || '' })) + .appendTo(table) + }) + + if (config.descriptionField) { + const descriptionContainer = $('
', { + css: { + 'max-height': '300px', + 'overflow-y': 'auto', + 'background-color': '#f5f5f5', + padding: '10px', + 'border-radius': '4px' + } + }) + const descriptionRow = $('
', { text: g('trans-duplicate-modal-text'), css: { width: '150px', 'vertical-align': 'top' } })) + .append($('').append(descriptionContainer)) + table.append(descriptionRow) + body.append(table) + + const descValue = item[config.descriptionField] + if (descValue) { + markdown2HTML(descValue, descriptionContainer[0]) + } else { + descriptionContainer.text('-') + } + } else { + body.append(table) + } + + modal.modal('show') + } + + function updateWarning () { + if (duplicateMatches.length > 0) { + duplicateWarning.html( + ' ' + + group.data('trans-duplicate-blocked') + ).removeClass('hidden') + } else { + duplicateWarning.addClass('hidden').empty() + } + } + + const fieldContains = config.fieldName + '__icontains' + const fieldIexact = config.fieldName + '__iexact' + + input.on('input', function () { + const value = $(this).val().trim() + clearTimeout(debounceTimer) + + if (value.length < 3) { + duplicateWarning.addClass('hidden').empty() + autocomplete.hide().empty() + duplicateMatches = [] + return + } + + const filterParam = {} + filterParam[fieldContains] = value + + debounceTimer = setTimeout(function () { + jsonRPC(config.rpcMethod, filterParam, function (data) { + duplicateMatches = data.filter(function (item) { + return item[config.fieldName].toLowerCase() === value.toLowerCase() + }) + updateWarning() + + autocomplete.empty() + if (data.length > 0) { + data.slice(0, maxItems).forEach(function (item) { + $('', { + href: '#', + class: 'list-group-item', + text: config.prefix + '-' + item.id + ': ' + item[config.fieldName] + }).on('click', function (e) { + e.preventDefault() + autocomplete.hide() + showModal(item) + }).appendTo(autocomplete) + }) + if (data.length > maxItems) { + $('', { + class: 'list-group-item disabled', + text: '... and ' + (data.length - maxItems) + ' more' + }).appendTo(autocomplete) + } + autocomplete.show() + } else { + autocomplete.hide() + } + }) + }, 500) + }) + + $(document).on('click', function (e) { + if (!$(e.target).closest(config.inputSelector + ', ' + config.autocompleteSelector).length) { + autocomplete.hide() + } + }) + + input.on('focus', function () { + if (autocomplete.children().length > 0) { + autocomplete.show() + } + }) + + duplicateWarning.closest('form').on('submit', function (e) { + const currentValue = input.val().trim() + if (currentValue.length < 1) { + return + } + + const exactParam = {} + exactParam[fieldIexact] = currentValue + + duplicateMatches = [] + jsonRPC(config.rpcMethod, exactParam, function (data) { + duplicateMatches = data + }, true) + + if (duplicateMatches.length > 0) { + e.preventDefault() + updateWarning() + showModal(duplicateMatches[0]) + return false + } + }) +} + export function discoverNestedTestPlans (inputData, callbackF) { const prefix = '    ' const result = [] diff --git a/tcms/templates/include/duplicate_check_modal.html b/tcms/templates/include/duplicate_check_modal.html new file mode 100644 index 0000000000..58a35b67b2 --- /dev/null +++ b/tcms/templates/include/duplicate_check_modal.html @@ -0,0 +1,22 @@ +{% load i18n %} + diff --git a/tcms/testcases/static/testcases/js/mutable.js b/tcms/testcases/static/testcases/js/mutable.js index c37d9c9e32..afc5c33139 100644 --- a/tcms/testcases/static/testcases/js/mutable.js +++ b/tcms/testcases/static/testcases/js/mutable.js @@ -1,4 +1,4 @@ -import { updateCategorySelectFromProduct } from '../../../../static/js/utils' +import { initDuplicateCheck, updateCategorySelectFromProduct } from '../../../../static/js/utils' export function pageTestcasesMutableReadyHandler () { $('#id_template').change(function () { @@ -51,6 +51,28 @@ export function pageTestcasesMutableReadyHandler () { showMinutes: true, showSeconds: true }) + + initDuplicateCheck({ + rpcMethod: 'TestCase.filter', + fieldName: 'summary', + inputSelector: '#id_summary', + groupSelector: '#summary-group', + warningSelector: '#duplicate-summary-warning', + autocompleteSelector: '#summary-autocomplete', + modalSelector: '#duplicate-modal', + prefix: 'TC', + detailUrlBase: '/case/', + descriptionField: 'text', + modalRows: function (g, tc) { + return [ + [g('trans-duplicate-modal-summary'), tc.summary], + [g('trans-duplicate-modal-status'), tc.case_status__name], + [g('trans-duplicate-modal-category'), tc.category__name], + [g('trans-duplicate-modal-priority'), tc.priority__value], + [g('trans-duplicate-modal-author'), tc.author__username] + ] + } + }) } function populateProductCategory () { diff --git a/tcms/testcases/templates/testcases/mutable.html b/tcms/testcases/templates/testcases/mutable.html index 8da89e7a9b..70f49cf1e4 100644 --- a/tcms/testcases/templates/testcases/mutable.html +++ b/tcms/testcases/templates/testcases/mutable.html @@ -20,18 +20,38 @@
{% csrf_token %} -
+
- + + {% if not object %} + + {% endif %} {% if test_plan %}

TP-{{ test_plan.pk }}: {{ test_plan.name }}

{% endif %} {{ form.summary.errors }} + {% if not object %} + + {% endif %}
+ {% if not object %} + {% include "include/duplicate_check_modal.html" %} + {% endif %} +
diff --git a/tcms/testplans/static/testplans/js/mutable.js b/tcms/testplans/static/testplans/js/mutable.js index 7ede72fd13..2b396c9b9d 100644 --- a/tcms/testplans/static/testplans/js/mutable.js +++ b/tcms/testplans/static/testplans/js/mutable.js @@ -1,5 +1,5 @@ import { testPlanAutoComplete } from '../../../../static/js/jsonrpc' -import { populateVersion } from '../../../../static/js/utils' +import { initDuplicateCheck, populateVersion } from '../../../../static/js/utils' const planCache = {} @@ -50,4 +50,26 @@ export function pageTestplansMutableReadyHandler () { // override the default inline-block style $('span.twitter-typeahead').css('display', 'block') + + initDuplicateCheck({ + rpcMethod: 'TestPlan.filter', + fieldName: 'name', + inputSelector: '#id_name', + groupSelector: '#name-group', + warningSelector: '#duplicate-name-warning', + autocompleteSelector: '#name-autocomplete', + modalSelector: '#duplicate-modal', + prefix: 'TP', + detailUrlBase: '/plan/', + descriptionField: 'text', + modalRows: function (g, tp) { + return [ + [g('trans-duplicate-modal-name'), tp.name], + [g('trans-duplicate-modal-product'), tp.product__name], + [g('trans-duplicate-modal-version'), tp.product_version__value], + [g('trans-duplicate-modal-type'), tp.type__name], + [g('trans-duplicate-modal-author'), tp.author__username] + ] + } + }) } diff --git a/tcms/testplans/templates/testplans/mutable.html b/tcms/testplans/templates/testplans/mutable.html index 34b8d242a6..36521f218a 100644 --- a/tcms/testplans/templates/testplans/mutable.html +++ b/tcms/testplans/templates/testplans/mutable.html @@ -20,14 +20,34 @@ {% csrf_token %} -
+
- - {{ form.name.errors }} + + {% if not object %} + + {% endif %} + {{ form.name.errors }} + {% if not object %} + + {% endif %}
+ {% if not object %} + {% include "include/duplicate_check_modal.html" %} + {% endif %} +
diff --git a/tcms/testruns/static/testruns/js/mutable.js b/tcms/testruns/static/testruns/js/mutable.js index c1b249fe06..1ed06b9822 100644 --- a/tcms/testruns/static/testruns/js/mutable.js +++ b/tcms/testruns/static/testruns/js/mutable.js @@ -1,6 +1,6 @@ import { initializeDateTimePicker } from '../../../../static/js/datetime_picker' import { jsonRPC } from '../../../../static/js/jsonrpc' -import { discoverNestedTestPlans, updateSelect, updateTestPlanSelectFromProduct } from '../../../../static/js/utils' +import { discoverNestedTestPlans, initDuplicateCheck, updateSelect, updateTestPlanSelectFromProduct } from '../../../../static/js/utils' export function pageTestrunsMutableReadyHandler () { initializeDateTimePicker('#id_planned_start') @@ -42,4 +42,25 @@ export function pageTestrunsMutableReadyHandler () { $('#add_id_build').click(function () { return showRelatedObjectPopup(this) }) + + initDuplicateCheck({ + rpcMethod: 'TestRun.filter', + fieldName: 'summary', + inputSelector: '#id_summary', + groupSelector: '#summary-group', + warningSelector: '#duplicate-summary-warning', + autocompleteSelector: '#summary-autocomplete', + modalSelector: '#duplicate-modal', + prefix: 'TR', + detailUrlBase: '/runs/', + descriptionField: 'notes', + modalRows: function (g, tr) { + return [ + [g('trans-duplicate-modal-summary'), tr.summary], + [g('trans-duplicate-modal-plan'), tr.plan__name], + [g('trans-duplicate-modal-build'), tr.build__name], + [g('trans-duplicate-modal-manager'), tr.manager__username] + ] + } + }) } diff --git a/tcms/testruns/templates/testruns/mutable.html b/tcms/testruns/templates/testruns/mutable.html index d215b3c919..93c1e725e9 100644 --- a/tcms/testruns/templates/testruns/mutable.html +++ b/tcms/testruns/templates/testruns/mutable.html @@ -19,11 +19,26 @@ {% csrf_token %} -
+
- + + {% if not object %} + + {% endif %} {{ form.summary.errors }} + {% if not object %} + + {% endif %}
@@ -41,6 +56,10 @@
+ {% if not object %} + {% include "include/duplicate_check_modal.html" %} + {% endif %} +