From 70cc0d4472b4c7ca1466b1c54583d2282adef5a0 Mon Sep 17 00:00:00 2001 From: Benjamin Forehand Jr Date: Mon, 15 Sep 2025 17:12:25 -0500 Subject: [PATCH 1/6] feature(nimbus): Add deliveries table to feature health page. --- experimenter/experimenter/nimbus_ui/forms.py | 25 +++++++--- .../nimbus_experiments/features.html | 46 ++++++++++++++++++- .../nimbus_ui/tests/test_views.py | 30 +++++++++++- experimenter/experimenter/nimbus_ui/views.py | 20 ++++++++ 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/experimenter/experimenter/nimbus_ui/forms.py b/experimenter/experimenter/nimbus_ui/forms.py index 4ae6baa7fc..36773d18b0 100644 --- a/experimenter/experimenter/nimbus_ui/forms.py +++ b/experimenter/experimenter/nimbus_ui/forms.py @@ -1480,6 +1480,7 @@ def save(self, commit=True): class FeaturesForm(forms.ModelForm): application = forms.ChoiceField( + required=False, label="", choices=NimbusExperiment.Application.choices, widget=forms.widgets.Select( @@ -1490,6 +1491,7 @@ class FeaturesForm(forms.ModelForm): initial=NimbusExperiment.Application.DESKTOP.value, ) feature_configs = forms.ChoiceField( + required=True, label="", choices=[], widget=SingleSelectWidget(), @@ -1497,14 +1499,18 @@ class FeaturesForm(forms.ModelForm): update_on_change_fields = ("application", "feature_configs") def get_feature_config_choices(self, application, qs): - return sorted( - [ - (application.pk, f"{application.name} - {application.description}") - for application in NimbusFeatureConfig.objects.all() - if application in qs - ], - key=lambda choice: choice[1].lower(), + choices = [("", "Nothing Selected")] # Add a default blank field. + choices.extend( + sorted( + [ + (application.pk, f"{application.name} - {application.description}") + for application in NimbusFeatureConfig.objects.all() + if application in qs + ], + key=lambda choice: choice[1].lower(), + ) ) + return choices class Meta: model = NimbusFeatureConfig @@ -1533,4 +1539,9 @@ def __init__(self, *args, **kwargs): "hx-swap": "outerHTML", } self.fields["application"].widget.attrs.update(htmx_attrs) + htmx_attrs.update( + { + "hx-select-oob": "#deliveries-table", + } + ) self.fields["feature_configs"].widget.attrs.update(htmx_attrs) diff --git a/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html b/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html index d5fe9e03b6..7220cb4f6c 100644 --- a/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html +++ b/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html @@ -30,11 +30,53 @@ {% for error in form.feature_configs.errors %}
{{ error }}
{% endfor %} -
{{ application }}
-
{{ feature_configs }}
+
+
+ Hugging Foxes + Deliveries +
+
+
+ + + + + + + + + + + + + + {% for experiment in experiments %} + + + + + + + + + {% endfor %} + + +
Recipe NameLaunch DateType Of DeliveryChannel(s)Minimum VersionPopulation SizeDelivery Brief
+ {{ experiment.name }} + {{ experiment.published_date|format_not_set }}{{ experiment.home_type_choice }}{{ experiment.get_channel_display }}{{ experiment.firefox_min_version|parse_version }}{{ experiment.population_percent|floatformat:"-1" }}{{ experiment.delivery_brief|format_not_set }}
+
+
+
{% endblock %} diff --git a/experimenter/experimenter/nimbus_ui/tests/test_views.py b/experimenter/experimenter/nimbus_ui/tests/test_views.py index 14a14944cb..abfb25c48d 100644 --- a/experimenter/experimenter/nimbus_ui/tests/test_views.py +++ b/experimenter/experimenter/nimbus_ui/tests/test_views.py @@ -3366,8 +3366,9 @@ def setUp(self): "feature-mobile": NimbusExperiment.Application.IOS, "feature-web": NimbusExperiment.Application.EXPERIMENTER, } + self.feature_configs = {} for item, value in self.features.items(): - NimbusFeatureConfigFactory.create( + self.feature_configs[item] = NimbusFeatureConfigFactory.create( slug=item, name=item.replace("-", " "), application=value ) @@ -3434,3 +3435,30 @@ def test_features_view_multiapplication_loads_in_feature_config(self): self.assertTrue(form.fields["application"]) self.assertEqual(form["application"].value(), applications[1].value) self.assertEqual(form["feature_configs"].value(), str(feature_config_multi.id)) + + @parameterized.expand( + [ + (NimbusExperiment.Application.DESKTOP, "feature-desktop"), + (NimbusExperiment.Application.IOS, "feature-mobile"), + (NimbusExperiment.Application.EXPERIMENTER, "feature-web"), + ] + ) + def test_features_view_renders_table_with_correct_elements( + self, application, feature_config + ): + experiment = f"Experiment {feature_config.replace('-', ' ')}" + NimbusExperimentFactory.create( + name=experiment, + application=application, + feature_configs=[self.feature_configs[feature_config]], + ) + + feature_id = self.feature_configs[feature_config].id + url = reverse("nimbus-ui-features") + response = self.client.get( + f"{url}?application={application.value}&feature_configs={feature_id}" + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "#deliveries-table") + self.assertContains(response, experiment) diff --git a/experimenter/experimenter/nimbus_ui/views.py b/experimenter/experimenter/nimbus_ui/views.py index a91c15d016..fd52bad3e3 100644 --- a/experimenter/experimenter/nimbus_ui/views.py +++ b/experimenter/experimenter/nimbus_ui/views.py @@ -644,6 +644,25 @@ class NimbusFeaturesView(TemplateView): def get_form(self): return FeaturesForm(self.request.GET or None) + def get_queryset(self): + qs = ( + NimbusExperiment.objects.with_merged_channel() + .filter(is_archived=False) + .order_by("-_updated_date_time") + ) + + app = self.request.GET.get("application") + if app: + qs = qs.filter(application=app) + + feature_id = self.request.GET.get("feature_configs") + if feature_id: + qs = qs.filter(feature_configs=feature_id).distinct() + else: + return None + + return qs + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) form = self.get_form() @@ -651,6 +670,7 @@ def get_context_data(self, **kwargs): context["links"] = NimbusUIConstants.FEATURE_PAGE_LINKS context["application"] = self.request.GET.get("application") context["feature_configs"] = self.request.GET.get("feature_configs") + context["experiments"] = self.get_queryset() return context From 2055962bb91b65cf80d6ac8bd8648e082c00c1b3 Mon Sep 17 00:00:00 2001 From: Benjamin Forehand Jr Date: Wed, 17 Sep 2025 10:28:57 -0500 Subject: [PATCH 2/6] Add background color and change font sizing. --- .../nimbus_ui/templates/nimbus_experiments/features.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html b/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html index 7220cb4f6c..eadc98b689 100644 --- a/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html +++ b/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html @@ -17,15 +17,15 @@ hx-swap="outerHTML"> {% csrf_token %}
-
+
- +
Application
{{ form.application|add_class:"form-control"|add_error_class:"is-invalid" }} {% for error in form.application.errors %}
{{ error }}
{% endfor %}
- +
Feature Config
{{ form.feature_configs|add_class:"form-control" }} {% for error in form.feature_configs.errors %}
{{ error }}
{% endfor %}
From 3f3236b46e99d72aa88911464b400ee2036ebc6c Mon Sep 17 00:00:00 2001 From: Benjamin Forehand Jr Date: Fri, 26 Sep 2025 13:52:25 -0500 Subject: [PATCH 3/6] Update to show No Deliveries if there are none. --- experimenter/experimenter/nimbus_ui/forms.py | 9 +-- .../nimbus_experiments/features.html | 70 +++++++++++-------- 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/experimenter/experimenter/nimbus_ui/forms.py b/experimenter/experimenter/nimbus_ui/forms.py index 36773d18b0..742024427c 100644 --- a/experimenter/experimenter/nimbus_ui/forms.py +++ b/experimenter/experimenter/nimbus_ui/forms.py @@ -1491,7 +1491,6 @@ class FeaturesForm(forms.ModelForm): initial=NimbusExperiment.Application.DESKTOP.value, ) feature_configs = forms.ChoiceField( - required=True, label="", choices=[], widget=SingleSelectWidget(), @@ -1499,7 +1498,7 @@ class FeaturesForm(forms.ModelForm): update_on_change_fields = ("application", "feature_configs") def get_feature_config_choices(self, application, qs): - choices = [("", "Nothing Selected")] # Add a default blank field. + choices = [] # Add a default blank field. choices.extend( sorted( [ @@ -1537,11 +1536,7 @@ def __init__(self, *args, **kwargs): "hx-select": "#features-form", "hx-target": "#features-form", "hx-swap": "outerHTML", + "hx-select-oob": "#deliveries-table", } self.fields["application"].widget.attrs.update(htmx_attrs) - htmx_attrs.update( - { - "hx-select-oob": "#deliveries-table", - } - ) self.fields["feature_configs"].widget.attrs.update(htmx_attrs) diff --git a/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html b/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html index eadc98b689..3c55a8f550 100644 --- a/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html +++ b/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html @@ -43,37 +43,45 @@
- - - - - - - - - - - - - - {% for experiment in experiments %} - - - - - - - - - {% endfor %} - - -
Recipe NameLaunch DateType Of DeliveryChannel(s)Minimum VersionPopulation SizeDelivery Brief
- {{ experiment.name }} - {{ experiment.published_date|format_not_set }}{{ experiment.home_type_choice }}{{ experiment.get_channel_display }}{{ experiment.firefox_min_version|parse_version }}{{ experiment.population_percent|floatformat:"-1" }}{{ experiment.delivery_brief|format_not_set }}
+
+
+ + + + + + + + + + + + + + {% for experiment in experiments %} + + + + + + + + + {% empty %} + + + + {% endfor %} + + +
Recipe NameLaunch DateType Of DeliveryChannel(s)Minimum VersionPopulation SizeDelivery Brief
+ {{ experiment.name }} + {{ experiment.published_date|format_not_set }}{{ experiment.home_type_choice }}{{ experiment.get_channel_display }}{{ experiment.firefox_min_version|parse_version }}{{ experiment.population_percent|floatformat:"-1" }}{{ experiment.delivery_brief|format_not_set }}
+

No Deliveries

+
From f2c3192731537a8949394c88d29706181a942c17 Mon Sep 17 00:00:00 2001 From: yashikakhurana Date: Mon, 29 Sep 2025 09:48:09 -0700 Subject: [PATCH 4/6] feat(nimbus): Set both default nothing selected --- experimenter/experimenter/nimbus_ui/forms.py | 59 +++++++++----------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/experimenter/experimenter/nimbus_ui/forms.py b/experimenter/experimenter/nimbus_ui/forms.py index 742024427c..247cd41125 100644 --- a/experimenter/experimenter/nimbus_ui/forms.py +++ b/experimenter/experimenter/nimbus_ui/forms.py @@ -1479,38 +1479,32 @@ def save(self, commit=True): class FeaturesForm(forms.ModelForm): - application = forms.ChoiceField( - required=False, - label="", - choices=NimbusExperiment.Application.choices, - widget=forms.widgets.Select( - attrs={ - "class": "form-select", - }, - ), - initial=NimbusExperiment.Application.DESKTOP.value, - ) - feature_configs = forms.ChoiceField( - label="", - choices=[], - widget=SingleSelectWidget(), - ) - update_on_change_fields = ("application", "feature_configs") - - def get_feature_config_choices(self, application, qs): - choices = [] # Add a default blank field. + def get_feature_config_choices(self, qs): + # Add a default blank field. + choices = [("", "Nothing selected")] choices.extend( sorted( [ - (application.pk, f"{application.name} - {application.description}") - for application in NimbusFeatureConfig.objects.all() - if application in qs + (feature.pk, f"{feature.name} - {feature.description}") + for feature in qs ], key=lambda choice: choice[1].lower(), ) ) return choices + application = forms.ChoiceField( + required=False, + choices=[("", "Nothing selected")] + list(NimbusExperiment.Application.choices), + widget=SingleSelectWidget(), + ) + feature_configs = forms.ChoiceField( + required=False, + choices=[("", "Nothing selected")], + widget=SingleSelectWidget(), + ) + update_on_change_fields = ("application", "feature_configs") + class Meta: model = NimbusFeatureConfig fields = ["application", "feature_configs"] @@ -1518,15 +1512,16 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - selected_app = self.data.get("application") or self.get_initial_for_field( - self.fields["application"], "application" - ) - features = NimbusFeatureConfig.objects.filter(application=selected_app).order_by( - "slug" - ) - self.fields["feature_configs"].choices = self.get_feature_config_choices( - selected_app, features - ) + # Default: nothing selected for application and features + selected_app = self.data.get("application") or self.initial.get("application") + if selected_app: + features = NimbusFeatureConfig.objects.filter(application=selected_app).order_by("slug") + self.fields["feature_configs"].choices = self.get_feature_config_choices(features) + else: + self.fields["feature_configs"].choices = [("", "Nothing selected")] + + self.fields["application"].initial = "" + self.fields["feature_configs"].initial = "" base_url = reverse("nimbus-ui-features") htmx_attrs = { From 6de15087e6bd93e6d1dcdd60c382918528ebc784 Mon Sep 17 00:00:00 2001 From: Benjamin Forehand Jr Date: Fri, 26 Sep 2025 13:52:25 -0500 Subject: [PATCH 5/6] Update to show No Deliveries if there are none. --- experimenter/experimenter/nimbus_ui/forms.py | 23 +++++- .../nimbus_experiments/features.html | 75 +++++++++---------- 2 files changed, 57 insertions(+), 41 deletions(-) diff --git a/experimenter/experimenter/nimbus_ui/forms.py b/experimenter/experimenter/nimbus_ui/forms.py index 247cd41125..f8deb78e1b 100644 --- a/experimenter/experimenter/nimbus_ui/forms.py +++ b/experimenter/experimenter/nimbus_ui/forms.py @@ -1479,9 +1479,26 @@ def save(self, commit=True): class FeaturesForm(forms.ModelForm): - def get_feature_config_choices(self, qs): - # Add a default blank field. - choices = [("", "Nothing selected")] + application = forms.ChoiceField( + required=False, + label="", + choices=NimbusExperiment.Application.choices, + widget=forms.widgets.Select( + attrs={ + "class": "form-select", + }, + ), + initial=NimbusExperiment.Application.DESKTOP.value, + ) + feature_configs = forms.ChoiceField( + label="", + choices=[], + widget=SingleSelectWidget(), + ) + update_on_change_fields = ("application", "feature_configs") + + def get_feature_config_choices(self, application, qs): + choices = [] # Add a default blank field. choices.extend( sorted( [ diff --git a/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html b/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html index 3c55a8f550..4792fac0c0 100644 --- a/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html +++ b/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html @@ -43,45 +43,44 @@
-
-
- - - - - - - - - - - - - - {% for experiment in experiments %} - - + {% endfor %} + + +
Recipe NameLaunch DateType Of DeliveryChannel(s)Minimum VersionPopulation SizeDelivery Brief
- {{ experiment.name }} +
+ + + + + + + + + + + + + + {% for experiment in experiments %} + + + + + + + + + {% empty %} + + - - - - - - - {% empty %} - - - - {% endfor %} - - -
Recipe NameLaunch DateType Of DeliveryChannel(s)Minimum VersionPopulation SizeDelivery Brief
+ {{ experiment.name }} + {{ experiment.published_date|format_not_set }}{{ experiment.home_type_choice }}{{ experiment.get_channel_display }}{{ experiment.firefox_min_version|parse_version }}{{ experiment.population_percent|floatformat:"-1" }}{{ experiment.delivery_brief|format_not_set }}
+

No Deliveries

{{ experiment.published_date|format_not_set }}{{ experiment.home_type_choice }}{{ experiment.get_channel_display }}{{ experiment.firefox_min_version|parse_version }}{{ experiment.population_percent|floatformat:"-1" }}{{ experiment.delivery_brief|format_not_set }}
-

No Deliveries

-
+
From ed3233caa2b33c13e6e7b99add4412d1ab566c1a Mon Sep 17 00:00:00 2001 From: Benjamin Forehand Jr Date: Mon, 29 Sep 2025 13:21:23 -0500 Subject: [PATCH 6/6] Updates. --- experimenter/experimenter/nimbus_ui/forms.py | 32 +++++-------------- .../nimbus_ui/tests/test_forms.py | 4 +-- .../nimbus_ui/tests/test_views.py | 6 ++-- 3 files changed, 12 insertions(+), 30 deletions(-) diff --git a/experimenter/experimenter/nimbus_ui/forms.py b/experimenter/experimenter/nimbus_ui/forms.py index f8deb78e1b..2114139ea2 100644 --- a/experimenter/experimenter/nimbus_ui/forms.py +++ b/experimenter/experimenter/nimbus_ui/forms.py @@ -1479,26 +1479,8 @@ def save(self, commit=True): class FeaturesForm(forms.ModelForm): - application = forms.ChoiceField( - required=False, - label="", - choices=NimbusExperiment.Application.choices, - widget=forms.widgets.Select( - attrs={ - "class": "form-select", - }, - ), - initial=NimbusExperiment.Application.DESKTOP.value, - ) - feature_configs = forms.ChoiceField( - label="", - choices=[], - widget=SingleSelectWidget(), - ) - update_on_change_fields = ("application", "feature_configs") - def get_feature_config_choices(self, application, qs): - choices = [] # Add a default blank field. + choices = [] choices.extend( sorted( [ @@ -1512,7 +1494,7 @@ def get_feature_config_choices(self, application, qs): application = forms.ChoiceField( required=False, - choices=[("", "Nothing selected")] + list(NimbusExperiment.Application.choices), + choices=[("", "Nothing selected"), *list(NimbusExperiment.Application.choices)], widget=SingleSelectWidget(), ) feature_configs = forms.ChoiceField( @@ -1532,10 +1514,12 @@ def __init__(self, *args, **kwargs): # Default: nothing selected for application and features selected_app = self.data.get("application") or self.initial.get("application") if selected_app: - features = NimbusFeatureConfig.objects.filter(application=selected_app).order_by("slug") - self.fields["feature_configs"].choices = self.get_feature_config_choices(features) - else: - self.fields["feature_configs"].choices = [("", "Nothing selected")] + features = NimbusFeatureConfig.objects.filter( + application=selected_app + ).order_by("slug") + self.fields["feature_configs"].choices = self.get_feature_config_choices( + selected_app, features + ) self.fields["application"].initial = "" self.fields["feature_configs"].initial = "" diff --git a/experimenter/experimenter/nimbus_ui/tests/test_forms.py b/experimenter/experimenter/nimbus_ui/tests/test_forms.py index 8b0fa7aefe..d53b2a1c5a 100644 --- a/experimenter/experimenter/nimbus_ui/tests/test_forms.py +++ b/experimenter/experimenter/nimbus_ui/tests/test_forms.py @@ -3835,8 +3835,8 @@ def test_features_view_default_fields_are_firefox_desktop(self): form = FeaturesForm() application = form.fields["application"] feature_configs = form.fields["feature_configs"] - self.assertEqual(application.initial, NimbusExperiment.Application.DESKTOP.value) - self.assertIsNone(feature_configs.initial) + self.assertEqual(application.initial, "") + self.assertEqual(feature_configs.initial, "") @parameterized.expand( [ diff --git a/experimenter/experimenter/nimbus_ui/tests/test_views.py b/experimenter/experimenter/nimbus_ui/tests/test_views.py index abfb25c48d..1e80b40a58 100644 --- a/experimenter/experimenter/nimbus_ui/tests/test_views.py +++ b/experimenter/experimenter/nimbus_ui/tests/test_views.py @@ -3384,11 +3384,9 @@ def test_features_view_dropdown_loads_correct_default(self): form = response.context["form"] self.assertTrue(form.fields["application"]) - self.assertEqual( - form.fields["application"].initial, NimbusExperiment.Application.DESKTOP.value - ) + self.assertEqual(form.fields["application"].initial, "") self.assertTrue(form.fields["feature_configs"]) - self.assertEqual(form.fields["feature_configs"].initial, None) + self.assertEqual(form.fields["feature_configs"].initial, "") @parameterized.expand( [