Added support for publications.

master
Pacman Ghost 5 years ago
parent 5272bf9934
commit 5719699379
  1. 39
      alembic/versions/6054f3f09626_added_the_publication_table.py
  2. 1
      asl_articles/__init__.py
  3. 11
      asl_articles/globvars.py
  4. 26
      asl_articles/models.py
  5. 108
      asl_articles/publications.py
  6. 109
      asl_articles/publishers.py
  7. 37
      asl_articles/search.py
  8. 15
      asl_articles/tests/fixtures/cascading-deletes.json
  9. 14
      asl_articles/tests/fixtures/publications.json
  10. 252
      asl_articles/tests/test_publications.py
  11. 79
      asl_articles/tests/test_publishers.py
  12. 24
      asl_articles/tests/utils.py
  13. 38
      asl_articles/utils.py
  14. 157
      web/package-lock.json
  15. 1
      web/package.json
  16. 1
      web/public/index.html
  17. 2
      web/src/App.css
  18. 45
      web/src/App.js
  19. 162
      web/src/PublicationSearchResult.js
  20. 157
      web/src/PublisherSearchResult.js
  21. 5
      web/src/SearchResults.css
  22. 163
      web/src/SearchResults.js
  23. 18
      web/src/utils.js

@ -0,0 +1,39 @@
"""Added the 'publication' table.
Revision ID: 6054f3f09626
Revises: 39196521adc5
Create Date: 2019-12-02 14:42:56.073111
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '6054f3f09626'
down_revision = '39196521adc5'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('publication',
sa.Column('pub_id', sa.Integer(), nullable=False),
sa.Column('pub_name', sa.String(length=100), nullable=False),
sa.Column('pub_edition', sa.String(length=100), nullable=True),
sa.Column('pub_description', sa.String(length=1000), nullable=True),
sa.Column('pub_url', sa.String(length=500), nullable=True),
sa.Column('publ_id', sa.Integer(), nullable=True),
sa.Column('time_created', sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column('time_updated', sa.TIMESTAMP(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['publ_id'], ['publisher.publ_id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('pub_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('publication')
# ### end Alembic commands ###

@ -74,6 +74,7 @@ import asl_articles.globvars #pylint: disable=cyclic-import
import asl_articles.main #pylint: disable=cyclic-import
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.utils #pylint: disable=cyclic-import
# initialize

@ -1,6 +1,8 @@
""" Global variables. """
from flask import make_response
from sqlalchemy.engine import Engine
from sqlalchemy import event
from asl_articles import app
from asl_articles.config.constants import APP_NAME, APP_VERSION
@ -37,3 +39,12 @@ def inject_template_params():
"APP_NAME": APP_NAME,
"APP_VERSION": APP_VERSION,
}
@event.listens_for( Engine, "connect" )
def on_db_connect( dbapi_connection, connection_record ): #pylint: disable=unused-argument
"""Database connection callback."""
if app.config[ "SQLALCHEMY_DATABASE_URI" ].startswith( "sqlite://" ):
# foreign keys must be enabled manually for SQLite :-/
curs = dbapi_connection.cursor()
curs.execute( "PRAGMA foreign_keys = ON" )
curs.close()

@ -11,12 +11,36 @@ class Publisher( db.Model ):
publ_id = db.Column( db.Integer, primary_key=True )
publ_name = db.Column( db.String(100), nullable=False )
publ_url = db.Column( db.String(500), nullable=True )
publ_description = db.Column( db.String(1000), nullable=True )
publ_url = db.Column( db.String(500), nullable=True )
# NOTE: time_created should be non-nullable, but getting this to work on SQlite and Postgres
# is more trouble than it's worth :-/
time_created = db.Column( db.TIMESTAMP(timezone=True), nullable=True )
time_updated = db.Column( db.TIMESTAMP(timezone=True), nullable=True )
#
children = db.relationship( "Publication", backref="parent", passive_deletes=True )
def __repr__( self ):
return "<Publisher:{}|{}>".format( self.publ_id, self.publ_name )
# ---------------------------------------------------------------------
class Publication( db.Model ):
"""Define the Publication model."""
pub_id = db.Column( db.Integer, primary_key=True )
pub_name = db.Column( db.String(100), nullable=False )
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 )
publ_id = db.Column( db.Integer,
db.ForeignKey( Publisher.__table__.c.publ_id, ondelete="CASCADE" ),
nullable=True
)
# NOTE: time_created should be non-nullable, but getting this to work on SQlite and Postgres
# is more trouble than it's worth :-/
time_created = db.Column( db.TIMESTAMP(timezone=True), nullable=True )
time_updated = db.Column( db.TIMESTAMP(timezone=True), nullable=True )
def __repr__( self ):
return "<Publication:{}|{}>".format( self.pub_id, self.pub_name )

@ -0,0 +1,108 @@
""" Handle publication requests. """
import datetime
import logging
from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Publication
from asl_articles.utils import get_request_args, clean_request_args, make_ok_response, apply_attrs
_logger = logging.getLogger( "db" )
_FIELD_NAMES = [ "pub_name", "pub_edition", "pub_description", "pub_url", "publ_id" ]
# ---------------------------------------------------------------------
@app.route( "/publications" )
def get_publications():
"""Get all publications."""
return jsonify( do_get_publications() )
def do_get_publications():
"""Get all publications."""
# NOTE: The front-end maintains a cache of the publications, so as a convenience,
# we return the current list as part of the response to a create/update/delete operation.
results = list( Publication.query )
return { r.pub_id: get_publication_vals(r) for r in results }
# ---------------------------------------------------------------------
@app.route( "/publication/<pub_id>" )
def get_publication( pub_id ):
"""Get a publication."""
_logger.debug( "Get publication: id=%s", pub_id )
pub = Publication.query.get( pub_id )
if not pub:
abort( 404 )
_logger.debug( "- %s", pub )
return jsonify( get_publication_vals( pub ) )
def get_publication_vals( pub ):
"""Extract public fields from a Publication record."""
return {
"pub_id": pub.pub_id,
"pub_name": pub.pub_name,
"pub_edition": pub.pub_edition,
"pub_description": pub.pub_description,
"pub_url": pub.pub_url,
"publ_id": pub.publ_id,
}
# ---------------------------------------------------------------------
@app.route( "/publication/create", methods=["POST"] )
def create_publication():
"""Create a publication."""
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Create publication:" )
)
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
vals[ "time_created" ] = datetime.datetime.now()
pub = Publication( **vals )
db.session.add( pub ) #pylint: disable=no-member
db.session.commit() #pylint: disable=no-member
_logger.debug( "- New ID: %d", pub.pub_id )
extras = { "pub_id": pub.pub_id }
if request.args.get( "list" ):
extras[ "publications" ] = do_get_publications()
return make_ok_response( cleaned=cleaned, extras=extras )
# ---------------------------------------------------------------------
@app.route( "/publication/update", methods=["POST"] )
def update_publication():
"""Update a publication."""
pub_id = request.json[ "pub_id" ]
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Update publication: id={}".format( pub_id ) )
)
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
vals[ "time_updated" ] = datetime.datetime.now()
pub = Publication.query.get( pub_id )
if not pub:
abort( 404 )
apply_attrs( pub, vals )
db.session.commit() #pylint: disable=no-member
extras = {}
if request.args.get( "list" ):
extras[ "publications" ] = do_get_publications()
return make_ok_response( cleaned=cleaned, extras=extras )
# ---------------------------------------------------------------------
@app.route( "/publication/delete/<pub_id>" )
def delete_publication( pub_id ):
"""Delete a publication."""
_logger.debug( "Delete publication: id=%s", pub_id )
pub = Publication.query.get( pub_id )
if not pub:
abort( 404 )
_logger.debug( "- %s", pub )
db.session.delete( pub ) #pylint: disable=no-member
db.session.commit() #pylint: disable=no-member
extras = {}
if request.args.get( "list" ):
extras[ "publications" ] = do_get_publications()
return make_ok_response( extras=extras )

@ -6,83 +6,118 @@ import logging
from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Publisher
from asl_articles.utils import get_request_args, apply_attrs, clean_html
from asl_articles.models import Publisher, Publication
from asl_articles.publications import do_get_publications
from asl_articles.utils import get_request_args, clean_request_args, make_ok_response, apply_attrs
_logger = logging.getLogger( "db" )
_FIELD_NAMES = [ "publ_name", "publ_url", "publ_description" ]
_FIELD_NAMES = [ "publ_name", "publ_description", "publ_url" ]
# ---------------------------------------------------------------------
@app.route( "/publishers/create", methods=["POST"] )
@app.route( "/publishers" )
def get_publishers():
"""Get all publishers."""
return jsonify( _do_get_publishers() )
def _do_get_publishers():
"""Get all publishers."""
# NOTE: The front-end maintains a cache of the publishers, so as a convenience,
# we return the current list as part of the response to a create/update/delete operation.
results = list( Publisher.query )
return { r.publ_id: get_publisher_vals(r) for r in results }
# ---------------------------------------------------------------------
@app.route( "/publisher/<publ_id>" )
def get_publisher( publ_id ):
"""Get a publisher."""
_logger.debug( "Get publisher: id=%s", publ_id )
# get the publisher
publ = Publisher.query.get( publ_id )
if not publ:
abort( 404 )
vals = get_publisher_vals( publ )
# include the number of associated publications
query = Publication.query.filter_by( publ_id = publ.publ_id )
vals[ "nPublications" ] = query.count()
_logger.debug( "- %s ; #publications=%d", publ, vals["nPublications"] )
return jsonify( vals )
def get_publisher_vals( publ ):
"""Extract public fields from a Publisher record."""
return {
"publ_id": publ.publ_id,
"publ_name": publ.publ_name,
"publ_description": publ.publ_description,
"publ_url": publ.publ_url,
}
# ---------------------------------------------------------------------
@app.route( "/publisher/create", methods=["POST"] )
def create_publisher():
"""Create a publisher."""
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Create publisher:" )
)
cleaned = _clean_vals( vals )
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
vals[ "time_created" ] = datetime.datetime.now()
publ = Publisher( **vals )
db.session.add( publ ) #pylint: disable=no-member
db.session.commit() #pylint: disable=no-member
_logger.debug( "- New ID: %d", publ.publ_id )
return _make_ok_response( cleaned, { "publ_id": publ.publ_id } )
extras = { "publ_id": publ.publ_id }
if request.args.get( "list" ):
extras[ "publishers" ] = _do_get_publishers()
return make_ok_response( cleaned=cleaned, extras=extras )
# ---------------------------------------------------------------------
@app.route( "/publishers/update", methods=["POST"] )
@app.route( "/publisher/update", methods=["POST"] )
def update_publisher():
"""Update a publisher."""
publ_id = request.json[ "publ_id" ]
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Update publisher: id={}".format( publ_id ) )
)
cleaned = _clean_vals( vals )
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
vals[ "time_updated" ] = datetime.datetime.now()
publ = Publisher.query.get( publ_id )
if not publ:
abort( 404 )
apply_attrs( publ, vals )
db.session.commit() #pylint: disable=no-member
return _make_ok_response( cleaned )
extras = {}
if request.args.get( "list" ):
extras[ "publishers" ] = _do_get_publishers()
return make_ok_response( cleaned=cleaned, extras=extras )
# ---------------------------------------------------------------------
@app.route( "/publishers/delete/<publ_id>" )
@app.route( "/publisher/delete/<publ_id>" )
def delete_publisher( publ_id ):
"""Delete a publisher."""
_logger.debug( "Delete publisher: %s", publ_id )
_logger.debug( "Delete publisher: id=%s", publ_id )
# get the publisher
publ = Publisher.query.get( publ_id )
if not publ:
abort( 404 )
_logger.debug( "- %s", publ )
# figure out which associated publications will be deleted
query = db.session.query( Publication.pub_id ).filter_by( publ_id = publ.publ_id ) #pylint: disable=no-member
deleted_pubs = [ r[0] for r in query ]
# delete the publisher
db.session.delete( publ ) #pylint: disable=no-member
db.session.commit() #pylint: disable=no-member
return _make_ok_response( None )
# ---------------------------------------------------------------------
def _make_ok_response( cleaned, extras=None ):
"""Generate a Flask 'success' response."""
# generate the basic response
resp = { "status": "OK" }
if extras:
resp.update( extras )
# check if any values were cleaned
if cleaned:
# yup - return the updated values to the caller
resp[ "warning" ] = "Some values had HTML removed."
resp[ "cleaned" ] = cleaned
return jsonify( resp )
def _clean_vals( vals ):
"""Clean incoming data."""
cleaned = {}
for f in _FIELD_NAMES:
val2 = clean_html( vals[f] )
if val2 != vals[f]:
_logger.debug( "Cleaned HTML: %s => %s", f, val2 )
vals[f] = val2
cleaned[f] = val2
return cleaned
extras = { "deletedPublications": deleted_pubs }
if request.args.get( "list" ):
extras[ "publishers" ] = _do_get_publishers()
extras[ "publications" ] = do_get_publications()
return make_ok_response( extras=extras )

@ -5,7 +5,9 @@ import logging
from flask import request, jsonify
from asl_articles import app
from asl_articles.models import Publisher
from asl_articles.models import Publisher, Publication
from asl_articles.publishers import get_publisher_vals
from asl_articles.publications import get_publication_vals
_logger = logging.getLogger( "search" )
@ -14,8 +16,13 @@ _logger = logging.getLogger( "search" )
@app.route( "/search", methods=["POST"] )
def search():
"""Run a search query."""
# initialize
query_string = request.json.get( "query" ).strip()
_logger.debug( "SEARCH: [%s]", query_string )
results = []
# return all publishers
query = Publisher.query
if query_string:
query = query.filter(
@ -24,11 +31,23 @@ def search():
query = query.order_by( Publisher.publ_name.asc() )
publishers = list( query )
_logger.debug( "- Found: %s", " ; ".join( str(p) for p in publishers ) )
publishers = [ {
"type": "publ",
"publ_id": p.publ_id,
"publ_name": p.publ_name,
"publ_description": p.publ_description,
"publ_url": p.publ_url,
} for p in publishers ]
return jsonify( publishers )
for publ in publishers:
publ = get_publisher_vals( publ )
publ["type"] = "publisher"
results.append( publ )
# return all publications
query = Publication.query
if query_string:
query = query.filter(
Publication.pub_name.ilike( "%{}%".format( query_string ) )
)
query = query.order_by( Publication.pub_name.asc() )
publications = list( query )
_logger.debug( "- Found: %s", " ; ".join( str(p) for p in publications ) )
for pub in publications:
pub = get_publication_vals( pub )
pub[ "type" ] = "publication"
results.append( pub )
return jsonify( results )

@ -0,0 +1,15 @@
{
"publisher": [
{ "publ_id": 1, "publ_name": "Cascading Deletes 0" },
{ "publ_id": 2, "publ_name": "Cascading Deletes 1" },
{ "publ_id": 3, "publ_name": "Cascading Deletes 2" }
],
"publication": [
{ "pub_name": "publication 1", "publ_id": "2" },
{ "pub_name": "publication 2a", "publ_id": "3" },
{ "pub_name": "publication 2b", "publ_id": "3" }
]
}

@ -0,0 +1,14 @@
{
"publisher": [
{ "publ_id": 1, "publ_name": "Avalon Hill", "publ_description": "AH description", "publ_url": "http://ah.com" },
{ "publ_id": 2, "publ_name": "Multiman Publishing", "publ_url": "http://mmp.com" }
],
"publication": [
{ "pub_name": "ASL Journal", "pub_edition": "1", "pub_description": "ASL Journal #1", "pub_url": "http://aslj.com/1" },
{ "pub_name": "ASL Journal", "pub_edition": "2", "pub_description": "ASL Journal #2", "pub_url": "http://aslj.com/2" },
{ "pub_name": "MMP News" }
]
}

@ -0,0 +1,252 @@
""" Test publication operations. """
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
# ---------------------------------------------------------------------
def test_edit_publication( webdriver, flask_app, dbconn ):
"""Test editing publications."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "publications.json" )
# edit "ASL Journal #2"
results = do_search( "asl journal" )
assert len(results) == 2
result = results[1]
_edit_publication( result, {
"name": " ASL Journal (updated) ",
"edition": " 2a ",
"description": " Updated ASLJ description. ",
"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/" ] )
# try to remove all fields from "ASL Journal #2" (should fail)
_edit_publication( result,
{ "name": "", "edition": "", "description": "", "url": "" },
expected_error = "Please specify the publication's name."
)
# enter something for the name
dlg = find_child( "#modal-form" )
set_elem_text( find_child( ".name input", dlg ), "Updated ASL Journal" )
find_child( "button.ok", dlg ).click()
# check that the search result was updated in the UI
results = find_children( "#search-results .search-result" )
result = results[1]
assert find_child( ".name a", result ) is None
assert find_child( ".name", result ).text == "Updated ASL Journal"
assert find_child( ".description", result ).text == ""
# check that the search result was updated in the database
results = do_search( "ASL Journal" )
assert get_result_names( results ) == [ "ASL Journal (1)", "Updated ASL Journal" ]
# ---------------------------------------------------------------------
def test_create_publication( webdriver, flask_app, dbconn ):
"""Test creating new publications."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "basic.json" )
# try creating a publication with no name (should fail)
_create_publication( {}, toast_type=None )
check_error_msg( "Please specify the publication's name." )
# enter a name and other details
dlg = find_child( "#modal-form" ) # nb: the form is still on-screen
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." )
set_elem_text( find_child( ".url input", dlg ), "http://new-publication.com" )
set_toast_marker( "info" )
find_child( "button.ok", dlg ).click()
wait_for( 2,
lambda: check_toast( "info", "created OK", contains=True )
)
# 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/"
] )
results = find_children( "#search-results .search-result" )
check_new_publication( results[0] )
# check that the new publication has been saved in the database
results = do_search( "new" )
assert len( results ) == 1
check_new_publication( results[0] )
# ---------------------------------------------------------------------
def test_delete_publication( webdriver, flask_app, dbconn ):
"""Test deleting publications."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "publications.json" )
# start to delete publication "ASL Journal #1", but cancel the operation
results = do_search( "ASL Journal" )
assert len(results) == 2
result = results[1]
assert find_child( ".name", result ).text == "ASL Journal (2)"
find_child( ".delete", result ).click()
check_ask_dialog( ( "Do you want to delete", "ASL Journal (2)" ), "cancel" )
# check that search results are unchanged on-screen
results2 = find_children( "#search-results .search-result" )
assert results2 == results
# check that the search results are unchanged in the database
results3 = do_search( "ASL Journal" )
assert results3 == results
# delete the publication "ASL Journal 2"
result = results3[1]
assert find_child( ".name", result ).text == "ASL Journal (2)"
find_child( ".delete", result ).click()
set_toast_marker( "info" )
check_ask_dialog( ( "Do you want to delete", "ASL Journal (2)" ), "ok" )
wait_for( 2,
lambda: check_toast( "info", "The publication was deleted." )
)
# check that search result was removed on-screen
results = find_children( "#search-results .search-result" )
assert get_result_names( results ) == [ "ASL Journal (1)" ]
# check that the search result was deleted from the database
results = do_search( "ASL Journal" )
assert get_result_names( results ) == [ "ASL Journal (1)" ]
# ---------------------------------------------------------------------
def test_unicode( webdriver, flask_app, dbconn ):
"""Test Unicode content."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "publications.json" )
# create a publication with Unicode content
_create_publication( {
"name": "japan = \u65e5\u672c",
"edition": "\u263a",
"url": "http://\ud55c\uad6d.com",
"description": "greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1"
} )
# check that the new publication is showing the Unicode content correctly
results = do_search( "japan" )
assert len( results ) == 1
_check_result( results[0], [
"japan = \u65e5\u672c", "\u263a",
"greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1",
"http://xn--3e0b707e.com/"
] )
# ---------------------------------------------------------------------
def test_clean_html( webdriver, flask_app, dbconn ):
"""Test cleaning HTML content."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "publications.json" )
# create a publication with HTML content
_create_publication( {
"name": "name: <span style='boo!'> <b>bold</b> <xxx>xxx</xxx> <i>italic</i>",
"edition": "<i>2</i>",
"description": "bad stuff here: <script>HCF</script>"
}, toast_type="warning" )
# check that the HTML was cleaned
results = wait_for( 2,
lambda: find_children( "#search-results .search-result" )
)
assert len( results ) == 1
result = results[0]
_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 )
# update the publication with new HTML content
_edit_publication( result, {
"name": "<div style='...'>updated</div>"
}, toast_type="warning" )
def check_result():
results = find_children( "#search-results .search-result" )
assert len( results ) == 1
result = results[0]
return find_child( ".name", result ).text == "updated (2)"
wait_for( 2, check_result )
assert check_toast( "warning", "Some values had HTML removed.", contains=True )
# ---------------------------------------------------------------------
def _create_publication( vals, toast_type="info" ):
"""Create a new publication."""
# initialize
if toast_type:
set_toast_marker( toast_type )
# create the new publication
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 )
find_child( "button.ok", dlg ).click()
if toast_type:
# check that the new publication was created successfully
wait_for( 2,
lambda: check_toast( toast_type, "created OK", contains=True )
)
def _edit_publication( result, vals, toast_type="info", expected_error=None ):
"""Edit a publication's details."""
# update the specified publication's details
find_child( ".edit", result ).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 )
set_toast_marker( toast_type )
find_child( "button.ok", dlg ).click()
if expected_error:
# we were expecting an error, confirm the error message
check_error_msg( expected_error )
else:
# we were expecting the update to work, confirm this
wait_for( 2,
lambda: check_toast( toast_type, "updated OK", contains=True )
)
# ---------------------------------------------------------------------
def _check_result( result, expected ):
"""Check a result."""
expected_name = expected[0]
if expected[1]:
expected_name += " ({})".format( expected[1] )
assert find_child( ".name", result ).text == expected_name
assert find_child( ".description", result ).text == expected[2]
elem = find_child( ".name a", result )
if elem:
assert elem.get_attribute( "href" ) == expected[3]
else:
assert expected[3] is None

@ -1,6 +1,6 @@
""" Test publisher operations. """
from asl_articles.tests.utils import init_tests, init_db, do_search, \
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
@ -19,18 +19,18 @@ def test_edit_publisher( webdriver, flask_app, dbconn ):
assert find_child( ".name", result ).text == "Avalon Hill"
_edit_publisher( result, {
"name": " Avalon Hill (updated) ",
"url": " http://ah-updated.com ",
"description": " Updated AH description. "
"description": " Updated AH description. ",
"url": " http://ah-updated.com "
} )
# check that the search result was updated in the UI
results = find_children( "#search-results .search-result" )
result = results[0]
_check_result( result, [ "Avalon Hill (updated)", "http://ah-updated.com/", "Updated AH description." ] )
_check_result( result, [ "Avalon Hill (updated)", "Updated AH description.", "http://ah-updated.com/" ] )
# try to remove all fields from "Avalon Hill" (should fail)
_edit_publisher( result,
{ "name": "", "url": "", "description": "" },
{ "name": "", "description": "", "url": "" },
expected_error = "Please specify the publisher's name."
)
@ -48,7 +48,7 @@ def test_edit_publisher( webdriver, flask_app, dbconn ):
# check that the search result was updated in the database
results = do_search( "" )
assert _get_result_names( results ) == [ "Le Franc Tireur", "Multiman Publishing", "Updated Avalon Hill" ]
assert get_result_names( results ) == [ "Le Franc Tireur", "Multiman Publishing", "Updated Avalon Hill" ]
# ---------------------------------------------------------------------
@ -79,7 +79,7 @@ def test_create_publisher( webdriver, flask_app, dbconn ):
# check that the new publisher appears in the UI
def check_new_publisher( result ):
_check_result( result, [ "New publisher", "http://new-publisher.com/", "New publisher description." ] )
_check_result( result, [ "New publisher", "New publisher description.", "http://new-publisher.com/" ] )
results = find_children( "#search-results .search-result" )
check_new_publisher( results[0] )
@ -124,11 +124,53 @@ def test_delete_publisher( webdriver, flask_app, dbconn ):
# check that search result was removed on-screen
results = find_children( "#search-results .search-result" )
assert _get_result_names( results ) == [ "Avalon Hill", "Multiman Publishing" ]
assert get_result_names( results ) == [ "Avalon Hill", "Multiman Publishing" ]
# check that the search result was deleted from the database
results = do_search( "" )
assert _get_result_names( results ) == [ "Avalon Hill", "Multiman Publishing" ]
assert get_result_names( results ) == [ "Avalon Hill", "Multiman Publishing" ]
# ---------------------------------------------------------------------
def test_cascading_deletes( webdriver, flask_app, dbconn ):
"""Test cascading deletes."""
# initialize
init_tests( webdriver, flask_app )
def do_test( publ_name, expected_warning, expected_pubs ):
# initialize
init_db( dbconn, "cascading-deletes.json" )
results = do_search( "" )
# delete the specified publisher
results = [ r for r in results if find_child(".name",r).text == publ_name ]
assert len( results ) == 1
find_child( ".delete", results[0] ).click()
check_ask_dialog( ( "Do you want to delete", publ_name, expected_warning ), "ok" )
# check that deleted associated publications were removed from the UI
def check_publications( results ):
results = [ find_child(".name",r).text for r in results ]
pubs = [ r for r in results if r.startswith( "publication" ) ]
assert pubs == expected_pubs
check_publications( find_children( "#search-results .search-result" ) )
# check that associated publications were removed from the database
results = do_search( "publication" )
check_publications( results )
# do the tests
do_test( "Cascading Deletes 0",
"No publications will be deleted", ["publication 1","publication 2a","publication 2b"]
)
do_test( "Cascading Deletes 1",
"1 associated publication will also be deleted", ["publication 2a","publication 2b"]
)
do_test( "Cascading Deletes 2",
"2 associated publications will also be deleted", ["publication 1"]
)
# ---------------------------------------------------------------------
@ -151,8 +193,8 @@ def test_unicode( webdriver, flask_app, dbconn ):
assert len( results ) == 1
_check_result( results[0], [
"japan = \u65e5\u672c",
"http://xn--3e0b707e.com/",
"greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1"
"greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1",
"http://xn--3e0b707e.com/"
] )
# ---------------------------------------------------------------------
@ -176,7 +218,7 @@ def test_clean_html( webdriver, flask_app, dbconn ):
)
assert len( results ) == 1
result = results[0]
_check_result( result, [ "name: bold xxx italic", None, "bad stuff here:" ] )
_check_result( result, [ "name: bold xxx italic", "bad stuff here:", None ] )
assert find_child( ".name span" ).get_attribute( "innerHTML" ) \
== "name: <span> <b>bold</b> xxx <i>italic</i></span>"
assert check_toast( "warning", "Some values had HTML removed.", contains=True )
@ -234,19 +276,12 @@ def _edit_publisher( result, vals, toast_type="info", expected_error=None ):
# ---------------------------------------------------------------------
def _get_result_names( results ):
"""Get the names from a list of search results."""
return [
find_child( ".name", r ).text
for r in results
]
def _check_result( result, expected ):
"""Check a result."""
assert find_child( ".name", result ).text == expected[0]
assert find_child( ".description", result ).text == expected[1]
elem = find_child( ".name a", result )
if elem:
assert elem.get_attribute( "href" ) == expected[1]
assert elem.get_attribute( "href" ) == expected[2]
else:
assert expected[1] is None
assert find_child( ".description", result ).text == expected[2]
assert expected[2] is None

@ -9,6 +9,8 @@ import sqlalchemy.orm
import sqlalchemy.sql.expression
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By
from selenium.common.exceptions import NoSuchElementException
@ -48,14 +50,27 @@ def do_search( query ):
form = find_child( "#search-form" )
assert form
elem = find_child( ".query", form )
elem.clear()
elem.send_keys( query )
# FUDGE! Calling elem.clear() then send_keys(query) has a weird effect in Chromium if the query
# is empty. The previous query gets repeated instead - is the browser auto-filling the field?
actions = ActionChains( _webdriver ).move_to_element( elem ).click() \
.key_down( Keys.CONTROL ).send_keys( "a" ).key_up( Keys.CONTROL ) \
.send_keys( Keys.DELETE )
if query:
actions = actions.send_keys( query )
actions.perform()
find_child( "button[type='submit']", form ).click()
# return the results
wait_for( 2, lambda: get_seqno() != curr_seqno )
return find_children( "#search-results .search-result" )
def get_result_names( results ):
"""Get the names from a list of search results."""
return [
find_child( ".name", r ).text
for r in results
]
# ---------------------------------------------------------------------
def init_db( engine, fname ):
@ -71,10 +86,11 @@ def init_db( engine, fname ):
data = json.load( open( fname, "r" ) )
# load the test data into the database
for table_name,rows in data.items():
for table_name in ["publisher","publication"]:
model = getattr( asl_articles.models, table_name.capitalize() )
session.query( model ).delete()
session.bulk_insert_mappings( model, rows )
if table_name in data:
session.bulk_insert_mappings( model, data[table_name] )
session.commit()
return session

@ -3,6 +3,7 @@
import re
import logging
from flask import jsonify
import lxml.html.clean
_html_whitelists = None
@ -23,20 +24,42 @@ def get_request_args( vals, keys, log=None ):
log[0].debug( "- %s = %s", k, str(vals[k]) )
return vals
def apply_attrs( obj, vals ):
"""Update an object's attributes."""
for k,v in vals.items():
setattr( obj, k, v )
def clean_request_args( vals, fields, logger ):
"""Clean incoming data."""
cleaned = {}
for f in fields:
if isinstance( vals[f], str ):
val2 = clean_html( vals[f] )
if val2 != vals[f]:
logger.debug( "Cleaned HTML: %s => %s", f, val2 )
vals[f] = val2
cleaned[f] = val2
return cleaned
def make_ok_response( extras=None, cleaned=None ):
"""Generate a Flask 'success' response."""
# generate the basic response
resp = { "status": "OK" }
if extras:
resp.update( extras )
# check if any values were cleaned
if cleaned:
# yup - return the updated values to the caller
resp[ "warning" ] = "Some values had HTML removed."
resp[ "cleaned" ] = cleaned
return jsonify( resp )
# ---------------------------------------------------------------------
def clean_html( val ):
"""Sanitize HTML using a whitelist."""
# strip the HTML
# check if we need to do anything
val = val.strip()
if not val:
return val
# strip the HTML
args = {}
if _html_whitelists["tags"]:
args[ "allow_tags" ] = _html_whitelists["tags"]
@ -76,6 +99,11 @@ def load_html_whitelists( app ):
# ---------------------------------------------------------------------
def apply_attrs( obj, vals ):
"""Update an object's attributes."""
for k,v in vals.items():
setattr( obj, k, v )
def to_bool( val ):
"""Interpret a value as a boolean."""
if val is None:

157
web/package-lock.json generated

@ -940,11 +940,87 @@
"resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-9.0.1.tgz",
"integrity": "sha512-6It2EVfGskxZCQhuykrfnALg7oVeiI6KclWSmGDqB0AiInVrTGB9Jp9i4/Ad21u9Jde/voVQz6eFX/eSg/UsPA=="
},
"@emotion/cache": {
"version": "10.0.19",
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.19.tgz",
"integrity": "sha512-BoiLlk4vEsGBg2dAqGSJu0vJl/PgVtCYLBFJaEO8RmQzPugXewQCXZJNXTDFaRlfCs0W+quesayav4fvaif5WQ==",
"requires": {
"@emotion/sheet": "0.9.3",
"@emotion/stylis": "0.8.4",
"@emotion/utils": "0.11.2",
"@emotion/weak-memoize": "0.2.4"
}
},
"@emotion/core": {
"version": "10.0.22",
"resolved": "https://registry.npmjs.org/@emotion/core/-/core-10.0.22.tgz",
"integrity": "sha512-7eoP6KQVUyOjAkE6y4fdlxbZRA4ILs7dqkkm6oZUJmihtHv0UBq98VgPirq9T8F9K2gKu0J/au/TpKryKMinaA==",
"requires": {
"@babel/runtime": "^7.5.5",
"@emotion/cache": "^10.0.17",
"@emotion/css": "^10.0.22",
"@emotion/serialize": "^0.11.12",
"@emotion/sheet": "0.9.3",
"@emotion/utils": "0.11.2"
}
},
"@emotion/css": {
"version": "10.0.22",
"resolved": "https://registry.npmjs.org/@emotion/css/-/css-10.0.22.tgz",
"integrity": "sha512-8phfa5mC/OadBTmGpMpwykIVH0gFCbUoO684LUkyixPq4F1Wwri7fK5Xlm8lURNBrd2TuvTbPUGxFsGxF9UacA==",
"requires": {
"@emotion/serialize": "^0.11.12",
"@emotion/utils": "0.11.2",
"babel-plugin-emotion": "^10.0.22"
}
},
"@emotion/hash": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.7.3.tgz",
"integrity": "sha512-14ZVlsB9akwvydAdaEnVnvqu6J2P6ySv39hYyl/aoB6w/V+bXX0tay8cF6paqbgZsN2n5Xh15uF4pE+GvE+itw=="
},
"@emotion/memoize": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.3.tgz",
"integrity": "sha512-2Md9mH6mvo+ygq1trTeVp2uzAKwE2P7In0cRpD/M9Q70aH8L+rxMLbb3JCN2JoSWsV2O+DdFjfbbXoMoLBczow=="
},
"@emotion/serialize": {
"version": "0.11.14",
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.14.tgz",
"integrity": "sha512-6hTsySIuQTbDbv00AnUO6O6Xafdwo5GswRlMZ5hHqiFx+4pZ7uGWXUQFW46Kc2taGhP89uXMXn/lWQkdyTosPA==",
"requires": {
"@emotion/hash": "0.7.3",
"@emotion/memoize": "0.7.3",
"@emotion/unitless": "0.7.4",
"@emotion/utils": "0.11.2",
"csstype": "^2.5.7"
}
},
"@emotion/sheet": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.3.tgz",
"integrity": "sha512-c3Q6V7Df7jfwSq5AzQWbXHa5soeE4F5cbqi40xn0CzXxWW9/6Mxq48WJEtqfWzbZtW9odZdnRAkwCQwN12ob4A=="
},
"@emotion/stylis": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.4.tgz",
"integrity": "sha512-TLmkCVm8f8gH0oLv+HWKiu7e8xmBIaokhxcEKPh1m8pXiV/akCiq50FvYgOwY42rjejck8nsdQxZlXZ7pmyBUQ=="
},
"@emotion/unitless": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.4.tgz",
"integrity": "sha512-kBa+cDHOR9jpRJ+kcGMsysrls0leukrm68DmFQoMIWQcXdr2cZvyvypWuGYT7U+9kAExUE7+T7r6G3C3A6L8MQ=="
},
"@emotion/utils": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.2.tgz",
"integrity": "sha512-UHX2XklLl3sIaP6oiMmlVzT0J+2ATTVpf0dHQVyPJHTkOITvXfaSqnRk6mdDhV9pR8T/tHc3cex78IKXssmzrA=="
},
"@emotion/weak-memoize": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.4.tgz",
"integrity": "sha512-6PYY5DVdAY1ifaQW6XYTnOMihmBVT27elqSjEoodchsGjzYlEsTQMcEhSud99kVawatyTZRTiVkJ/c6lwbQ7nA=="
},
"@hapi/address": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.1.2.tgz",
@ -2179,6 +2255,23 @@
"object.assign": "^4.1.0"
}
},
"babel-plugin-emotion": {
"version": "10.0.23",
"resolved": "https://registry.npmjs.org/babel-plugin-emotion/-/babel-plugin-emotion-10.0.23.tgz",
"integrity": "sha512-1JiCyXU0t5S2xCbItejCduLGGcKmF3POT0Ujbexog2MI4IlRcIn/kWjkYwCUZlxpON0O5FC635yPl/3slr7cKQ==",
"requires": {
"@babel/helper-module-imports": "^7.0.0",
"@emotion/hash": "0.7.3",
"@emotion/memoize": "0.7.3",
"@emotion/serialize": "^0.11.14",
"babel-plugin-macros": "^2.0.0",
"babel-plugin-syntax-jsx": "^6.18.0",
"convert-source-map": "^1.5.0",
"escape-string-regexp": "^1.0.5",
"find-root": "^1.1.0",
"source-map": "^0.5.7"
}
},
"babel-plugin-istanbul": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-5.2.0.tgz",
@ -2213,6 +2306,11 @@
"resolved": "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.4.tgz",
"integrity": "sha512-S6d+tEzc5Af1tKIMbsf2QirCcPdQ+mKUCY2H1nJj1DyA1ShwpsoxEOAwbWsG5gcXNV/olpvQd9vrUWRx4bnhpw=="
},
"babel-plugin-syntax-jsx": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
"integrity": "sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY="
},
"babel-plugin-syntax-object-rest-spread": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz",
@ -5657,6 +5755,11 @@
"pkg-dir": "^3.0.0"
}
},
"find-root": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="
},
"find-up": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
@ -8421,6 +8524,11 @@
"p-is-promise": "^2.0.0"
}
},
"memoize-one": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz",
"integrity": "sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA=="
},
"memory-fs": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
@ -10790,11 +10898,24 @@
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.3.tgz",
"integrity": "sha512-bOUvMWFQVk5oz8Ded9Xb7WVdEi3QGLC8tH7HmYP0Fdp4Bn3qw0tRFmr5TW6mvahzvmrK4a6bqWGfCevBflP+Xw=="
},
"react-input-autosize": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-2.2.2.tgz",
"integrity": "sha512-jQJgYCA3S0j+cuOwzuCd1OjmBmnZLdqQdiLKRYrsMMzbjUrVDS5RvJUDwJqA7sKuksDuzFtm6hZGKFu7Mjk5aw==",
"requires": {
"prop-types": "^15.5.8"
}
},
"react-is": {
"version": "16.11.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz",
"integrity": "sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw=="
},
"react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"react-scripts": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-3.2.0.tgz",
@ -10856,6 +10977,42 @@
"workbox-webpack-plugin": "4.3.1"
}
},
"react-select": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-3.0.8.tgz",
"integrity": "sha512-v9LpOhckLlRmXN5A6/mGGEft4FMrfaBFTGAnuPHcUgVId7Je42kTq9y0Z+Ye5z8/j0XDT3zUqza8gaRaI1PZIg==",
"requires": {
"@babel/runtime": "^7.4.4",
"@emotion/cache": "^10.0.9",
"@emotion/core": "^10.0.9",
"@emotion/css": "^10.0.9",
"memoize-one": "^5.0.0",
"prop-types": "^15.6.0",
"react-input-autosize": "^2.2.2",
"react-transition-group": "^2.2.1"
},
"dependencies": {
"dom-helpers": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
"integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
"requires": {
"@babel/runtime": "^7.1.2"
}
},
"react-transition-group": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
"integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
"requires": {
"dom-helpers": "^3.4.0",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2",
"react-lifecycles-compat": "^3.0.4"
}
}
}
},
"react-toastify": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-5.4.1.tgz",

@ -11,6 +11,7 @@
"react-dom": "^16.11.0",
"react-draggable": "^4.1.0",
"react-scripts": "3.2.0",
"react-select": "^3.0.8",
"react-toastify": "^5.4.1"
},
"scripts": {

@ -4,7 +4,6 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" />
<link rel="manifest" href="/manifest.json" />
</head>

@ -6,7 +6,7 @@
.Toastify__toast--warn { background: #f0c010 ; }
.Toastify__toast--error { background: #e04060 ; }
#menu { margin-bottom: 5px ; padding: 2px 5px ; background: #b0e0f0 ; border: 1px dotted #80d0f0 ; font-size: 75% ; }
#menu { margin-bottom: 5px ; padding: 2px 5px ; background: #ddd ; border: 1px dotted #80d0f0 ; font-size: 75% ; }
#menu a { text-decoration: none ; color: #000 ; }
.monospace { margin-top: 0.5em ; font-family: monospace ; font-style: italic ; font-size: 80% ; }

@ -3,7 +3,9 @@ import ReactDOMServer from "react-dom/server" ;
import { ToastContainer, toast } from "react-toastify" ;
import "react-toastify/dist/ReactToastify.min.css" ;
import SearchForm from "./SearchForm" ;
import { SearchResults, SearchResult } from "./SearchResults" ;
import { SearchResults } from "./SearchResults" ;
import { PublisherSearchResult } from "./PublisherSearchResult" ;
import { PublicationSearchResult } from "./PublicationSearchResult" ;
import ModalForm from "./ModalForm";
import AskDialog from "./AskDialog" ;
import "./App.css" ;
@ -15,7 +17,6 @@ const queryString = require( "query-string" ) ;
export default class App extends React.Component
{
constructor( props ) {
// figure out the base URL of the Flask backend server
// NOTE: We allow the caller to do this since the test suite will usually spin up
@ -26,6 +27,7 @@ export default class App extends React.Component
if ( ! flaskBaseUrl )
flaskBaseUrl = process.env.REACT_APP_FLASK_URL ;
// initialize the App
super( props ) ;
this.state = {
searchResults: [],
@ -42,8 +44,11 @@ export default class App extends React.Component
return ( <div>
<div id="menu">
[<a href="/" className="new-publisher"
onClick={ (e) => { e.preventDefault() ; SearchResult.onNewPublisher( this._onNewPublisher.bind(this) ) ; } }
onClick={ (e) => { e.preventDefault() ; PublisherSearchResult.onNewPublisher( this._onNewPublisher.bind(this) ) ; } }
>New publisher</a>]
[<a href="/" className="new-publication"
onClick={ (e) => { e.preventDefault() ; PublicationSearchResult.onNewPublication( this._onNewPublication.bind(this) ) ; } }
>New publication</a>]
</div>
<SearchForm onSearch={this.onSearch.bind(this)} />
<SearchResults seqNo={this.state.searchSeqNo} searchResults={this.state.searchResults} />
@ -62,6 +67,29 @@ export default class App extends React.Component
</div> ) ;
}
componentDidMount() {
// initialize the caches
// NOTE: We maintain caches of the publishers and publications, so that we can quickly populate droplists.
// The backend server returns updated lists after any operation that could change them (create/update/delete),
// which is simpler and less error-prone than trying to manually keep our caches in sync. It's less efficient,
// but it won't happen too often, there won't be too many entries, and the database server is local.
this.caches = {} ;
axios.get( this.state.flaskBaseUrl + "/publishers" )
.then( resp => {
this.caches.publishers = resp.data ;
} )
.catch( err => {
this.showErrorToast( <div> Couldn't load the publishers: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
axios.get( this.state.flaskBaseUrl + "/publications" )
.then( resp => {
this.caches.publications = resp.data ;
} )
.catch( err => {
this.showErrorToast( <div> Couldn't load the publications: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
}
onSearch( query ) {
// run the search
axios.post( this.state.flaskBaseUrl + "/search", {
@ -76,11 +104,14 @@ export default class App extends React.Component
} ) ;
}
_onNewPublisher( publ_id, vals ) {
// add the new publisher to the start of the search results
// NOTE: This isn't really the right thing to do, since the new publisher might not actually be
_onNewPublisher( publ_id, vals ) { this._addNewSearchResult( vals, "publisher", "publ_id", publ_id ) ; }
_onNewPublication( pub_id, vals ) { this._addNewSearchResult( vals, "publication", "pub_id", pub_id ) ; }
_addNewSearchResult( vals, srType, idName, idVal ) {
// add the new search result to the start of the search results
// NOTE: This isn't really the right thing to do, since the new object might not actually be
// a result for the current search, but it's nice to give the user some visual feedback.
vals.publ_id = publ_id ;
vals.type = srType ;
vals[ idName ] = idVal ;
let newSearchResults = [ vals ] ;
newSearchResults.push( ...this.state.searchResults ) ;
this.setState( { searchResults: newSearchResults } ) ;

@ -0,0 +1,162 @@
import React from "react" ;
import ReactDOMServer from "react-dom/server" ;
import Select from "react-select" ;
import { gAppRef } from "./index.js" ;
import { makeOptionalLink } from "./utils.js" ;
const axios = require( "axios" ) ;
// --------------------------------------------------------------------
export class PublicationSearchResult extends React.Component
{
render() {
return ( <div className="search-result publication">
<div className="name"> { makeOptionalLink( this._makeDisplayName(), this.props.data.pub_url ) }
<img src="/images/edit.png" className="edit" onClick={this.onEditPublication.bind(this)} alt="Edit this publication." />
<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}} />
</div> ) ;
}
static onNewPublication( notify ) {
PublicationSearchResult._doEditPublication( {}, (newVals,refs) => {
axios.post( gAppRef.state.flaskBaseUrl + "/publication/create?list=1", newVals )
.then( resp => {
// update the cached publications
gAppRef.caches.publications = resp.data.publications ;
// unload any cleaned values
for ( let r in refs ) {
if ( resp.data.cleaned && resp.data.cleaned[r] )
newVals[ r ] = resp.data.cleaned[ r ] ;
}
// update the UI with the new details
notify( resp.data.pub_id, newVals ) ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The new publication was created OK. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The new publication was created OK. </div> ) ;
gAppRef.closeModalForm() ;
} )
.catch( err => {
gAppRef.showErrorMsg( <div> Couldn't create the publication: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
} ) ;
}
onEditPublication() {
PublicationSearchResult._doEditPublication( this.props.data, (newVals,refs) => {
// send the updated details to the server
newVals.pub_id = this.props.data.pub_id ;
axios.post( gAppRef.state.flaskBaseUrl + "/publication/update?list=1", newVals )
.then( resp => {
// update the cached publications
gAppRef.caches.publications = resp.data.publications ;
// 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] ;
this.forceUpdate() ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The publication was updated OK. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The publication was updated OK. </div> ) ;
gAppRef.closeModalForm() ;
} )
.catch( err => {
gAppRef.showErrorMsg( <div> Couldn't update the publication: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
} );
}
static _doEditPublication( vals, notify ) {
let refs = {} ;
let publishers = [ { value: null, label: <i>(none)</i> } ] ;
let currPubl = 0 ;
for ( let p of Object.entries(gAppRef.caches.publishers) ) {
publishers.push( {
value: p[1].publ_id,
label: <span dangerouslySetInnerHTML={{__html: p[1].publ_name}} />
} ) ;
if ( p[1].publ_id === vals.publ_id )
currPubl = publishers.length - 1 ;
}
publishers.sort( (lhs,rhs) => {
return ReactDOMServer.renderToStaticMarkup( lhs.label ).localeCompare( ReactDOMServer.renderToStaticMarkup( rhs.label ) ) ;
} ) ;
const content = <div>
<div className="row name"> <label> Name: </label>
<input type="text" defaultValue={vals.pub_name} ref={(r) => refs.pub_name=r} />
</div>
<div className="row edition"> <label> Edition: </label>
<input type="text" defaultValue={vals.pub_edition} ref={(r) => refs.pub_edition=r} />
</div>
<div className="row publisher"> <label> Publisher: </label>
<Select options={publishers} isSearchable={true}
defaultValue = { publishers[ currPubl ] }
ref = { (r) => refs.publ_id=r }
/>
</div>
<div className="row description"> <label> Description: </label>
<textarea defaultValue={vals.pub_description} ref={(r) => refs.pub_description=r} />
</div>
<div className="row url"> <label> Web: </label>
<input type="text" defaultValue={vals.pub_url} ref={(r) => refs.pub_url=r} />
</div>
</div> ;
const buttons = {
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() ;
if ( newVals.pub_name === "" ) {
gAppRef.showErrorMsg( <div> Please specify the publication's name. </div>) ;
return ;
}
// notify the caller about the new details
notify( newVals, refs ) ;
},
Cancel: () => { gAppRef.closeModalForm() ; },
} ;
const isNew = Object.keys( vals ).length === 0 ;
gAppRef.showModalForm( isNew?"New publication":"Edit publication", content, buttons ) ;
}
onDeletePublication() {
// confirm the operation
const content = ( <div>
Do you want to delete this publication?
<div style={{margin:"0.5em 0 0 2em",fontStyle:"italic"}} dangerouslySetInnerHTML = {{ __html: this._makeDisplayName() }} />
</div> ) ;
gAppRef.ask( content, {
"OK": () => {
// delete the publication on the server
axios.get( gAppRef.state.flaskBaseUrl + "/publication/delete/" + this.props.data.pub_id + "?list=1" )
.then( resp => {
// update the cached publications
gAppRef.caches.publications = resp.data.publications ;
// update the UI
this.props.onDelete( "pub_id", this.props.data.pub_id ) ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The publication was deleted. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The publication was deleted. </div> ) ;
} )
.catch( err => {
gAppRef.showErrorToast( <div> Couldn't delete the publication: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
},
"Cancel": null,
} ) ;
}
_makeDisplayName() {
if ( this.props.data.pub_edition )
return this.props.data.pub_name + " (" + this.props.data.pub_edition + ")" ;
else
return this.props.data.pub_name ;
}
}

@ -0,0 +1,157 @@
import React from "react" ;
import { gAppRef } from "./index.js" ;
import { makeOptionalLink, pluralString } from "./utils.js" ;
const axios = require( "axios" ) ;
// --------------------------------------------------------------------
export class PublisherSearchResult extends React.Component
{
render() {
return ( <div className="search-result publisher">
<div className="name"> { makeOptionalLink( this.props.data.publ_name, this.props.data.publ_url ) }
<img src="/images/edit.png" className="edit" onClick={this.onEditPublisher.bind(this)} alt="Edit this publisher." />
<img src="/images/delete.png" className="delete" onClick={this.onDeletePublisher.bind(this)} alt="Delete this publisher." />
</div>
<div className="description" dangerouslySetInnerHTML={{__html: this.props.data.publ_description}} />
</div> ) ;
}
static onNewPublisher( notify ) {
PublisherSearchResult._doEditPublisher( {}, (newVals,refs) => {
axios.post( gAppRef.state.flaskBaseUrl + "/publisher/create?list=1", newVals )
.then( resp => {
// update the cached publishers
gAppRef.caches.publishers = resp.data.publishers ;
// unload any cleaned values
for ( let r in refs ) {
if ( resp.data.cleaned && resp.data.cleaned[r] )
newVals[ r ] = resp.data.cleaned[ r ] ;
}
// update the UI with the new details
notify( resp.data.publ_id, newVals ) ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The new publisher was created OK. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The new publisher was created OK. </div> ) ;
gAppRef.closeModalForm() ;
} )
.catch( err => {
gAppRef.showErrorMsg( <div> Couldn't create the publisher: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
} ) ;
}
onEditPublisher() {
PublisherSearchResult._doEditPublisher( this.props.data, (newVals,refs) => {
// send the updated details to the server
newVals.publ_id = this.props.data.publ_id ;
axios.post( gAppRef.state.flaskBaseUrl + "/publisher/update?list=1", newVals )
.then( resp => {
// update the cached publishers
gAppRef.caches.publishers = resp.data.publishers ;
// 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] ;
this.forceUpdate() ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The publisher was updated OK. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The publisher was updated OK. </div> ) ;
gAppRef.closeModalForm() ;
} )
.catch( err => {
gAppRef.showErrorMsg( <div> Couldn't update the publisher: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
} );
}
static _doEditPublisher( vals, notify ) {
let refs = {} ;
const content = <div>
<div className="row name"> <label> Name: </label>
<input type="text" defaultValue={vals.publ_name} ref={(r) => refs.publ_name=r} />
</div>
<div className="row description"> <label> Description: </label>
<textarea defaultValue={vals.publ_description} ref={(r) => refs.publ_description=r} />
</div>
<div className="row url"> <label> Web: </label>
<input type="text" defaultValue={vals.publ_url} ref={(r) => refs.publ_url=r} />
</div>
</div> ;
const buttons = {
OK: () => {
// unload the new values
let newVals = {} ;
for ( let r in refs )
newVals[ r ] = refs[r].value.trim() ;
if ( newVals.publ_name === "" ) {
gAppRef.showErrorMsg( <div> Please specify the publisher's name. </div>) ;
return ;
}
// notify the caller about the new details
notify( newVals, refs ) ;
},
Cancel: () => { gAppRef.closeModalForm() ; },
} ;
const isNew = Object.keys( vals ).length === 0 ;
gAppRef.showModalForm( isNew?"New publisher":"Edit publisher", content, buttons ) ;
}
onDeletePublisher() {
let doDelete = ( nPubs ) => {
let warning ;
if ( typeof nPubs === "number" ) {
if ( nPubs === 0 )
warning = <div> No publications will be deleted. </div> ;
else
warning = <div> { pluralString(nPubs,"associated publication") + " will also be deleted." } </div> ;
} else {
warning = ( <div> <img className="icon" src="/images/error.png" alt="Error." />
WARNING: Couldn't check if any associated publications will be deleted:
<div className="monospace"> {nPubs.toString()} </div>
</div> ) ;
}
let content = ( <div>
Do you want to delete this publisher?
<div style={{margin:"0.5em 0 0.5em 2em",fontStyle:"italic"}} dangerouslySetInnerHTML={{__html: this.props.data.publ_name}} />
{warning}
</div> ) ;
gAppRef.ask( content, {
"OK": () => {
// delete the publisher on the server
axios.get( gAppRef.state.flaskBaseUrl + "/publisher/delete/" + this.props.data.publ_id + "?list=1" )
.then( resp => {
// update the cached publishers
gAppRef.caches.publishers = resp.data.publishers ;
gAppRef.caches.publications = resp.data.publications ; // nb: because of cascading deletes
// update the UI
this.props.onDelete( "publ_id", this.props.data.publ_id ) ;
resp.data.deletedPublications.forEach( pub_id => {
this.props.onDelete( "pub_id", pub_id ) ;
} ) ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The publisher was deleted. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The publisher was deleted. </div> ) ;
} )
.catch( err => {
gAppRef.showErrorToast( <div> Couldn't delete the publisher: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
},
"Cancel": null,
} ) ;
} ;
// get the publisher details
axios.get( gAppRef.state.flaskBaseUrl + "/publisher/" + this.props.data.publ_id )
.then( resp => {
doDelete( resp.data.nPublications ) ;
} )
.catch( err => {
doDelete( err ) ;
} ) ;
}
}

@ -5,8 +5,11 @@
font-size: 90% ;
}
.search-result .name { border: 1px solid #c0c0c0 ; background: #f0f0f0 ; padding: 2px 5px ; }
.search-result .name { padding: 2px 5px ; }
.search-result .name a { font-weight: bold ; text-decoration: none ; }
.search-result .name img.edit { margin-left: 0.5em ; height: 0.8em ; cursor: pointer ; }
.search-result .name img.delete { float: right ; margin: 0.2em 0 0 0.5em ; height: 0.8em ; cursor: pointer ; }
.search-result .description { font-size: 80% ; padding: 2px 5px ; }
.search-result.publisher .name { border: 1px solid #c0c0c0 ; background: #a0e0f0 ; }
.search-result.publication .name { border: 1px solid #c0c0c0 ; background: #d0a080 ; }

@ -1,9 +1,9 @@
import React from "react" ;
import "./SearchResults.css" ;
import { PublisherSearchResult } from "./PublisherSearchResult" ;
import { PublicationSearchResult } from "./PublicationSearchResult" ;
import { gAppRef } from "./index.js" ;
const axios = require( "axios" ) ;
// --------------------------------------------------------------------
export class SearchResults extends React.Component
@ -12,156 +12,33 @@ export class SearchResults extends React.Component
render() {
if ( ! this.props.searchResults || this.props.searchResults.length === 0 )
return null ;
const elems = this.props.searchResults.map(
sr => <SearchResult key={sr.publ_id} publ_id={sr.publ_id} data={sr}
onDelete = { this.onDeleteSearchResult.bind( this ) }
/>
) ;
return ( <div id="search-results" seqno={this.props.seqNo}>
{elems}
</div> ) ;
let elems = [] ;
this.props.searchResults.forEach( sr => {
if ( sr.type === "publisher" ) {
elems.push( <PublisherSearchResult key={"publisher:"+sr.publ_id} data={sr}
onDelete = { this.onDeleteSearchResult.bind( this ) }
/> ) ;
} else if ( sr.type === "publication" ) {
elems.push( <PublicationSearchResult key={"publication:"+sr.pub_id} data={sr}
onDelete = { this.onDeleteSearchResult.bind( this ) }
/> ) ;
} else {
gAppRef.logInternalError( "Unknown search result type.", sr.type ) ;
}
} ) ;
return <div id="search-results" seqno={this.props.seqNo}> {elems} </div> ;
}
onDeleteSearchResult( id ) {
onDeleteSearchResult( idName, idVal ) {
for ( let i=0 ; i < this.props.searchResults.length ; ++i ) {
const sr = this.props.searchResults[ i ] ;
if ( sr.publ_id === id ) {
if ( sr[idName] === idVal ) {
this.props.searchResults.splice( i, 1 ) ;
this.forceUpdate() ;
return ;
}
}
gAppRef.logInternalError( "Tried to delete an unknown search result", "id="+id ) ;
}
}
// --------------------------------------------------------------------
export class SearchResult extends React.Component
{
render() {
function make_name( data ) {
if ( data.publ_url )
return ( <a href={data.publ_url} target="_blank" rel="noopener noreferrer">
{data.publ_name}
</a>
) ;
else
return <span dangerouslySetInnerHTML={{__html: data.publ_name}} /> ;
}
return ( <div className="search-result">
<div className="name">
{ make_name( this.props.data ) }
<img src="/images/edit.png" className="edit" onClick={this.onEditPublisher.bind(this)} alt="Edit this publisher." />
<img src="/images/delete.png" className="delete" onClick={this.onDeletePublisher.bind(this)} alt="Delete this publisher." />
</div>
<div className="description" dangerouslySetInnerHTML={{__html: this.props.data.publ_description}} />
</div> ) ;
}
onEditPublisher() {
SearchResult._doEditPublisher( this.props.data, (newVals,refs) => {
// send the updated details to the server
newVals.publ_id = this.props.publ_id ;
axios.post( gAppRef.state.flaskBaseUrl + "/publishers/update", newVals )
.then( resp => {
// update the UI with the new details
for ( var r in refs )
this.props.data[ r ] = (resp.data.cleaned && resp.data.cleaned[r]) || newVals[r] ;
this.forceUpdate() ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The publisher was updated OK. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The publisher was updated OK. </div> ) ;
gAppRef.closeModalForm() ;
} )
.catch( err => {
gAppRef.showErrorMsg( <div> Couldn't update the publisher: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
} );
}
static _doEditPublisher( vals, notify ) {
let refs = {} ;
const content = <div>
<div className="row name"> <label> Name: </label>
<input type="text" defaultValue={vals.publ_name} ref={(r) => refs.publ_name=r} />
</div>
<div className="row url"> <label> Web: </label>
<input type="text" defaultValue={vals.publ_url} ref={(r) => refs.publ_url=r} />
</div>
<div className="row description"> <label> Description: </label>
<textarea defaultValue={vals.publ_description} ref={(r) => refs.publ_description=r} />
</div>
</div> ;
const buttons = {
OK: () => {
// unload the new values
let newVals = {} ;
for ( var r in refs )
newVals[ r ] = refs[r].value.trim() ;
if ( newVals.publ_name === "" ) {
gAppRef.showErrorMsg( <div> Please specify the publisher's name. </div>) ;
return ;
}
// notify the caller about the new details
notify( newVals, refs ) ;
},
Cancel: () => { gAppRef.closeModalForm() ; },
} ;
const isNew = Object.keys( vals ).length === 0 ;
gAppRef.showModalForm( isNew?"New publisher":"Edit publisher", content, buttons ) ;
}
static onNewPublisher( notify ) {
SearchResult._doEditPublisher( {}, (newVals,refs) => {
axios.post( gAppRef.state.flaskBaseUrl + "/publishers/create", newVals )
.then( resp => {
// unload any cleaned values
for ( var r in refs ) {
if ( resp.data.cleaned && resp.data.cleaned[r] )
newVals[ r ] = resp.data.cleaned[ r ] ;
}
// update the UI with the new details
notify( resp.data.publ_id, newVals ) ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The new publisher was created OK. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The new publisher was created OK. </div> ) ;
gAppRef.closeModalForm() ;
} )
.catch( err => {
gAppRef.showErrorMsg( <div> Couldn't create the publisher: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
} ) ;
}
onDeletePublisher() {
// confirm the operation
const content = ( <div>
Do you want to delete this publisher?
<div style={{margin:"0.5em 0 0 2em",fontStyle:"italic"}} dangerouslySetInnerHTML={{__html: this.props.data.publ_name}} />
</div> ) ;
gAppRef.ask( content, {
"OK": () => {
// delete the publisher on the server
axios.get( gAppRef.state.flaskBaseUrl + "/publishers/delete/" + this.props.data.publ_id )
.then( resp => {
// update the UI
this.props.onDelete( this.props.data.publ_id ) ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The publisher was deleted. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The publisher was deleted. </div> ) ;
} )
.catch( err => {
gAppRef.showErrorToast( <div> Couldn't delete the publisher: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
},
"Cancel": null,
} ) ;
gAppRef.logInternalError( "Tried to delete an unknown search result", idName+"="+idVal ) ;
}
}

@ -1,3 +1,21 @@
import React from "react" ;
// --------------------------------------------------------------------
export function makeOptionalLink( caption, url ) {
let link = <span dangerouslySetInnerHTML={{ __html: caption }} /> ;
if ( url )
link = <a href={url} target="_blank" rel="noopener noreferrer"> {link} </a> ;
return link ;
}
export function slugify( val ) {
return val.toLowerCase().replace( " ", "-" ) ;
}
export function pluralString( n, str1, str2 ) {
if ( n === 1 )
return n + " " + str1 ;
else
return n + " " + (str2 ? str2 : str1+"s") ;
}

Loading…
Cancel
Save