diff --git a/experimenter/experimenter/experiments/models.py b/experimenter/experimenter/experiments/models.py index d662836911..8bdcbc433b 100644 --- a/experimenter/experimenter/experiments/models.py +++ b/experimenter/experimenter/experiments/models.py @@ -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()) @@ -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()) @@ -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( @@ -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: @@ -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)), ) @@ -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 ( @@ -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( @@ -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()) diff --git a/experimenter/experimenter/nimbus_ui_new/filtersets.py b/experimenter/experimenter/nimbus_ui_new/filtersets.py index 9d19f839a1..a4b27db4fc 100644 --- a/experimenter/experimenter/nimbus_ui_new/filtersets.py +++ b/experimenter/experimenter/nimbus_ui_new/filtersets.py @@ -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) diff --git a/experimenter/experimenter/nimbus_ui_new/forms.py b/experimenter/experimenter/nimbus_ui_new/forms.py index b4dbc87618..e83381a063 100644 --- a/experimenter/experimenter/nimbus_ui_new/forms.py +++ b/experimenter/experimenter/nimbus_ui_new/forms.py @@ -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: @@ -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) @@ -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 diff --git a/experimenter/experimenter/nimbus_ui_new/static/css/style.scss b/experimenter/experimenter/nimbus_ui_new/static/css/style.scss index aeb2b4688b..043fbfbbed 100644 --- a/experimenter/experimenter/nimbus_ui_new/static/css/style.scss +++ b/experimenter/experimenter/nimbus_ui_new/static/css/style.scss @@ -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 { diff --git a/experimenter/experimenter/nimbus_ui_new/static/js/edit_audience.js b/experimenter/experimenter/nimbus_ui_new/static/js/edit_audience.js new file mode 100644 index 0000000000..e537cfaf51 --- /dev/null +++ b/experimenter/experimenter/nimbus_ui_new/static/js/edit_audience.js @@ -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(); + }); +}); diff --git a/experimenter/experimenter/nimbus_ui_new/static/webpack.config.js b/experimenter/experimenter/nimbus_ui_new/static/webpack.config.js index 254a035c75..6f234fe542 100644 --- a/experimenter/experimenter/nimbus_ui_new/static/webpack.config.js +++ b/experimenter/experimenter/nimbus_ui_new/static/webpack.config.js @@ -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", diff --git a/experimenter/experimenter/nimbus_ui_new/templates/common/with_sidebar.html b/experimenter/experimenter/nimbus_ui_new/templates/common/with_sidebar.html index f9342298d3..0b7575ed96 100644 --- a/experimenter/experimenter/nimbus_ui_new/templates/common/with_sidebar.html +++ b/experimenter/experimenter/nimbus_ui_new/templates/common/with_sidebar.html @@ -5,7 +5,7 @@ {% block content %}