Added support for tags.

master
Pacman Ghost 5 years ago
parent 14257c7b9a
commit 0dcc040d4b
  1. 30
      alembic/versions/4594e1b85c8b_added_columns_to_store_tags.py
  2. 1
      asl_articles/__init__.py
  3. 26
      asl_articles/articles.py
  4. 2
      asl_articles/models.py
  5. 12
      asl_articles/publications.py
  6. 38
      asl_articles/tags.py
  7. 59
      asl_articles/tests/react_select.py
  8. 41
      asl_articles/tests/test_articles.py
  9. 39
      asl_articles/tests/test_publications.py
  10. 116
      asl_articles/tests/test_tags.py
  11. 22
      asl_articles/tests/utils.py
  12. 17
      asl_articles/utils.py
  13. 19
      web/src/App.js
  14. 39
      web/src/ArticleSearchResult.js
  15. 37
      web/src/PublicationSearchResult.js
  16. 3
      web/src/SearchResults.css
  17. 18
      web/src/utils.js

@ -0,0 +1,30 @@
"""Added columns to store tags.
Revision ID: 4594e1b85c8b
Revises: 85abe5bcbac0
Create Date: 2019-12-09 09:21:42.902996
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4594e1b85c8b'
down_revision = '85abe5bcbac0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('article', sa.Column('article_tags', sa.String(length=1000), nullable=True))
op.add_column('publication', sa.Column('pub_tags', sa.String(length=1000), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('publication', 'pub_tags')
op.drop_column('article', 'article_tags')
# ### end Alembic commands ###

@ -76,6 +76,7 @@ import asl_articles.search #pylint: disable=cyclic-import
import asl_articles.publishers #pylint: disable=cyclic-import
import asl_articles.publications #pylint: disable=cyclic-import
import asl_articles.articles #pylint: disable=cyclic-import
import asl_articles.tags #pylint: disable=cyclic-import
import asl_articles.utils #pylint: disable=cyclic-import
# initialize

@ -7,11 +7,13 @@ from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Article
from asl_articles.utils import get_request_args, clean_request_args, make_ok_response, apply_attrs
from asl_articles.tags import do_get_tags
from asl_articles.utils import get_request_args, clean_request_args, encode_tags, decode_tags, apply_attrs, \
make_ok_response
_logger = logging.getLogger( "db" )
_FIELD_NAMES = [ "*article_title", "article_subtitle", "article_snippet", "article_url", "pub_id" ]
_FIELD_NAMES = [ "*article_title", "article_subtitle", "article_snippet", "article_url", "article_tags", "pub_id" ]
# ---------------------------------------------------------------------
@ -33,6 +35,7 @@ def get_article_vals( article ):
"article_subtitle": article.article_subtitle,
"article_snippet": article.article_snippet,
"article_url": article.article_url,
"article_tags": decode_tags( article.article_tags ),
"pub_id": article.pub_id,
}
@ -44,15 +47,17 @@ def create_article():
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Create article:" )
)
vals[ "article_tags" ] = encode_tags( vals.get( "article_tags" ) )
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
vals[ "time_created" ] = datetime.datetime.now()
article = Article( **vals )
db.session.add( article ) #pylint: disable=no-member
db.session.commit() #pylint: disable=no-member
_logger.debug( "- New ID: %d", article.article_id )
return make_ok_response( cleaned=cleaned,
extras = { "article_id": article.article_id }
)
extras = { "article_id": article.article_id }
if request.args.get( "list" ):
extras[ "tags" ] = do_get_tags()
return make_ok_response( cleaned=cleaned, extras=extras )
# ---------------------------------------------------------------------
@ -63,6 +68,7 @@ def update_article():
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Update article: id={}".format( article_id ) )
)
vals[ "article_tags" ] = encode_tags( vals.get( "article_tags" ) )
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
vals[ "time_updated" ] = datetime.datetime.now()
article = Article.query.get( article_id )
@ -70,7 +76,10 @@ def update_article():
abort( 404 )
apply_attrs( article, vals )
db.session.commit() #pylint: disable=no-member
return make_ok_response( cleaned=cleaned )
extras = {}
if request.args.get( "list" ):
extras[ "tags" ] = do_get_tags()
return make_ok_response( cleaned=cleaned, extras=extras )
# ---------------------------------------------------------------------
@ -84,4 +93,7 @@ def delete_article( article_id ):
_logger.debug( "- %s", article )
db.session.delete( article ) #pylint: disable=no-member
db.session.commit() #pylint: disable=no-member
return make_ok_response( extras={} )
extras = {}
if request.args.get( "list" ):
extras[ "tags" ] = do_get_tags()
return make_ok_response( extras=extras )

@ -33,6 +33,7 @@ class Publication( db.Model ):
pub_edition = db.Column( db.String(100), nullable=True )
pub_description = db.Column( db.String(1000), nullable=True )
pub_url = db.Column( db.String(500), nullable=True )
pub_tags = db.Column( db.String(1000), nullable=True )
publ_id = db.Column( db.Integer,
db.ForeignKey( Publisher.__table__.c.publ_id, ondelete="CASCADE" ),
nullable=True
@ -57,6 +58,7 @@ class Article( db.Model ):
article_subtitle = db.Column( db.String(200), nullable=True )
article_snippet = db.Column( db.String(5000), nullable=True )
article_url = db.Column( db.String(500), nullable=True )
article_tags = db.Column( db.String(1000), nullable=True )
pub_id = db.Column( db.Integer,
db.ForeignKey( Publication.__table__.c.pub_id, ondelete="CASCADE" ),
nullable=True

@ -7,11 +7,13 @@ from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Publication, Article
from asl_articles.utils import get_request_args, clean_request_args, make_ok_response, apply_attrs
from asl_articles.tags import do_get_tags
from asl_articles.utils import get_request_args, clean_request_args, encode_tags, decode_tags, apply_attrs, \
make_ok_response
_logger = logging.getLogger( "db" )
_FIELD_NAMES = [ "*pub_name", "pub_edition", "pub_description", "pub_url", "publ_id" ]
_FIELD_NAMES = [ "*pub_name", "pub_edition", "pub_description", "pub_url", "pub_tags", "publ_id" ]
# ---------------------------------------------------------------------
@ -51,6 +53,7 @@ def get_publication_vals( pub ):
"pub_edition": pub.pub_edition,
"pub_description": pub.pub_description,
"pub_url": pub.pub_url,
"pub_tags": decode_tags( pub.pub_tags ),
"publ_id": pub.publ_id,
}
@ -62,6 +65,7 @@ def create_publication():
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Create publication:" )
)
vals[ "pub_tags" ] = encode_tags( vals.get( "pub_tags" ) )
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
vals[ "time_created" ] = datetime.datetime.now()
pub = Publication( **vals )
@ -71,6 +75,7 @@ def create_publication():
extras = { "pub_id": pub.pub_id }
if request.args.get( "list" ):
extras[ "publications" ] = do_get_publications()
extras[ "tags" ] = do_get_tags()
return make_ok_response( cleaned=cleaned, extras=extras )
# ---------------------------------------------------------------------
@ -82,6 +87,7 @@ def update_publication():
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Update publication: id={}".format( pub_id ) )
)
vals[ "pub_tags" ] = encode_tags( vals.get( "pub_tags" ) )
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
vals[ "time_updated" ] = datetime.datetime.now()
pub = Publication.query.get( pub_id )
@ -92,6 +98,7 @@ def update_publication():
extras = {}
if request.args.get( "list" ):
extras[ "publications" ] = do_get_publications()
extras[ "tags" ] = do_get_tags()
return make_ok_response( cleaned=cleaned, extras=extras )
# ---------------------------------------------------------------------
@ -119,4 +126,5 @@ def delete_publication( pub_id ):
extras = { "deleteArticles": deleted_articles }
if request.args.get( "list" ):
extras[ "publications" ] = do_get_publications()
extras[ "tags" ] = do_get_tags()
return make_ok_response( extras=extras )

@ -0,0 +1,38 @@
""" Handle tag requests. """
from collections import defaultdict
from flask import jsonify
from asl_articles import app, db
from asl_articles.models import Publication, Article
# ---------------------------------------------------------------------
@app.route( "/tags" )
def get_tags():
"""Get all tags."""
return jsonify( do_get_tags() )
def do_get_tags():
"""Get all tags."""
# get all the tags
# NOTE: This is pretty inefficient, since an article/publication's tags are munged into one big string
# and stored in a single column, so we need to manually unpack everything, but we'll see how it goes...
tags = defaultdict( int )
def count_tags( query ):
for row in query:
if not row[0]:
continue
for tag in row[0].split( ";" ):
tags[ tag ] = tags[ tag ] + 1
count_tags( db.session.query( Publication.pub_tags ) ) #pylint: disable=no-member
count_tags( db.session.query( Article.article_tags ) ) #pylint: disable=no-member
# sort the results
tags = sorted( tags.items(),
key = lambda v: ( -v[1], v[0] ) # sort by # instances, then name
)
return tags

@ -0,0 +1,59 @@
""" Control a react-select droplist. """
from selenium.webdriver.common.keys import Keys
from asl_articles.tests.utils import find_child, find_children
# ---------------------------------------------------------------------
class ReactSelect:
"""Control a react-select droplist."""
def __init__( self, elem ):
self.select = elem
def select_by_name( self, val ):
"""Select an option by name."""
find_child( ".react-select__dropdown-indicator", self.select ).click()
options = [ e for e in find_children( ".react-select__option", self.select )
if e.text == val
]
assert len( options ) == 1
options[0].click()
def get_multiselect_choices( self ):
"""Get the available multi-select choices."""
btn = find_child( ".react-select__dropdown-indicator", self.select )
btn.click() # show the dropdown
choices = [ e.text for e in find_children( ".react-select__option", self.select ) ]
btn.click() # close the dropdown
return choices
def get_multiselect_values( self ):
"""Get the current multi-select values."""
return [ e.text for e in find_children( ".react-select__multi-value", self.select ) ]
def update_multiselect_values( self, *vals ):
"""Add/remove multi-select values."""
for v in vals:
if v.startswith( "+" ):
self.add_multiselect_value( v[1:] )
elif v.startswith( "-" ):
self.remove_multiselect_value( v[1:] )
else:
assert False, "Multi-select values must start with +/-."
def add_multiselect_value( self, val ):
"""Add a multi-select value."""
elem = find_child( "input", self.select )
elem.clear()
elem.send_keys( val )
elem.send_keys( Keys.RETURN )
def remove_multiselect_value( self, val ):
"""Remove a multi-select value."""
for elem in find_children( ".react-select__multi-value", self.select ):
if elem.text == val:
find_child( ".react-select__multi-value__remove", elem ).click()
return
assert False, "Can't find multi-select value: {}".format( val )

@ -6,7 +6,7 @@ import json
from asl_articles.tests.utils import init_tests, do_search, get_result_names, \
wait_for, wait_for_elem, find_child, find_children, set_elem_text, \
set_toast_marker, check_toast, check_ask_dialog, check_error_msg
from asl_articles.tests.utils import ReactSelect
from asl_articles.tests.react_select import ReactSelect
# ---------------------------------------------------------------------
@ -24,17 +24,20 @@ def test_edit_article( webdriver, flask_app, dbconn ):
"title": " Updated title ",
"subtitle": " Updated subtitle ",
"snippet": " Updated snippet. ",
"tags": [ "+abc", "+xyz" ],
"url": " http://updated-article.com ",
} )
# check that the search result was updated in the UI
results = find_children( "#search-results .search-result" )
result = results[0]
_check_result( result, [ "Updated title", "Updated subtitle", "Updated snippet.", "http://updated-article.com/" ] )
_check_result( result,
[ "Updated title", "Updated subtitle", "Updated snippet.", ["abc","xyz"], "http://updated-article.com/" ]
)
# try to remove all fields from the article (should fail)
_edit_article( result,
{ "title": "", "subtitle": "", "snippet": "", "url": "" },
{ "title": "", "subtitle": "", "snippet": "", "tags": ["-abc","-xyz"], "url": "" },
expected_error = "Please specify the article's title."
)
@ -49,10 +52,11 @@ def test_edit_article( webdriver, flask_app, dbconn ):
assert find_child( ".title a", result ) is None
assert find_child( ".title", result ).text == "Tin Cans Rock!"
assert find_child( ".snippet", result ).text == ""
assert find_children( ".tag", result ) == []
# check that the search result was updated in the database
results = do_search( "tin can" )
_check_result( results[0], [ "Tin Cans Rock!", None, "", None ] )
_check_result( results[0], [ "Tin Cans Rock!", None, "", [], None ] )
# ---------------------------------------------------------------------
@ -71,6 +75,8 @@ def test_create_article( webdriver, flask_app, dbconn ):
set_elem_text( find_child( ".title input", dlg ), "New article" )
set_elem_text( find_child( ".subtitle input", dlg ), "New subtitle" )
set_elem_text( find_child( ".snippet textarea", dlg ), "New snippet." )
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
select.update_multiselect_values( "+111", "+222", "+333" )
set_elem_text( find_child( ".url input", dlg ), "http://new-snippet.com" )
set_toast_marker( "info" )
find_child( "button.ok", dlg ).click()
@ -81,7 +87,7 @@ def test_create_article( webdriver, flask_app, dbconn ):
# check that the new article appears in the UI
def check_new_article( result ):
_check_result( result, [
"New article", "New subtitle", "New snippet.", "http://new-snippet.com/"
"New article", "New subtitle", "New snippet.", ["111","222","333"], "http://new-snippet.com/"
] )
results = find_children( "#search-results .search-result" )
check_new_article( results[0] )
@ -197,6 +203,7 @@ def test_unicode( webdriver, flask_app, dbconn ):
"title": "japan = \u65e5\u672c",
"subtitle": "s.korea = \ud55c\uad6d",
"snippet": "greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1",
"tags": [ "+\u0e51", "+\u0e52", "+\u0e53" ],
"url": "http://\ud55c\uad6d.com"
} )
@ -207,6 +214,7 @@ def test_unicode( webdriver, flask_app, dbconn ):
"japan = \u65e5\u672c",
"s.korea = \ud55c\uad6d",
"greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1",
[ "\u0e51", "\u0e52", "\u0e53" ],
"http://xn--3e0b707e.com/"
] )
@ -231,7 +239,7 @@ def test_clean_html( webdriver, flask_app, dbconn ):
)
assert len( results ) == 1
result = results[0]
_check_result( result, [ "title: bold xxx italic", "italicized subtitle", "bad stuff here:", None ] )
_check_result( result, [ "title: bold xxx italic", "italicized subtitle", "bad stuff here:", [], None ] )
assert find_child( ".title span" ).get_attribute( "innerHTML" ) \
== "title: <span> <b>bold</b> xxx <i>italic</i></span>"
assert find_child( ".subtitle" ).get_attribute( "innerHTML" ) \
@ -261,8 +269,12 @@ def _create_article( vals, toast_type="info" ):
find_child( "#menu .new-article" ).click()
dlg = wait_for_elem( 2, "#modal-form" )
for k,v in vals.items():
sel = ".{} {}".format( k , "textarea" if k == "snippet" else "input" )
set_elem_text( find_child( sel, dlg ), v )
if k == "tags":
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
select.update_multiselect_values( *v )
else:
sel = ".{} {}".format( k , "textarea" if k == "snippet" else "input" )
set_elem_text( find_child( sel, dlg ), v )
find_child( "button.ok", dlg ).click()
if toast_type:
# check that the new article was created successfully
@ -279,6 +291,9 @@ def _edit_article( result, vals, toast_type="info", expected_error=None ):
if k == "publication":
select = ReactSelect( find_child( ".publication .react-select", dlg ) )
select.select_by_name( v )
elif k == "tags":
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
select.update_multiselect_values( *v )
else:
sel = ".{} {}".format( k , "textarea" if k == "snippet" else "input" )
set_elem_text( find_child( sel, dlg ), v )
@ -297,15 +312,21 @@ def _edit_article( result, vals, toast_type="info", expected_error=None ):
def _check_result( result, expected ):
"""Check a result."""
# check the title and subtitle
assert find_child( ".title span", result ).text == expected[0]
elem = find_child( ".subtitle", result )
if elem:
assert elem.text == expected[1]
else:
assert expected[1] is None
# check the snippet
assert find_child( ".snippet", result ).text == expected[2]
# check the tags
tags = [ t.text for t in find_children( ".tag", result ) ]
assert tags == expected[3]
# check the article's link
elem = find_child( ".title a", result )
if elem:
assert elem.get_attribute( "href" ) == expected[3]
assert elem.get_attribute( "href" ) == expected[4]
else:
assert expected[3] is None
assert expected[4] is None

@ -6,7 +6,7 @@ import json
from asl_articles.tests.utils import init_tests, init_db, do_search, get_result_names, \
wait_for, wait_for_elem, find_child, find_children, set_elem_text, \
set_toast_marker, check_toast, check_ask_dialog, check_error_msg
from asl_articles.tests.utils import ReactSelect
from asl_articles.tests.react_select import ReactSelect
# ---------------------------------------------------------------------
@ -24,17 +24,20 @@ def test_edit_publication( webdriver, flask_app, dbconn ):
"name": " ASL Journal (updated) ",
"edition": " 2a ",
"description": " Updated ASLJ description. ",
"tags": [ "+abc", "+xyz" ],
"url": " http://aslj-updated.com ",
} )
# check that the search result was updated in the UI
results = find_children( "#search-results .search-result" )
result = results[1]
_check_result( result, [ "ASL Journal (updated)", "2a", "Updated ASLJ description.", "http://aslj-updated.com/" ] )
_check_result( result,
[ "ASL Journal (updated)", "2a", "Updated ASLJ description.", ["abc","xyz"], "http://aslj-updated.com/" ]
)
# try to remove all fields from "ASL Journal #2" (should fail)
_edit_publication( result,
{ "name": "", "edition": "", "description": "", "url": "" },
{ "name": "", "edition": "", "description": "", "tags":["-abc","-xyz"], "url": "" },
expected_error = "Please specify the publication's name."
)
@ -49,6 +52,7 @@ def test_edit_publication( webdriver, flask_app, dbconn ):
assert find_child( ".name a", result ) is None
assert find_child( ".name", result ).text == "Updated ASL Journal"
assert find_child( ".description", result ).text == ""
assert find_children( ".tag", result ) == []
# check that the search result was updated in the database
results = do_search( "ASL Journal" )
@ -71,6 +75,8 @@ def test_create_publication( webdriver, flask_app, dbconn ):
set_elem_text( find_child( ".name input", dlg ), "New publication" )
set_elem_text( find_child( ".edition input", dlg ), "#1" )
set_elem_text( find_child( ".description textarea", dlg ), "New publication description." )
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
select.update_multiselect_values( "+111", "+222", "+333" )
set_elem_text( find_child( ".url input", dlg ), "http://new-publication.com" )
set_toast_marker( "info" )
find_child( "button.ok", dlg ).click()
@ -81,7 +87,7 @@ def test_create_publication( webdriver, flask_app, dbconn ):
# check that the new publication appears in the UI
def check_new_publication( result ):
_check_result( result, [
"New publication", "#1", "New publication description.", "http://new-publication.com/"
"New publication", "#1", "New publication description.", ["111","222","333"], "http://new-publication.com/"
] )
results = find_children( "#search-results .search-result" )
check_new_publication( results[0] )
@ -240,6 +246,7 @@ def test_unicode( webdriver, flask_app, dbconn ):
_create_publication( {
"name": "japan = \u65e5\u672c",
"edition": "\u263a",
"tags": [ "+\u0e51", "+\u0e52", "+\u0e53" ],
"url": "http://\ud55c\uad6d.com",
"description": "greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1"
} )
@ -250,6 +257,7 @@ def test_unicode( webdriver, flask_app, dbconn ):
_check_result( results[0], [
"japan = \u65e5\u672c", "\u263a",
"greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1",
[ "\u0e51", "\u0e52", "\u0e53" ],
"http://xn--3e0b707e.com/"
] )
@ -274,7 +282,7 @@ def test_clean_html( webdriver, flask_app, dbconn ):
)
assert len( results ) == 1
result = results[0]
_check_result( result, [ "name: bold xxx italic", "2", "bad stuff here:", None ] )
_check_result( result, [ "name: bold xxx italic", "2", "bad stuff here:", [], None ] )
assert find_child( ".name span" ).get_attribute( "innerHTML" ) \
== "name: <span> <b>bold</b> xxx <i>italic</i></span> (<i>2</i>)"
assert check_toast( "warning", "Some values had HTML removed.", contains=True )
@ -302,8 +310,12 @@ def _create_publication( vals, toast_type="info" ):
find_child( "#menu .new-publication" ).click()
dlg = wait_for_elem( 2, "#modal-form" )
for k,v in vals.items():
sel = ".{} {}".format( k , "textarea" if k == "description" else "input" )
set_elem_text( find_child( sel, dlg ), v )
if k == "tags":
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
select.update_multiselect_values( *v )
else:
sel = ".{} {}".format( k , "textarea" if k == "description" else "input" )
set_elem_text( find_child( sel, dlg ), v )
find_child( "button.ok", dlg ).click()
if toast_type:
# check that the new publication was created successfully
@ -320,6 +332,9 @@ def _edit_publication( result, vals, toast_type="info", expected_error=None ):
if k == "publisher":
select = ReactSelect( find_child( ".publisher .react-select", dlg ) )
select.select_by_name( v )
elif k == "tags":
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
select.update_multiselect_values( *v )
else:
sel = ".{} {}".format( k , "textarea" if k == "description" else "input" )
set_elem_text( find_child( sel, dlg ), v )
@ -338,13 +353,19 @@ def _edit_publication( result, vals, toast_type="info", expected_error=None ):
def _check_result( result, expected ):
"""Check a result."""
# check the name and edition
expected_name = expected[0]
if expected[1]:
expected_name += " ({})".format( expected[1] )
assert find_child( ".name", result ).text == expected_name
# check the description
assert find_child( ".description", result ).text == expected[2]
# check the tags
tags = [ t.text for t in find_children( ".tag", result ) ]
assert tags == expected[3]
# check the publication's link
elem = find_child( ".name a", result )
if elem:
assert elem.get_attribute( "href" ) == expected[3]
assert elem.get_attribute( "href" ) == expected[4]
else:
assert expected[3] is None
assert expected[4] is None

@ -0,0 +1,116 @@
""" Test tag operations. """
import urllib.request
import json
from asl_articles.tests.utils import init_tests, wait_for_elem, find_child, find_children, \
find_search_result , get_result_names
from asl_articles.tests.react_select import ReactSelect
from asl_articles.tests.test_publications import _create_publication, _edit_publication
from asl_articles.tests.test_articles import _create_article, _edit_article
# ---------------------------------------------------------------------
def test_tags( webdriver, flask_app, dbconn ):
"""Test tag operations."""
# initialize
init_tests( webdriver, flask_app, dbconn )
# create a test publication and article
_create_publication( { "name": "publication 1" } )
_create_article( { "title": "article 1" } )
_check_tags( flask_app, {
"publication 1": [],
"article 1": []
} )
# add some tags to the publication
_edit_publication( find_search_result( "publication 1" ), {
"tags": [ "+aaa", "+bbb" ]
} )
_check_tags( flask_app, {
"publication 1": [ "aaa", "bbb" ],
"article 1": []
} )
# add some tags to the article
_edit_article( find_search_result( "article 1" ), {
"tags": [ "+bbb", "+ccc" ]
} )
_check_tags( flask_app, {
"publication 1": [ "aaa", "bbb" ],
"article 1": [ "bbb", "ccc" ]
} )
# remove some tags from the publication
_edit_article( find_search_result( "publication 1" ), {
"tags": [ "-bbb" ]
} )
_check_tags( flask_app, {
"publication 1": [ "aaa" ],
"article 1": [ "bbb", "ccc" ]
} )
# remove some tags from the article
_edit_article( find_search_result( "article 1" ), {
"tags": [ "-ccc", "-bbb" ]
} )
_check_tags( flask_app, {
"publication 1": [ "aaa" ],
"article 1": []
} )
# add duplicate tags to the publication
_edit_article( find_search_result( "publication 1" ), {
"tags": [ "+bbb", "+aaa", "+eee" ]
} )
_check_tags( flask_app, {
"publication 1": [ "aaa","bbb","eee" ],
"article 1": []
} )
# ---------------------------------------------------------------------
def _check_tags( flask_app, expected ):
"""Check the tags in the UI and database."""
# get the complete list of expected tags
expected_available = set()
for tags in expected.values():
expected_available.update( tags )
# check the tags in the UI
elems = find_children( "#search-results .search-result" )
assert set( get_result_names( elems ) ) == set( expected.keys() )
for sr in elems:
# check the tags in the search result
name = find_child( ".name span", sr ).text
tags = [ t.text for t in find_children( ".tag", sr ) ]
assert tags == expected[ name ]
# check the tags in the publication/article
find_child( ".edit", sr ).click()
dlg = wait_for_elem( 2, "#modal-form" )
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
assert select.get_multiselect_values() == expected[ name ]
# check that the list of available tags is correct
# NOTE: We don't bother checking the tag order here.
assert set( select.get_multiselect_choices() ) == expected_available.difference( expected[name] )
find_child( "button.cancel", dlg ).click()
def fixup_tags( tags ):
return [] if tags is None else tags
# check the tags in the database
for sr in elems:
if sr.text.startswith( "publication" ):
pub_id = sr.get_attribute( "testing--pub_id" )
url = flask_app.url_for( "get_publication", pub_id=pub_id )
pub = json.load( urllib.request.urlopen( url ) )
assert expected[ pub["pub_name"] ] == fixup_tags( pub["pub_tags"] )
elif sr.text.startswith( "article" ):
article_id = sr.get_attribute( "testing--article_id" )
url = flask_app.url_for( "get_article", article_id=article_id )
article = json.load( urllib.request.urlopen( url ) )
assert expected[ article["article_title"] ] == fixup_tags( article["article_tags"] )

@ -104,6 +104,13 @@ def get_result_names( results ):
for r in results
]
def find_search_result( name ):
"""Find a search result."""
elems = find_children( "#search-results .search-result" )
elems = [ e for e in elems if find_child( ".name span", e ).text == name ]
assert len(elems) == 1
return elems[0]
# ---------------------------------------------------------------------
def wait_for( timeout, func ):
@ -203,21 +210,6 @@ def _make_toast_stored_msg_id( toast_type ):
# ---------------------------------------------------------------------
class ReactSelect:
"""Control a react-select droplist."""
def __init__( self, elem ):
self.select = elem
def select_by_name( self, val ):
"""Select an option by name."""
find_child( "svg", self.select ).click()
options = [ e for e in find_children( ".react-select__option", self.select )
if e.text == val
]
assert len( options ) == 1
options[0].click()
# ---------------------------------------------------------------------
def set_elem_text( elem, val ):
"""Set the text for an element."""
elem.clear()

@ -112,6 +112,23 @@ def load_html_whitelists( app ):
# ---------------------------------------------------------------------
def encode_tags( tags ):
"""Encode tags prior to storing them in the database."""
# FUDGE! We store tags as a single string in the database, using ; as a separator, which means
# that we can't have a semicolon in a tag itself :-/, so we replace them with a comma.
if not tags:
return None
tags = [ t.replace( ";", "," ) for t in tags ]
return ";".join( t.lower() for t in tags )
def decode_tags( tags ):
"""Decode tags after loading them from the database."""
if not tags:
return None
return tags.split( ";" )
# ---------------------------------------------------------------------
def apply_attrs( obj, vals ):
"""Update an object's attributes."""
for k,v in vals.items():

@ -93,6 +93,13 @@ export default class App extends React.Component
.catch( err => {
this.showErrorToast( <div> Couldn't load the publications: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
axios.get( this.makeFlaskUrl( "/tags" ) )
.then( resp => {
this.caches.tags = resp.data ;
} )
.catch( err => {
this.showErrorToast( <div> Couldn't load the tags: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
}
onSearch( query ) {
@ -198,6 +205,18 @@ export default class App extends React.Component
console.log( detail ) ;
}
makeTagLists( tags ) {
// convert the tags into a list suitable for CreatableSelect
// NOTE: react-select uses the "value" field to determine which choices have already been selected
// and thus should not be shown in the droplist of available choices.
let tagList = [] ;
if ( tags )
tags.map( tag => tagList.push( { value: tag, label: tag } ) ) ;
// create another list for all known tags
let allTags = this.caches.tags.map( tag => { return { value: tag[0], label: tag[0] } } ) ;
return [ tagList, allTags ] ;
}
makeFlaskUrl( url, args ) {
// generate a URL for the Flask backend server
url = this._flaskBaseUrl + url ;

@ -1,8 +1,9 @@
import React from "react" ;
import ReactDOMServer from "react-dom/server" ;
import Select from "react-select" ;
import CreatableSelect from "react-select/creatable" ;
import { gAppRef } from "./index.js" ;
import { makeOptionalLink } from "./utils.js" ;
import { makeOptionalLink, unloadCreatableSelect } from "./utils.js" ;
const axios = require( "axios" ) ;
@ -13,6 +14,9 @@ export class ArticleSearchResult extends React.Component
render() {
const pub = gAppRef.caches.publications[ this.props.data.pub_id ] ;
let tags = [] ;
if ( this.props.data.article_tags )
this.props.data.article_tags.map( t => tags.push( <div key={t} className="tag"> {t} </div> ) ) ;
// NOTE: The "title" field is also given the CSS class "name" so that the normal CSS will apply to it.
// Some tests also look for a generic ".name" class name when checking search results.
return ( <div className="search-result article"
@ -25,13 +29,16 @@ export class ArticleSearchResult extends React.Component
{ this.props.data.article_subtitle && <div className="subtitle" dangerouslySetInnerHTML={{ __html: this.props.data.article_subtitle }} /> }
</div>
<div className="snippet" dangerouslySetInnerHTML={{__html: this.props.data.article_snippet}} />
{ tags.length > 0 && <div className="tags"> <label>Tags:</label> {tags} </div> }
</div> ) ;
}
static onNewArticle( notify ) {
ArticleSearchResult._doEditArticle( {}, (newVals,refs) => {
axios.post( gAppRef.makeFlaskUrl( "/article/create" ), newVals )
axios.post( gAppRef.makeFlaskUrl( "/article/create", {list:1} ), newVals )
.then( resp => {
// update the cached tags
gAppRef.caches.tags = resp.data.tags ;
// unload any cleaned values
for ( let r in refs ) {
if ( resp.data.cleaned && resp.data.cleaned[r] )
@ -55,8 +62,10 @@ export class ArticleSearchResult extends React.Component
ArticleSearchResult._doEditArticle( this.props.data, (newVals,refs) => {
// send the updated details to the server
newVals.article_id = this.props.data.article_id ;
axios.post( gAppRef.makeFlaskUrl( "/article/update" ), newVals )
axios.post( gAppRef.makeFlaskUrl( "/article/update", {list:1} ), newVals )
.then( resp => {
// update the cached tags
gAppRef.caches.tags = resp.data.tags ;
// update the UI with the new details
for ( let r in refs )
this.props.data[ r ] = (resp.data.cleaned && resp.data.cleaned[r]) || newVals[r] ;
@ -75,6 +84,7 @@ export class ArticleSearchResult extends React.Component
static _doEditArticle( vals, notify ) {
let refs = {} ;
// initialize the publications
let publications = [ { value: null, label: <i>(none)</i> } ] ;
let currPub = 0 ;
for ( let p of Object.entries(gAppRef.caches.publications) ) {
@ -88,6 +98,9 @@ export class ArticleSearchResult extends React.Component
publications.sort( (lhs,rhs) => {
return ReactDOMServer.renderToStaticMarkup( lhs.label ).localeCompare( ReactDOMServer.renderToStaticMarkup( rhs.label ) ) ;
} ) ;
// initialize the tags
const tags = gAppRef.makeTagLists( vals.article_tags ) ;
// prepare the form content
const content = <div>
<div className="row title"> <label> Title: </label>
<input type="text" defaultValue={vals.article_title} ref={(r) => refs.article_title=r} />
@ -101,6 +114,12 @@ export class ArticleSearchResult extends React.Component
ref = { (r) => refs.pub_id=r }
/>
</div>
<div className="row tags"> <label> Tags: </label>
<CreatableSelect className="react-select" classNamePrefix="react-select" options={tags[1]} isMulti
defaultValue = {tags[0]}
ref = { (r) => refs.article_tags=r }
/>
</div>
<div className="row snippet"> <label> Snippet: </label>
<textarea defaultValue={vals.article_snippet} ref={(r) => refs.article_snippet=r} />
</div>
@ -112,8 +131,14 @@ export class ArticleSearchResult extends React.Component
OK: () => {
// unload the new values
let newVals = {} ;
for ( let r in refs )
newVals[ r ] = (r === "pub_id") ? refs[r].state.value && refs[r].state.value.value : refs[r].value.trim() ;
for ( let r in refs ) {
if ( r === "pub_id" )
newVals[ r ] = refs[r].state.value && refs[r].state.value.value ;
else if ( r === "article_tags" )
newVals[ r ] = unloadCreatableSelect( refs[r] ) ;
else
newVals[ r ] = refs[r].value.trim() ;
}
if ( newVals.article_title === "" ) {
gAppRef.showErrorMsg( <div> Please specify the article's title. </div>) ;
return ;
@ -136,8 +161,10 @@ export class ArticleSearchResult extends React.Component
gAppRef.ask( content, "ask", {
"OK": () => {
// delete the article on the server
axios.get( gAppRef.makeFlaskUrl( "/article/delete/" + this.props.data.article_id ) )
axios.get( gAppRef.makeFlaskUrl( "/article/delete/" + this.props.data.article_id, {list:1} ) )
.then( resp => {
// update the cached tags
gAppRef.caches.tags = resp.data.tags ;
// update the UI
this.props.onDelete( "article_id", this.props.data.article_id ) ;
if ( resp.data.warning )

@ -1,8 +1,9 @@
import React from "react" ;
import ReactDOMServer from "react-dom/server" ;
import Select from "react-select" ;
import CreatableSelect from "react-select/creatable" ;
import { gAppRef } from "./index.js" ;
import { makeOptionalLink, pluralString } from "./utils.js" ;
import { makeOptionalLink, unloadCreatableSelect, pluralString } from "./utils.js" ;
const axios = require( "axios" ) ;
@ -13,6 +14,9 @@ export class PublicationSearchResult extends React.Component
render() {
const publ = gAppRef.caches.publishers[ this.props.data.publ_id ] ;
let tags = [] ;
if ( this.props.data.pub_tags )
this.props.data.pub_tags.map( t => tags.push( <div key={t} className="tag"> {t} </div> ) ) ;
return ( <div className="search-result publication"
ref = { r => gAppRef.setTestAttribute( r, "pub_id", this.props.data.pub_id ) }
>
@ -22,6 +26,7 @@ export class PublicationSearchResult extends React.Component
<img src="/images/delete.png" className="delete" onClick={this.onDeletePublication.bind(this)} alt="Delete this publication." />
</div>
<div className="description" dangerouslySetInnerHTML={{__html: this.props.data.pub_description}} />
{ tags.length > 0 && <div className="tags"> <label>Tags:</label> {tags} </div> }
</div> ) ;
}
@ -29,8 +34,9 @@ export class PublicationSearchResult extends React.Component
PublicationSearchResult._doEditPublication( {}, (newVals,refs) => {
axios.post( gAppRef.makeFlaskUrl( "/publication/create", {list:1} ), newVals )
.then( resp => {
// update the cached publications
// update the caches
gAppRef.caches.publications = resp.data.publications ;
gAppRef.caches.tags = resp.data.tags ;
// unload any cleaned values
for ( let r in refs ) {
if ( resp.data.cleaned && resp.data.cleaned[r] )
@ -56,8 +62,9 @@ export class PublicationSearchResult extends React.Component
newVals.pub_id = this.props.data.pub_id ;
axios.post( gAppRef.makeFlaskUrl( "/publication/update", {list:1} ), newVals )
.then( resp => {
// update the cached publications
// update the caches
gAppRef.caches.publications = resp.data.publications ;
gAppRef.caches.tags = resp.data.tags ;
// update the UI with the new details
for ( let r in refs )
this.props.data[ r ] = (resp.data.cleaned && resp.data.cleaned[r]) || newVals[r] ;
@ -76,6 +83,7 @@ export class PublicationSearchResult extends React.Component
static _doEditPublication( vals, notify ) {
let refs = {} ;
// initialize the publishers
let publishers = [ { value: null, label: <i>(none)</i> } ] ;
let currPubl = 0 ;
for ( let p of Object.entries(gAppRef.caches.publishers) ) {
@ -89,7 +97,9 @@ export class PublicationSearchResult extends React.Component
publishers.sort( (lhs,rhs) => {
return ReactDOMServer.renderToStaticMarkup( lhs.label ).localeCompare( ReactDOMServer.renderToStaticMarkup( rhs.label ) ) ;
} ) ;
// initialize the tags
const tags = gAppRef.makeTagLists( vals.pub_tags ) ;
// prepare the form content
const content = <div>
<div className="row name"> <label> Name: </label>
<input type="text" defaultValue={vals.pub_name} ref={(r) => refs.pub_name=r} />
@ -103,6 +113,12 @@ export class PublicationSearchResult extends React.Component
ref = { (r) => refs.publ_id=r }
/>
</div>
<div className="row tags"> <label> Tags: </label>
<CreatableSelect className="react-select" classNamePrefix="react-select" options={tags[1]} isMulti
defaultValue = {tags[0]}
ref = { (r) => refs.pub_tags=r }
/>
</div>
<div className="row description"> <label> Description: </label>
<textarea defaultValue={vals.pub_description} ref={(r) => refs.pub_description=r} />
</div>
@ -114,8 +130,14 @@ export class PublicationSearchResult extends React.Component
OK: () => {
// unload the new values
let newVals = {} ;
for ( let r in refs )
newVals[ r ] = (r === "publ_id") ? refs[r].state.value && refs[r].state.value.value : refs[r].value.trim() ;
for ( let r in refs ) {
if ( r === "publ_id" )
newVals[ r ] = refs[r].state.value && refs[r].state.value.value ;
else if ( r === "pub_tags" )
newVals[ r ] = unloadCreatableSelect( refs[r] ) ;
else
newVals[ r ] = refs[r].value.trim() ;
}
if ( newVals.pub_name === "" ) {
gAppRef.showErrorMsg( <div> Please specify the publication's name. </div>) ;
return ;
@ -154,8 +176,9 @@ export class PublicationSearchResult extends React.Component
// delete the publication on the server
axios.get( gAppRef.makeFlaskUrl( "/publication/delete/" + this.props.data.pub_id, {list:1} ) )
.then( resp => {
// update the cached publications
// update the caches
gAppRef.caches.publications = resp.data.publications ;
gAppRef.caches.tags = resp.data.tags ;
// update the UI
this.props.onDelete( "pub_id", this.props.data.pub_id ) ;
resp.data.deleteArticles.forEach( article_id => {

@ -18,3 +18,6 @@
.search-result.publication .name { border: 1px solid #c0c0c0 ; background: #d0a080 ; }
.search-result.article .title { border: 1px solid #c0c0c0 ; background: #60f000 ; }
.search-result.article .subtitle { font-size: 80% ; font-style: italic ; }
.search-result .tags { margin-top: 0.25em ; font-size: 80% ; font-style: italic ; color: #666 ; }
.search-result .tags .tag { display: inline ; margin-right: 0.25em ; padding: 0 2px ; border: 1px solid #ccc ; background: #f0f0f0 ; }

@ -2,6 +2,24 @@ import React from "react" ;
// --------------------------------------------------------------------
export function unloadCreatableSelect( sel ) {
// unload the values from a CreatableSelect
if ( ! sel.state.value )
return [] ;
const vals = sel.state.value.map( v => v.label ) ;
// dedupe the values (trying to preserve order)
let vals2=[], used={} ;
for ( let i=0 ; i < vals.length ; ++i ) {
if ( ! used[ vals[i] ] ) {
vals2.push( vals[i] ) ;
used[ vals[i] ] = true ;
}
}
return vals2 ;
}
// --------------------------------------------------------------------
export function makeOptionalLink( caption, url ) {
let link = <span dangerouslySetInnerHTML={{ __html: caption }} /> ;
if ( url )

Loading…
Cancel
Save