Add {% querystring %} template tag to add, remove, and update querystring parameters

Review Request #9712 — Created March 1, 2018 and updated

mandeep
Djblets
release-1.0.x
9745
22f8586...
djblets
brennie

Previously, it was only possible to update a single query parameter with
{% querystring_with %}. However, in the name of future proofing, it
became necessary to make a new template tag {% querystring %}
that can take an arbitrary number of key-value pairs now with different
modes in the form of:

{% querystring “mode” "key1=value1" "key2=value2" %}

The three different modes are:
“remove” - Will remove keys from the query string.
“append” - Will append values for its given key without overwriting.
“update” - Will add to or replace part of a query string.

Ran unit tests.

  • 0
  • 0
  • 203
  • 1
  • 204
Description From Last Updated
Checks run (1 failed, 1 succeeded)
flake8 failed.
JSHint passed.

flake8

brennie
  1. Thanks for the patch Mandeep!

    I've left some mostly-stylistic comments here as it looks pretty good.

    However, I think it might be better to take a different approach (which I outline in one of the comments).

    As you fix issues locally, make sure to mark them as fixed here and then, once you've fixed them all, commit and re-post (rbt post -u).

    Thanks again!

  2. Your summary should be in the imperitive mood, i.e., it should read like you are giving a command or order. How about:

    Update querystring_with to take an arbitrary number of key-value pairs

  3. You have several lines that contain whitespace. You can set up Sublime to trim this on save via

    Preferences -> Settings -> User and adding the following:

        "trim_trailing_white_space_on_save": true
    

    and

        "ensure_newline_at_eof_on_save": true
    
  4. Please wrap your description at 72 lines.

    You can install AutoWrap (Cmd+Shift+P, PCI (for package control install), AutoWrap) and set the following settings for the Git Commit syntax

    {
      "rulers": [52, 72],
      "auto_wrap": true,
      "auto_wrap_width": 72
    }
    

    (To edit the syntax setting for a specific filetype you can open a new file and do Cmd+Shift+P, and type syntax git commit. Then go to Preferences -> Settings -> Syntax Specific and add those settings.)

  5. Your description mentions RB but this is a change to Djblets. Your future change to RB should refer to those changes since Djblets has no notion review requests, etc.

    How about:

    Previously, it was only possible to update a single query parameter with
    `{% querystring_with %}`. However, it is frequently the case that it
    would be handy to update the querystring from within the template rather
    than having to enumerate all possible cases in the view. Now
    `{% querystring_with %}` can take an arbitrary number of key-value pairs
    in the form of:
    
    ```html+django
    {% querystring_with "key1" "value1" "key2" "value2" %}
    ```
    

    Our description and testing done fields support markdown

  6. This documentation needs to be updated to indicate that the function can update multiple query parameters.

  7. djblets/util/templatetags/djblets_utils.py (Diff revision 1)
     
     
     
     
     
     
     

    This function no longer takes these aruments. You will want to update this section.

  8. I did some talking with Christian and I'm not convinced this is the best way to handle this. Instead, we may want to forgo the simple parsing that register.simple_tag gives us and do our own parsing so that we could do:

    {% querystring_with sorted='1' page='2' foo-bar='baz' %}
    

    I can work with you to write a templatetag parser that understands this syntax.

    1. I honestly think the proper way of going is to pass the equivalent of a query string, instead of creating our own syntax. So instead, something like this:

      {% querystring_with "sorted=1" "page=2" "foo-bar=baz" %}
      

      The reason is that, while query string arguments are often in key=value form, that's just a convention and not a requirement. If we create our own syntax here, it might look nice but it might not be future-proof (for instance, if used with generating URLs for third-party services we're integrating with).

      These are all valid query strings:

      # Pretty standard
      ?sorted=1&page=2
      
      # Values aren't required
      ?sorted=
      ?sorted
      
      # Nor are keys, really
      ?=873613761823
      
      # Technically, ; is equivalent to &
      ?a=b;c=d
      
      # Keys with spaces are allowed, using any variant
      ?a%20b=def
      ?a+b=def
      ?a b=def
      
      # '=' is valid in values
      ?a=b=c=d
      
      # There are different variations on arrays in query strings
      ?a[]=1&a[]=2&a[]=3
      ?a[0]=1&a[1]=2&a[2]=3
      ?a=1&a=2&a=3
      

      By using the argument form provided above, we can represent any of the above without problems:

      {% queryset_with "sorted=1" "page=2" %}
      {% queryset_with "sorted=" %}
      {% queryset_with "sorted" %}
      {% queryset_with "=873613761823" %}
      {% queryset_with "a b=def" %}
      {% queryset_with "a=b=c=d" "e=f=g=h" %}
      {% queryset_with "a=1" "a=2" "a=3" %}
      

      It's only a step away from providing the entire query string with & separations, which we could do, but the advantage of breaking it apart here is readability and the ability to use variables for some of those, like:

      {% if sorted %}
      {%  definevar reverse_sort %}sorted=0{% enddefinevar %}
      {% else %}
      {%  definevar reverse_sort %}sorted=1{% enddefinevar %}
      {% endif %}
      
      ...
      
      {% queryset_with reverse_sort "view=all" %}
      

      Python's parse_qsl handles all of the queryset variations I mentioned (when used with keep_blank_values=True), and Django wraps this with django.http.QueryDict, which knows how to deal with arrays and to re-encode back as a query string. That means we can easily leverage QueryDict to do the hard work for us, basing it on the current page's query string and then updating it with the parsed version, spitting out a new query string we're able to use.

      (Note that we'll want unit tests for all the variations above as well.)

  9. djblets/util/templatetags/djblets_utils.py (Diff revision 1)
     
     
     
     

    Since attr and value are used immediately, we can move them inline, like so:

    query[args[i].encode('utf-8')] = args[i + 1].encode('utf-8')
    
  10. Undo this change.

  11. tmp isn't a very descriptive variable name.

    How about render_result?

  12. Can you add a trailing comma?

  13. This is only used once so it could be moved inline.

  14. t_dict isn't a very expressive variable name.

    How about expected_result?

    However, it can be moved inline (see next comment)

  15. I think it would be fine to do:

    self.assertEqual(
        parse_qs(render_result[1:]),
        {
            'bar': ['baz'],
            'foo': ['bar'],
        })
    
  16. Can you add a trailing comma?

    This should also be dedented so that it looks like:

    render_result = t.render(Context({
        'request': request,
    })
    
  17. See comments on previous function about moving things inline.

  18. 
      
mandeep
Review request changed

Commit:

-ccf26d2281df2820d0ddd346f6c9cacb8cef50d5
+c05d8ff2e0016e92754f72f36d1353367dcad645

Diff:

Revision 2 (+64 -27)

Show changes

Checks run (1 failed, 1 succeeded)

flake8 failed.
JSHint passed.

flake8

mandeep
Review request changed

Checks run (1 failed, 1 succeeded)

flake8 failed.
JSHint passed.

flake8

mandeep
Review request changed

Checks run (1 failed, 1 succeeded)

flake8 failed.
JSHint passed.

flake8

mandeep
brennie
  1. 
      
  2. Your summary should be in the imperitive mood, i.e., it should read like a command. For example:

    Update querystring_with to support an arbitrary number of attributes and values
    
  3. djblets/util/templatetags/djblets_utils.py (Diff revision 5)
     
     
     

    Docstring summary should fit on a single line.

  4. Missing context (which is a django.template.Context)

  5. *args (tuple)

  6. djblets/util/templatetags/djblets_utils.py (Diff revision 5)
     
     
     

    Documentation should always be sentence-case.

    How about:

    Multiple querystring fragments (e.g., "foo=1") that will be used to update the initial querystring.
    
  7. djblets/util/templatetags/djblets_utils.py (Diff revision 5)
     
     
     
     
     
     

    These should no longer be here.

  8. djblets/util/tests/test_djblets_utils_tags.py (Diff revision 5)
     
     
     
     
     

    These tests would be easier to read if the initial state was:

    {
    'foo': 'foo',
    'bar': 'bar',
    'baz': 'baz',
    'qux': 'qux',
    }
    

    and the templatetag changed them to something else.

  9. 
      
brennie
  1. 
      
  2. We are going to want to make this into a new template tag.

    The reason being is that an old usage of {% querystring_with "foo" "bar" %} which used to do ?foo=bar now does ?foo&bar (and both are correct usages).

    So we will want this to do warning.warn("...", DeprecationWarning) with instructions to use the new template tag (lets call it querystring_with_fragments).

  3. 
      
mandeep
mandeep
mandeep
mandeep
brennie
  1. 
      
  2. Can you add unit tests for {% querystring_with_fragments "foo" %}

  3. Can you add unit tests for {% querystring_with_fragments "foo=bar=baz" %}

  4. Can you add unit tests for Can you add unit tests for {% querystring_with_fragments "foo=" %}

  5. Can you add unit tests for {% querystring_with_fragments "a=1" "a=2" "a=3 %}?

    The result should be ?a=1&a=2&a=3.

    1. How do you deal with the cases where a is already present in the query string and

      1. we want to add more values of a; or
      2. we want to replace a?
  6. Can you add unit tests for {% querystring_with_fragments "=foo" %}

  7. Can you add unit tests for {% querystring_with_fragments "foo bar=baz qux" %}

  8. Can you add unit tests for {% querystring_with_fragments "a=b=c=d" "e=f=g=h" %}

  9. Typo: `context.

  10. djblets/util/templatetags/djblets_utils.py (Diff revision 7)
     
     
     

    While technically correct, how about:

    The Django template rendering context.
    

    It is what we use elsewhere.

  11. djblets/util/templatetags/djblets_utils.py (Diff revision 7)
     
     
     

    Blank line between these.

  12. djblets/util/templatetags/djblets_utils.py (Diff revision 7)
     
     
     
     
     
     
     

    Undo this indentation.

  13. """ on next line.

  14. See comment on other templatetag about this.

  15. djblets/util/templatetags/djblets_utils.py (Diff revision 7)
     
     
     

    The description should not be indented, i.e.

    unicode:
    The new URL ...
    
  16. Wrong templatetag :)

  17. This should use the parse_qs method becuase dict iteration order is not guaranteed.

  18. Mind doing bar: "bar" just to keep in line with other examples?

  19. Mind ordering this foo then bar like above?

  20. 
      
mandeep
Review request changed

Summary:

-Added a new template tag querystring_with_fragments to support an arbitrary number of attributes and values
+Updated template tag querystring_with_fragments to support "modes"

Description:

   

Previously, it was only possible to update a single query parameter with

    {% querystring_with %}. However, it is frequently the case that it
    would be handy to update the querystring from within the template rather
    than having to enumerate all possible cases in the view. Now
    {% querystring_with_fragments %} can take an arbitrary number of
~   key-value pairs in the form of:

  ~ key-value pairs in the form of now with different modes:

   
   

   
~  

{% querystring_with "key1=value1" "key2=value2" %}

  ~

{% querystring_with “mode” "key1=value1" "key2=value2" %}

  +
  +

There are three different modes are:

  + “remove” - Will remove keys from the query string.
  + “append” - Will append values for its given key without overwriting.
  + “update” - Will add to or replace part of a query string.

Testing Done:

~  

Ran unit tests

  ~

Ran unit tests.

Commit:

-5887d815320d43e69fc74815b6f405d2df9c0006
+a8a77edf85e9aacc716ca899e9ebd728852cdabd

Diff:

Revision 8 (+488 -9)

Show changes

Checks run (1 failed, 1 succeeded)

flake8 failed.
JSHint passed.

flake8

mandeep
Review request changed

Commit:

-a8a77edf85e9aacc716ca899e9ebd728852cdabd
+dcbbc35d43ba1c1736f2c8ac9efb054b5119b764

Diff:

Revision 9 (+494 -9)

Show changes

Checks run (1 failed, 1 succeeded)

flake8 failed.
JSHint passed.

flake8

mandeep
mandeep
brennie
  1. 
      
  2. Your summary should be in the imperitive mood (i.e., it should read like a command or order).

    How about:

    Add {% querystring %} template tag to add, remove, and update querystring parameters
    
  3. Can you add unit tests with unicode keys and values with e.g. han characters?

  4. djblets/util/templatetags/djblets_utils.py (Diff revision 10)
     
     
     

    six.moves before six.moves.urllib

  5. """ on next line.

  6. djblets/util/templatetags/djblets_utils.py (Diff revision 10)
     
     
     
     
     
     
     
     
     
     
     
     
     
     
     
     

    This should be a header of Args and describe what it is in addition to its values. E.g.

    Args:
    
        mode (unicode):
            How the querystring will be modified. This should be one of the following values:
    
            ``"update"``:
                Replace the values for the specified key(s) in the query string.
    
            ``"append"``:
                Add new values for the specified key(s) to the query string.
    
            ``"remove"``:
                Remove the specified key(s) from the query string.
    
                If no value is provided, all instances of the key will be removed.
    
  7. This just returns the querystring, not the URL.

  8. djblets/util/templatetags/djblets_utils.py (Diff revision 10)
     
     
     
     
     

    The < should line up with the c in code-block.

  9. djblets/util/templatetags/djblets_utils.py (Diff revision 10)
     
     
     
     
     
     

    Instead of having this highly nested, we can pull things out into temporaries to make it more readable.

    How about:

    for arg in args:
        parsed = QueryDict(arg)
    
        for key in part:
            key = key.encode('utf-8')
            values = [
                value.encode('utf-8')
                for value in sparsed.getlist(key)
            ]
            query.setlist(key, value)
    
  10. Can we add support to remove specific key-vaue pairs, in addition to removing the entire set?

    e.g.

    "remove" "a=4" would remove "a=4" from ?a=1&a=2&a=3&a=4 leaving a=1&a=2&a=3.

    1. I can make a test for this and confirm

  11. djblets/util/templatetags/djblets_utils.py (Diff revision 10)
     
     
     
     
     

    How about:

    query.pop(arg.encode('utf-8'), None)
    

    This does not require the try/except.

  12. You can use the same approach I outlined above to make this more readable.

  13. The tag is no longer called QuerystringWithFragments

  14. 
      
mandeep
mandeep
brennie
  1. Some minor style nitpicks. Otherwise this looks good to me.

  2. The < in <a should be aligned with the c in the code-block above.

  3. This message is no longer correct. It should include the update mode.

  4. djblets/util/templatetags/djblets_utils.py (Diff revision 11)
     
     
     
     
     
     
     
     
     
     
     
     
     

    Can we format like the following example? This makes it clear it is a string literal and renders as a <code> element in the docs.

    ``'update'``:
        ....
    
    ``'append'``:
        ....
    
    ``'remove'``:
        ....
    
  5. This should be first in the list of args.

    This is missing its type.

  6. djblets/util/templatetags/djblets_utils.py (Diff revision 11)
     
     
     
     
     
     
     
     
     
     
     
     

    These all need to be de-dented one space so that the first character lines up with the c in code-block.

    reStructuredText code blocks are only indented three spaces instead of the usual four that we use.

    You may want to enable "show indents" in ST3 (View->Indentation).

  7. No blank line here.

  8. The implementation of QueryDict.urlencode actually forces the text to bytes, so we don't need to encode the attrs or values here or below.

    See: implementation

  9. No blank line here.

  10. No blank line here.

  11. No blank line here.

  12. This is no longer required.

  13. djblets/util/tests/test_djblets_utils_tags.py (Diff revision 11)
     
     
     
     
     
     
     

    Six has moves support for this:

    from django.utils.six.moves.html_parser import HTMLParser
    
  14. No blank line after function docstrings. Here and below.

  15. This one doesn't assert that rendered_result.startswith('?').

  16. """ on next line.

    "args get" fits on previous line.

  17. This blank line is unnecessary here and in all tests below.

  18. No period at the end of test docstrings (because they print out as <DOCSTRING> ... OK). Here and below.

  19. djblets/util/tests/test_djblets_utils_tags.py (Diff revision 11)
     
     
     

    This fits on a single line.

    You seem to be wrapping your docstrings at ~70 rather than 79. Please correct this here and below.

  20. 
      
mandeep
Review request changed

Commit:

-bc0f79dcfb6e715c1efc673e636e3d4b4cdbeb83
+b4f86bfa7e642d94dcebb52c2c11daa12d55a585

Diff:

Revision 12 (+532 -12)

Show changes

Checks run (1 failed, 1 succeeded)

flake8 failed.
JSHint passed.

flake8

mandeep
brennie
  1. Again, mostly some formatting nits. I did have a few suggestions on ways to simplify a few things :)

  2. Description has a grammar-o:

    "There are three different modes are"

  3. djblets/util/templatetags/djblets_utils.py (Diff revision 13)
     
     
     
     

    Undo this change, please.

  4. djblets/util/templatetags/djblets_utils.py (Diff revision 13)
     
     
     
     
     
     
     
     
     

    Undo this change, please.

  5. djblets/util/templatetags/djblets_utils.py (Diff revision 13)
     
     
     

    This is not indented a multiple of four. (It is missing one space.)

  6. Should not be indented relative to prior line.

  7. Remove this blank line

  8. djblets/util/templatetags/djblets_utils.py (Diff revision 13)
     
     
     
     
     
     
     

    This is setting the same list in a loopp len(parsed.getlist(attr)) times.

    This also doesn't need to encode values.

    What this should be is:

    query.setlist(parsed.getlist(attr))
    
  9. Remove this line.

  10. djblets/util/templatetags/djblets_utils.py (Diff revision 13)
     
     
     

    Blank line between these.

  11. Remove this line.

  12. Again, no need to encode.

  13. Remove this line.

  14. djblets/util/templatetags/djblets_utils.py (Diff revision 13)
     
     
     
     
     
     
     
     
     

    Since we are appending every single entry in args into query, we can simply do:

    for arg in args:
        query.update(QueryDict(arg))
    

    QueryDict.update appends to lists instead of overwriting them.

  15. Again, no need to encode.

  16. Again, no need to encode.

  17. 
      
mandeep
mandeep
brennie
  1. 
      
  2. djblets/util/templatetags/djblets_utils.py (Diff revisions 13 - 14)
     
     
     
     
     
     
     
     
     
     
     

    I think this will miss some cases, e.g. {% querystring "remove" "x&y=1&z=" %}. Now, this is an edge case, but we should still handle it well.

    We know that in the above example it will result in:

    {'x': [''], 'y': ['1'], 'z': ['']}
    

    We can't really distinguish between z= and z in the argument, so we should treat them both the same.

    for attr in parsed:
        values = parsed.getlist(attr)
    
        if values == ['']:
            # An empty value means either `attr` or `attr=` was provided.
            # In either case, remove all values.
            query.pop(attr, None)
        else:
            values = query.getlist(attr)
    
            for value in parsed.getlist(attr):
                try:
                    values.remove(value)
                except ValueError:
                    pass
    
  3. djblets/util/templatetags/djblets_utils.py (Diff revisions 13 - 14)
     
     

    We only need to do getlist once since we are modifying the same list in each iteration of the loop

  4. 
      
mandeep
brennie
  1. 
      
  2. Can you surround "foo=1" with double backticks, e.g.

    ``"foo=1"``
    
  3. djblets/util/templatetags/djblets_utils.py (Diff revision 15)
     
     
     
     
     
     
     
     
     

    The second for loop needs to be nested in the first, otherwise only the last element in args will be removed form the querystring. Can you add a unit test that does multiple removes? e.g. {% querystring "remove" "a" "b" "c=1" %}

  4. djblets/util/templatetags/djblets_utils.py (Diff revision 15)
     
     
     
     
     
     
     
     
     

    This needs to be indented to be aligned with the if. It is currently always executing because there is no break in the for loop

  5. 
      
mandeep
brennie
  1. 
      
  2. Can you add a test for {% querystring "remove" "foo=1" "foo=2" %} for a querystring ?foo=1&foo=2&foo=3?

  3. djblets/util/templatetags/djblets_utils.py (Diff revisions 15 - 16)
     
     
     

    Can you fix the formatting here? These can both go on the same line.

  4. Test docstrings that go over one line need to have """ on the following line. Same for all other tests here.

  5. 
      
chipx86
  1. 
      
    1. By the way, this looks like a lot of comments, but most are just the same mistake or two made in multiple places. I wanted to point out the subtle ones so that you had a checklist and didn't have to go hunting for them yourself.

      Thanks for the contribution! Overall, this is a great addition, and is going to make things a lot easier in places!

  2. This will actually be a django.template.RequestContext.

  3. djblets/util/templatetags/djblets_utils.py (Diff revision 16)
     
     
     

    This should not be indented.

  4. djblets/util/templatetags/djblets_utils.py (Diff revision 16)
     
     
     
     

    No blank line here.

  5. The key=value should be in quotes, right?

    For "mode", is the equivalent always "update"? If so, we can say that explicitly. In fact, we can help further with examples:

    warnings.warn(
        '{% querystring_with "%(attr)s" "%(value)s" %} is deprecated ...'
        'Please use {% querystring "update" "%(attr)s=%(value)s" %} instead.'
        % {
            'key': attr,
            'value': value,
        },
        DeprecationWarning)
    
  6. Too long for the line. Must fit in 79 characters.

    It's also missing an ending period.

  7. See the type above.

  8. djblets/util/templatetags/djblets_utils.py (Diff revision 16)
     
     
     

    This can be on the same line.

  9. djblets/util/templatetags/djblets_utils.py (Diff revision 16)
     
     
     

    I think you can just do:

    query = context['request'].GET.copy()
    

    The copy will be mutable.

    1. If I do the above, the resulting query would be a dict and not be a QueryDict().

  10. djblets/util/templatetags/djblets_utils.py (Diff revision 16)
     
     
     

    This is going to set the list of values for every value in the list of values. You should be able to remove the for loop here.

  11. djblets/util/templatetags/djblets_utils.py (Diff revision 16)
     
     
     

    This doesn't seem right. We're overriding to_remove every time, meaning we're only ever getting the list for the last attribute. Was the rest of this meant to be within the for loop?

    This means to me that we're missing some very crucial unit tests somewhere.

  12. This should raise a TemplateSyntaxError.

  13. This is in the wrong import group.

  14. The trailing period should remain. You only remove this for docstrings for unit tests themselves (as they're outputted to the terminal and shouldn't end in a period).

  15. djblets/util/tests/test_djblets_utils_tags.py (Diff revision 16)
     
     
     
     
     
     
     
     
     
     
     
     
     
     

    The tests in this module should be updated to check for warnings as well. Search the codebase for catch_warnings for examples.

  16. Needs a trailing period.

  17. This should be removed.

  18. Last entries in a dictionary should always have a trailing comma.

  19. I'd recommend just setting self.request in setUp() (not setUpClass(), since it'll be modified) and referencing it in all the tests.

  20. Trailing comma.

  21. """ on the next line.

    "overridden".

  22. Trailing comma.

  23. djblets/util/tests/test_djblets_utils_tags.py (Diff revision 16)
     
     
     
     
     
     

    Best to explicitly check the resulting value, rather than introducing another parsing/checking step. That just leads to problems, as more things can go wrong, and reviewers/future contributors have no idea what the result is even supposed to be.

    This applies to all tests.

  24. "overridden"

    I'd also add "that" before "get".

  25. djblets/util/tests/test_djblets_utils_tags.py (Diff revision 16)
     
     
     
     

    You can start the template on the first line, like:

    t = Template('{% load djblets_utils %}'
                 '{% querystring .... %}')
    

    Same for other tests.

  26. Trailing comma.

  27. Trailing comma.

  28. Trailing comma.

  29. Trailing comma.

  30. """ on the next line.

    "overridden"

  31. Trailing comma.

  32. """ on the next line.

    No trailing period.

  33. This should be removed.

  34. """ on the next line.

  35. Trailing comma.

  36. """ on the next line.

  37. Trailing comma.

  38. """ on the next line.

    "a key fragments" doesn't make sense. Maybe just "a key fragment?" What's a key fragment?

  39. Trailing comma.

  40. """ on the next line.

  41. Trailing comma.

  42. This should be removed.

  43. """ on the next line.

  44. Trailing comma.

  45. "non-existing"

    """ on the next line.

  46. Trailing comma.

  47. """ on the next line.

  48. Trailing comma.

  49. """ on the next line.

  50. Trailing comma.

  51. """ on the next line.

  52. Trailing comma.

  53. """ on the next line.

  54. Trailing comma.

  55. """ on the next line.

  56. Trailing comma.

  57. 
      
mandeep
brennie
  1. 
      
  2. djblets/util/templatetags/djblets_utils.py (Diff revision 17)
     
     
     

    The } should line up with the % and the D in DeprecationWarning.

  3. Can you specify that this comes from this template tag, e.g.

    raise TemplateSyntaxError('Invalid mode for {%% querystring %%}: %s' % mode)
    
  4. Single quotes here.

  5. 
      
mandeep
Review request changed

Commit:

-aa774d8790e674a441d257a3f8d7525f38ea3481
+22f8586f0104b68bd31a0a46b0e53905f3818258

Diff:

Revision 18 (+494 -9)

Show changes

Checks run (2 succeeded)

flake8 passed.
JSHint passed.
Loading...