Skip to content

Commit efa49ae

Browse files
feat(nimbus): Create new tag or edit the existing tag (#13791)
Because - We want to give the ability to the users to create and modify the available tags This commit - Add a button on the sidebar to manage tags - Managing tags allows either to edit the tag name/color or to give the option to add a new tag - Dynamically create the prefilled tag name and generate a random color - Validation for the duplicate tag name or empty tag name - New factory to create the tags for the test Note: In the follow-up PR will give the users the ability to assign tags to the deliveries Fixes #13518
1 parent 68b64a3 commit efa49ae

File tree

12 files changed

+361
-9
lines changed

12 files changed

+361
-9
lines changed

experimenter/experimenter/experiments/tests/factories.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
NimbusFeatureConfig,
3333
NimbusIsolationGroup,
3434
NimbusVersionedSchema,
35+
Tag,
3536
)
3637
from experimenter.openidc.tests.factories import UserFactory
3738
from experimenter.outcomes import Outcomes
@@ -880,3 +881,11 @@ class NimbusFmlErrorDataClass:
880881
col: int
881882
message: str
882883
highlight: str
884+
885+
886+
class TagFactory(factory.django.DjangoModelFactory):
887+
name = factory.LazyAttribute(lambda o: faker.unique.word().title())
888+
color = factory.LazyAttribute(lambda o: faker.hex_color())
889+
890+
class Meta:
891+
model = Tag

experimenter/experimenter/nimbus_ui/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class NimbusUIConstants:
1717
ERROR_NAME_MAPS_TO_EXISTING_SLUG = (
1818
"Name maps to a pre-existing slug, please choose another name."
1919
)
20+
ERROR_TAG_DUPLICATE_NAME = "Tag with this Name already exists."
2021

2122
RISK_MESSAGE_URL = "https://mozilla-hub.atlassian.net/wiki/spaces/FIREFOX/pages/208308555/Message+Consult+Creation"
2223
REVIEW_URL = "https://experimenter.info/access"

experimenter/experimenter/nimbus_ui/forms.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import random
12
from collections import defaultdict
23
from datetime import UTC, datetime
34

45
import markus
56
from django import forms
67
from django.contrib.auth.models import User
7-
from django.forms import inlineformset_factory
8+
from django.forms import BaseModelFormSet, inlineformset_factory
89
from django.http import HttpRequest
910
from django.urls import reverse
1011
from django.utils.text import slugify
@@ -20,6 +21,7 @@
2021
NimbusExperimentBranchThroughExcluded,
2122
NimbusExperimentBranchThroughRequired,
2223
NimbusFeatureConfig,
24+
Tag,
2325
)
2426
from experimenter.kinto.tasks import (
2527
nimbus_check_kinto_push_queue_by_collection,
@@ -1541,3 +1543,60 @@ def __init__(self, *args, **kwargs):
15411543
}
15421544
self.fields["application"].widget.attrs.update(htmx_attrs)
15431545
self.fields["feature_configs"].widget.attrs.update(htmx_attrs)
1546+
1547+
1548+
class TagForm(forms.ModelForm):
1549+
name = forms.CharField(
1550+
required=True,
1551+
max_length=100,
1552+
label="Tag Name",
1553+
widget=forms.TextInput(attrs={"class": "form-control"}),
1554+
)
1555+
color = forms.CharField(
1556+
required=True,
1557+
max_length=7,
1558+
label="Color",
1559+
widget=forms.TextInput(
1560+
attrs={"type": "color", "class": "form-control form-control-color"}
1561+
),
1562+
)
1563+
1564+
class Meta:
1565+
model = Tag
1566+
fields = ["name", "color"]
1567+
1568+
1569+
class TagBaseFormSet(BaseModelFormSet):
1570+
def clean(self):
1571+
if any(self.errors):
1572+
return
1573+
1574+
names = []
1575+
for form in self.forms:
1576+
name = form.cleaned_data.get("name")
1577+
if name:
1578+
names.append(name.lower())
1579+
1580+
if len(names) != len(set(names)):
1581+
raise forms.ValidationError(NimbusUIConstants.ERROR_TAG_DUPLICATE_NAME)
1582+
1583+
def create_tag(self):
1584+
# Create a new tag with a unique name and random color
1585+
base_name = "Tag"
1586+
existing_count = Tag.objects.count()
1587+
# Generate a range
1588+
tag_names_all = [f"{base_name} {i}" for i in range(1, existing_count + 1)]
1589+
tag_names_used = set(Tag.objects.values_list("name", flat=True))
1590+
tag_names_free = sorted(set(tag_names_all) - tag_names_used)
1591+
name = (
1592+
tag_names_free[0] if tag_names_free else f"{base_name} {existing_count + 1}"
1593+
)
1594+
1595+
random_color = f"#{random.randint(0, 0xFFFFFF):06x}"
1596+
tag = Tag.objects.create(name=name, color=random_color)
1597+
return tag
1598+
1599+
1600+
TagFormSet = forms.modelformset_factory(
1601+
Tag, form=TagForm, formset=TagBaseFormSet, extra=0, can_delete=False
1602+
)

experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/experiment_base.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,11 @@ <h1 class="modal-title fs-5" id="cloneModalLabel">
8484
</div>
8585
</div>
8686
</div>
87+
<div class="modal fade" id="tagsModal" tabindex="-1" aria-hidden="true">
88+
<div class="modal-dialog">
89+
<div class="modal-content" id="tagsModalContent">
90+
<!-- Content will be loaded via HTMX -->
91+
</div>
92+
</div>
93+
</div>
8794
{% endblock %}

experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/sidebar.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@
3535
</p>
3636
</div>
3737
{% endif %}
38+
<strong class="ms-3">Tags</strong>
39+
<hr class="my-0 mb-2">
40+
<button type="button"
41+
class="btn btn-link text-start p-2 mb-2 text-decoration-none"
42+
data-bs-toggle="modal"
43+
data-bs-target="#tagsModal"
44+
hx-get="{% url 'nimbus-ui-tags-manage' %}"
45+
hx-target="#tagsModalContent">
46+
<i class="fa-solid fa-tags pe-2"></i>
47+
Manage Tags
48+
</button>
3849
<strong class="ms-3">Actions</strong>
3950
<hr class="my-0 mb-2">
4051
{% include "common/sidebar_link.html" with title="Clone" link="" icon="fa-regular fa-copy fa-lg" data_bs_toggle="modal" data_bs_target="#cloneModal" %}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<div id="tags-list">
2+
{{ formset.management_form }}
3+
{% if formset.forms %}
4+
{% for form in formset %}
5+
<div class="d-flex align-items-start mb-3">
6+
{{ form.id }}
7+
<div class="me-3">{{ form.color }}</div>
8+
<div class="flex-grow-1">
9+
{{ form.name }}
10+
{% if form.name.errors %}<div class="invalid-feedback d-block mt-1">{{ form.name.errors|first }}</div>{% endif %}
11+
</div>
12+
</div>
13+
{% endfor %}
14+
{% else %}
15+
<div class="text-center py-4 text-muted">
16+
<i class="fa-solid fa-tags fa-2x mb-3"></i>
17+
<p class="mb-1">No tags available</p>
18+
<small>Add a tag using the Create button below</small>
19+
</div>
20+
{% endif %}
21+
</div>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{% load nimbus_extras %}
2+
3+
<div class="modal-header">
4+
<h5 class="modal-title">Manage Tags</h5>
5+
<button type="button"
6+
class="btn-close"
7+
data-bs-dismiss="modal"
8+
aria-label="Close"></button>
9+
</div>
10+
<div class="modal-body"
11+
id="tags-modal-body"
12+
style="height: 400px;
13+
overflow-y: auto">
14+
<form id="tags-form" method="post">
15+
{% csrf_token %}
16+
<div id="tags-list">
17+
{% include "nimbus_experiments/tags_list_partial.html" %}
18+
19+
</div>
20+
</form>
21+
</div>
22+
<div class="modal-footer d-flex justify-content-between position-sticky bottom-0 bg-white border-top">
23+
<button type="button"
24+
class="btn btn-outline-primary"
25+
hx-post="{% url 'nimbus-ui-tags-create' %}"
26+
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
27+
hx-target="#tags-list"
28+
hx-swap="innerHTML"
29+
hx-on::after-request="document.getElementById('tags-modal-body').scrollTop = document.getElementById('tags-modal-body').scrollHeight">
30+
+ Create
31+
</button>
32+
<button type="button"
33+
class="btn btn-primary"
34+
hx-post="{% url 'nimbus-ui-tags-save' %}"
35+
hx-include="#tags-form"
36+
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
37+
hx-target="#tags-list"
38+
hx-swap="innerHTML">Save</button>
39+
</div>

experimenter/experimenter/nimbus_ui/tests/test_forms.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
NimbusExperimentFactory,
2828
NimbusFeatureConfigFactory,
2929
NimbusVersionedSchemaFactory,
30+
TagFactory,
3031
)
3132
from experimenter.kinto.tasks import (
3233
nimbus_check_kinto_push_queue_by_collection,
@@ -68,6 +69,8 @@
6869
ReviewToDraftForm,
6970
SignoffForm,
7071
SubscribeForm,
72+
TagForm,
73+
TagFormSet,
7174
TakeawaysForm,
7275
ToggleArchiveForm,
7376
UnsubscribeForm,
@@ -3894,3 +3897,67 @@ def test_features_view_feature_config_field_updates_correctly(
38943897
),
38953898
feature_configs.choices,
38963899
)
3900+
3901+
3902+
class TestTagForm(TestCase):
3903+
def test_valid_form(self):
3904+
data = {"name": "Test Tag", "color": "#ff0000"}
3905+
form = TagForm(data)
3906+
self.assertTrue(form.is_valid())
3907+
tag = form.save()
3908+
self.assertEqual(tag.name, "Test Tag")
3909+
self.assertEqual(tag.color, "#ff0000")
3910+
3911+
def test_invalid_form_missing_name(self):
3912+
data = {"color": "#ff0000"}
3913+
form = TagForm(data)
3914+
self.assertFalse(form.is_valid())
3915+
self.assertIn("name", form.errors)
3916+
3917+
def test_invalid_form_missing_color(self):
3918+
data = {"name": "Test Tag"}
3919+
form = TagForm(data)
3920+
self.assertFalse(form.is_valid())
3921+
self.assertIn("color", form.errors)
3922+
3923+
3924+
class TestTagFormSet(TestCase):
3925+
def test_formset_prevents_duplicate_names(self):
3926+
data = {
3927+
"form-TOTAL_FORMS": "2",
3928+
"form-INITIAL_FORMS": "0",
3929+
"form-MIN_NUM_FORMS": "0",
3930+
"form-MAX_NUM_FORMS": "1000",
3931+
"form-0-name": "Duplicate",
3932+
"form-0-color": "#ff0000",
3933+
"form-1-name": "duplicate",
3934+
"form-1-color": "#00ff00",
3935+
}
3936+
formset = TagFormSet(data)
3937+
self.assertFalse(formset.is_valid())
3938+
self.assertIn(
3939+
NimbusUIConstants.ERROR_TAG_DUPLICATE_NAME, str(formset.non_form_errors())
3940+
)
3941+
3942+
def test_formset_allows_unique_names(self):
3943+
data = {
3944+
"form-TOTAL_FORMS": "2",
3945+
"form-INITIAL_FORMS": "0",
3946+
"form-MIN_NUM_FORMS": "0",
3947+
"form-MAX_NUM_FORMS": "1000",
3948+
"form-0-name": "Tag One",
3949+
"form-0-color": "#ff0000",
3950+
"form-1-name": "Tag Two",
3951+
"form-1-color": "#00ff00",
3952+
}
3953+
formset = TagFormSet(data)
3954+
self.assertTrue(formset.is_valid())
3955+
3956+
def test_create_tag_generates_unique_name(self):
3957+
TagFactory.create(name="Tag 1")
3958+
TagFactory.create(name="Tag 2")
3959+
formset = TagFormSet()
3960+
tag = formset.create_tag()
3961+
self.assertEqual(tag.name, "Tag 3")
3962+
self.assertTrue(tag.color.startswith("#"))
3963+
self.assertEqual(len(tag.color), 7)

experimenter/experimenter/nimbus_ui/tests/test_views.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,21 @@
1818
NimbusExperimentBranchThroughExcluded,
1919
NimbusExperimentBranchThroughRequired,
2020
NimbusFeatureConfig,
21+
Tag,
2122
)
2223
from experimenter.experiments.tests.factories import (
2324
NimbusBranchFactory,
2425
NimbusDocumentationLinkFactory,
2526
NimbusExperimentFactory,
2627
NimbusFeatureConfigFactory,
28+
TagFactory,
2729
)
2830
from experimenter.kinto.tasks import (
2931
nimbus_check_kinto_push_queue_by_collection,
3032
nimbus_synchronize_preview_experiments_in_kinto,
3133
)
3234
from experimenter.klaatu.tasks import klaatu_start_job
35+
from experimenter.nimbus_ui.constants import NimbusUIConstants
3336
from experimenter.nimbus_ui.filtersets import (
3437
FeaturesPageSortChoices,
3538
MyDeliveriesChoices,
@@ -3917,3 +3920,77 @@ def test_features_view_deliveries_table_can_sort_by_total_clients(self):
39173920
[e.slug for e in response.context["experiments_delivered"]],
39183921
[experiment2.slug, experiment1.slug],
39193922
)
3923+
3924+
3925+
class TestTagsManageView(AuthTestCase):
3926+
def test_tags_manage_view_renders(self):
3927+
TagFactory.create(name="Tag 1")
3928+
TagFactory.create(name="Tag 2")
3929+
response = self.client.get(reverse("nimbus-ui-tags-manage"))
3930+
self.assertEqual(response.status_code, 200)
3931+
self.assertContains(response, "Tag 1")
3932+
self.assertContains(response, "Tag 2")
3933+
3934+
3935+
class TestTagCreateView(AuthTestCase):
3936+
def test_create_tag(self):
3937+
response = self.client.post(reverse("nimbus-ui-tags-create"))
3938+
self.assertEqual(response.status_code, 200)
3939+
self.assertEqual(Tag.objects.count(), 1)
3940+
self.assertEqual(Tag.objects.first().name, "Tag 1")
3941+
3942+
3943+
class TestTagSaveView(AuthTestCase):
3944+
def test_save_valid_tags(self):
3945+
tag = TagFactory.create(name="Tag 1", color="#ff0000")
3946+
response = self.client.post(
3947+
reverse("nimbus-ui-tags-save"),
3948+
{
3949+
"form-TOTAL_FORMS": "1",
3950+
"form-INITIAL_FORMS": "1",
3951+
"form-MIN_NUM_FORMS": "0",
3952+
"form-MAX_NUM_FORMS": "1000",
3953+
"form-0-id": tag.id,
3954+
"form-0-name": "Updated Tag",
3955+
"form-0-color": "#00ff00",
3956+
},
3957+
)
3958+
self.assertEqual(response.status_code, 200)
3959+
tag.refresh_from_db()
3960+
self.assertEqual(tag.color, "#00ff00")
3961+
3962+
def test_save_invalid_tags(self):
3963+
tag = TagFactory.create(name="Tag 1", color="#ff0000")
3964+
response = self.client.post(
3965+
reverse("nimbus-ui-tags-save"),
3966+
{
3967+
"form-TOTAL_FORMS": "1",
3968+
"form-INITIAL_FORMS": "1",
3969+
"form-MIN_NUM_FORMS": "0",
3970+
"form-MAX_NUM_FORMS": "1000",
3971+
"form-0-id": tag.id,
3972+
"form-0-name": "",
3973+
"form-0-color": "#00ff00",
3974+
},
3975+
)
3976+
self.assertEqual(response.status_code, 200)
3977+
self.assertContains(response, "This field is required.")
3978+
3979+
def test_save_duplicate_tags(self):
3980+
TagFactory.create(name="Existing Tag")
3981+
tag = TagFactory.create(name="Tag 1", color="#ff0000")
3982+
3983+
response = self.client.post(
3984+
reverse("nimbus-ui-tags-save"),
3985+
{
3986+
"form-TOTAL_FORMS": "1",
3987+
"form-INITIAL_FORMS": "1",
3988+
"form-MIN_NUM_FORMS": "0",
3989+
"form-MAX_NUM_FORMS": "1000",
3990+
"form-0-id": tag.id,
3991+
"form-0-name": "Existing Tag",
3992+
"form-0-color": "#00ff00",
3993+
},
3994+
)
3995+
self.assertEqual(response.status_code, 200)
3996+
self.assertContains(response, NimbusUIConstants.ERROR_TAG_DUPLICATE_NAME)

0 commit comments

Comments
 (0)