From d48b0046c1972895cc399c26273724e6d2c31711 Mon Sep 17 00:00:00 2001 From: tanaydin Date: Thu, 12 Dec 2024 22:54:40 +0100 Subject: [PATCH] Refactor admin inline actions and fix tabular inline handling. Simplified and reorganized JavaScript for managing inline actions, improving readability and reusability. Updated CSS class selectors and templates to ensure consistent behavior across inline tabular forms. Adjusted test cases to reflect the changes and maintain compatibility. --- .../contrib/admin/static/admin/css/forms.css | 4 + .../contrib/admin/static/admin/js/actions.js | 142 +++++++----- tests/admin_inlines/tests.py | 211 ++++++++++++++++++ 3 files changed, 307 insertions(+), 50 deletions(-) diff --git a/django/contrib/admin/static/admin/css/forms.css b/django/contrib/admin/static/admin/css/forms.css index c6ce78833e..9fb6a08ed2 100644 --- a/django/contrib/admin/static/admin/css/forms.css +++ b/django/contrib/admin/static/admin/css/forms.css @@ -467,6 +467,10 @@ body.popup .submit-row { font-size: 0.75rem; } +.inline-group tr:has(td.delete input:checked) { + background-color: var(--selected-row); +} + .empty-form { display: none; } diff --git a/django/contrib/admin/static/admin/js/actions.js b/django/contrib/admin/static/admin/js/actions.js index 04b25e9684..af571a2a81 100644 --- a/django/contrib/admin/static/admin/js/actions.js +++ b/django/contrib/admin/static/admin/js/actions.js @@ -85,7 +85,8 @@ acrossQuestions: "div.actions span.question", acrossClears: "div.actions span.clear", allToggleId: "action-toggle", - selectedClass: "selected" + selectedClass: "selected", + inline: false, }; window.Actions = function(actionCheckboxes, options) { @@ -102,32 +103,6 @@ shiftPressed = event.shiftKey; }); - document.getElementById(options.allToggleId).addEventListener('click', function(event) { - checker(actionCheckboxes, options, this.checked); - updateCounter(actionCheckboxes, options); - }); - - document.querySelectorAll(options.acrossQuestions + " a").forEach(function(el) { - el.addEventListener('click', function(event) { - event.preventDefault(); - const acrossInputs = document.querySelectorAll(options.acrossInput); - acrossInputs.forEach(function(acrossInput) { - acrossInput.value = 1; - }); - showClear(options); - }); - }); - - document.querySelectorAll(options.acrossClears + " a").forEach(function(el) { - el.addEventListener('click', function(event) { - event.preventDefault(); - document.getElementById(options.allToggleId).checked = false; - clearAcross(options); - checker(actionCheckboxes, options, false); - updateCounter(actionCheckboxes, options); - }); - }); - function affectedCheckboxes(target, withModifier) { const multiSelect = (lastChecked && withModifier && lastChecked !== target); if (!multiSelect) { @@ -138,32 +113,91 @@ const lastCheckedIndex = checkboxes.findIndex(el => el === lastChecked); const startIndex = Math.min(targetIndex, lastCheckedIndex); const endIndex = Math.max(targetIndex, lastCheckedIndex); - const filtered = checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex)); - return filtered; - }; + return checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex)); + } - Array.from(document.getElementById('result_list').tBodies).forEach(function(el) { - el.addEventListener('change', function(event) { - const target = event.target; - if (target.classList.contains('action-select')) { - const checkboxes = affectedCheckboxes(target, shiftPressed); - checker(checkboxes, options, target.checked); + if (!options.inline) { + document.querySelectorAll(options.acrossQuestions + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 1; + }); + showClear(options); + }); + }); + document.querySelectorAll(options.acrossClears + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + document.getElementById(options.allToggleId).checked = false; + clearAcross(options); + checker(actionCheckboxes, options, false); updateCounter(actionCheckboxes, options); - lastChecked = target; - } else { - list_editable_changed = true; + }); + }); + document.getElementById(options.allToggleId).addEventListener('click', function(event) { + checker(actionCheckboxes, options, this.checked); + updateCounter(actionCheckboxes, options); + }); + + Array.from(document.getElementById('result_list').tBodies).forEach( + function(el) { + el.addEventListener('change', function(event) { + const target = event.target; + if (target.classList.contains('action-select')) { + const checkboxes = affectedCheckboxes(target, shiftPressed); + checker(checkboxes, options, target.checked); + updateCounter(actionCheckboxes, options); + lastChecked = target; + } else { + list_editable_changed = true; + } + }); + }); + + document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) { + if (list_editable_changed) { + const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost.")); + if (!confirmed) { + event.preventDefault(); + } } }); - }); + // Sync counter when navigating to the page, such as through the back + // button. + window.addEventListener('pageshow', (event) => updateCounter(actionCheckboxes, options)); + } else if (options.inline) { + const handleCheckboxChange = (event) => { + const target = event.target; - document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) { - if (list_editable_changed) { - const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost.")); - if (!confirmed) { - event.preventDefault(); + if (!lastChecked || !target.name.endsWith('-DELETE')) { + lastChecked = target; + return; } - } - }); + + if (lastChecked.name.slice(0, -9) === target.name.slice(0, -9)) { + // Checking for if clicked checkboxes are in the same form with forms common prefix. + const checkboxes = affectedCheckboxes(target, shiftPressed); + checker(checkboxes, options, target.checked); + } + + lastChecked = target; + }; + + const attachChangeListener = (element) => { + if (!element) { + return; + } + element.addEventListener('change', handleCheckboxChange); + }; + + // Handle tabular inline tables + document.querySelectorAll('.tabular tbody').forEach(attachChangeListener); + + // Handle stacked inlines + document.querySelectorAll('.inline-related').forEach(attachChangeListener); + } const el = document.querySelector('#changelist-form input[name=_save]'); // The button does not exist if no fields are editable. @@ -179,9 +213,6 @@ } }); } - // Sync counter when navigating to the page, such as through the back - // button. - window.addEventListener('pageshow', (event) => updateCounter(actionCheckboxes, options)); }; // Call function fn when the DOM is loaded and ready. If it is already @@ -200,5 +231,16 @@ if (actionsEls.length > 0) { Actions(actionsEls); } + const tabularActionsEls = document.querySelectorAll( + 'td.delete input[type="checkbox"]'); + if (tabularActionsEls.length > 0) { + defaults.inline = true; + Actions(tabularActionsEls); + } + const stackedActionsEls = document.querySelectorAll('span.delete input[type="checkbox"]'); + if (stackedActionsEls.length > 0) { + defaults.inline = true; + Actions(stackedActionsEls); + } }); } diff --git a/tests/admin_inlines/tests.py b/tests/admin_inlines/tests.py index b08ab3a52a..2d1c7df8a5 100644 --- a/tests/admin_inlines/tests.py +++ b/tests/admin_inlines/tests.py @@ -1931,6 +1931,217 @@ class SeleniumTests(AdminSeleniumTestCase): with self.disable_implicit_wait(): self.assertCountSeleniumElements(rows_selector, 0) + def test_delete_multiselect_tabular_inline(self): + from selenium.webdriver.common.action_chains import ActionChains + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + + def fill_form_data(): + self.selenium.find_element(By.ID, "id_name").send_keys("Family Name") + # Fill inline forms + for group in [1, 2]: + for i in range(3): + self.selenium.find_element( + By.ID, f"id_{-group}-{i}-name" + ).send_keys(f"person name {group} {i+1}") + + def get_delete_checkboxes(): + checkboxes = {} + for group in range(1, 5): + for i in range(3): + key = f"{group}_{i}" + checkboxes[key] = self.selenium.find_element( + By.ID, f"id_-{group}-{i}-DELETE" + ) + return checkboxes + + def scroll_to_element(element): + self.selenium.execute_script( + "window.scrollTo(0, %s);" % element.location["y"] + ) + + def shift_click_range(start_element, end_element): + actions = ActionChains(self.selenium) + actions.move_to_element(start_element) + actions.key_down(Keys.SHIFT) + actions.click() + actions.move_to_element(end_element) + actions.click() + actions.key_up(Keys.SHIFT) + actions.perform() + + def assert_checkbox_states(checkboxes, expected_states): + for key, expected in expected_states.items(): + actual = checkboxes[key].get_property("checked") + self.assertEqual( + actual, + expected, + f"Checkbox {key} state mismatch." + f" Expected: {expected}, Got: {actual}", + ) + + # Setup + self.admin_login(username="super", password="secret") + self.selenium.get( + self.live_server_url + reverse("admin:admin_inlines_capofamiglia_add") + ) + + # Fill and save form + fill_form_data() + with self.wait_page_loaded(): + btn = self.selenium.find_element( + By.XPATH, '//input[@value="Save and continue editing"]' + ) + scroll_to_element(btn) + btn.click() + + # Get all delete checkboxes + checkboxes = get_delete_checkboxes() + + # Test 1: Initial state - all unchecked + assert_checkbox_states( + checkboxes, + { + "1_0": False, + "1_1": False, + "1_2": False, + "2_0": False, + "2_1": False, + "2_2": False, + "3_0": False, + "3_1": False, + "3_2": False, + "4_0": False, + "4_1": False, + "4_2": False, + }, + ) + + # Test 2: Select first row completely + scroll_to_element(checkboxes["1_0"]) + shift_click_range(checkboxes["1_0"], checkboxes["1_2"]) + assert_checkbox_states( + checkboxes, + { + "1_0": True, + "1_1": True, + "1_2": True, + "2_0": False, + "2_1": False, + "2_2": False, + }, + ) + + # Test 3: Unselect middle checkbox in first row + scroll_to_element(checkboxes["1_0"]) + shift_click_range(checkboxes["1_2"], checkboxes["1_0"]) + assert_checkbox_states( + checkboxes, + { + "1_0": False, + "1_1": False, + "1_2": False, + "2_0": False, + "2_1": False, + "2_2": False, + }, + ) + + # Test 4: Select across rows + actions = ActionChains(self.selenium) + scroll_to_element(checkboxes["1_0"]) + actions.move_to_element(checkboxes["1_0"]).key_down( + Keys.SHIFT + ).click().perform() + scroll_to_element(checkboxes["2_2"]) + actions.move_to_element(checkboxes["2_2"]).click().perform() + assert_checkbox_states( + checkboxes, + { + "1_0": True, + "1_1": False, + "1_2": False, + "2_0": False, + "2_1": False, + "2_2": True, + }, + ) + + # Test 5: Select all remaining checkboxes + scroll_to_element(checkboxes["1_0"]) + shift_click_range(checkboxes["1_0"], checkboxes["1_2"]) + scroll_to_element(checkboxes["2_0"]) + shift_click_range(checkboxes["2_0"], checkboxes["2_1"]) + assert_checkbox_states( + checkboxes, + { + "1_0": True, + "1_1": True, + "1_2": True, + "2_0": True, + "2_1": True, + "2_2": True, + }, + ) + + scroll_to_element(checkboxes["3_0"]) + shift_click_range(checkboxes["3_0"], checkboxes["3_2"]) + assert_checkbox_states( + checkboxes, + { + "3_0": True, + "3_1": True, + "3_2": True, + "4_0": False, + "4_1": False, + "4_2": False, + }, + ) + scroll_to_element(checkboxes["3_0"]) + shift_click_range(checkboxes["3_2"], checkboxes["3_0"]) + assert_checkbox_states( + checkboxes, + { + "3_0": False, + "3_1": False, + "3_2": False, + "4_0": False, + "4_1": False, + "4_2": False, + }, + ) + + actions = ActionChains(self.selenium) + scroll_to_element(checkboxes["3_0"]) + actions.move_to_element(checkboxes["3_0"]).key_down( + Keys.SHIFT + ).click().perform() + scroll_to_element(checkboxes["4_2"]) + actions.move_to_element(checkboxes["4_2"]).click().perform() + assert_checkbox_states( + checkboxes, + { + "3_0": True, + "3_1": False, + "3_2": False, + "4_0": False, + "4_1": False, + "4_2": True, + }, + ) + + scroll_to_element(checkboxes["1_0"]) + shift_click_range(checkboxes["1_0"], checkboxes["1_2"]) + scroll_to_element(checkboxes["2_0"]) + shift_click_range(checkboxes["2_0"], checkboxes["2_2"]) + scroll_to_element(checkboxes["3_0"]) + shift_click_range(checkboxes["3_0"], checkboxes["3_2"]) + scroll_to_element(checkboxes["4_0"]) + shift_click_range(checkboxes["4_0"], checkboxes["4_2"]) + btn2 = self.selenium.find_element(By.XPATH, '//input[@value="Save"]') + scroll_to_element(btn2) + btn2.click() + def test_delete_invalid_stacked_inlines(self): from selenium.common.exceptions import NoSuchElementException from selenium.webdriver.common.by import By