Skip to content

Migrating to UI Component Framework

A guide for app developers transitioning to the UI Component Framework.

Introduction

This guide helps you migrate your existing Nautobot app views to use the new UI Component Framework. The framework provides a declarative approach to building object detail views, reducing boilerplate code while ensuring consistency across the platform.

For complete UI Component Framework documentation, see: Nautobot UI Framework Documentation

Why Migrate?

Benefits

  • Reduced template maintenance
  • Consistent UI patterns across apps
  • Standardized component behavior

Before and After Examples

Secret model

In this example, we're migrating an existing view (the detail view for the Secret model) to use the UI Component Framework. We'll also convert it from using older generic class-based views to using the newer NautobotUIViewSet class pattern.

Secret Before

nautobot/extras/views.py
...

class SecretListView(generic.ObjectListView):
    queryset = Secret.objects.all()
    filterset = filters.SecretFilterSet
    filterset_form = forms.SecretFilterForm
    table = tables.SecretTable


class SecretView(generic.ObjectView):
    queryset = Secret.objects.all()

    def get_extra_context(self, request, instance):
        provider = registry["secrets_providers"].get(instance.provider)

        groups = instance.secrets_groups.distinct()
        groups_table = tables.SecretsGroupTable(groups, orderable=False)

        return {
            "format": format_,
            "provider_name": provider.name if provider else instance.provider,
            "groups_table": groups_table,
            **super().get_extra_context(request, instance),
        }


class SecretEditView(generic.ObjectEditView):
    queryset = Secret.objects.all()  # (1)
    model_form = forms.SecretForm


class SecretDeleteView(generic.ObjectDeleteView):
    queryset = Secret.objects.all()


class SecretBulkDeleteView(generic.BulkDeleteView):
    queryset = Secret.objects.all()
    filterset = filters.SecretFilterSet
    table = tables.SecretTable

...
  1. Note the repetition of information such as queryset across each distinct view class here. Reducing this repetition is one of the key benefits of moving to use NautobotUIViewSet.
nautobot/extras/templates/extras/secret.html
{% extends 'generic/object_detail.html' %}
{% load helpers %}

{% block extra_buttons %}
    <a onClick="checkSecret()" class="btn btn-default">  <!-- (1) -->
        Check Secret
    </a>
{% endblock extra_buttons %}

{% block content_left_page %}
    <div class="panel panel-default">  <!-- (2) -->
        <div class="panel-heading">
            <strong>Secret</strong>
        </div>
        <table class="table table-hover panel-body attr-table">
            <tr>
                <td>Description</td>
                <td>{{ object.description | placeholder }}</td>
            </tr>
            <tr>
                <td>Provider</td>
                <td>{{ provider_name }}</td>
            </tr>
        </table>
    </div>
    <div class="panel panel-default">  <!-- (3) -->
        <div class="panel-heading">
            <strong>Parameters</strong>
        </div>
        <table class="table table-hover panel-body attr-table">
            {% for param, value in object.parameters.items %}
                <tr>
                    <td><code>{{ param }}</code></td>
                    <td><code>{{ value }}</code></td>
                </tr>
            {% endfor %}
        </table>
    </div>
{% endblock content_left_page %}

{% block content_right_page %}
    <!-- (4) -->
    {% include 'utilities/obj_table.html' with table=groups_table table_template='panel_table.html' heading='Groups containing this secret' %}
{% endblock content_right_page %}

{% block javascript %}
{{ block.super }}
<script>
    function checkSecret() {
        $.ajax({
            url: "{% url 'extras-api:secret-check' pk=object.pk %}",
            dataType: 'json',
            success: function(json) {
                if(json.result) {
                    alert("The secret was successfully retrieved.")
                } else {
                    alert("Error retrieving secret: \n\n" + json.message)
                }
            },
            error: function(xhr) {
                alert("Error checking secret: \n\n" + xhr.responseText);
            }
        });
    }
</script>
{% endblock javascript %}
  1. Custom button added to this page, with custom JavaScript later in the file associated with it.
  2. First panel in this page, on the left side.
  3. Second panel in this page, on the left side below the first panel.
  4. Third panel in this page, on the right side. Note that it renders groups_table, which was manually added to the template rendering context in get_extra_context() above.
nautobot/extras/urls.py
...
    path("secrets/", views.SecretListView.as_view(), name="secret_list"),
    path("secrets/add/", views.SecretEditView.as_view(), name="secret_add"),
    path("secrets/delete/", views.SecretBulkDeleteView.as_view(), name="secret_bulk_delete"),
    path("secrets/<uuid:pk>/", views.SecretView.as_view(), name="secret"),
    path("secrets/<uuid:pk>/edit/", views.SecretEditView.as_view(), name="secret_edit"),
    path("secrets/<uuid:pk>/delete/", views.SecretDeleteView.as_view(), name="secret_delete"),
    path(
        "secrets/<uuid:pk>/changelog/",
        views.ObjectChangeLogView.as_view(),
        name="secret_changelog",
        kwargs={"model": Secret},
    ),
    path(
        "secrets/<uuid:pk>/notes/",
        views.ObjectNotesView.as_view(),
        name="secret_notes",
        kwargs={"model": Secret},
    ),
...

Secret After

nautobot/extras/views.py
...

class SecretUIViewSet(
    ObjectDetailViewMixin,
    ObjectListViewMixin,
    ObjectEditViewMixin,
    ObjectDestroyViewMixin,
    ObjectBulkDestroyViewMixin,
    # no ObjectBulkUpdateViewMixin here yet (1)
    ObjectChangeLogViewMixin,
    ObjectNotesViewMixin,
):
    queryset = Secret.objects.all()
    form_class = forms.SecretForm  # (2)
    filterset_class = filters.SecretFilterSet
    filterset_form_class = forms.SecretFilterForm
    table_class = tables.SecretTable

    object_detail_content = object_detail.ObjectDetailContent(
        panels=[
            object_detail.ObjectFieldsPanel(
                weight=100,
                section=SectionChoices.LEFT_HALF,
                fields="__all__",
                exclude_fields=["parameters"],  # (3)
            ),
            object_detail.KeyValueTablePanel(
                weight=200,
                section=SectionChoices.LEFT_HALF,
                label="Parameters",
                context_data_key="parameters",  # (4)
            ),
            object_detail.ObjectsTablePanel(
                weight=100,  # (5)
                section=SectionChoices.RIGHT_HALF,
                table_title="Groups containing this secret",
                table_class=tables.SecretsGroupTable,
                table_attribute="secrets_groups",  # (6)
                footer_content_template_path=None,  # (7)
            ),
        ],
        extra_buttons=[
            object_detail.Button(
                weight=100,
                label="Check Secret",
                icon="mdi-test-tube",
                javascript_template_path="extras/secret_check.js",
                attributes={"onClick": "checkSecret()"},
            ),
        ],
    )

    def get_extra_context(self, request, instance):
        ctx = super().get_extra_context(request, instance)
        if self.action == "retrieve":
            ctx["parameters"] = instance.parameters
        return ctx

...
  1. The existing Secret views didn't provide a bulk-edit view, probably because there aren't many (any?) Secret attributes that make sense to bulk-edit. We're preserving that behavior here by using the individual view Mixin classes instead of the single NautobotUIViewSet class as we otherwise might.
  2. Note the change in attribute names here compared to the generic class-based views - model_form to form_class, filterset to filterset_class, filterset_form to filterset_form_class, table to table_class.
  3. Matching the original template - we don't render obj.parameters in this panel because we're going to render it specially in the next panel.
  4. In other words, the data to be rendered into this panel will come from the key "parameters" in the render context, which we're specifically injecting in get_extra_context() below.
  5. We restart our weight numbers from 100 here because this panel begins a different section of the page.
  6. In other words, this table will render obj.secrets_groups as its data.
  7. This disables the otherwise automatic injection of an "Add Secrets Group" button in the footer of this table. That might be nice to support in the future but it's a bit tricky because of the many-to-many relationship between Secret and Secrets Group, so for now, we disable it.

Note that we've removed nautobot/extras/templates/extras/secret.html entirely, as a custom template is no longer needed. In its place we have a much smaller template that just contains the custom JavaScript needed for the custom button on this page:

nautobot/extras/templates/extras/secret_check.js
function checkSecret() {
    $.ajax({
        url: "{% url 'extras-api:secret-check' pk=object.pk %}",
        dataType: 'json',
        success: function(json) {
            if(json.result) {
                alert("The secret was successfully retrieved.")
            } else {
                alert("Error retrieving secret: \n\n" + json.message)
            }
        },
        error: function(xhr) {
            alert("Error checking secret: \n\n" + xhr.responseText);
        }
    });
}
nautobot/extras/urls.py
...
router.register("secrets", views.SecretUIViewSet)
...

As a side benefit of this change, we've also reduced the amount of code (Python + HTML) that we have to maintain for this set of views by a fairly significant percentage.

ClusterType model

Screenshot of migrated view:

  • ClusterTypeView Before/After Example

ClusterType Before

nautobot/virtualization/templates/virtualization/clustertype.html
{% load helpers %}

{% block content_left_page %}
        <div class="panel panel-default">
            <div class="panel-heading">
                <strong>Cluster Type</strong>
            </div>
            <table class="table table-hover panel-body attr-table">
                <tr>
                    <td>Description</td>
                    <td>{{ object.description|placeholder }}</td>
                </tr>
                <tr>
                    <td>Clusters</td>
                    <td>
                        <a href="{% url 'virtualization:cluster_list' %}?cluster_type={{ object.name }}">{{ cluster_table.rows|length }}</a>
                    </td>
                </tr>
            </table>
        </div>
{% endblock content_left_page %}

{% block content_right_page %}
        <div class="panel panel-default">
            <div class="panel-heading">
                <strong>Clusters</strong>
            </div>
            {% include 'inc/table.html' with table=cluster_table %}
            {% if perms.virtualization.add_cluster %}
                <div class="panel-footer text-right noprint">
                    <a href="{% url 'virtualization:cluster_add' %}?cluster_type={{ object.pk }}" class="btn btn-xs btn-primary">
                        <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add cluster
                    </a>
                </div>
            {% endif %}
        </div>
        {% include 'inc/paginator.html' with paginator=cluster_table.paginator page=cluster_table.page %}
        <div class="row"></div>
{% endblock content_right_page %}
  1. Both panels are declared in the HTML.
  2. Add cluster button needed to be added manually in HTML code.
nautobot/virtualization/views.py
class ClusterTypeView(generic.ObjectView):
    queryset = ClusterType.objects.all()

    def get_extra_context(self, request, instance):
        # Clusters
        clusters = Cluster.objects.restrict(request.user, "view").filter(cluster_type=instance)

        cluster_table = tables.ClusterTable(clusters)
        cluster_table.columns.hide("cluster_type")

        paginate = {
            "paginator_class": EnhancedPaginator,
            "per_page": get_paginate_count(request),
        }
        RequestConfig(request, paginate).configure(cluster_table)

        return {"cluster_table": cluster_table, **super().get_extra_context(request, instance)}
  1. You need to manually get the data to populate Clusters table along with pagination.
  2. To pass the data into the template you need to override the get_extra_context method.

ClusterType After

File nautobot/virtualization/templates/virtualization/clustertype.html can be deleted.

ClusterTypeView after changes:

nautobot/virtualization/views.py
class ClusterTypeView(generic.ObjectView):
    queryset = ClusterType.objects.all()

    object_detail_content = object_detail.ObjectDetailContent(
        panels=(
            object_detail.ObjectFieldsPanel(
                weight=100,
                section=SectionChoices.LEFT_HALF,
                fields="__all__",
            ),
            object_detail.ObjectsTablePanel(
                weight=100,
                section=SectionChoices.RIGHT_HALF,
                table_class=tables.ClusterTable,
                table_filter="cluster_type",
                exclude_columns=["cluster_type"],
            ),
        ),
    )
  1. There is no need to override get_extra_context method or fetch data for the table manually.
  2. Proper labels are set automatically both for Cluster Type and Clusters panel according to the models names.
  3. ObjectsTablePanel has built in support for Add button and render Add cluster button automatically.
  4. There is no need for custom template or to maintain custom HTML file. HTML template is standardized across the Nautobot.
  5. Pagination support is also built-in into ObjectsTablePanel.
  6. You can easily shuffle Panels if needed or adjust displayed fields at the Panel declaration level without need to look for HTML template.