- {% if can_tag %}
-
- {% endif %}
{% if can_tag %}
{% if tag_adding_error %}
{{ tag_adding_error }}
- {% endif %}
-
diff --git a/kitsune/questions/views.py b/kitsune/questions/views.py
index f6b0806f422..82cd96ec79d 100644
--- a/kitsune/questions/views.py
+++ b/kitsune/questions/views.py
@@ -455,6 +455,7 @@ def question_details(
{
"all_products": products,
"all_topics": topics,
+ "all_tags": SumoTag.objects.active(),
"related_documents": related_documents,
"related_questions": related_questions,
"question_images": question_images,
@@ -1039,7 +1040,6 @@ def add_tag_async(request, question_id):
"""Add a (case-insensitive) tag to question asyncronously. Return empty.
If the question already has the tag, do nothing.
-
"""
if request.content_type == "application/json":
diff --git a/kitsune/sumo/static/sumo/js/tags.filter.js b/kitsune/sumo/static/sumo/js/tags.filter.js
deleted file mode 100644
index 262d29e09d5..00000000000
--- a/kitsune/sumo/static/sumo/js/tags.filter.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import "jquery-ui/ui/widgets/autocomplete";
-import _each from "underscore/modules/each";
-import _keys from "underscore/modules/keys";
-
-/*
- * A tag filtering form.
- */
-
-function init($container) {
- var $form = $container ? $container.find('form') : $('#tag-filter form'),
- $tags = $form.find('input[type="text"]'), $btn = $form.find('input[type="submit"], button'),
- $hidden = $(''),
- vocab = $tags.data('vocabulary'),
- lowerVocab = {};
-
- if (!$form.length) {
- return;
- }
-
- // Create a lower case vocab for case insensitive match.
- _each(_keys(vocab), function(name) {
- lowerVocab[name.toLowerCase()] = vocab[name];
- });
-
- // Add a hidden field for comma-separated slugs.
- $hidden.attr('name', $tags.attr('name'))
- .appendTo($form);
- $tags.removeAttr('name');
-
- // Disable button while text input is empty.
- $btn.attr('disabled', 'disabled');
- $tags.on('keyup', function() {
- if ($tags.val()) {
- $btn.prop("disabled", false);
- } else {
- $btn.attr('disabled', 'disabled');
- }
- });
-
- // Set up autocomplete
- // Skip if the autocomplete plugin isn't available (unit tests).
- if ($tags.autocomplete) {
- $tags.autocomplete({
- source: _keys(vocab),
- delay: 0,
- minLength: 1
- });
- }
-
- // When form is submitted, get the slugs to send over in request.
- $form.on("submit", function() {
- var tagNames = $tags.val(),
- slugNames = [],
- currentSlugs = $form.find('input.current-tagged').val(),
- slugs,
- invalid = false;
-
- // For each tag name, find the slug.
- _each(tagNames.split(','), function(tag) {
- var trimmed = tag.trim(),
- slug = lowerVocab[trimmed.toLowerCase()];
- if (slug) {
- slugNames.push(slug);
- } else if (trimmed) {
- invalid = true;
- alert(interpolate(gettext('Invalid tag entered: %s'), [tag]));
- }
- });
-
- // Invalid or no tags? No requests!
- if (invalid || slugNames.length === 0) {
- $form.trigger('ajaxComplete');
- if (!invalid) {
- alert(gettext('No tags entered.'));
- }
- return false;
- }
- slugs = slugNames.join(',');
-
- // Prepend any existing filters applied.
- if (currentSlugs) {
- slugs = currentSlugs + ',' + slugs;
- }
- $hidden.val(slugs);
- });
-}
-
-const TagsFilter = {
- init: init
-};
-export default TagsFilter;
-
-$(function() {
- TagsFilter.init();
-});
diff --git a/kitsune/sumo/static/sumo/js/tags.js b/kitsune/sumo/static/sumo/js/tags.js
index b8ddd35ba61..ec707395223 100644
--- a/kitsune/sumo/static/sumo/js/tags.js
+++ b/kitsune/sumo/static/sumo/js/tags.js
@@ -1,303 +1,35 @@
-import "jquery-ui/ui/widgets/autocomplete";
-import _keys from "underscore/modules/keys";
-
-/*
-* tags.js
-* Scripts to support tagging.
-*/
-
-(function ($) {
-
- // Initialize tagging features.
- function init() {
- initVocab();
- initTagAdding(); // first because it visibly dims the Add button
- initAutoComplete();
- initTagRemoval();
- }
-
- // Parse the tag vocab out of the embedded JSON and store it on an attr on
- // the .tags block surrounding each set of add and remove forms.
- function initVocab() {
- $('div.tags[data-tag-vocab-json]').each(
- function () {
- var $tagContainer = $(this);
- var parsedVocab = $tagContainer.data('tag-vocab-json');
- $tagContainer.data('tagVocab', _keys(parsedVocab));
- }
- );
- }
-
- // Attach an autocomplete widget to each input.autocomplete-tags. Get
- // completion data from the data-tag-vocab attr on the nearest div.tags
- // object outside the input.
- function initAutoComplete() {
- // Return a function() that sets the enabledness of the Add button appropriately.
- function makeButtonTender($addForm) {
- var $adder = $addForm.find('input.adder'),
- $input = $addForm.find('input.autocomplete-tags'),
- $tagsDiv = $input.closest('div.tags'),
- vocab = $tagsDiv.data('tagVocab'),
- $tagList = inputToTagList($input);
-
- // Enable Add button if the entered tag is in the vocabulary. Else,
- // disable it. If the user has the can_add_tag permission, let him
- // add whatever he wants, as long as it has some non-whitespace in
- // it.
- function tendAddButton() {
- // TODO: Optimization: use the calculation already done for the
- // autocomplete menu to limit the search space.
- var tagName = $.trim($input.val()),
- inVocab = inArrayCaseInsensitive(tagName, vocab) !== -1,
- isOnscreen = tagIsOnscreen(tagName, $tagList);
- $adder.attr('disabled', !tagName.length || isOnscreen ||
- (!inVocab));
- }
-
- return tendAddButton;
- }
-
- // Return an autocomplete vocab source callback that produces the
- // full vocab minus the already applied tags.
- //
- // $tags -- a .tags element containing a vocab in its tagVocab attr
- function makeVocabCallback($tags) {
- var vocab = $tags.data('tagVocab'),
- $tagList = $tags.find('ul.tag-list');
-
- function vocabCallback(request, response) {
- var appliedTags = getAppliedTags($tagList),
- vocabMinusApplied = $.grep(vocab,
- function (e, i) {
- return $.inArray(e, appliedTags) === -1;
- }
- );
- response(filter(vocabMinusApplied, request.term));
- }
-
- return vocabCallback;
- }
-
- $('input.autocomplete-tags').each(
- function () {
- var $input = $(this),
- tender = makeButtonTender($input.closest('form'));
-
- $input.autocomplete({
- source: makeVocabCallback($input.closest('div.tags')),
- delay: 0,
- minLength: 1, // Adjust with size of vocab.
- // Starting small for discoverability.
- close: tender
+import TomSelect from 'tom-select';
+
+document.addEventListener('DOMContentLoaded', () => {
+ document.querySelectorAll('.tag-select').forEach(dropdown => {
+ const addUrl = dropdown.dataset.addUrl;
+ const removeUrl = dropdown.dataset.removeUrl;
+ const csrfToken = document.querySelector('input[name=csrfmiddlewaretoken]')?.value;
+
+ new TomSelect(dropdown, {
+ plugins: ['remove_button'],
+ maxItems: null,
+ create: false,
+ onItemAdd: async (tagId) => {
+ await fetch(addUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': csrfToken
+ },
+ body: JSON.stringify({ tags: [tagId] })
+ });
+ },
+ onItemRemove: async (tagId) => {
+ await fetch(removeUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': csrfToken
+ },
+ body: JSON.stringify({ tagId })
});
-
- // keyup isn't triggered by pasting into the field. FWIW,
- // Google Suggest also punts on this.
- $input.on('keyup', tender);
- $input.bind('autocompletechange', tender);
- }
- );
- }
-
- function initTagRemoval() {
- // Attach a tag-removal function to each clickable "x":
- $('div.tags').each(
- function () {
- var $div = $(this),
- async = !$div.hasClass('deferred');
- $div.find('.tag').each(
- function () {
- attachRemoverHandlerTo($(this), async);
- }
- );
- }
- );
-
- // Prevent the form, if it exists, from submitting so our AJAX handler
- // is always called:
- $('form.remove-tag-form').on("submit", function () { return false; });
- }
-
- // Attach onclick removal handlers to every .remove element in $tag.
- function attachRemoverHandlerTo($container, async) {
- $container.find('.remover').on("click",
- function () {
- var $remover = $(this),
- $tag = $remover.closest('.tag'),
- tagName = $tag.find('.tag-name').text(),
- csrf = $remover.closest('form')
- .find('input[name=csrfmiddlewaretoken]').val();
-
- function makeTagDisappear() {
- $tag.remove();
- // TODO: Update Add button state in case a tag is
- // removed whose name is presently in the Add field.
- }
-
- if (async) {
- $tag.addClass('in-progress'); // Dim for immediate feedback.
- $.ajax({
- type: 'POST',
- url: $remover.closest('form.remove-tag-form').data('action-async'),
- data: { name: tagName, csrfmiddlewaretoken: csrf },
- success: makeTagDisappear,
- error: function makeTagReappear() {
- $tag.removeClass('in-progress');
- }
- });
- } else {
- makeTagDisappear();
- }
- return false;
- }
- );
- }
-
- // $container is either a form or a div.tags.
- function addTag($container, async) {
- var $input = $container.find('input.autocomplete-tags'),
- tagName = $input.val(),
- vocab = $input.closest('div.tags').data('tagVocab'),
- tagIndex = inArrayCaseInsensitive(tagName, vocab),
- csrf = $container.find('input[name=csrfmiddlewaretoken]').val(),
- $tag;
-
- // Add a (ghostly, if async) tag to the onscreen
- // list and return the tag element. If the tag was
- // already onscreen, do nothing and return null.
- function putTagOnscreen(name) {
- var $tagList = inputToTagList($input);
- if (!(tagIsOnscreen(name, $tagList))) {
- var $li = $("
");
- if (async) {
- $li.addClass('in-progress');
- } else {
- // Add hidden input to persist form state, and make the removal X work.
- var $hidden = $("");
- $hidden.attr('value', name);
- $hidden.attr('name', $input.attr('name'));
- $li.prepend($hidden);
- attachRemoverHandlerTo($li, false);
- }
- $li.find('.tag-name').text(name);
- $li.find('input.remover').attr('name', 'remove-tag-' + name);
- $tagList.append($li);
- return $li;
- }
- }
-
- if (tagIndex === -1) {
- if (async) { // If we're operating wholly client side until Submit is clicked, it would be weird to pretend you've added to the server-side vocab.
- vocab.push(tagName);
- }
- } else { // Canonicalize case.
- tagName = vocab[tagIndex]; // Canonicalize case.
- }
-
- $tag = putTagOnscreen(tagName);
-
- if ($tag && async) {
- $.ajax({
- type: 'POST',
- url: $container.data('action-async'),
- data: { 'tag-name': tagName, csrfmiddlewaretoken: csrf },
- success: function solidifyTag(data) {
- // Make an onscreen tag non-ghostly,
- // canonicalize its name,
- // activate its remover button, and
- // add it to the local vocab.
- var url = data.tagUrl,
- tagNameSpan = $tag.find('.tag-name');
- tagNameSpan.replaceWith($("")
- .attr('href', url)
- .text(tagNameSpan.text()));
- $tag.removeClass('in-progress');
- attachRemoverHandlerTo($tag, true);
- },
- error: function disintegrateTag(data) {
- $tag.remove();
- }
- });
- }
-
- // Clear the input field.
- $input.val('');
- $container.find('input.adder').attr('disabled', true);
- return false;
- }
-
- function initTagAdding() {
- // Dim all Add buttons. We'll undim them upon valid input.
- $('div.tags input.adder:enabled').attr('disabled', true);
-
- $('.tag-adder').each(function () {
- var $this = $(this),
- async = !$this.hasClass('deferred');
- function handler() {
- return addTag($this, async);
- }
- if ($this.is('form')) {
- $this.on('submit', handler);
- } else {
- $this.find('input.adder').on("click", handler);
}
});
- }
-
- // Given the tag-adding form, return the tag list in the corresponding
- // tag-removing form.
- function inputToTagList($input) {
- return $input.closest('div.tags').find('ul.tag-list');
- }
-
-
- // Case-insensitive array filter
- // Ripped off from jquery.ui.autocomplete.js. Why can't I get at these
- // via, e.g., $.ui.autocomplete.filter?
-
- function escapeRegex(value) {
- return value.replace(/([\^\$\(\)\[\]\{\}\*\.\+\?\|\\])/gi, '\\$1');
- }
-
- function filter(array, term) {
- var matcher = new RegExp(escapeRegex(term), 'i');
- return $.grep(array, function (value) {
- return matcher.test(value.label || value.value || value);
- });
- }
-
-
- // Like inArray but for strings only and case-insensitive.
- // TODO: Think about sorting and using binary search.
- function inArrayCaseInsensitive(str, ary) {
- var matcher = new RegExp('^' + escapeRegex(str) + '$', 'i');
- for (var i = 0; i < ary.length; i++) {
- if (matcher.test(ary[i])) {
- return i;
- }
- }
- return -1;
- }
-
- // Return the tags already applied to an object.
- // Specifically, given a .tag-list, return an array of tag names in it.
- // (Tags in the process of being added or removed are considered applied.)
- function getAppliedTags($tagList) {
- var tagNames = [];
- $tagList.find('.tag .tag-name').each(
- function (i, e) {
- tagNames.push($(e).text());
- }
- );
- return tagNames;
- }
-
- // Return whether the tag of the given name is in the visible list.
- // The in-the-process-of-being-added-or-removed state is considered onscreen.
- function tagIsOnscreen(tagName, $tagList) {
- return inArrayCaseInsensitive(tagName, getAppliedTags($tagList)) !== -1;
- }
-
- $(init);
-
-})(jQuery);
+ });
+});
diff --git a/kitsune/sumo/static/sumo/js/tests/tagsfiltertests.js b/kitsune/sumo/static/sumo/js/tests/tagsfiltertests.js
deleted file mode 100644
index 7cb913bf6d5..00000000000
--- a/kitsune/sumo/static/sumo/js/tests/tagsfiltertests.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import {expect} from 'chai';
-
-import TagsFilter from "sumo/js/tags.filter";
-
-describe('k', () => {
- describe('TagsFilter', () => {
- beforeEach(() => {
- $('body').empty().html(`
-
-
-
-
-
-
-
-
`
- );
-
- TagsFilter.init($('body'));
- // Don't let forms submit
- $('form').on("submit", (e) => e.preventDefault());
- });
-
- function check(input, output) {
- $('form').find('input[type="text"]').val(input);
- $('form').trigger("submit");
- expect($('form').find('input[name="tagged"]').val()).to.equal(output);
- }
-
- it('should work with one tag', () => {
- check('Name 1', 'slug-1');
- });
-
- it('should work with two tags', () => {
- check('Name 1, Name 2', 'slug-1,slug-2');
- });
-
- it('should work with three tags', () => {
- check('Name 1, Name 2, Name 3', 'slug-1,slug-2,slug-3');
- });
-
- it('should be case insensitive', () => {
- check('nAmE 1', 'slug-1');
- });
-
- it("shouldn't overwrite pre-existing values", () => {
- let $h = $('');
- $('form').append($h);
- check('Name 1', 'slug-7,slug-1');
- });
- });
-});
diff --git a/kitsune/sumo/static/sumo/js/topics.js b/kitsune/sumo/static/sumo/js/topics.js
index 4a643c252f3..3d75a2aa8c9 100644
--- a/kitsune/sumo/static/sumo/js/topics.js
+++ b/kitsune/sumo/static/sumo/js/topics.js
@@ -1,9 +1,11 @@
document.addEventListener('DOMContentLoaded', function () {
- var dropdown = document.getElementById('products-topics-dropdown');
- dropdown.addEventListener('change', function () {
- let selectedUrl = this.value;
- if (selectedUrl) {
- window.location.href = selectedUrl;
- }
- });
-});
\ No newline at end of file
+ let dropdown = document.getElementById('products-topics-dropdown');
+ if (dropdown) {
+ dropdown.addEventListener('change', function () {
+ let selectedUrl = this.value;
+ if (selectedUrl) {
+ window.location.href = selectedUrl;
+ }
+ });
+ }
+});
diff --git a/kitsune/sumo/static/sumo/scss/layout/_forum.scss b/kitsune/sumo/static/sumo/scss/layout/_forum.scss
index 551ef47b4aa..3a0839aa957 100644
--- a/kitsune/sumo/static/sumo/scss/layout/_forum.scss
+++ b/kitsune/sumo/static/sumo/scss/layout/_forum.scss
@@ -616,10 +616,6 @@
}
}
-.tag-adder {
- padding: p.$spacing-md 0;
-}
-
.answer {
padding: p.$spacing-2xl 0;
border-bottom: 1px solid var(--color-border);
diff --git a/kitsune/tags/forms.py b/kitsune/tags/forms.py
deleted file mode 100644
index f36d1dfdf9d..00000000000
--- a/kitsune/tags/forms.py
+++ /dev/null
@@ -1,158 +0,0 @@
-import json
-
-from django.forms import MultipleChoiceField, Widget
-from django.forms.utils import flatatt
-from django.utils.datastructures import MultiValueDict
-from django.utils.encoding import force_str
-from django.utils.html import escape
-from django.utils.safestring import mark_safe
-from django.utils.translation import gettext as _
-
-from kitsune.tags.models import SumoTag
-
-
-# TODO: Factor out dependency on taggit so it can be a generic large-vocab
-# selector.
-class TagWidget(Widget):
- """Widget which sticks each tag in a separate
-
- Designed to have the tag selection submitted all at once when a Submit
- button is clicked
-
- """
-
- # If True, render without editing controls:
- read_only = False
-
- # async_urls is a tuple: (URL for async add POSTs, URL for async remove
- # POSTs). If this is (), assume you want to queue up tag adds and removes
- # and submit them all at once through a form you wrap around this widget
- # yourself. In this case, tag names will not be links, because we'd have
- # to design some way of computing the URLs without hitting the network.
- async_urls = ()
-
- # make_link should be a function that takes a tag slug and returns some
- # kind of meaningful link. Ignored if async_urls is ().
- def make_link(self, slug):
- return "#"
-
- # TODO: Add async_remove_url and async_add_url kwargs holding URLs to
- # direct async remove and add requests to. The client app is then
- # responsible for routing to those and doing the calls to remove/add
- # the tag.
-
- def _render_tag_list_items(self, control_name, tag_names):
- """Represent applied tags and render controls to allow removal."""
-
- def render_one(tag):
- output = '
'
-
- # Hidden input for form state:
- if not self.async_urls:
- output += "" % flatatt(
- {"value": force_str(tag.name), "type": "hidden", "name": control_name}
- )
-
- # Linkless tag name:
- output += '%s' % escape(tag.name)
- else:
- # Anchor for link to by-tag view:
- output += '%s' % (
- escape(self.make_link(tag.slug)),
- escape(tag.name),
- )
-
- # Remove button:
- if not self.read_only:
- output += (
- '' % escape(tag.name)
- )
-
- output += "
"
- return output
-
- tags = SumoTag.objects.active().filter(name__in=tag_names)
- representations = [render_one(t) for t in tags]
- return "\n".join(representations)
-
- def render(self, name, value, attrs=None, renderer=None):
- """Render a hidden input for each choice plus a blank text input."""
- output = '
"
-
- if not self.read_only:
- # Insert a hidden before the removers so
- # hitting return doesn't wreak destruction:
- output += ''
-
- # TODO: Render the little form around the tags as a JS-less fallback
- # iff self.async_urls. And don't add the hidden Add button above.
-
- output += '
'
-
- output += self._render_tag_list_items(name, value or [])
-
- output += "
"
-
- # TODO: Add a TagField kwarg for synchronous tag add URL, and draw the
- # form here if it's filled out.
-
- if not self.read_only:
- # Add a field for inputting new tags. Since it's named the same as
- # the hidden inputs, it should handily work as a JS-less fallback.
- input_attrs = self.build_attrs(
- attrs, type="text", name=name, **{"class": "autocomplete-tags"}
- )
- output += "" % flatatt(input_attrs)
-
- # Add the Add button:
- output += "" % flatatt(
- dict(type="submit", value=_("Add"), **{"class": "adder"})
- )
-
- output += "
"
- return mark_safe(output)
-
- def value_from_datadict(self, data, files, name):
- # TODO: removed 'MergeDict' from classinfo check below
- # could find not explicit use of MergeDict elsewhere in the codebase, so
- # i think we're okay here?
- if isinstance(data, MultiValueDict):
- return data.getlist(name)
- return data.get(name, None)
-
-
-class TagField(MultipleChoiceField):
- """A field semantically equivalent to a MultipleChoiceField--just with a
- list of choices so long that it would be awkward to display.
-
- The `choices` kwarg passed to the constructor should be a callable.
-
- If you use this, you'll probably also want to set many of the TagWidget's
- attrs after form instantiation. There's no opportunity to set them at
- construction, since TagField is typically instantiated deep within taggit.
-
- """
-
- widget = TagWidget
-
- # Unlike in the superclass, `choices` kwarg to __init__ is unused.
-
- def valid_value(self, value):
- """Check the validity of a single tag."""
- return SumoTag.objects.active().filter(name=value).exists()
-
- def to_python(self, value):
- """Ignore the input field if it's blank; don't make a tag called ''."""
- return [v for v in super(TagField, self).to_python(value) if v]
diff --git a/kitsune/tags/models.py b/kitsune/tags/models.py
index ceb1a67fa3f..d50cab74a3f 100644
--- a/kitsune/tags/models.py
+++ b/kitsune/tags/models.py
@@ -23,13 +23,6 @@ def __init__(self, *args, **kwargs):
kwargs.setdefault("through", SumoTaggedItem)
super().__init__(*args, **kwargs)
- def formfield(self, form_class=None, **kwargs):
- """Swap in our custom TagField."""
- from kitsune.tags.forms import TagField
-
- form_class = form_class or TagField
- return super().formfield(form_class, **kwargs)
-
class SumoTag(TagBase):
is_archived = models.BooleanField(default=False)
diff --git a/webpack/entrypoints.js b/webpack/entrypoints.js
index 70daac05e39..218acee87cd 100644
--- a/webpack/entrypoints.js
+++ b/webpack/entrypoints.js
@@ -41,7 +41,6 @@ const entrypoints = {
],
questions: [
"sumo/js/questions.js",
- "sumo/js/tags.filter.js",
"sumo/js/tags.js",
"sumo/js/reportabuse.js",
"sumo/js/questions.metrics.js",