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