Skip to content

Commit

Permalink
feat(nimbus): add audience form for new nimbus ui
Browse files Browse the repository at this point in the history
Because

* We are implementing all the forms in HTMX
* Time to do the audience form

This commit

* Adds the audience form to the new Nimbus UI

fixes #10839
  • Loading branch information
jaredlockhart committed Dec 17, 2024
1 parent 8e0a8da commit aa88376
Show file tree
Hide file tree
Showing 14 changed files with 625 additions and 16 deletions.
33 changes: 27 additions & 6 deletions experimenter/experimenter/experiments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,9 @@ def get_update_overview_url(self):
def get_update_metrics_url(self):
return reverse("nimbus-new-update-metrics", kwargs={"slug": self.slug})

def get_update_audience_url(self):
return reverse("nimbus-new-update-audience", kwargs={"slug": self.slug})

@property
def experiment_url(self):
return urljoin(f"https://{settings.HOSTNAME}", self.get_absolute_url())
Expand Down Expand Up @@ -541,8 +544,7 @@ def targeting(self):
if self.targeting_config and self.targeting_config.targeting:
sticky_expressions.append(self.targeting_config.targeting)

is_desktop = self.application == self.Application.DESKTOP
if is_desktop and self.channel:
if self.is_desktop and self.channel:
expressions.append(f'browserSettings.update.channel == "{self.channel}"')

sticky_expressions.extend(self._get_targeting_min_version())
Expand All @@ -567,7 +569,7 @@ def targeting(self):
sticky_expressions.append(f"region in {countries}")

enrollments_map_key = "enrollments_map"
if is_desktop:
if self.is_desktop:
enrollments_map_key = "enrollmentsMap"

if excluded_experiments := NimbusExperimentBranchThroughExcluded.objects.filter(
Expand Down Expand Up @@ -601,7 +603,7 @@ def targeting(self):
if self.is_sticky and sticky_expressions:
expressions.append(
make_sticky_targeting_expression(
is_desktop, self.is_rollout, sticky_expressions
self.is_desktop, self.is_rollout, sticky_expressions
)
)
else:
Expand All @@ -610,7 +612,7 @@ def targeting(self):
if prefs := self._get_targeting_pref_conflicts():
expressions.append(
make_sticky_targeting_expression(
is_desktop,
self.is_desktop,
self.is_rollout,
(f"!('{pref}'|preferenceIsUserSet)" for pref in sorted(prefs)),
)
Expand Down Expand Up @@ -643,6 +645,10 @@ def treatment_branches(self):
branches = branches.exclude(id=self.reference_branch.id)
return list(branches)

@property
def is_desktop(self):
return self.application == self.Application.DESKTOP

@property
def is_draft(self):
return (
Expand Down Expand Up @@ -962,6 +968,21 @@ def rollout_monitoring_dashboard_url(self):
slug=self.slug.replace("-", "_")
)

def format_branch_choice(self, branch_slug):
branch_name = "All branches"
if branch_slug is not None:
branch_name = branch_slug.capitalize()
return (
f"{self.slug}:{branch_slug}",
f"{self.name} ({branch_name})",
)

def branch_choices(self):
choices = [self.format_branch_choice(None)]
for branch in self.branches.all():
choices.append(self.format_branch_choice(branch.slug))
return choices

@property
def required_experiments_branches(self):
return NimbusExperimentBranchThroughRequired.objects.filter(
Expand Down Expand Up @@ -1358,7 +1379,7 @@ def clone(self, name, user, rollout_branch_slug=None, changed_on=None):

cloned.feature_configs.add(*self.feature_configs.all())
cloned.countries.add(*self.countries.all())
if self.application == self.Application.DESKTOP:
if self.is_desktop:
cloned.locales.add(*self.locales.all())
cloned.languages.add(*self.languages.all())
cloned.projects.add(*self.projects.all())
Expand Down
1 change: 1 addition & 0 deletions experimenter/experimenter/nimbus_ui_new/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class SortChoices(models.TextChoices):

class IconMultiSelectWidget(MultiSelectWidget):
template_name = "common/sidebar_select.html"
class_attrs = "selectpicker form-control bg-body-tertiary"

def __init__(self, *args, attrs, **kwargs):
self.icon = kwargs.pop("icon", None)
Expand Down
169 changes: 167 additions & 2 deletions experimenter/experimenter/nimbus_ui_new/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@
from django.http import HttpRequest
from django.utils.text import slugify

from experimenter.base.models import Country, Language, Locale
from experimenter.experiments.changelog_utils import generate_nimbus_changelog
from experimenter.experiments.models import NimbusDocumentationLink, NimbusExperiment
from experimenter.experiments.models import (
NimbusDocumentationLink,
NimbusExperiment,
NimbusExperimentBranchThroughExcluded,
NimbusExperimentBranchThroughRequired,
)
from experimenter.nimbus_ui_new.constants import NimbusUIConstants
from experimenter.outcomes import Outcomes
from experimenter.projects.models import Project
from experimenter.segments import Segments
from experimenter.targeting.constants import NimbusTargetingConfig


class NimbusChangeLogFormMixin:
Expand Down Expand Up @@ -143,15 +150,18 @@ def get_changelog_message(self):


class MultiSelectWidget(forms.SelectMultiple):
class_attrs = "selectpicker form-control"

def __init__(self, *args, attrs=None, **kwargs):
attrs = attrs or {}
attrs.update(
{
"class": "selectpicker form-control bg-body-tertiary",
"class": self.class_attrs,
"data-live-search": "true",
"data-live-search-placeholder": "Search",
}
)

super().__init__(*args, attrs=attrs, **kwargs)


Expand Down Expand Up @@ -327,6 +337,161 @@ def get_changelog_message(self):
return f"{self.request.user} updated metrics"


class AudienceForm(NimbusChangeLogFormMixin, forms.ModelForm):
def get_experiment_branch_choices():
return sorted(
[
branch_choice
for experiment in NimbusExperiment.objects.all()
for branch_choice in experiment.branch_choices()
]
)

def get_targeting_config_choices():
return sorted(
[
(targeting.slug, f"{targeting.name} - {targeting.description}")
for targeting in NimbusTargetingConfig.targeting_configs
],
)

channel = forms.ChoiceField(
required=False,
label="",
choices=NimbusExperiment.Channel.choices,
widget=forms.widgets.Select(
attrs={
"class": "form-select",
},
),
)
firefox_min_version = forms.ChoiceField(
required=False,
label="",
choices=NimbusExperiment.Version.choices,
widget=forms.widgets.Select(
attrs={
"class": "form-select",
},
),
)
firefox_max_version = forms.ChoiceField(
required=False,
label="",
choices=NimbusExperiment.Version.choices,
widget=forms.widgets.Select(
attrs={
"class": "form-select",
},
),
)
locales = forms.ModelMultipleChoiceField(
required=False,
queryset=Locale.objects.all().order_by("code"),
widget=MultiSelectWidget(),
)
languages = forms.ModelMultipleChoiceField(
required=False,
queryset=Language.objects.all().order_by("code"),
widget=MultiSelectWidget(),
)
countries = forms.ModelMultipleChoiceField(
required=False,
queryset=Country.objects.all().order_by("code"),
widget=MultiSelectWidget(),
)
targeting_config_slug = forms.ChoiceField(
required=False,
label="",
choices=get_targeting_config_choices,
widget=forms.widgets.Select(
attrs={
"class": "form-select",
},
),
)
excluded_experiments_branches = forms.MultipleChoiceField(
required=False,
choices=get_experiment_branch_choices,
widget=MultiSelectWidget(),
)
required_experiments_branches = forms.MultipleChoiceField(
required=False,
choices=get_experiment_branch_choices,
widget=MultiSelectWidget(),
)
is_sticky = forms.BooleanField(required=False)
population_percent = forms.DecimalField(
required=False, widget=forms.NumberInput(attrs={"class": "form-control"})
)
total_enrolled_clients = forms.IntegerField(
required=False, widget=forms.NumberInput(attrs={"class": "form-control"})
)
proposed_enrollment = forms.IntegerField(
required=False, widget=forms.NumberInput(attrs={"class": "form-control"})
)
proposed_duration = forms.IntegerField(
required=False, widget=forms.NumberInput(attrs={"class": "form-control"})
)

class Meta:
model = NimbusExperiment
fields = [
"channel",
"countries",
"excluded_experiments_branches",
"firefox_max_version",
"firefox_min_version",
"is_sticky",
"languages",
"locales",
"population_percent",
"proposed_duration",
"proposed_enrollment",
"required_experiments_branches",
"targeting_config_slug",
"total_enrolled_clients",
]

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setup_initial_experiments_branches("required_experiments_branches")
self.setup_initial_experiments_branches("excluded_experiments_branches")

def setup_initial_experiments_branches(self, field_name):
self.initial[field_name] = [
branch.child_experiment.format_branch_choice(branch.branch_slug)[0]
for branch in getattr(self.instance, field_name)
]

def save_experiments_branches(self, field_name, model):
experiments_branches = self.cleaned_data.pop(field_name)

if experiments_branches is not None:
model.objects.filter(parent_experiment=self.instance).all().delete()
for experiment_branch in experiments_branches:
experiment_slug, branch_slug = experiment_branch.split(":")
if branch_slug.strip() == "None":
branch_slug = None
model.objects.create(
parent_experiment=self.instance,
child_experiment=NimbusExperiment.objects.get(slug=experiment_slug),
branch_slug=branch_slug,
)

def save(self, *args, **kwargs):
self.save_experiments_branches(
"required_experiments_branches", NimbusExperimentBranchThroughRequired
)
self.save_experiments_branches(
"excluded_experiments_branches", NimbusExperimentBranchThroughExcluded
)
return super().save(*args, **kwargs)

def get_changelog_message(self):
return f"{self.request.user} updated audience"


class SubscribeForm(NimbusChangeLogFormMixin, forms.ModelForm):
class Meta:
model = NimbusExperiment
Expand Down
9 changes: 9 additions & 0 deletions experimenter/experimenter/nimbus_ui_new/static/css/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@
calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
}

.bootstrap-select.is-invalid {
border-color: var(--bs-form-invalid-border-color) !important;
padding-right: calc(1.5em + 0.75rem);
background-image: url("data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 12 12%27 width=%2712%27 height=%2712%27 fill=%27none%27 stroke=%27%23dc3545%27%3e%3ccircle cx=%276%27 cy=%276%27 r=%274.5%27/%3e%3cpath stroke-linejoin=%27round%27 d=%27M5.8 3.6h.4L6 6.5z%27/%3e%3ccircle cx=%276%27 cy=%278.2%27 r=%27.6%27 fill=%27%23dc3545%27 stroke=%27none%27/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right calc(0.375em + 0.1875rem) center;
background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
}

@include color-mode(light) {
.bootstrap-select {
.dropdown-menu {
Expand Down
24 changes: 24 additions & 0 deletions experimenter/experimenter/nimbus_ui_new/static/js/edit_audience.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as $ from "jquery";

const setupRangeSlider = () => {
const rangeInput = document.getElementById("id_population_percent_slider");
const textInput = document.getElementById("id_population_percent");

// Update text input when range slider changes
rangeInput.addEventListener("input", function () {
textInput.value = rangeInput.value;
});

// Update range slider when text input changes
textInput.addEventListener("input", function () {
rangeInput.value = textInput.value;
});
};

$(() => {
setupRangeSlider();

document.body.addEventListener("htmx:afterSwap", function () {
setupRangeSlider();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module.exports = {
entry: {
app: "./js/index.js",
experiment_list: "./js/experiment_list.js",
edit_audience: "./js/edit_audience.js",
},
output: {
filename: "[name].bundle.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
{% block content %}
<div id="content" class="container-fluid">
<div class="row">
<div class="col-xl-2 col-xxl-2">
<div class="col-xl-2 col-xxl-2 pe-3">
<div class="offcanvas-xl offcanvas-start bg-body-tertiary"
tabindex="-1"
id="offcanvasSidebar"
Expand All @@ -25,7 +25,7 @@ <h5 class="offcanvas-title" id="offcanvasSidebarLabel">Filter</h5>
</div>
</div>
</div>
<div class="col-xl-10 col-xxl-10">
<div class="col-xl-10 col-xxl-10 ps-3">
{% block main_content_header %}{% endblock %}

{% block main_content %}
Expand Down
Loading

0 comments on commit aa88376

Please sign in to comment.