Repository: nautobot/nautobot

Issue:
Add new template tag which hyperlinks an object, but opens in a new tab when clicked.
### As ...

P.D. - Plugin Developer

### I want ...

I want a template tag similar to `hyperlinked_object` which adds the `target="_blank"` attribute.

https://github.com/nautobot/nautobot/blob/594743bf49ade45899034a4e34c83af956036ca4/nautobot/core/templatetags/helpers.py#L46

### So that ...

The hyperlinked object opens in a new tab.  

### I know this is done when...

I add a hyperlinked object using the new template tag and the object opens in a new tab.  

### Optional - Feature groups this request pertains to.

- [ ] Automation
- [ ] Circuits
- [ ] DCIM
- [ ] IPAM
- [ ] Misc (including Data Sources)
- [ ] Organization
- [X] Plugins (and other Extensibility)
- [ ] Security (Secrets, etc)
- [ ] Image Management
- [X] UI/UX
- [ ] Documentation
- [ ] Other (not directly a platform feature)

### Database Changes

No database change required

### External Dependencies

No new dependencies required
Add new template tag which hyperlinks an object, but opens in a new tab when clicked.
### As ...

P.D. - Plugin Developer

### I want ...

I want a template tag similar to `hyperlinked_object` which adds the `target="_blank"` attribute.

https://github.com/nautobot/nautobot/blob/594743bf49ade45899034a4e34c83af956036ca4/nautobot/core/templatetags/helpers.py#L46

### So that ...

The hyperlinked object opens in a new tab.  

### I know this is done when...

I add a hyperlinked object using the new template tag and the object opens in a new tab.  

### Optional - Feature groups this request pertains to.

- [ ] Automation
- [ ] Circuits
- [ ] DCIM
- [ ] IPAM
- [ ] Misc (including Data Sources)
- [ ] Organization
- [X] Plugins (and other Extensibility)
- [ ] Security (Secrets, etc)
- [ ] Image Management
- [X] UI/UX
- [ ] Documentation
- [ ] Other (not directly a platform feature)

### Database Changes

No database change required

### External Dependencies

No new dependencies required


Discussion:
Can you give some examples of use cases where you'd want the template to "force" this behavior, rather than giving the user the option to select "Open link in new tab" on their own when that's what they desire?
@glennmatthews  I am mostly concerned with the first use case, but provided a second one for conversation sake.  
### Use cases

* You want to provide links to other objects on a page with unsaved work.

    * Some custom apps could have pages where the user does a bunch of client side work.  You provide them a link on that page to something helpful and they click on it before submitting their unsaved work back to the server.
    
* You want to provide links to other objects on a page with media.
    *   You might want to provide a link to an object but not interrupt any media that the user is consuming on that page.


Can you give some examples of use cases where you'd want the template to "force" this behavior, rather than giving the user the option to select "Open link in new tab" on their own when that's what they desire?
@glennmatthews  I am mostly concerned with the first use case, but provided a second one for conversation sake.  
### Use cases

* You want to provide links to other objects on a page with unsaved work.

    * Some custom apps could have pages where the user does a bunch of client side work.  You provide them a link on that page to something helpful and they click on it before submitting their unsaved work back to the server.
    
* You want to provide links to other objects on a page with media.
    *   You might want to provide a link to an object but not interrupt any media that the user is consuming on that page.



Resolving patch:
diff --git a/nautobot/core/templatetags/helpers.py b/nautobot/core/templatetags/helpers.py
--- a/nautobot/core/templatetags/helpers.py
+++ b/nautobot/core/templatetags/helpers.py
@@ -8,7 +8,7 @@
 from django.contrib.staticfiles.finders import find
 from django.templatetags.static import static, StaticNode
 from django.urls import NoReverseMatch, reverse
-from django.utils.html import format_html
+from django.utils.html import format_html, format_html_join
 from django.utils.safestring import mark_safe
 from django.utils.text import slugify as django_slugify
 from django_jinja import library
@@ -71,14 +71,7 @@ def hyperlinked_object(value, field="display"):
         >>> hyperlinked_object(location, "name")
         '<a href="/dcim/locations/leaf/">Leaf</a>'
     """
-    if value is None:
-        return placeholder(value)
-    display = getattr(value, field) if hasattr(value, field) else str(value)
-    if hasattr(value, "get_absolute_url"):
-        if hasattr(value, "description") and value.description:
-            return format_html('<a href="{}" title="{}">{}</a>', value.get_absolute_url(), value.description, display)
-        return format_html('<a href="{}">{}</a>', value.get_absolute_url(), display)
-    return format_html("{}", display)
+    return _build_hyperlink(value, field)
 
 
 @library.filter()
@@ -822,3 +815,67 @@ def queryset_to_pks(obj):
     result = list(obj.values_list("pk", flat=True)) if obj else []
     result = [str(entry) for entry in result]
     return ",".join(result)
+
+
+@library.filter()
+@register.filter()
+def hyperlinked_object_target_new_tab(value, field="display"):
+    """Render and link to a Django model instance, if any, or render a placeholder if not.
+
+    Similar to the hyperlinked_object filter, but passes attributes needed to open the link in new tab.
+
+    Uses the specified object field if available, otherwise uses the string representation of the object.
+    If the object defines `get_absolute_url()` this will be used to hyperlink the displayed object;
+    additionally if there is an `object.description` this will be used as the title of the hyperlink.
+
+    Args:
+        value (Union[django.db.models.Model, None]): Instance of a Django model or None.
+        field (Optional[str]): Name of the field to use for the display value. Defaults to "display".
+
+    Returns:
+        (str): String representation of the value (hyperlinked if it defines get_absolute_url()) or a placeholder.
+
+    Examples:
+        >>> hyperlinked_object_target_new_tab(device)
+        '<a href="/dcim/devices/3faafe8c-bdd6-4317-88dc-f791e6988caa/" target="_blank" rel="noreferrer">Device 1</a>'
+        >>> hyperlinked_object_target_new_tab(device_role)
+        '<a href="/dcim/device-roles/router/" title="Devices that are routers, not switches" target="_blank" rel="noreferrer">Router</a>'
+        >>> hyperlinked_object_target_new_tab(None)
+        '<span class="text-muted">&mdash;</span>'
+        >>> hyperlinked_object_target_new_tab("Hello")
+        'Hello'
+        >>> hyperlinked_object_target_new_tab(location)
+        '<a href="/dcim/locations/leaf/" target="_blank" rel="noreferrer">Root → Intermediate → Leaf</a>'
+        >>> hyperlinked_object_target_new_tab(location, "name")
+        '<a href="/dcim/locations/leaf/" target="_blank" rel="noreferrer">Leaf</a>'
+    """
+    return _build_hyperlink(value, field, target="_blank", rel="noreferrer")
+
+
+def _build_hyperlink(value, field="", target="", rel=""):
+    """Internal function used by filters to build hyperlinks.
+
+    Args:
+        value (Union[django.db.models.Model, None]): Instance of a Django model or None.
+        field (Optional[str]): Name of the field to use for the display value. Defaults to "display".
+        target (Optional[str]): Location to open the linked document.  Defaults to "" which is _self.
+        rel (Optional[str]): Relationship between current document and linked document. Defaults to "".
+
+    Returns:
+        (str): String representation of the value (hyperlinked if it defines get_absolute_url()) or a placeholder.
+    """
+    if value is None:
+        return placeholder(value)
+
+    attributes = {}
+    display = getattr(value, field) if hasattr(value, field) else str(value)
+    if hasattr(value, "get_absolute_url"):
+        attributes["href"] = value.get_absolute_url()
+        if hasattr(value, "description") and value.description:
+            attributes["title"] = value.description
+        if target:
+            attributes["target"] = target
+        if rel:
+            attributes["rel"] = rel
+        return format_html("<a {}>{}</a>", format_html_join(" ", '{}="{}"', attributes.items()), display)
+    return format_html("{}", display)


Test patch:
diff --git a/nautobot/core/tests/test_templatetags_helpers.py b/nautobot/core/tests/test_templatetags_helpers.py
--- a/nautobot/core/tests/test_templatetags_helpers.py
+++ b/nautobot/core/tests/test_templatetags_helpers.py
@@ -283,3 +283,35 @@ def test_support_message(self):
                     helpers.support_message(),
                     "<p>Settings <strong>support</strong> message:</p><ul><li>Item 1</li><li>Item 2</li></ul>",
                 )
+
+    def test_hyperlinked_object_target_new_tab(self):
+        # None gives a placeholder
+        self.assertEqual(helpers.hyperlinked_object_target_new_tab(None), helpers.placeholder(None))
+        # An object without get_absolute_url gives a string
+        self.assertEqual(helpers.hyperlinked_object_target_new_tab("hello"), "hello")
+        # An object with get_absolute_url gives a hyperlink
+        location = models.Location.objects.first()
+        # Initially remove description if any
+        location.description = ""
+        location.save()
+        self.assertEqual(
+            helpers.hyperlinked_object_target_new_tab(location),
+            f'<a href="/dcim/locations/{location.pk}/" target="_blank" rel="noreferrer">{location.name}</a>',
+        )
+        # An object with get_absolute_url and a description gives a titled hyperlink
+        location.description = "An important location"
+        location.save()
+        self.assertEqual(
+            helpers.hyperlinked_object_target_new_tab(location),
+            f'<a href="/dcim/locations/{location.pk}/" title="An important location" target="_blank" rel="noreferrer">{location.name}</a>',
+        )
+        # Optionally you can request a field other than the object's display string
+        self.assertEqual(
+            helpers.hyperlinked_object_target_new_tab(location, "name"),
+            f'<a href="/dcim/locations/{location.pk}/" title="An important location" target="_blank" rel="noreferrer">{location.name}</a>',
+        )
+        # If you request a nonexistent field, it defaults to the string representation
+        self.assertEqual(
+            helpers.hyperlinked_object_target_new_tab(location, "foo"),
+            f'<a href="/dcim/locations/{location.pk}/" title="An important location" target="_blank" rel="noreferrer">{location!s}</a>',
+        )