diff --git a/django/contrib/admin/static/admin/js/actions.js b/django/contrib/admin/static/admin/js/actions.js index 04b25e9684..cae337b0c7 100644 --- a/django/contrib/admin/static/admin/js/actions.js +++ b/django/contrib/admin/static/admin/js/actions.js @@ -51,29 +51,33 @@ } actionCheckboxes.forEach(function(el) { el.checked = checked; - el.closest('tr').classList.toggle(options.selectedClass, checked); + if (!options.inline) { + el.closest('tr').classList.toggle(options.selectedClass, checked); + } }); } function updateCounter(actionCheckboxes, options) { - const sel = Array.from(actionCheckboxes).filter(function(el) { - return el.checked; - }).length; - const counter = document.querySelector(options.counterContainer); - // data-actions-icnt is defined in the generated HTML - // and contains the total amount of objects in the queryset - const actions_icnt = Number(counter.dataset.actionsIcnt); - counter.textContent = interpolate( - ngettext('%(sel)s of %(cnt)s selected', '%(sel)s of %(cnt)s selected', sel), { - sel: sel, - cnt: actions_icnt - }, true); - const allToggle = document.getElementById(options.allToggleId); - allToggle.checked = sel === actionCheckboxes.length; - if (allToggle.checked) { - showQuestion(options); - } else { - clearAcross(options); + if (!options.inline) { + const sel = Array.from(actionCheckboxes).filter(function(el) { + return el.checked; + }).length; + const counter = document.querySelector(options.counterContainer); + // data-actions-icnt is defined in the generated HTML + // and contains the total amount of objects in the queryset + const actions_icnt = Number(counter.dataset.actionsIcnt); + counter.textContent = interpolate( + ngettext('%(sel)s of %(cnt)s selected', '%(sel)s of %(cnt)s selected', sel), { + sel: sel, + cnt: actions_icnt + }, true); + const allToggle = document.getElementById(options.allToggleId); + allToggle.checked = sel === actionCheckboxes.length; + if (allToggle.checked) { + showQuestion(options); + } else { + clearAcross(options); + } } } @@ -85,7 +89,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,11 +107,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(); @@ -142,28 +142,70 @@ return filtered; }; - 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 + .getElementById(options.allToggleId) + .addEventListener('click', function(event) { + checker(actionCheckboxes, options, this.checked); updateCounter(actionCheckboxes, options); - lastChecked = target; - } else { - list_editable_changed = true; + }); + + 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(); + } } }); - }); + } - 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 (options.inline) { + const handleCheckboxChange = (event) => { + const target = event.target; + + if (!lastChecked || !target.name.endsWith('-DELETE')) { + lastChecked = target; + return; } - } - }); + + if (lastChecked.name.slice(0, -9) === target.name.slice(0, -9)) { + 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_inline_table 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. @@ -200,5 +242,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/django/contrib/admin/templates/admin/edit_inline/tabular.html b/django/contrib/admin/templates/admin/edit_inline/tabular.html index 7acfda7bd1..9f68c61479 100644 --- a/django/contrib/admin/templates/admin/edit_inline/tabular.html +++ b/django/contrib/admin/templates/admin/edit_inline/tabular.html @@ -15,7 +15,7 @@ {% if inline_admin_formset.is_collapsible %}{% endif %} {{ inline_admin_formset.formset.non_form_errors }} - +
{% for field in inline_admin_formset.fields %} diff --git a/django/contrib/auth/common-passwords.txt.gz b/django/contrib/auth/common-passwords.txt.gz index bc94fdec38..7980ee3a47 100644 Binary files a/django/contrib/auth/common-passwords.txt.gz and b/django/contrib/auth/common-passwords.txt.gz differ diff --git a/tests/admin_inlines/admin.py b/tests/admin_inlines/admin.py index c3983985c3..20734eb9e8 100644 --- a/tests/admin_inlines/admin.py +++ b/tests/admin_inlines/admin.py @@ -320,10 +320,18 @@ class ConsigliereInline(admin.TabularInline): model = Consigliere +class ConsigliereInlineStacked(admin.StackedInline): + model = Consigliere + + class SottoCapoInline(admin.TabularInline): model = SottoCapo +class SottoCapoInlineStacked(admin.StackedInline): + model = SottoCapo + + class ProfileInline(admin.TabularInline): model = Profile extra = 1 @@ -510,7 +518,14 @@ site.register(Holder4, Holder4Admin) site.register(Holder5, Holder5Admin) site.register(Author, AuthorAdmin) site.register( - CapoFamiglia, inlines=[ConsigliereInline, SottoCapoInline, ReadOnlyInlineInline] + CapoFamiglia, + inlines=[ + ConsigliereInline, + SottoCapoInline, + ReadOnlyInlineInline, + ConsigliereInlineStacked, + SottoCapoInlineStacked, + ], ) site.register(ProfileCollection, inlines=[ProfileInline]) site.register(ParentModelWithCustomPk, inlines=[ChildModel1Inline, ChildModel2Inline]) diff --git a/tests/admin_inlines/tests.py b/tests/admin_inlines/tests.py index b08ab3a52a..5a3ea337d3 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 [1, 2, 3, 4]: + 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