Repository: wagtail/wagtail

Issue:
get_add_url() value in IndexView overriden by client side LinkController
### Issue Summary

I'm trying to override `get_add_url()` method on snippets `IndexView` to include the URL query parameter of the currently active parent FK filter in the list view, in order to prepopulate the parent field on the add form.

This indeed works on the backend and when looking at the page source, the correct URL is set on the add button, but [LinkController](https://github.com/wagtail/wagtail/blob/main/client/src/controllers/LinkController.ts#L11C14-L11C28) strips those query parameters and overwrites the link.

### Steps to Reproduce

models.py
```py
import uuid
from django.db import models

# Create your models here.
class Parent(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    title = models.CharField(max_length=100)


class Child(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    title = models.CharField(max_length=100)
    parent = models.ForeignKey(Parent, on_delete=models.CASCADE)

```

wagtail_hooks.py
```py
from wagtail.admin.utils import set_query_params
from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import (
    IndexView,
    SnippetViewSet,
)


from .models import Child, Parent

class PrefilledParentIndexView(IndexView):

    def get_add_url(self):
        add_url = super().get_add_url()
        if self.filters.data.get("parent"):
            add_url = set_query_params(
                add_url, {"parent": self.filters.data["parent"]}
            )
        return add_url


class ParentViewSet(SnippetViewSet):
    model = Parent

class ChildViewSet(SnippetViewSet):
    model = Child
    list_filter = ["parent"]
    index_view_class = PrefilledParentIndexView


register_snippet(ChildViewSet)
register_snippet(ParentViewSet)

```

You can find the whole project [here](https://github.com/bezidejni/get_add_url_repro).

Steps to reproduce:
1. Set up the project
2. Go into the admin panel and create a new parent
3. Go into the child index view and turn on the filter by parent, adding `?parent=<parent_uuid>` to the URL.
4. Click on the "add" button.
5. I would expect that the URL I'm taken to is `/admin/snippets/bug/child/add/?parent=<parent_uuid>`, but is `/admin/snippets/bug/child/add/` instead.

In general, I would expect whatever I set in `get_add_url()` to be, in fact, the URL that's set in the UI.

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes

### Technical details

-   Python version: Python 3.10.4
-   Django version: Version: 5.0.3
-   Wagtail version: Version: 6.0.1
-   Browser version: https://www.whatsmybrowser.org/b/HMGAR

### Working on this

Unfortunately at this time, I don't have enough time and probably not enough JS skills to work on this.
 


Discussion:
Hi, this is expected. The reason why we resorted to client-side updates for the add link is because the listing results get refreshed via AJAX as you apply search/filtering, so we need to have a way to reflect the filters query params to the add URL on the client side.

Looking at your configuration, you should be able to fix this by overriding `header_buttons` on the `IndexView` class so that the `parent` key is reflected:

https://github.com/wagtail/wagtail/blob/caf9142c5dac1fbd8555569f2d30c187ba4dd740/wagtail/admin/views/generic/models.py#L328-L340

e.g.

```diff
     @cached_property
     def header_buttons(self):
         buttons = []
         if self.add_url:
             buttons.append(
                 HeaderButton(
                     self.add_item_label,
                     url=self.add_url,
                     icon_name="plus",
-                    attrs={"data-w-link-reflect-keys-value": '["locale"]'},
+                    attrs={"data-w-link-reflect-keys-value": '["parent"]'},
                 )
             )
         return buttons
```

You can verify that when the `parent` filter value is changed in the filter popup, the add URL will also be updated to reflect the `parent` URL param.

For completeness sake, if you have URL params in the `add_url` that you want to preserve regardless of what happens after filtering, you can add `"data-w-link-preserve-keys-value"` to the `attrs`. Any keys specified in this list will never be removed/updated on the client side.
Hi @laymonage , thanks for the quick reply and the solution to this!

If this is the intended behavior, it might be good to add a note to the documentation indicating that as it's not really quite clear right away why the link is being overwritten, and what's the relationship between the default add_url and the client side code.
Thanks for the suggestion! We were short on time and `LinkController` was added last minute to at least have the ability to update the add URL when the listing is updated on search/filter, but I don't think it's stable enough to be publicly documented. Maybe this can be added as an upgrade consideration in the release notes.

Although now that you mention it, perhaps we should change the default behaviour of the controller to use the keys of the params that are in the URL (at the time the controller is loaded) as the default value for `preserve-keys`. That way, as long as the server adds the params to the initial link, they will be updated when the listing is refreshed (provided the filter uses the same URL param key). This should make the `header_buttons` override unnecessary, which means you won't have to do anything when upgrading.

I'll see what I can do :)
> Although now that you mention it, perhaps we should change the default behaviour of the controller to use the keys of the params that are in the URL (at the time the controller is loaded) as the default value for preserve-keys. That way, as long as the server adds the params to the initial link, they will be updated when the listing is refreshed (provided the filter uses the same URL param key). This should make the header_buttons override unnecessary, which makes it easier to upgrade.

That seems like a great solution, I think this is a good default behavior that would be expected from a user. :)

Resolving patch:
diff --git a/wagtail/admin/views/generic/base.py b/wagtail/admin/views/generic/base.py
--- a/wagtail/admin/views/generic/base.py
+++ b/wagtail/admin/views/generic/base.py
@@ -442,5 +442,8 @@ def get_context_data(self, *args, **kwargs):
             and self.filters
             and self.results_only
         )
+        context["render_buttons_fragment"] = (
+            context.get("header_buttons") and self.results_only
+        )
 
         return context
diff --git a/wagtail/admin/views/generic/models.py b/wagtail/admin/views/generic/models.py
--- a/wagtail/admin/views/generic/models.py
+++ b/wagtail/admin/views/generic/models.py
@@ -334,7 +334,6 @@ def header_buttons(self):
                     self.add_item_label,
                     url=self.add_url,
                     icon_name="plus",
-                    attrs={"data-w-link-reflect-keys-value": '["locale"]'},
                 )
             )
         return buttons
@@ -349,10 +348,6 @@ def header_more_buttons(self):
                     url=self.xlsx_export_url,
                     icon_name="download",
                     priority=90,
-                    attrs={
-                        "data-controller": "w-link",
-                        "data-w-link-preserve-keys-value": '["export"]',
-                    },
                 )
             )
             buttons.append(
@@ -361,10 +356,6 @@ def header_more_buttons(self):
                     url=self.csv_export_url,
                     icon_name="download",
                     priority=100,
-                    attrs={
-                        "data-controller": "w-link",
-                        "data-w-link-preserve-keys-value": '["export"]',
-                    },
                 )
             )
 
diff --git a/wagtail/admin/widgets/button.py b/wagtail/admin/widgets/button.py
--- a/wagtail/admin/widgets/button.py
+++ b/wagtail/admin/widgets/button.py
@@ -109,9 +109,8 @@ def __init__(
         attrs = attrs.copy()
         attrs.update(
             {
-                "data-controller": "w-tooltip w-link",
+                "data-controller": "w-tooltip",
                 "data-w-tooltip-content-value": label,
-                "data-action": "w-swap:reflect@document->w-link#setParams",
                 "aria-label": label,
             }
         )


Test patch:
diff --git a/client/src/controllers/TeleportController.test.js b/client/src/controllers/TeleportController.test.js
--- a/client/src/controllers/TeleportController.test.js
+++ b/client/src/controllers/TeleportController.test.js
@@ -89,9 +89,9 @@ describe('TeleportController', () => {
 
       await Promise.resolve();
 
-      expect(document.getElementById('target-container').innerHTML).toEqual(
-        '<div id="content">Some content</div>',
-      );
+      expect(
+        document.getElementById('target-container').innerHTML.trim(),
+      ).toEqual('<div id="content">Some content</div>');
     });
 
     it('should allow for a default target container within the root element of a shadow DOM', async () => {
@@ -136,9 +136,9 @@ describe('TeleportController', () => {
 
       await Promise.resolve();
 
-      expect(document.getElementById('target-container').innerHTML).toEqual(
-        '<div id="content">Some content</div>',
-      );
+      expect(
+        document.getElementById('target-container').innerHTML.trim(),
+      ).toEqual('<div id="content">Some content</div>');
     });
 
     it('should not clear the target container if the reset value is unset (false)', async () => {
@@ -160,12 +160,84 @@ describe('TeleportController', () => {
 
       await Promise.resolve();
 
+      const contents = document.getElementById('target-container').innerHTML;
+      expect(contents).toContain('<p>I should still be here</p>');
+      expect(contents).toContain('<div id="content">Some content</div>');
+    });
+
+    it('should allow the template to contain multiple children', async () => {
+      document.body.innerHTML += `
+        <div id="target-container"></div>
+        `;
+
+      const template = document.querySelector('template');
+      template.setAttribute(
+        'data-w-teleport-target-value',
+        '#target-container',
+      );
+
+      const otherTemplateContent = document.createElement('div');
+      otherTemplateContent.innerHTML = 'Other content';
+      otherTemplateContent.id = 'other-content';
+      template.content.appendChild(otherTemplateContent);
+
+      expect(document.getElementById('target-container').innerHTML).toEqual('');
+
+      application.start();
+
+      await Promise.resolve();
+
+      const container = document.getElementById('target-container');
+      const content = container.querySelector('#content');
+      const otherContent = container.querySelector('#other-content');
+      expect(content).not.toBeNull();
+      expect(otherContent).not.toBeNull();
+      expect(content.innerHTML.trim()).toEqual('Some content');
+      expect(otherContent.innerHTML.trim()).toEqual('Other content');
+    });
+
+    it('should not throw an error if the template content is empty', async () => {
+      document.body.innerHTML += `
+        <div id="target-container"><p>I should still be here</p></div>
+        `;
+
+      const template = document.querySelector('template');
+      template.setAttribute(
+        'data-w-teleport-target-value',
+        '#target-container',
+      );
+
+      expect(document.getElementById('target-container').innerHTML).toEqual(
+        '<p>I should still be here</p>',
+      );
+
+      const errors = [];
+
+      document.getElementById('template').innerHTML = '';
+      application.handleError = (error, message) => {
+        errors.push({ error, message });
+      };
+
+      await Promise.resolve(application.start());
+
+      expect(errors).toEqual([]);
+
       expect(document.getElementById('target-container').innerHTML).toEqual(
-        '<p>I should still be here</p><div id="content">Some content</div>',
+        '<p>I should still be here</p>',
       );
     });
 
-    it('should throw an error if the template content is empty', async () => {
+    it('should allow erasing the target container by using an empty template with reset value set to true', async () => {
+      document.body.innerHTML += `
+        <div id="target-container"><p>I should not be here</p></div>
+        `;
+
+      const template = document.querySelector('template');
+      template.setAttribute(
+        'data-w-teleport-target-value',
+        '#target-container',
+      );
+      template.setAttribute('data-w-teleport-reset-value', 'true');
       const errors = [];
 
       document.getElementById('template').innerHTML = '';
@@ -176,12 +248,10 @@ describe('TeleportController', () => {
 
       await Promise.resolve(application.start());
 
-      expect(errors).toEqual([
-        {
-          error: new Error('Invalid template content.'),
-          message: 'Error connecting controller',
-        },
-      ]);
+      expect(errors).toEqual([]);
+
+      const contents = document.getElementById('target-container').innerHTML;
+      expect(contents).toEqual('');
     });
 
     it('should throw an error if a valid target container cannot be resolved', async () => {
diff --git a/wagtail/snippets/tests/test_viewset.py b/wagtail/snippets/tests/test_viewset.py
--- a/wagtail/snippets/tests/test_viewset.py
+++ b/wagtail/snippets/tests/test_viewset.py
@@ -1536,3 +1536,35 @@ def test_index_view_get_add_url_is_respected_with_i18n(self):
         soup = self.get_soup(response.content)
         links = soup.find_all("a", attrs={"href": add_url})
         self.assertEqual(len(links), 1)
+
+    def test_index_results_view_get_add_url_teleports_to_header(self):
+        response = self.client.get(self.get_url("list_results"))
+        add_url = self.get_url("add") + "?customised=param"
+        soup = self.get_soup(response.content)
+        template = soup.find(
+            "template",
+            {
+                "data-controller": "w-teleport",
+                "data-w-teleport-target-value": "#w-slim-header-buttons",
+            },
+        )
+        self.assertIsNotNone(template)
+        links = template.find_all("a", attrs={"href": add_url})
+        self.assertEqual(len(links), 1)
+
+    @override_settings(WAGTAIL_I18N_ENABLED=True)
+    def test_index_results_view_get_add_url_teleports_to_header_with_i18n(self):
+        Locale.objects.create(language_code="fr")
+        response = self.client.get(self.get_url("list_results") + "?locale=fr")
+        add_url = self.get_url("add") + "?locale=fr&customised=param"
+        soup = self.get_soup(response.content)
+        template = soup.find(
+            "template",
+            {
+                "data-controller": "w-teleport",
+                "data-w-teleport-target-value": "#w-slim-header-buttons",
+            },
+        )
+        self.assertIsNotNone(template)
+        links = template.find_all("a", attrs={"href": add_url})
+        self.assertEqual(len(links), 1)