Skip to content

Commit 8d4a0c0

Browse files
bug(nimbus): display branch name validation errors (#13819)
Becuase * Branch names and slugs are editable after experiment creation time * We need to enforce slug uniqueness for branches * We missed adding a validator that checked this to the branch form This commit * Adds a validator for unique name/slug pairs for branches * Adds tests fixes #13603
1 parent 8d3460e commit 8d4a0c0

File tree

3 files changed

+152
-0
lines changed

3 files changed

+152
-0
lines changed

experimenter/experimenter/nimbus_ui/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ class NimbusUIConstants:
1313

1414
ERROR_NAME_INVALID = "This is not a valid name."
1515
ERROR_SLUG_DUPLICATE = "An experiment with this slug already exists."
16+
ERROR_SLUG_DUPLICATE_BRANCH = "A branch with this slug already exists."
17+
1618
ERROR_HYPOTHESIS_PLACEHOLDER = "Please enter a hypothesis."
1719
ERROR_NAME_MAPS_TO_EXISTING_SLUG = (
1820
"Name maps to a pre-existing slug, please choose another name."

experimenter/experimenter/nimbus_ui/forms.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,19 @@ def is_valid(self):
555555
and self.screenshot_formset.is_valid()
556556
)
557557

558+
def clean_name(self):
559+
name = self.cleaned_data["name"]
560+
slug = slugify(name)
561+
if not slug:
562+
raise forms.ValidationError(NimbusUIConstants.ERROR_NAME_INVALID)
563+
if (
564+
NimbusBranch.objects.exclude(id=self.instance.id)
565+
.filter(experiment=self.instance.experiment, slug=slug)
566+
.exists()
567+
):
568+
raise forms.ValidationError(NimbusUIConstants.ERROR_SLUG_DUPLICATE_BRANCH)
569+
return name
570+
558571
def clean(self):
559572
cleaned_data = super().clean()
560573
if "name" in cleaned_data:

experimenter/experimenter/nimbus_ui/tests/test_forms.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2807,6 +2807,143 @@ def test_can_save_with_empty_value(self):
28072807
json.dumps({}),
28082808
)
28092809

2810+
def test_empty_branch_name_invalid(self):
2811+
application = NimbusExperiment.Application.DESKTOP
2812+
feature_config1 = NimbusFeatureConfigFactory.create(application=application)
2813+
feature_config2 = NimbusFeatureConfigFactory.create(application=application)
2814+
experiment = NimbusExperimentFactory.create_with_lifecycle(
2815+
NimbusExperimentFactory.Lifecycles.CREATED,
2816+
application=application,
2817+
feature_configs=[feature_config1],
2818+
equal_branch_ratio=False,
2819+
is_localized=False,
2820+
is_rollout=False,
2821+
localizations=None,
2822+
prevent_pref_conflicts=False,
2823+
warn_feature_schema=False,
2824+
)
2825+
experiment.branches.all().delete()
2826+
experiment.changes.all().delete()
2827+
2828+
reference_branch = NimbusBranchFactory.create(experiment=experiment, ratio=1)
2829+
experiment.reference_branch = reference_branch
2830+
experiment.save()
2831+
2832+
reference_branch_feature_config1_value = reference_branch.feature_values.filter(
2833+
feature_config=feature_config1
2834+
).get()
2835+
2836+
form = NimbusBranchesForm(
2837+
instance=experiment,
2838+
data={
2839+
"feature_configs": [feature_config1.id, feature_config2.id],
2840+
"equal_branch_ratio": False,
2841+
"is_rollout": False,
2842+
"prevent_pref_conflicts": True,
2843+
"warn_feature_schema": True,
2844+
"branches-TOTAL_FORMS": "1",
2845+
"branches-INITIAL_FORMS": "1",
2846+
"branches-MIN_NUM_FORMS": "0",
2847+
"branches-MAX_NUM_FORMS": "1000",
2848+
"branches-0-id": reference_branch.id,
2849+
"branches-0-name": "",
2850+
"branches-0-description": "Control Description",
2851+
"branches-0-ratio": 2,
2852+
"branches-0-feature-value-TOTAL_FORMS": "1",
2853+
"branches-0-feature-value-INITIAL_FORMS": "1",
2854+
"branches-0-feature-value-MIN_NUM_FORMS": "0",
2855+
"branches-0-feature-value-MAX_NUM_FORMS": "1000",
2856+
"branches-0-feature-value-0-id": (
2857+
reference_branch_feature_config1_value.id
2858+
),
2859+
"branches-0-feature-value-0-value": "",
2860+
"branches-0-screenshots-TOTAL_FORMS": "0",
2861+
"branches-0-screenshots-INITIAL_FORMS": "0",
2862+
"branches-0-screenshots-MIN_NUM_FORMS": "0",
2863+
"branches-0-screenshots-MAX_NUM_FORMS": "1000",
2864+
"is_localized": True,
2865+
"localizations": json.dumps({"localization-key": "localization-value"}),
2866+
},
2867+
request=self.request,
2868+
)
2869+
2870+
self.assertFalse(form.is_valid(), form.errors)
2871+
self.assertEqual(
2872+
form.errors, {"branches": [{"name": [NimbusUIConstants.ERROR_NAME_INVALID]}]}
2873+
)
2874+
2875+
def test_duplicate_branch_name_invalid(self):
2876+
application = NimbusExperiment.Application.DESKTOP
2877+
feature_config1 = NimbusFeatureConfigFactory.create(application=application)
2878+
experiment = NimbusExperimentFactory.create_with_lifecycle(
2879+
NimbusExperimentFactory.Lifecycles.CREATED,
2880+
application=application,
2881+
feature_configs=[feature_config1],
2882+
equal_branch_ratio=False,
2883+
)
2884+
experiment.branches.all().delete()
2885+
2886+
reference_branch = NimbusBranchFactory.create(
2887+
experiment=experiment, name="Control", slug="control"
2888+
)
2889+
treatment_branch = NimbusBranchFactory.create(experiment=experiment)
2890+
experiment.reference_branch = reference_branch
2891+
experiment.save()
2892+
2893+
reference_branch_feature_config1_value = reference_branch.feature_values.filter(
2894+
feature_config=feature_config1
2895+
).get()
2896+
treatment_branch_feature_config1_value = treatment_branch.feature_values.filter(
2897+
feature_config=feature_config1
2898+
).get()
2899+
2900+
form = NimbusBranchesForm(
2901+
instance=experiment,
2902+
data={
2903+
"feature_configs": [feature_config1.id],
2904+
"equal_branch_ratio": True,
2905+
"branches-TOTAL_FORMS": "2",
2906+
"branches-INITIAL_FORMS": "2",
2907+
"branches-MIN_NUM_FORMS": "0",
2908+
"branches-MAX_NUM_FORMS": "1000",
2909+
"branches-0-id": reference_branch.id,
2910+
"branches-0-name": "Control",
2911+
"branches-0-description": "Control Description",
2912+
"branches-0-feature-value-TOTAL_FORMS": "1",
2913+
"branches-0-feature-value-INITIAL_FORMS": "1",
2914+
"branches-0-feature-value-MIN_NUM_FORMS": "0",
2915+
"branches-0-feature-value-MAX_NUM_FORMS": "1000",
2916+
"branches-0-feature-value-0-id": (
2917+
reference_branch_feature_config1_value.id
2918+
),
2919+
"branches-0-feature-value-0-value": json.dumps(
2920+
{"control-feature1-key": "control-feature-1-value"}
2921+
),
2922+
"branches-1-id": treatment_branch.id,
2923+
"branches-1-name": "Control",
2924+
"branches-1-description": "Treatment Description",
2925+
"branches-1-feature-value-TOTAL_FORMS": "1",
2926+
"branches-1-feature-value-INITIAL_FORMS": "1",
2927+
"branches-1-feature-value-MIN_NUM_FORMS": "0",
2928+
"branches-1-feature-value-MAX_NUM_FORMS": "1000",
2929+
"branches-1-feature-value-0-id": (
2930+
treatment_branch_feature_config1_value.id
2931+
),
2932+
"branches-1-feature-value-0-value": json.dumps(
2933+
{"treatment-feature-1-key": "treatment-feature-1-value"}
2934+
),
2935+
"is_localized": True,
2936+
"localizations": json.dumps({"localization-key": "localization-value"}),
2937+
},
2938+
request=self.request,
2939+
)
2940+
2941+
self.assertFalse(form.is_valid(), form.errors)
2942+
self.assertEqual(
2943+
form.errors,
2944+
{"branches": [{}, {"name": [NimbusUIConstants.ERROR_SLUG_DUPLICATE_BRANCH]}]},
2945+
)
2946+
28102947

28112948
class TestNimbusBranchCreateForm(RequestFormTestCase):
28122949
def test_form_saves_branches(self):

0 commit comments

Comments
 (0)