Added support for articles.

master
Pacman Ghost 5 years ago
parent 5719699379
commit d0e01039b3
  1. 39
      alembic/versions/85abe5bcbac0_added_the_article_table.py
  2. 1
      asl_articles/__init__.py
  3. 87
      asl_articles/articles.py
  4. 24
      asl_articles/models.py
  5. 24
      asl_articles/publications.py
  6. 25
      asl_articles/publishers.py
  7. 21
      asl_articles/search.py
  8. 12
      asl_articles/tests/fixtures/articles.json
  9. 41
      asl_articles/tests/fixtures/cascading-deletes-1.json
  10. 15
      asl_articles/tests/fixtures/cascading-deletes-2.json
  11. 15
      asl_articles/tests/fixtures/cascading-deletes.json
  12. 255
      asl_articles/tests/test_articles.py
  13. 46
      asl_articles/tests/test_publications.py
  14. 80
      asl_articles/tests/test_publishers.py
  15. 2
      asl_articles/tests/utils.py
  16. 5
      web/src/App.js
  17. 152
      web/src/ArticleSearchResult.js
  18. 96
      web/src/PublicationSearchResult.js
  19. 30
      web/src/PublisherSearchResult.js
  20. 3
      web/src/SearchResults.css
  21. 40
      web/src/SearchResults.js

@ -0,0 +1,39 @@
"""Added the 'article' table.
Revision ID: 85abe5bcbac0
Revises: 6054f3f09626
Create Date: 2019-12-08 03:28:43.202699
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '85abe5bcbac0'
down_revision = '6054f3f09626'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('article',
sa.Column('article_id', sa.Integer(), nullable=False),
sa.Column('article_title', sa.String(length=200), nullable=False),
sa.Column('article_subtitle', sa.String(length=200), nullable=True),
sa.Column('article_snippet', sa.String(length=5000), nullable=True),
sa.Column('article_url', sa.String(length=500), nullable=True),
sa.Column('pub_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(['pub_id'], ['publication.pub_id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('article_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('article')
# ### end Alembic commands ###

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

@ -0,0 +1,87 @@
""" Handle article requests. """
import datetime
import logging
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
_logger = logging.getLogger( "db" )
_FIELD_NAMES = [ "article_title", "article_subtitle", "article_snippet", "article_url", "pub_id" ]
# ---------------------------------------------------------------------
@app.route( "/article/<article_id>" )
def get_article( article_id ):
"""Get an article."""
_logger.debug( "Get article: id=%s", article_id )
article = Article.query.get( article_id )
if not article:
abort( 404 )
_logger.debug( "- %s", article )
return jsonify( get_article_vals( article ) )
def get_article_vals( article ):
"""Extract public fields from an Article record."""
return {
"article_id": article.article_id,
"article_title": article.article_title,
"article_subtitle": article.article_subtitle,
"article_snippet": article.article_snippet,
"article_url": article.article_url,
"pub_id": article.pub_id,
}
# ---------------------------------------------------------------------
@app.route( "/article/create", methods=["POST"] )
def create_article():
"""Create an article."""
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Create article:" )
)
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 }
)
# ---------------------------------------------------------------------
@app.route( "/article/update", methods=["POST"] )
def update_article():
"""Update an article."""
article_id = request.json[ "article_id" ]
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Update article: id={}".format( article_id ) )
)
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
vals[ "time_updated" ] = datetime.datetime.now()
article = Article.query.get( article_id )
if not article:
abort( 404 )
apply_attrs( article, vals )
db.session.commit() #pylint: disable=no-member
return make_ok_response( cleaned=cleaned )
# ---------------------------------------------------------------------
@app.route( "/article/delete/<article_id>" )
def delete_article( article_id ):
"""Delete an article."""
_logger.debug( "Delete article: id=%s", article_id )
article = Article.query.get( article_id )
if not article:
abort( 404 )
_logger.debug( "- %s", article )
db.session.delete( article ) #pylint: disable=no-member
db.session.commit() #pylint: disable=no-member
return make_ok_response( extras={} )

@ -41,6 +41,30 @@ class Publication( db.Model ):
# 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( "Article", backref="parent", passive_deletes=True )
def __repr__( self ):
return "<Publication:{}|{}>".format( self.pub_id, self.pub_name )
# ---------------------------------------------------------------------
class Article( db.Model ):
"""Define the Article model."""
article_id = db.Column( db.Integer, primary_key=True )
article_title = db.Column( db.String(200), nullable=False )
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 )
pub_id = db.Column( db.Integer,
db.ForeignKey( Publication.__table__.c.pub_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 "<Article:{}|{}>".format( self.article_id, self.article_title )

@ -6,7 +6,7 @@ import logging
from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Publication
from asl_articles.models import Publication, Article
from asl_articles.utils import get_request_args, clean_request_args, make_ok_response, apply_attrs
_logger = logging.getLogger( "db" )
@ -24,7 +24,7 @@ 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 )
results = Publication.query.all()
return { r.pub_id: get_publication_vals(r) for r in results }
# ---------------------------------------------------------------------
@ -36,8 +36,12 @@ def get_publication( pub_id ):
pub = Publication.query.get( pub_id )
if not pub:
abort( 404 )
_logger.debug( "- %s", pub )
return jsonify( get_publication_vals( pub ) )
vals = get_publication_vals( pub )
# include the number of associated articles
query = Article.query.filter_by( pub_id = pub_id )
vals[ "nArticles" ] = query.count()
_logger.debug( "- %s ; #articles=%d", pub, vals["nArticles"] )
return jsonify( vals )
def get_publication_vals( pub ):
"""Extract public fields from a Publication record."""
@ -95,14 +99,24 @@ def update_publication():
@app.route( "/publication/delete/<pub_id>" )
def delete_publication( pub_id ):
"""Delete a publication."""
_logger.debug( "Delete publication: id=%s", pub_id )
# get the publication
pub = Publication.query.get( pub_id )
if not pub:
abort( 404 )
_logger.debug( "- %s", pub )
# figure out which associated articles will be deleted
query = db.session.query( Article.article_id ).filter_by( pub_id = pub_id ) #pylint: disable=no-member
deleted_articles = [ r[0] for r in query ]
# delete the publication
db.session.delete( pub ) #pylint: disable=no-member
db.session.commit() #pylint: disable=no-member
extras = {}
extras = { "deleteArticles": deleted_articles }
if request.args.get( "list" ):
extras[ "publications" ] = do_get_publications()
return make_ok_response( extras=extras )

@ -6,7 +6,7 @@ import logging
from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Publisher, Publication
from asl_articles.models import Publisher, Publication, Article
from asl_articles.publications import do_get_publications
from asl_articles.utils import get_request_args, clean_request_args, make_ok_response, apply_attrs
@ -25,7 +25,7 @@ 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 )
results = Publisher.query.all()
return { r.publ_id: get_publisher_vals(r) for r in results }
# ---------------------------------------------------------------------
@ -40,9 +40,15 @@ def get_publisher( publ_id ):
abort( 404 )
vals = get_publisher_vals( publ )
# include the number of associated publications
query = Publication.query.filter_by( publ_id = publ.publ_id )
query = Publication.query.filter_by( publ_id = publ_id )
vals[ "nPublications" ] = query.count()
_logger.debug( "- %s ; #publications=%d", publ, vals["nPublications"] )
# include the number of associated articles
query = db.session.query #pylint: disable=no-member
query = query( Article, Publication ) \
.filter( Publication.publ_id == publ_id ) \
.filter( Article.pub_id == Publication.pub_id )
vals[ "nArticles" ] = query.count()
_logger.debug( "- %s ; #publications=%d ; #articles=%d", publ, vals["nPublications"], vals["nArticles"] )
return jsonify( vals )
def get_publisher_vals( publ ):
@ -109,14 +115,21 @@ def delete_publisher( publ_id ):
_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
query = db.session.query( Publication.pub_id ).filter_by( publ_id = publ_id ) #pylint: disable=no-member
deleted_pubs = [ r[0] for r in query ]
# figure out which associated articles will be deleted
query = db.session.query #pylint: disable=no-member
query = query( Article.article_id ).join( Publication ) \
.filter( Publication.publ_id == publ_id ) \
.filter( Article.pub_id == Publication.pub_id )
deleted_articles = [ r[0] for r in query ]
# delete the publisher
db.session.delete( publ ) #pylint: disable=no-member
db.session.commit() #pylint: disable=no-member
extras = { "deletedPublications": deleted_pubs }
extras = { "deletedPublications": deleted_pubs, "deletedArticles": deleted_articles }
if request.args.get( "list" ):
extras[ "publishers" ] = _do_get_publishers()
extras[ "publications" ] = do_get_publications()

@ -5,9 +5,10 @@ import logging
from flask import request, jsonify
from asl_articles import app
from asl_articles.models import Publisher, Publication
from asl_articles.models import Publisher, Publication, Article
from asl_articles.publishers import get_publisher_vals
from asl_articles.publications import get_publication_vals
from asl_articles.articles import get_article_vals
_logger = logging.getLogger( "search" )
@ -29,7 +30,7 @@ def search():
Publisher.publ_name.ilike( "%{}%".format( query_string ) )
)
query = query.order_by( Publisher.publ_name.asc() )
publishers = list( query )
publishers = query.all()
_logger.debug( "- Found: %s", " ; ".join( str(p) for p in publishers ) )
for publ in publishers:
publ = get_publisher_vals( publ )
@ -43,11 +44,25 @@ def search():
Publication.pub_name.ilike( "%{}%".format( query_string ) )
)
query = query.order_by( Publication.pub_name.asc() )
publications = list( query )
publications = query.all()
_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 all articles
query = Article.query
if query_string:
query = query.filter(
Article.article_title.ilike( "%{}%".format( query_string ) )
)
query = query.order_by( Article.article_title.asc() )
articles = query.all()
_logger.debug( "- Found: %s", " ; ".join( str(a) for a in articles ) )
for article in articles:
article = get_article_vals( article )
article[ "type" ] = "article"
results.append( article )
return jsonify( results )

@ -0,0 +1,12 @@
{
"article": [
{ "article_title": "Smoke Gets In Your Eyes", "article_subtitle": "All You Wanted To Know About SMOKE",
"article_snippet": "SMOKE comes in all shapes, colors, and sizes."
},
{ "article_title": "What To Do If You Have A Tin Can",
"article_snippet": "My Panthers... My beautiful Panthers!"
}
]
}

@ -0,0 +1,41 @@
{
"publisher": [
{ "publ_id": 1, "publ_name": "#pubs=0, #articles=0" },
{ "publ_id": 2, "publ_name": "#pubs=1, #articles=0" },
{ "publ_id": 3, "publ_name": "#pubs=1, #articles=1" },
{ "publ_id": 4, "publ_name": "#pubs=1, #articles=2" },
{ "publ_id": 5, "publ_name": "#pubs=2, #articles=0" },
{ "publ_id": 6, "publ_name": "#pubs=2, #articles=1" },
{ "publ_id": 7, "publ_name": "#pubs=2, #articles=2" },
{ "publ_id": 8, "publ_name": "#pubs=2, #articles=4" }
],
"publication": [
{ "pub_id": 20, "pub_name": "publication 2", "publ_id": "2" },
{ "pub_id": 30, "pub_name": "publication 3", "publ_id": "3" },
{ "pub_id": 40, "pub_name": "publication 4", "publ_id": "4" },
{ "pub_id": 50, "pub_name": "publication 5a", "publ_id": "5" },
{ "pub_id": 51, "pub_name": "publication 5b", "publ_id": "5" },
{ "pub_id": 60, "pub_name": "publication 6a", "publ_id": "6" },
{ "pub_id": 61, "pub_name": "publication 6b", "publ_id": "6" },
{ "pub_id": 70, "pub_name": "publication 7a", "publ_id": "7" },
{ "pub_id": 71, "pub_name": "publication 7b", "publ_id": "7" },
{ "pub_id": 80, "pub_name": "publication 8a", "publ_id": "8" },
{ "pub_id": 81, "pub_name": "publication 8b", "publ_id": "8" }
],
"article": [
{ "article_title": "article 3", "pub_id": 30 },
{ "article_title": "article 4a", "pub_id": 40 },
{ "article_title": "article 4b", "pub_id": 40 },
{ "article_title": "article 6a", "pub_id": 60 },
{ "article_title": "article 7a", "pub_id": 70 },
{ "article_title": "article 7b", "pub_id": 71 },
{ "article_title": "article 8a.1", "pub_id": 80 },
{ "article_title": "article 8a.2", "pub_id": 80 },
{ "article_title": "article 8b.1", "pub_id": 81 },
{ "article_title": "article 8b.2", "pub_id": 81 }
]
}

@ -0,0 +1,15 @@
{
"publication": [
{ "pub_id": 1, "pub_name": "Cascading Deletes 1" },
{ "pub_id": 2, "pub_name": "Cascading Deletes 2" },
{ "pub_id": 3, "pub_name": "Cascading Deletes 3" }
],
"article": [
{ "article_title": "article 2", "pub_id": "2" },
{ "article_title": "article 3a", "pub_id": "3" },
{ "article_title": "article 3b", "pub_id": "3" }
]
}

@ -1,15 +0,0 @@
{
"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,255 @@
""" Test article 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_article( webdriver, flask_app, dbconn ):
"""Test editing articles."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "articles.json" )
# edit "What To Do If You Have A Tin Can"
results = do_search( "tin can" )
assert len(results) == 1
result = results[0]
_edit_article( result, {
"title": " Updated title ",
"subtitle": " Updated subtitle ",
"snippet": " Updated snippet. ",
"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/" ] )
# try to remove all fields from the article (should fail)
_edit_article( result,
{ "title": "", "subtitle": "", "snippet": "", "url": "" },
expected_error = "Please specify the article's title."
)
# enter something for the name
dlg = find_child( "#modal-form" )
set_elem_text( find_child( ".title input", dlg ), "Tin Cans Rock!" )
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[0]
assert find_child( ".title a", result ) is None
assert find_child( ".title", result ).text == "Tin Cans Rock!"
assert find_child( ".snippet", result ).text == ""
# check that the search result was updated in the database
results = do_search( "tin can" )
_check_result( results[0], [ "Tin Cans Rock!", None, "", None ] )
# ---------------------------------------------------------------------
def test_create_article( webdriver, flask_app, dbconn ):
"""Test creating new articles."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "basic.json" )
# try creating a article with no name (should fail)
_create_article( {}, toast_type=None )
check_error_msg( "Please specify the article's title." )
# enter a name and other details
dlg = find_child( "#modal-form" ) # nb: the form is still on-screen
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." )
set_elem_text( find_child( ".url input", dlg ), "http://new-snippet.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 article appears in the UI
def check_new_article( result ):
_check_result( result, [
"New article", "New subtitle", "New snippet.", "http://new-snippet.com/"
] )
results = find_children( "#search-results .search-result" )
check_new_article( results[0] )
# check that the new article has been saved in the database
results = do_search( "new" )
assert len( results ) == 1
check_new_article( results[0] )
# ---------------------------------------------------------------------
def test_delete_article( webdriver, flask_app, dbconn ):
"""Test deleting articles."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "articles.json" )
# start to delete article "Smoke Gets In Your Eyes", but cancel the operation
results = do_search( "smoke" )
assert len(results) == 1
result = results[0]
find_child( ".delete", result ).click()
check_ask_dialog( ( "Delete this article?", "Smoke Gets In Your Eyes" ), "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( "smoke" )
assert results3 == results
# delete the article "Smoke Gets In Your Eyes"
result = results3[0]
find_child( ".delete", result ).click()
set_toast_marker( "info" )
check_ask_dialog( ( "Delete this article?", "Smoke Gets In Your Eyes" ), "ok" )
wait_for( 2,
lambda: check_toast( "info", "The article was deleted." )
)
# check that search result was removed on-screen
results = find_children( "#search-results .search-result" )
assert get_result_names( results ) == []
# check that the search result was deleted from the database
results = do_search( "smoke" )
assert get_result_names( results ) == []
# ---------------------------------------------------------------------
def test_unicode( webdriver, flask_app, dbconn ):
"""Test Unicode content."""
# initialize
init_tests( webdriver, flask_app )
init_db( dbconn, "basic.json" )
# create a article with Unicode content
_create_article( {
"title": "japan = \u65e5\u672c",
"subtitle": "s.korea = \ud55c\uad6d",
"snippet": "greece = \u0395\u03bb\u03bb\u03ac\u03b4\u03b1",
"url": "http://\ud55c\uad6d.com"
} )
# check that the new article is showing the Unicode content correctly
results = do_search( "japan" )
assert len( results ) == 1
_check_result( results[0], [
"japan = \u65e5\u672c",
"s.korea = \ud55c\uad6d",
"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, "basic.json" )
# create a article with HTML content
_create_article( {
"title": "title: <span style='boo!'> <b>bold</b> <xxx>xxx</xxx> <i>italic</i>",
"subtitle": "<i>italicized subtitle</i>",
"snippet": "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, [ "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" ) \
== "<i>italicized subtitle</i>"
assert check_toast( "warning", "Some values had HTML removed.", contains=True )
# update the article with new HTML content
_edit_article( result, {
"title": "<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( ".title span", result ).text == "updated"
wait_for( 2, check_result )
assert check_toast( "warning", "Some values had HTML removed.", contains=True )
# ---------------------------------------------------------------------
def _create_article( vals, toast_type="info" ):
"""Create a new article."""
# initialize
if toast_type:
set_toast_marker( toast_type )
# create the new article
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 )
find_child( "button.ok", dlg ).click()
if toast_type:
# check that the new article was created successfully
wait_for( 2,
lambda: check_toast( toast_type, "created OK", contains=True )
)
def _edit_article( result, vals, toast_type="info", expected_error=None ):
"""Edit a article's details."""
# update the specified article'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 == "snippet" 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."""
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
assert find_child( ".snippet", result ).text == expected[2]
elem = find_child( ".title a", result )
if elem:
assert elem.get_attribute( "href" ) == expected[3]
else:
assert expected[3] is None

@ -104,7 +104,7 @@ def test_delete_publication( webdriver, flask_app, dbconn ):
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_ask_dialog( ( "Delete this publication?", "ASL Journal (2)" ), "cancel" )
# check that search results are unchanged on-screen
results2 = find_children( "#search-results .search-result" )
@ -119,7 +119,7 @@ def test_delete_publication( webdriver, flask_app, dbconn ):
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" )
check_ask_dialog( ( "Delete this publication?", "ASL Journal (2)" ), "ok" )
wait_for( 2,
lambda: check_toast( "info", "The publication was deleted." )
)
@ -134,6 +134,48 @@ def test_delete_publication( webdriver, flask_app, dbconn ):
# ---------------------------------------------------------------------
def test_cascading_deletes( webdriver, flask_app, dbconn ):
"""Test cascading deletes."""
# initialize
init_tests( webdriver, flask_app )
def do_test( pub_name, expected_warning, expected_articles ):
# initialize
init_db( dbconn, "cascading-deletes-2.json" )
results = do_search( "" )
# delete the specified publication
results = [ r for r in results if find_child(".name",r).text == pub_name ]
assert len( results ) == 1
find_child( ".delete", results[0] ).click()
check_ask_dialog( ( "Delete this publication?", pub_name, expected_warning ), "ok" )
# check that deleted associated articles were removed from the UI
def check_articles( results ):
results = [ find_child(".name",r).text for r in results ]
articles = [ r for r in results if r.startswith( "article" ) ]
assert articles == expected_articles
check_articles( find_children( "#search-results .search-result" ) )
# check that associated articles were removed from the database
results = do_search( "article" )
check_articles( results )
# do the tests
do_test( "Cascading Deletes 1",
"No articles will be deleted", ["article 2","article 3a","article 3b"]
)
do_test( "Cascading Deletes 2",
"1 associated article will also be deleted", ["article 3a","article 3b"]
)
do_test( "Cascading Deletes 3",
"2 associated articles will also be deleted", ["article 2"]
)
# ---------------------------------------------------------------------
def test_unicode( webdriver, flask_app, dbconn ):
"""Test Unicode content."""

@ -102,7 +102,7 @@ def test_delete_publisher( webdriver, flask_app, dbconn ):
result = results[1]
assert find_child( ".name", result ).text == "Le Franc Tireur"
find_child( ".delete", result ).click()
check_ask_dialog( ( "Do you want to delete", "Le Franc Tireur" ), "cancel" )
check_ask_dialog( ( "Delete this publisher?", "Le Franc Tireur" ), "cancel" )
# check that search results are unchanged on-screen
results2 = find_children( "#search-results .search-result" )
@ -117,7 +117,7 @@ def test_delete_publisher( webdriver, flask_app, dbconn ):
assert find_child( ".name", result ).text == "Le Franc Tireur"
find_child( ".delete", result ).click()
set_toast_marker( "info" )
check_ask_dialog( ( "Do you want to delete", "Le Franc Tireur" ), "ok" )
check_ask_dialog( ( "Delete this publisher?", "Le Franc Tireur" ), "ok" )
wait_for( 2,
lambda: check_toast( "info", "The publisher was deleted." )
)
@ -138,38 +138,76 @@ def test_cascading_deletes( webdriver, flask_app, dbconn ):
# initialize
init_tests( webdriver, flask_app )
def do_test( publ_name, expected_warning, expected_pubs ):
def check_results( results, sr_type, expected, expected_deletions ):
expected = [ "{} {}".format( sr_type, p ) for p in expected ]
expected = [ e for e in expected if e not in expected_deletions ]
results = [ find_child(".name",r).text for r in results ]
results = [ r for r in results if r.startswith( sr_type ) ]
assert results == expected
def do_test( publ_name, expected_warning, expected_deletions ):
# initialize
init_db( dbconn, "cascading-deletes.json" )
init_db( dbconn, "cascading-deletes-1.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_ask_dialog( ( "Delete this publisher?", publ_name, expected_warning ), "ok" )
# check that associated publications were removed from the database
results = do_search( "publication" )
check_publications( results )
# check that deleted associated publications/articles were removed from the UI
results = find_children( "#search-results .search-result" )
def check_publications():
check_results( results,
"publication", [ "2", "3", "4", "5a", "5b", "6a", "6b", "7a", "7b", "8a", "8b" ],
expected_deletions
)
def check_articles():
check_results( results,
"article", [ "3", "4a", "4b", "6a", "7a", "7b", "8a.1", "8a.2", "8b.1", "8b.2" ],
expected_deletions
)
check_publications()
check_articles()
# check that associated publications/articles were removed from the database
results = do_search( "" )
check_publications()
check_articles()
# do the tests
do_test( "Cascading Deletes 0",
"No publications will be deleted", ["publication 1","publication 2a","publication 2b"]
do_test( "#pubs=0, #articles=0",
"No publications nor articles will be deleted", []
)
do_test( "#pubs=1, #articles=0",
"1 publication will also be deleted",
[ "publication 2" ]
)
do_test( "#pubs=1, #articles=1",
"1 publication and 1 article will also be deleted",
[ "publication 3", "article 3" ]
)
do_test( "#pubs=1, #articles=2",
"1 publication and 2 articles will also be deleted",
[ "publication 4", "article 4a", "article 4b" ]
)
do_test( "#pubs=2, #articles=0",
"2 publications will also be deleted",
[ "publication 5a", "publication 5b" ]
)
do_test( "#pubs=2, #articles=1",
"2 publications and 1 article will also be deleted",
[ "publication 6a", "publication 6b", "article 6a" ]
)
do_test( "Cascading Deletes 1",
"1 associated publication will also be deleted", ["publication 2a","publication 2b"]
do_test( "#pubs=2, #articles=2",
"2 publications and 2 articles will also be deleted",
[ "publication 7a", "publication 7b", "article 7a", "article 7b" ]
)
do_test( "Cascading Deletes 2",
"2 associated publications will also be deleted", ["publication 1"]
do_test( "#pubs=2, #articles=4",
"2 publications and 4 articles will also be deleted",
[ "publication 8a", "publication 8b", "article 8a.1", "article 8a.2", "article 8b.1", "article 8b.2" ]
)
# ---------------------------------------------------------------------

@ -86,7 +86,7 @@ def init_db( engine, fname ):
data = json.load( open( fname, "r" ) )
# load the test data into the database
for table_name in ["publisher","publication"]:
for table_name in ["publisher","publication","article"]:
model = getattr( asl_articles.models, table_name.capitalize() )
session.query( model ).delete()
if table_name in data:

@ -6,6 +6,7 @@ import SearchForm from "./SearchForm" ;
import { SearchResults } from "./SearchResults" ;
import { PublisherSearchResult } from "./PublisherSearchResult" ;
import { PublicationSearchResult } from "./PublicationSearchResult" ;
import { ArticleSearchResult } from "./ArticleSearchResult" ;
import ModalForm from "./ModalForm";
import AskDialog from "./AskDialog" ;
import "./App.css" ;
@ -49,6 +50,9 @@ export default class App extends React.Component
[<a href="/" className="new-publication"
onClick={ (e) => { e.preventDefault() ; PublicationSearchResult.onNewPublication( this._onNewPublication.bind(this) ) ; } }
>New publication</a>]
[<a href="/" className="new-article"
onClick={ (e) => { e.preventDefault() ; ArticleSearchResult.onNewArticle( this._onNewArticle.bind(this) ) ; } }
>New article</a>]
</div>
<SearchForm onSearch={this.onSearch.bind(this)} />
<SearchResults seqNo={this.state.searchSeqNo} searchResults={this.state.searchResults} />
@ -106,6 +110,7 @@ export default class App extends React.Component
_onNewPublisher( publ_id, vals ) { this._addNewSearchResult( vals, "publisher", "publ_id", publ_id ) ; }
_onNewPublication( pub_id, vals ) { this._addNewSearchResult( vals, "publication", "pub_id", pub_id ) ; }
_onNewArticle( article_id, vals ) { this._addNewSearchResult( vals, "article", "article_id", article_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

@ -0,0 +1,152 @@
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 ArticleSearchResult extends React.Component
{
render() {
// 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">
<div className="title name"> { makeOptionalLink( this.props.data.article_title, this.props.data.article_url ) }
<img src="/images/edit.png" className="edit" onClick={this.onEditArticle.bind(this)} alt="Edit this article." />
<img src="/images/delete.png" className="delete" onClick={this.onDeleteArticle.bind(this)} alt="Delete this article." />
{ 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}} />
</div> ) ;
}
static onNewArticle( notify ) {
ArticleSearchResult._doEditArticle( {}, (newVals,refs) => {
axios.post( gAppRef.state.flaskBaseUrl + "/article/create", newVals )
.then( resp => {
// 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.article_id, newVals ) ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The new article was created OK. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The new article was created OK. </div> ) ;
gAppRef.closeModalForm() ;
} )
.catch( err => {
gAppRef.showErrorMsg( <div> Couldn't create the article: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
} ) ;
}
onEditArticle() {
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.state.flaskBaseUrl + "/article/update", newVals )
.then( resp => {
// 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 article was updated OK. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The article was updated OK. </div> ) ;
gAppRef.closeModalForm() ;
} )
.catch( err => {
gAppRef.showErrorMsg( <div> Couldn't update the article: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
} );
}
static _doEditArticle( vals, notify ) {
let refs = {} ;
let publications = [ { value: null, label: <i>(none)</i> } ] ;
let currPub = 0 ;
for ( let p of Object.entries(gAppRef.caches.publications) ) {
publications.push( {
value: p[1].pub_id,
label: <span dangerouslySetInnerHTML={{__html: p[1].pub_name}} />
} ) ;
if ( p[1].pub_id === vals.pub_id )
currPub = publications.length - 1 ;
}
publications.sort( (lhs,rhs) => {
return ReactDOMServer.renderToStaticMarkup( lhs.label ).localeCompare( ReactDOMServer.renderToStaticMarkup( rhs.label ) ) ;
} ) ;
const content = <div>
<div className="row title"> <label> Title: </label>
<input type="text" defaultValue={vals.article_title} ref={(r) => refs.article_title=r} />
</div>
<div className="row subtitle"> <label> Subtitle: </label>
<input type="text" defaultValue={vals.article_subtitle} ref={(r) => refs.article_subtitle=r} />
</div>
<div className="row publication"> <label> Publication: </label>
<Select options={publications} isSearchable={true}
defaultValue = { publications[ currPub ] }
ref = { (r) => refs.pub_id=r }
/>
</div>
<div className="row snippet"> <label> Snippet: </label>
<textarea defaultValue={vals.article_snippet} ref={(r) => refs.article_snippet=r} />
</div>
<div className="row url"> <label> Web: </label>
<input type="text" defaultValue={vals.article_url} ref={(r) => refs.article_url=r} />
</div>
</div> ;
const buttons = {
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() ;
if ( newVals.article_title === "" ) {
gAppRef.showErrorMsg( <div> Please specify the article's title. </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 article":"Edit article", content, buttons ) ;
}
onDeleteArticle() {
// confirm the operation
const content = ( <div>
Delete this article?
<div style={{margin:"0.5em 0 0 2em",fontStyle:"italic"}} dangerouslySetInnerHTML = {{ __html: this.props.data.article_title }} />
</div> ) ;
gAppRef.ask( content, {
"OK": () => {
// delete the article on the server
axios.get( gAppRef.state.flaskBaseUrl + "/article/delete/" + this.props.data.article_id )
.then( resp => {
// update the UI
this.props.onDelete( "article_id", this.props.data.article_id ) ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The article was deleted. <p> {resp.data.warning} </p> </div> ) ;
else
gAppRef.showInfoToast( <div> The article was deleted. </div> ) ;
} )
.catch( err => {
gAppRef.showErrorToast( <div> Couldn't delete the article: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
},
"Cancel": null,
} ) ;
}
}

@ -2,7 +2,7 @@ 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" ;
import { makeOptionalLink, pluralString } from "./utils.js" ;
const axios = require( "axios" ) ;
@ -125,31 +125,83 @@ export class PublicationSearchResult extends React.Component
}
onDeletePublication() {
let doDelete = ( nArticles ) => {
// confirm the operation
let warning ;
if ( typeof nArticles === "number" ) {
if ( nArticles === 0 )
warning = <div> No articles will be deleted. </div> ;
else
warning = <div> { pluralString(nArticles,"associated article") + " will also be deleted." } </div> ;
} else {
warning = ( <div> <img className="icon" src="/images/error.png" alt="Error." />
WARNING: Couldn't check if any associated articles will be deleted:
<div className="monospace"> {nArticles.toString()} </div>
</div> ) ;
}
const content = ( <div>
Delete this publication?
<div style={{margin:"0.5em 0 0.5em 2em",fontStyle:"italic"}} dangerouslySetInnerHTML = {{ __html: this._makeDisplayName() }} />
{warning}
</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 ) ;
resp.data.deleteArticles.forEach( article_id => {
this.props.onDelete( "article_id", article_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,
} ) ;
}
// get the publication details
axios.get( gAppRef.state.flaskBaseUrl + "/publication/" + this.props.data.pub_id )
.then( resp => {
doDelete( resp.data.nArticles ) ;
} )
.catch( err => {
doDelete( err ) ;
} ) ;
// confirm the operation
const content = ( <div>
Do you want to delete this publication?
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,
} ) ;
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() {

@ -101,21 +101,28 @@ export class PublisherSearchResult extends React.Component
}
onDeletePublisher() {
let doDelete = ( nPubs ) => {
let doDelete = ( nPubs, nArticles ) => {
// confirm the operation
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 {
if ( typeof nPubs !== "number" ) {
// something went wrong when getting the number of associated publications/articles
// (we can continue, but we warn the user)
warning = ( <div> <img className="icon" src="/images/error.png" alt="Error." />
WARNING: Couldn't check if any associated publications will be deleted:
WARNING: Couldn't check if any publications or articles will also be deleted:
<div className="monospace"> {nPubs.toString()} </div>
</div> ) ;
} else if ( nPubs === 0 && nArticles === 0 )
warning = <div> No publications nor articles will be deleted. </div> ;
else {
let vals = [] ;
if ( nPubs > 0 )
vals.push( pluralString( nPubs, "publication" ) ) ;
if ( nArticles > 0 )
vals.push( pluralString( nArticles, "article" ) ) ;
warning = <div> {warning} { vals.join(" and ") + " will also be deleted." } </div> ;
}
let content = ( <div>
Do you want to delete this publisher?
Delete this publisher?
<div style={{margin:"0.5em 0 0.5em 2em",fontStyle:"italic"}} dangerouslySetInnerHTML={{__html: this.props.data.publ_name}} />
{warning}
</div> ) ;
@ -132,6 +139,9 @@ export class PublisherSearchResult extends React.Component
resp.data.deletedPublications.forEach( pub_id => {
this.props.onDelete( "pub_id", pub_id ) ;
} ) ;
resp.data.deletedArticles.forEach( article_id => {
this.props.onDelete( "article_id", article_id ) ;
} ) ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The publisher was deleted. <p> {resp.data.warning} </p> </div> ) ;
else
@ -147,7 +157,7 @@ export class PublisherSearchResult extends React.Component
// get the publisher details
axios.get( gAppRef.state.flaskBaseUrl + "/publisher/" + this.props.data.publ_id )
.then( resp => {
doDelete( resp.data.nPublications ) ;
doDelete( resp.data.nPublications, resp.data.nArticles ) ;
} )
.catch( err => {
doDelete( err ) ;

@ -1,4 +1,5 @@
#search-results { margin-top: 0.5em ; }
#search-results .no-results { font-style: italic ; }
.search-result {
margin: 0.25em 0 ; padding: 0.1em 0.2em ;
@ -13,3 +14,5 @@
.search-result.publisher .name { border: 1px solid #c0c0c0 ; background: #a0e0f0 ; }
.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 ; }

@ -2,6 +2,7 @@ import React from "react" ;
import "./SearchResults.css" ;
import { PublisherSearchResult } from "./PublisherSearchResult" ;
import { PublicationSearchResult } from "./PublicationSearchResult" ;
import { ArticleSearchResult } from "./ArticleSearchResult" ;
import { gAppRef } from "./index.js" ;
// --------------------------------------------------------------------
@ -10,23 +11,30 @@ export class SearchResults extends React.Component
{
render() {
let results ;
if ( ! this.props.searchResults || this.props.searchResults.length === 0 )
return null ;
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> ;
results = (this.props.seqNo === 0) ? null : <div className="no-results"> No results. </div> ;
else {
results = [] ;
this.props.searchResults.forEach( sr => {
if ( sr.type === "publisher" ) {
results.push( <PublisherSearchResult key={"publisher:"+sr.publ_id} data={sr}
onDelete = { this.onDeleteSearchResult.bind( this ) }
/> ) ;
} else if ( sr.type === "publication" ) {
results.push( <PublicationSearchResult key={"publication:"+sr.pub_id} data={sr}
onDelete = { this.onDeleteSearchResult.bind( this ) }
/> ) ;
} else if ( sr.type === "article" ) {
results.push( <ArticleSearchResult key={"article:"+sr.article_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}> {results} </div> ;
}
onDeleteSearchResult( idName, idVal ) {

Loading…
Cancel
Save