From 002bc9b0837c9828058ff09966fde2a74e3b2349 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?= <cuu508@gmail.com>
Date: Wed, 14 Jun 2023 15:06:37 +0300
Subject: [PATCH] Decouple check's name from slug, allow users to set
 hand-picked slugs

---
 CHANGELOG.md                           |  1 +
 hc/front/forms.py                      |  1 +
 hc/front/tests/test_add_check.py       |  3 +-
 hc/front/tests/test_update_name.py     |  5 ++--
 hc/front/views.py                      |  6 ++--
 static/css/slug-suggestions.css        |  4 +++
 static/js/checks.js                    |  1 +
 static/js/slug-suggestions.js          | 40 ++++++++++++++++++++++++++
 templates/base.html                    |  1 +
 templates/front/add_check_modal.html   | 26 ++++++++++++++++-
 templates/front/details.html           |  1 +
 templates/front/my_checks.html         |  1 +
 templates/front/my_checks_desktop.html |  3 +-
 templates/front/update_name_modal.html | 29 ++++++++++++++++++-
 14 files changed, 114 insertions(+), 8 deletions(-)
 create mode 100644 static/css/slug-suggestions.css
 create mode 100644 static/js/slug-suggestions.js

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ece64bc8..ebe4378e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
 ### Improvements
 - Configure logging to log unhandled exceptions to console even when DEBUG=False (#835)
 - Make hc.lib.emails raise exceptions when EMAIL_ settings are not set
+- Decouple check's name from slug, allow users to set hand-picked slugs
 
 ## v2.9.2 - 2023-06-05
 
diff --git a/hc/front/forms.py b/hc/front/forms.py
index 621c18d2..3cfc65e4 100644
--- a/hc/front/forms.py
+++ b/hc/front/forms.py
@@ -69,6 +69,7 @@ class HeadersField(forms.Field):
 
 class NameTagsForm(forms.Form):
     name = forms.CharField(max_length=100, required=False)
+    slug = forms.SlugField(max_length=100, required=False)
     tags = forms.CharField(max_length=500, required=False)
     desc = forms.CharField(required=False)
 
diff --git a/hc/front/tests/test_add_check.py b/hc/front/tests/test_add_check.py
index 6f77e46c..d5a6dca3 100644
--- a/hc/front/tests/test_add_check.py
+++ b/hc/front/tests/test_add_check.py
@@ -14,6 +14,7 @@ class AddCheckTestCase(BaseTestCase):
     def _payload(self, **kwargs):
         payload = {
             "name": "Test",
+            "slug": "custom-slug",
             "tags": "foo bar",
             "kind": "simple",
             "timeout": "120",
@@ -31,7 +32,7 @@ class AddCheckTestCase(BaseTestCase):
         check = Check.objects.get()
         self.assertEqual(check.project, self.project)
         self.assertEqual(check.name, "Test")
-        self.assertEqual(check.slug, "test")
+        self.assertEqual(check.slug, "custom-slug")
         self.assertEqual(check.tags, "foo bar")
         self.assertEqual(check.kind, "simple")
         self.assertEqual(check.timeout.total_seconds(), 120)
diff --git a/hc/front/tests/test_update_name.py b/hc/front/tests/test_update_name.py
index 25640b2a..185784fb 100644
--- a/hc/front/tests/test_update_name.py
+++ b/hc/front/tests/test_update_name.py
@@ -14,12 +14,13 @@ class UpdateNameTestCase(BaseTestCase):
 
     def test_it_works(self):
         self.client.login(username="alice@example.org", password="password")
-        r = self.client.post(self.url, data={"name": "Alice Was Here"})
+        form = {"name": "Alice Was Here", "slug": "custom-slug"}
+        r = self.client.post(self.url, data=form)
         self.assertRedirects(r, self.redirect_url)
 
         self.check.refresh_from_db()
         self.assertEqual(self.check.name, "Alice Was Here")
-        self.assertEqual(self.check.slug, "alice-was-here")
+        self.assertEqual(self.check.slug, "custom-slug")
 
     def test_redirect_preserves_querystring(self):
         referer = self.redirect_url + "?tag=foo"
diff --git a/hc/front/views.py b/hc/front/views.py
index 98794b26..d678b525 100644
--- a/hc/front/views.py
+++ b/hc/front/views.py
@@ -481,7 +481,8 @@ def add_check(request, code):
         return HttpResponseBadRequest()
 
     check = Check(project=project)
-    check.set_name_slug(form.cleaned_data["name"])
+    check.name = form.cleaned_data["name"]
+    check.slug = form.cleaned_data["slug"]
     check.tags = form.cleaned_data["tags"]
     check.kind = form.cleaned_data["kind"]
     check.timeout = form.cleaned_data["timeout"]
@@ -504,7 +505,8 @@ def update_name(request, code):
 
     form = forms.NameTagsForm(request.POST)
     if form.is_valid():
-        check.set_name_slug(form.cleaned_data["name"])
+        check.name = form.cleaned_data["name"]
+        check.slug = form.cleaned_data["slug"]
         check.tags = form.cleaned_data["tags"]
         check.desc = form.cleaned_data["desc"]
         check.save()
diff --git a/static/css/slug-suggestions.css b/static/css/slug-suggestions.css
new file mode 100644
index 00000000..b157be50
--- /dev/null
+++ b/static/css/slug-suggestions.css
@@ -0,0 +1,4 @@
+.slug-help-block code {
+    color: var(--text-muted);
+    background: var(--pre-bg);
+}
\ No newline at end of file
diff --git a/static/js/checks.js b/static/js/checks.js
index 00fd323a..363f80ca 100644
--- a/static/js/checks.js
+++ b/static/js/checks.js
@@ -7,6 +7,7 @@ $(function () {
 
         $("#update-name-form").attr("action", url);
         $("#update-name-input").val(this.dataset.name);
+        $("#update-slug-input").val(this.dataset.slug);
 
         var tagsSelectize = document.getElementById("update-tags-input").selectize;
         tagsSelectize.setValue(this.dataset.tags.split(" "));
diff --git a/static/js/slug-suggestions.js b/static/js/slug-suggestions.js
new file mode 100644
index 00000000..d874dd7f
--- /dev/null
+++ b/static/js/slug-suggestions.js
@@ -0,0 +1,40 @@
+$(function () {
+    function slugify(text) {
+        return text
+            .normalize("NFKD")
+            .split("")
+            .map(ch => ch.charCodeAt(0) < 256 ? ch : "")
+            .join("")
+            .toLowerCase()
+            .replace(/[^\w\s-]/g, "")
+            .replace(/[-\s]+/g, "-")
+            .replace(/^-+/, "")
+            .replace(/-+$/, "");
+    }
+
+    $(".with-slug-suggestions").each(function() {
+        var nameInput = $("input[name='name']", this);
+        var slugInput = $("input[name='slug']", this);
+        var btn = $(".use-suggested-slug", this);
+        var help = $(".slug-help-block", this);
+
+        function update() {
+            var suggested = slugify(nameInput.val());
+            if (suggested) {
+                help.html(`Suggested value: <code>${suggested}</code>`);
+            } else {
+                help.text("Allowed characters: a-z, A-Z, 0-9, hyphens, underscores.");
+            }
+
+            btn.attr("disabled", !suggested);
+        }
+
+        $(nameInput).on("keyup change", update);
+        $(this).on("shown.bs.modal", update);
+
+        btn.click(function() {
+            slugInput.val(slugify(nameInput.val()));
+        });
+    });
+
+});
diff --git a/templates/base.html b/templates/base.html
index 9619b15c..7833a028 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -59,6 +59,7 @@
     <link rel="stylesheet" href="{% static 'css/signal_form.css' %}" type="text/css">
     <link rel="stylesheet" href="{% static 'css/project.css' %}" type="text/css">
     <link rel="stylesheet" href="{% static 'css/signup.css' %}" type="text/css">
+    <link rel="stylesheet" href="{% static 'css/slug-suggestions.css' %}" type="text/css">
     {% endcompress %}
 </head>
 <body class="page-{{ page }}{% if request.user.is_authenticated and request.profile.theme == 'dark' %} dark{% endif%}">
diff --git a/templates/front/add_check_modal.html b/templates/front/add_check_modal.html
index f79eb84c..56e03238 100644
--- a/templates/front/add_check_modal.html
+++ b/templates/front/add_check_modal.html
@@ -1,4 +1,4 @@
-<div id="add-check-modal" class="modal simple">
+<div id="add-check-modal" class="modal simple with-slug-suggestions">
     <div class="modal-dialog">
         <form class="form-horizontal" method="post" action="{% url 'hc-add-check' project.code %}">
             {% csrf_token %}
@@ -28,6 +28,30 @@
                         </div>
                     </div>
 
+                    <div class="form-group">
+                        <label for="add-check-slug" class="col-sm-3 control-label">
+                            Slug
+                        </label>
+                        <div class="col-sm-9">
+                            <div class="input-group">
+                                <input
+                                    id="add-check-slug"
+                                    name="slug"
+                                    type="text"
+                                    maxlength="100"
+                                    value="{{ check.slug }}"
+                                    pattern="[a-zA-Z0-9_-]+"
+                                    class="form-control" />
+                                <span class="input-group-btn">
+                                    <button
+                                        class="btn btn-default use-suggested-slug"
+                                        type="button">Use Suggested</button>
+                                </span>
+                            </div>
+                            <span class="help-block slug-help-block"></span>
+                        </div>
+                    </div>
+
                     <div class="form-group">
                         <label for="add-check-tags" class="col-sm-3 control-label">
                             Tags
diff --git a/templates/front/details.html b/templates/front/details.html
index bc7c5fa0..a8a662ae 100644
--- a/templates/front/details.html
+++ b/templates/front/details.html
@@ -363,5 +363,6 @@
 <script src="{% static 'js/ping_details.js' %}"></script>
 <script src="{% static 'js/details.js' %}"></script>
 <script src="{% static 'js/initialize-timezone-selects.js' %}"></script>
+<script src="{% static 'js/slug-suggestions.js' %}"></script>
 {% endcompress %}
 {% endblock %}
diff --git a/templates/front/my_checks.html b/templates/front/my_checks.html
index c44bd3e1..a108ec2b 100644
--- a/templates/front/my_checks.html
+++ b/templates/front/my_checks.html
@@ -102,5 +102,6 @@
 <script src="{% static 'js/checks.js' %}"></script>
 <script src="{% static 'js/add-check-modal.js' %}"></script>
 <script src="{% static 'js/initialize-timezone-selects.js' %}"></script>
+<script src="{% static 'js/slug-suggestions.js' %}"></script>
 {% endcompress %}
 {% endblock %}
diff --git a/templates/front/my_checks_desktop.html b/templates/front/my_checks_desktop.html
index 9f36abb7..ac0df4b7 100644
--- a/templates/front/my_checks_desktop.html
+++ b/templates/front/my_checks_desktop.html
@@ -66,6 +66,7 @@
         </td>
         <td>
             <div data-name="{{ check.name }}"
+                 data-slug="{{ check.slug }}"
                  data-tags="{{ check.tags }}"
                  data-desc="{{ check.desc }}"
                  class="my-checks-name {% if not check.name %}unnamed{% endif %}">
@@ -77,7 +78,7 @@
         </td>
         <td class="hidden-xs hidden-sm hidden-md">
             {% if project.show_slugs and not check.slug %}
-                <span class="unavailable">unavailable, set name first</span>
+                <span class="unavailable">unavailable, slug not set</span>
             {% else %}
                 <span class="my-checks-url">{{ check.url|format_ping_endpoint }}</span>
                 {% if project.show_slugs and check.slug in ambiguous %}
diff --git a/templates/front/update_name_modal.html b/templates/front/update_name_modal.html
index 434332e3..dc4ebe37 100644
--- a/templates/front/update_name_modal.html
+++ b/templates/front/update_name_modal.html
@@ -1,4 +1,4 @@
-<div id="update-name-modal" class="modal">
+<div id="update-name-modal" class="modal with-slug-suggestions">
     <div class="modal-dialog">
         <form
             id="update-name-form"
@@ -33,6 +33,33 @@
                         </div>
                     </div>
 
+                    <div class="form-group">
+                        <label for="update-slug-input" class="col-sm-2 control-label">
+                            Slug
+                        </label>
+                        <div class="col-sm-10">
+                            <div class="input-group">
+                                <input
+                                    id="update-slug-input"
+                                    name="slug"
+                                    type="text"
+                                    maxlength="100"
+                                    value="{{ check.slug }}"
+                                    pattern="[a-zA-Z0-9_-]+"
+                                    data-src-id="update-name-input"
+                                    data-btn-id="use-suggested"
+                                    data-preview-id="slug-help"
+                                    class="form-control" />
+                                <span class="input-group-btn">
+                                    <button
+                                        class="btn btn-default use-suggested-slug"
+                                        type="button">Use Suggested</button>
+                                </span>
+                            </div>
+                            <span class="help-block slug-help-block"></span>
+                        </div>
+                    </div>
+
                     <div class="form-group">
                         <label for="update-tags-input" class="col-sm-2 control-label">
                             Tags