Added suport for authors.

master
Pacman Ghost 4 years ago
parent 8276bb4b29
commit a21db9ccf3
  1. 43
      alembic/versions/1ee62841eb90_added_the_author_table.py
  2. 1
      asl_articles/__init__.py
  3. 82
      asl_articles/articles.py
  4. 29
      asl_articles/authors.py
  5. 76
      asl_articles/models.py
  6. 28
      asl_articles/publications.py
  7. 27
      asl_articles/publishers.py
  8. 5
      asl_articles/tests/test_articles.py
  9. 83
      asl_articles/tests/test_authors.py
  10. 7
      asl_articles/tests/test_tags.py
  11. 17
      asl_articles/utils.py
  12. 31
      web/src/App.js
  13. 67
      web/src/ArticleSearchResult.js
  14. 31
      web/src/PublicationSearchResult.js
  15. 24
      web/src/PublisherSearchResult.js
  16. 23
      web/src/utils.js

@ -0,0 +1,43 @@
"""Added the 'author' table.
Revision ID: 1ee62841eb90
Revises: 4594e1b85c8b
Create Date: 2019-12-11 07:25:38.054776
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1ee62841eb90'
down_revision = '4594e1b85c8b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('author',
sa.Column('author_id', sa.Integer(), nullable=False),
sa.Column('author_name', sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint('author_id'),
sa.UniqueConstraint('author_name')
)
op.create_table('article_author',
sa.Column('article_author_id', sa.Integer(), nullable=False),
sa.Column('seq_no', sa.Integer(), nullable=False),
sa.Column('article_id', sa.Integer(), nullable=False),
sa.Column('author_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['article_id'], ['article.article_id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['author_id'], ['author.author_id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('article_author_id'),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('article_author')
op.drop_table('author')
# ### end Alembic commands ###

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

@ -6,7 +6,8 @@ import logging
from flask import request, jsonify, abort
from asl_articles import app, db
from asl_articles.models import Article
from asl_articles.models import Article, Author, ArticleAuthor
from asl_articles.authors import do_get_authors
from asl_articles.tags import do_get_tags
from asl_articles.utils import get_request_args, clean_request_args, encode_tags, decode_tags, apply_attrs, \
make_ok_response
@ -29,10 +30,14 @@ def get_article( article_id ):
def get_article_vals( article ):
"""Extract public fields from an Article record."""
authors = sorted( article.article_authors,
key = lambda a: a.seq_no
)
return {
"article_id": article.article_id,
"article_title": article.article_title,
"article_subtitle": article.article_subtitle,
"article_authors": [ a.author_id for a in authors ],
"article_snippet": article.article_snippet,
"article_url": article.article_url,
"article_tags": decode_tags( article.article_tags ),
@ -44,56 +49,117 @@ def get_article_vals( article ):
@app.route( "/article/create", methods=["POST"] )
def create_article():
"""Create an article."""
# parse the input
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Create article:" )
)
vals[ "article_tags" ] = encode_tags( vals.get( "article_tags" ) )
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
warnings = []
updated = clean_request_args( vals, _FIELD_NAMES, warnings, _logger )
# create the new article
vals[ "time_created" ] = datetime.datetime.now()
article = Article( **vals )
db.session.add( article ) #pylint: disable=no-member
db.session.flush() #pylint: disable=no-member
new_article_id = article.article_id
_save_authors( article, updated )
db.session.commit() #pylint: disable=no-member
_logger.debug( "- New ID: %d", article.article_id )
extras = { "article_id": article.article_id }
_logger.debug( "- New ID: %d", new_article_id )
# generate the response
extras = { "article_id": new_article_id }
if request.args.get( "list" ):
extras[ "authors" ] = do_get_authors()
extras[ "tags" ] = do_get_tags()
return make_ok_response( cleaned=cleaned, extras=extras )
return make_ok_response( updated=updated, extras=extras, warnings=warnings )
def _save_authors( article, updated_fields ):
"""Save the article's authors."""
# delete the existing article-author rows
query = db.session.query( ArticleAuthor ) #pylint: disable=no-member
query.filter( ArticleAuthor.article_id == article.article_id ).delete()
# add the article-author rows
authors = request.json.get( "article_authors", [] )
author_ids = []
new_authors = False
for seq_no,author in enumerate( authors ):
if isinstance( author, int ):
# this is an existing author
author_id = author
else:
# this is a new author - create it
assert isinstance( author, str )
author = Author( author_name=author )
db.session.add( author ) #pylint: disable=no-member
db.session.flush() #pylint: disable=no-member
author_id = author.author_id
new_authors = True
_logger.debug( "Created new author \"%s\": id=%d", author, author_id )
db.session.add( #pylint: disable=no-member
ArticleAuthor( seq_no=seq_no, article_id=article.article_id, author_id=author_id )
)
author_ids.append( author_id )
# check if we created any new authors
if new_authors:
# yup - let the caller know about them
updated_fields[ "article_authors"] = author_ids
# ---------------------------------------------------------------------
@app.route( "/article/update", methods=["POST"] )
def update_article():
"""Update an article."""
# parse the input
article_id = request.json[ "article_id" ]
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Update article: id={}".format( article_id ) )
)
vals[ "article_tags" ] = encode_tags( vals.get( "article_tags" ) )
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
vals[ "time_updated" ] = datetime.datetime.now()
warnings = []
updated = clean_request_args( vals, _FIELD_NAMES, warnings, _logger )
# update the article
article = Article.query.get( article_id )
if not article:
abort( 404 )
_save_authors( article, updated )
vals[ "time_updated" ] = datetime.datetime.now()
apply_attrs( article, vals )
db.session.commit() #pylint: disable=no-member
# generate the response
extras = {}
if request.args.get( "list" ):
extras[ "authors" ] = do_get_authors()
extras[ "tags" ] = do_get_tags()
return make_ok_response( cleaned=cleaned, extras=extras )
return make_ok_response( updated=updated, extras=extras, warnings=warnings )
# ---------------------------------------------------------------------
@app.route( "/article/delete/<article_id>" )
def delete_article( article_id ):
"""Delete an article."""
# parse the input
_logger.debug( "Delete article: id=%s", article_id )
article = Article.query.get( article_id )
if not article:
abort( 404 )
_logger.debug( "- %s", article )
# delete the article
db.session.delete( article ) #pylint: disable=no-member
db.session.commit() #pylint: disable=no-member
# generate the response
extras = {}
if request.args.get( "list" ):
extras[ "authors" ] = do_get_authors()
extras[ "tags" ] = do_get_tags()
return make_ok_response( extras=extras )

@ -0,0 +1,29 @@
""" Handle author requests. """
from flask import jsonify
from asl_articles import app
from asl_articles.models import Author
# ---------------------------------------------------------------------
@app.route( "/authors" )
def get_authors():
"""Get all authors."""
return jsonify( do_get_authors() )
def do_get_authors():
"""Get all authors."""
# get all the authors
return {
r.author_id: _get_author_vals(r)
for r in Author.query #pylint: disable=not-an-iterable
}
def _get_author_vals( author ):
"""Extract public fields from an Author record."""
return {
"author_id": author.author_id,
"author_name": author.author_name
}

@ -11,14 +11,14 @@ class Publisher( db.Model ):
publ_id = db.Column( db.Integer, primary_key=True )
publ_name = db.Column( db.String(100), nullable=False )
publ_description = db.Column( db.String(1000), nullable=True )
publ_url = db.Column( db.String(500), nullable=True )
publ_description = db.Column( db.String(1000) )
publ_url = db.Column( db.String(500) )
# 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 )
time_created = db.Column( db.TIMESTAMP(timezone=True) )
time_updated = db.Column( db.TIMESTAMP(timezone=True) )
#
children = db.relationship( "Publication", backref="parent", passive_deletes=True )
publications = db.relationship( "Publication", backref="parent", passive_deletes=True )
def __repr__( self ):
return "<Publisher:{}|{}>".format( self.publ_id, self.publ_name )
@ -30,20 +30,19 @@ class Publication( db.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 )
pub_tags = db.Column( db.String(1000), nullable=True )
pub_edition = db.Column( db.String(100) )
pub_description = db.Column( db.String(1000) )
pub_url = db.Column( db.String(500) )
pub_tags = db.Column( db.String(1000) )
publ_id = db.Column( db.Integer,
db.ForeignKey( Publisher.__table__.c.publ_id, ondelete="CASCADE" ),
nullable=True
db.ForeignKey( Publisher.__table__.c.publ_id, ondelete="CASCADE" )
)
# 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 )
time_created = db.Column( db.TIMESTAMP(timezone=True) )
time_updated = db.Column( db.TIMESTAMP(timezone=True) )
#
children = db.relationship( "Article", backref="parent", passive_deletes=True )
articles = db.relationship( "Article", backref="parent", passive_deletes=True )
def __repr__( self ):
return "<Publication:{}|{}>".format( self.pub_id, self.pub_name )
@ -55,18 +54,51 @@ class Article( db.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 )
article_tags = db.Column( db.String(1000), nullable=True )
article_subtitle = db.Column( db.String(200) )
article_snippet = db.Column( db.String(5000) )
article_url = db.Column( db.String(500) )
article_tags = db.Column( db.String(1000) )
pub_id = db.Column( db.Integer,
db.ForeignKey( Publication.__table__.c.pub_id, ondelete="CASCADE" ),
nullable=True
db.ForeignKey( Publication.__table__.c.pub_id, ondelete="CASCADE" )
)
# 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 )
time_created = db.Column( db.TIMESTAMP(timezone=True) )
time_updated = db.Column( db.TIMESTAMP(timezone=True) )
#
article_authors = db.relationship( "ArticleAuthor", backref="parent_article", passive_deletes=True )
def __repr__( self ):
return "<Article:{}|{}>".format( self.article_id, self.article_title )
# ---------------------------------------------------------------------
class Author( db.Model ):
"""Define the Author model."""
author_id = db.Column( db.Integer, primary_key=True )
author_name = db.Column( db.String(100), nullable=False, unique=True )
#
article_authors = db.relationship( "ArticleAuthor", backref="parent_author", passive_deletes=True )
def __repr__( self ):
return "<Author:{}|{}>".format( self.author_id, self.author_name )
class ArticleAuthor( db.Model ):
"""Define the link between Article's and Author's."""
article_author_id = db.Column( db.Integer, primary_key=True )
seq_no = db.Column( db.Integer, nullable=False )
article_id = db.Column( db.Integer,
db.ForeignKey( Article.__table__.c.article_id, ondelete="CASCADE" ),
nullable = False
)
author_id = db.Column( db.Integer,
db.ForeignKey( Author.__table__.c.author_id, ondelete="CASCADE" ),
nullable = False
)
def __repr__( self ):
return "<ArticleAuthor:{}|{}:{},{}>".format( self.article_author_id,
self.seq_no, self.article_id, self.author_id
)

@ -62,44 +62,58 @@ def get_publication_vals( pub ):
@app.route( "/publication/create", methods=["POST"] )
def create_publication():
"""Create a publication."""
# parse the input
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Create publication:" )
)
vals[ "pub_tags" ] = encode_tags( vals.get( "pub_tags" ) )
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
warnings = []
updated = clean_request_args( vals, _FIELD_NAMES, warnings, _logger )
# create the new publication
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 )
# generate the response
extras = { "pub_id": pub.pub_id }
if request.args.get( "list" ):
extras[ "publications" ] = do_get_publications()
extras[ "tags" ] = do_get_tags()
return make_ok_response( cleaned=cleaned, extras=extras )
return make_ok_response( updated=updated, extras=extras, warnings=warnings )
# ---------------------------------------------------------------------
@app.route( "/publication/update", methods=["POST"] )
def update_publication():
"""Update a publication."""
# parse the input
pub_id = request.json[ "pub_id" ]
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Update publication: id={}".format( pub_id ) )
)
vals[ "pub_tags" ] = encode_tags( vals.get( "pub_tags" ) )
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
vals[ "time_updated" ] = datetime.datetime.now()
warnings = []
updated = clean_request_args( vals, _FIELD_NAMES, warnings, _logger )
# update the publication
pub = Publication.query.get( pub_id )
if not pub:
abort( 404 )
vals[ "time_updated" ] = datetime.datetime.now()
apply_attrs( pub, vals )
db.session.commit() #pylint: disable=no-member
# generate the response
extras = {}
if request.args.get( "list" ):
extras[ "publications" ] = do_get_publications()
extras[ "tags" ] = do_get_tags()
return make_ok_response( cleaned=cleaned, extras=extras )
return make_ok_response( updated=updated, extras=extras, warnings=warnings )
# ---------------------------------------------------------------------
@ -107,9 +121,8 @@ def update_publication():
def delete_publication( pub_id ):
"""Delete a publication."""
# parse the input
_logger.debug( "Delete publication: id=%s", pub_id )
# get the publication
pub = Publication.query.get( pub_id )
if not pub:
abort( 404 )
@ -123,6 +136,7 @@ def delete_publication( pub_id ):
db.session.delete( pub ) #pylint: disable=no-member
db.session.commit() #pylint: disable=no-member
# generate the response
extras = { "deleteArticles": deleted_articles }
if request.args.get( "list" ):
extras[ "publications" ] = do_get_publications()

@ -65,40 +65,54 @@ def get_publisher_vals( publ ):
@app.route( "/publisher/create", methods=["POST"] )
def create_publisher():
"""Create a publisher."""
# parse the input
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Create publisher:" )
)
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
warnings = []
updated = clean_request_args( vals, _FIELD_NAMES, warnings, _logger )
# create the new publisher
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 )
# generate the response
extras = { "publ_id": publ.publ_id }
if request.args.get( "list" ):
extras[ "publishers" ] = _do_get_publishers()
return make_ok_response( cleaned=cleaned, extras=extras )
return make_ok_response( updated=updated, extras=extras, warnings=warnings )
# ---------------------------------------------------------------------
@app.route( "/publisher/update", methods=["POST"] )
def update_publisher():
"""Update a publisher."""
# parse the input
publ_id = request.json[ "publ_id" ]
vals = get_request_args( request.json, _FIELD_NAMES,
log = ( _logger, "Update publisher: id={}".format( publ_id ) )
)
cleaned = clean_request_args( vals, _FIELD_NAMES, _logger )
vals[ "time_updated" ] = datetime.datetime.now()
warnings = []
updated = clean_request_args( vals, _FIELD_NAMES, warnings, _logger )
# update the publication
publ = Publisher.query.get( publ_id )
if not publ:
abort( 404 )
vals[ "time_updated" ] = datetime.datetime.now()
apply_attrs( publ, vals )
db.session.commit() #pylint: disable=no-member
# generate the response
extras = {}
if request.args.get( "list" ):
extras[ "publishers" ] = _do_get_publishers()
return make_ok_response( cleaned=cleaned, extras=extras )
return make_ok_response( updated=updated, extras=extras, warnings=warnings )
# ---------------------------------------------------------------------
@ -106,9 +120,8 @@ def update_publisher():
def delete_publisher( publ_id ):
"""Delete a publisher."""
# parse the input
_logger.debug( "Delete publisher: id=%s", publ_id )
# get the publisher
publ = Publisher.query.get( publ_id )
if not publ:
abort( 404 )

@ -288,7 +288,10 @@ def _edit_article( result, vals, toast_type="info", expected_error=None ):
find_child( ".edit", result ).click()
dlg = wait_for_elem( 2, "#modal-form" )
for k,v in vals.items():
if k == "publication":
if k == "authors":
select = ReactSelect( find_child( ".authors .react-select", dlg ) )
select.update_multiselect_values( *v )
elif k == "publication":
select = ReactSelect( find_child( ".publication .react-select", dlg ) )
select.select_by_name( v )
elif k == "tags":

@ -0,0 +1,83 @@
""" Test author operations. """
import urllib.request
import json
from asl_articles.tests.utils import init_tests, find_child, wait_for_elem, find_search_result
from asl_articles.tests.react_select import ReactSelect
from asl_articles.tests.test_articles import _create_article, _edit_article
# ---------------------------------------------------------------------
def test_authors( webdriver, flask_app, dbconn ):
"""Test author operations."""
# initialize
init_tests( webdriver, flask_app, dbconn )
# create some test articles
_create_article( { "title": "article 1" } )
_create_article( { "title": "article 2" } )
all_authors = set()
_check_authors( flask_app, all_authors, [ [], [] ] )
# add an author to article #1
_edit_article( find_search_result( "article 1" ), {
"authors": [ "+andrew" ]
} )
_check_authors( flask_app, all_authors, [ ["andrew"], [] ] )
# add authors to article #2
_edit_article( find_search_result( "article 2" ), {
"authors": [ "+bob", "+charlie" ]
} )
_check_authors( flask_app, all_authors, [ ["andrew"], ["bob","charlie"] ] )
# add/remove authors to article #2
_edit_article( find_search_result( "article 2" ), {
"authors": [ "+dan", "-charlie", "+andrew" ]
} )
_check_authors( flask_app, all_authors, [ ["andrew"], ["bob","dan","andrew"] ] )
# add new/existing authors to article #1
# NOTE: The main thing we're checking here is that despite new and existing authors
# being added to the article, their order is preserved.
_edit_article( find_search_result( "article 1" ), {
"authors": [ "+bob", "+new1", "+charlie", "+new2" ]
} )
_check_authors( flask_app, all_authors, [
["andrew","bob","new1","charlie","new2"], ["bob","dan","andrew"]
] )
# ---------------------------------------------------------------------
def _check_authors( flask_app, all_authors, expected ):
"""Check the authors of the test articles."""
# update the complete list of authors
# NOTE: Unlike tags, authors remain in the database even if no-one is referencing them,
# so we need to track them over the life of the entire series of tests.
for authors in expected:
all_authors.update( authors )
# check the authors in the UI
for article_no,authors in enumerate( expected ):
# check the authors for the next article
sr = find_search_result( "article {}".format( 1+article_no ) )
find_child( ".edit", sr ).click()
dlg = wait_for_elem( 2, "#modal-form" )
select = ReactSelect( find_child( ".authors .react-select", dlg ) )
assert select.get_multiselect_values() == authors
# check that the list of available authors is correct
assert select.get_multiselect_choices() == sorted( all_authors.difference( authors ) )
# close the dialog
find_child( "button.cancel", dlg ).click()
# check the authors in the database
url = flask_app.url_for( "get_authors" )
authors = json.load( urllib.request.urlopen( url ) )
assert set( a["author_name"] for a in authors.values() ) == all_authors

@ -4,7 +4,7 @@ import urllib.request
import json
from asl_articles.tests.utils import init_tests, wait_for_elem, find_child, find_children, \
find_search_result , get_result_names
find_search_result, get_result_names
from asl_articles.tests.react_select import ReactSelect
from asl_articles.tests.test_publications import _create_publication, _edit_publication
@ -85,18 +85,23 @@ def _check_tags( flask_app, expected ):
elems = find_children( "#search-results .search-result" )
assert set( get_result_names( elems ) ) == set( expected.keys() )
for sr in elems:
# check the tags in the search result
name = find_child( ".name span", sr ).text
tags = [ t.text for t in find_children( ".tag", sr ) ]
assert tags == expected[ name ]
# check the tags in the publication/article
find_child( ".edit", sr ).click()
dlg = wait_for_elem( 2, "#modal-form" )
select = ReactSelect( find_child( ".tags .react-select", dlg ) )
assert select.get_multiselect_values() == expected[ name ]
# check that the list of available tags is correct
# NOTE: We don't bother checking the tag order here.
assert set( select.get_multiselect_choices() ) == expected_available.difference( expected[name] )
# close the dialog
find_child( "button.cancel", dlg ).click()
def fixup_tags( tags ):

@ -30,7 +30,7 @@ def get_request_args( vals, arg_names, log=None ):
abort( 400, "Missing required values: {}".format( ", ".join( required ) ) )
return vals
def clean_request_args( vals, fields, logger ):
def clean_request_args( vals, fields, warnings, logger ):
"""Clean incoming data."""
cleaned = {}
for f in fields:
@ -38,9 +38,10 @@ def clean_request_args( vals, fields, logger ):
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
logger.debug( "Cleaned HTML: %s => %s", f, val2 )
warnings.append( "Some values had HTML removed." )
return cleaned
def _parse_arg_name( arg_name ):
@ -49,17 +50,15 @@ def _parse_arg_name( arg_name ):
return ( arg_name[1:], True ) # required argument
return ( arg_name, False ) # optional argument
def make_ok_response( extras=None, cleaned=None ):
def make_ok_response( extras=None, updated=None, warnings=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
if updated:
resp[ "updated" ] = updated
if warnings:
resp[ "warnings" ] = warnings
return jsonify( resp )
# ---------------------------------------------------------------------

@ -27,7 +27,7 @@ export default class App extends React.Component
searchSeqNo: 0,
modalForm: null,
askDialog: null,
startupTasks: [ "caches.publishers", "caches.publications", "caches.tags" ],
startupTasks: [ "caches.publishers", "caches.publications", "caches.authors", "caches.tags" ],
} ;
// initialize
@ -84,10 +84,10 @@ export default class App extends React.Component
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.
// NOTE: We maintain caches of key objects, 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.makeFlaskUrl( "/publishers" ) )
.then( resp => {
@ -105,6 +105,14 @@ export default class App extends React.Component
.catch( err => {
this.showErrorToast( <div> Couldn't load the publications: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
axios.get( this.makeFlaskUrl( "/authors" ) )
.then( resp => {
this.caches.authors = resp.data ;
this._onStartupTask( "caches.authors" ) ;
} )
.catch( err => {
this.showErrorToast( <div> Couldn't load the authors: <div className="monospace"> {err.toString()} </div> </div> ) ;
} ) ;
axios.get( this.makeFlaskUrl( "/tags" ) )
.then( resp => {
this.caches.tags = resp.data ;
@ -187,6 +195,19 @@ export default class App extends React.Component
) ;
}
showWarnings( caption, warnings ) {
let content ;
if ( !warnings || warnings.length === 0 )
content = caption ;
else if ( warnings.length === 1 )
content = <div> {caption} <p> {warnings[0]} </p> </div> ;
else {
let bullets = warnings.map( (warning,i) => <li key={i}> {warning} </li> ) ;
content = <div> {caption} <ul> {bullets} </ul> </div> ;
}
this.showWarningToast( content ) ;
}
ask( content, iconType, buttons ) {
// prepare the buttons
let buttons2 = [] ;

@ -3,7 +3,7 @@ import ReactDOMServer from "react-dom/server" ;
import Select from "react-select" ;
import CreatableSelect from "react-select/creatable" ;
import { gAppRef } from "./index.js" ;
import { makeOptionalLink, unloadCreatableSelect } from "./utils.js" ;
import { makeOptionalLink, unloadCreatableSelect, applyUpdatedVals } from "./utils.js" ;
const axios = require( "axios" ) ;
@ -37,17 +37,15 @@ export class ArticleSearchResult extends React.Component
ArticleSearchResult._doEditArticle( {}, (newVals,refs) => {
axios.post( gAppRef.makeFlaskUrl( "/article/create", {list:1} ), newVals )
.then( resp => {
// update the cached tags
// update the caches
gAppRef.caches.authors = resp.data.authors ;
gAppRef.caches.tags = resp.data.tags ;
// unload any cleaned values
for ( let r in refs ) {
if ( resp.data.cleaned && resp.data.cleaned[r] )
newVals[ r ] = resp.data.cleaned[ r ] ;
}
// unload any updated values
applyUpdatedVals( newVals, newVals, resp.data.updated, refs ) ;
// 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> ) ;
if ( resp.data.warnings )
gAppRef.showWarnings( "The new article was created OK.", resp.data.warnings ) ;
else
gAppRef.showInfoToast( <div> The new article was created OK. </div> ) ;
gAppRef.closeModalForm() ;
@ -64,14 +62,14 @@ export class ArticleSearchResult extends React.Component
newVals.article_id = this.props.data.article_id ;
axios.post( gAppRef.makeFlaskUrl( "/article/update", {list:1} ), newVals )
.then( resp => {
// update the cached tags
// update the caches
gAppRef.caches.authors = resp.data.authors ;
gAppRef.caches.tags = resp.data.tags ;
// update the UI with the new details
for ( let r in refs )
this.props.data[ r ] = (resp.data.cleaned && resp.data.cleaned[r]) || newVals[r] ;
applyUpdatedVals( this.props.data, newVals, resp.data.updated, refs ) ;
this.forceUpdate() ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The article was updated OK. <p> {resp.data.warning} </p> </div> ) ;
if ( resp.data.warnings )
gAppRef.showWarnings( "The article was updated OK.", resp.data.warnings ) ;
else
gAppRef.showInfoToast( <div> The article was updated OK. </div> ) ;
gAppRef.closeModalForm() ;
@ -98,6 +96,17 @@ export class ArticleSearchResult extends React.Component
publications.sort( (lhs,rhs) => {
return ReactDOMServer.renderToStaticMarkup( lhs.label ).localeCompare( ReactDOMServer.renderToStaticMarkup( rhs.label ) ) ;
} ) ;
// initialize the authors
let authors = [] ;
for ( let a of Object.entries(gAppRef.caches.authors) )
authors.push( { value: a[1].author_id, label: a[1].author_name } );
authors.sort( (lhs,rhs) => { return lhs.label.localeCompare( rhs.label ) ; } ) ;
let currAuthors = [] ;
if ( vals.article_authors ) {
currAuthors = vals.article_authors.map( a => {
return { value: a, label: gAppRef.caches.authors[a].author_name }
} ) ;
}
// initialize the tags
const tags = gAppRef.makeTagLists( vals.article_tags ) ;
// prepare the form content
@ -108,6 +117,12 @@ export class ArticleSearchResult extends React.Component
<div className="row subtitle"> <label> Subtitle: </label>
<input type="text" defaultValue={vals.article_subtitle} ref={(r) => refs.article_subtitle=r} />
</div>
<div className="row authors"> <label> Authors: </label>
<CreatableSelect className="react-select" classNamePrefix="react-select" options={authors} isMulti
defaultValue = {currAuthors}
ref = { (r) => refs.article_authors=r }
/>
</div>
<div className="row publication"> <label> Publication: </label>
<Select className="react-select" classNamePrefix="react-select" options={publications} isSearchable={true}
defaultValue = { publications[ currPub ] }
@ -134,9 +149,20 @@ export class ArticleSearchResult extends React.Component
for ( let r in refs ) {
if ( r === "pub_id" )
newVals[ r ] = refs[r].state.value && refs[r].state.value.value ;
else if ( r === "article_tags" )
newVals[ r ] = unloadCreatableSelect( refs[r] ) ;
else
else if ( r === "article_authors" ) {
let vals = unloadCreatableSelect( refs[r] ) ;
newVals.article_authors = [] ;
vals.forEach( v => {
if ( v.__isNew__ )
newVals.article_authors.push( v.label ) ; // nb: string = new author name
else
newVals.article_authors.push( v.value ) ; // nb: integer = existing author ID
} ) ;
}
else if ( r === "article_tags" ) {
let vals= unloadCreatableSelect( refs[r] ) ;
newVals[ r ] = vals.map( v => v.label ) ;
} else
newVals[ r ] = refs[r].value.trim() ;
}
if ( newVals.article_title === "" ) {
@ -163,12 +189,13 @@ export class ArticleSearchResult extends React.Component
// delete the article on the server
axios.get( gAppRef.makeFlaskUrl( "/article/delete/" + this.props.data.article_id, {list:1} ) )
.then( resp => {
// update the cached tags
// update the caches
gAppRef.caches.authors = resp.data.authors ;
gAppRef.caches.tags = resp.data.tags ;
// 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> ) ;
if ( resp.data.warnings )
gAppRef.showWarnings( "The article was deleted.", resp.data.warnings ) ;
else
gAppRef.showInfoToast( <div> The article was deleted. </div> ) ;
} )

@ -3,7 +3,7 @@ import ReactDOMServer from "react-dom/server" ;
import Select from "react-select" ;
import CreatableSelect from "react-select/creatable" ;
import { gAppRef } from "./index.js" ;
import { makeOptionalLink, unloadCreatableSelect, pluralString } from "./utils.js" ;
import { makeOptionalLink, unloadCreatableSelect, pluralString, applyUpdatedVals } from "./utils.js" ;
const axios = require( "axios" ) ;
@ -37,15 +37,12 @@ export class PublicationSearchResult extends React.Component
// update the caches
gAppRef.caches.publications = resp.data.publications ;
gAppRef.caches.tags = resp.data.tags ;
// unload any cleaned values
for ( let r in refs ) {
if ( resp.data.cleaned && resp.data.cleaned[r] )
newVals[ r ] = resp.data.cleaned[ r ] ;
}
// unload any updated values
applyUpdatedVals( newVals, newVals, resp.data.updated, refs ) ;
// 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> ) ;
if ( resp.data.warnings )
gAppRef.showWarnings( "The new publication was created OK.", resp.data.warnings ) ;
else
gAppRef.showInfoToast( <div> The new publication was created OK. </div> ) ;
gAppRef.closeModalForm() ;
@ -66,11 +63,10 @@ export class PublicationSearchResult extends React.Component
gAppRef.caches.publications = resp.data.publications ;
gAppRef.caches.tags = resp.data.tags ;
// update the UI with the new details
for ( let r in refs )
this.props.data[ r ] = (resp.data.cleaned && resp.data.cleaned[r]) || newVals[r] ;
applyUpdatedVals( this.props.data, newVals, resp.data.updated, refs ) ;
this.forceUpdate() ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The publication was updated OK. <p> {resp.data.warning} </p> </div> ) ;
if ( resp.data.warnings )
gAppRef.showWarnings( "The publication was updated OK.", resp.data.warnings ) ;
else
gAppRef.showInfoToast( <div> The publication was updated OK. </div> ) ;
gAppRef.closeModalForm() ;
@ -133,9 +129,10 @@ export class PublicationSearchResult extends React.Component
for ( let r in refs ) {
if ( r === "publ_id" )
newVals[ r ] = refs[r].state.value && refs[r].state.value.value ;
else if ( r === "pub_tags" )
newVals[ r ] = unloadCreatableSelect( refs[r] ) ;
else
else if ( r === "pub_tags" ) {
let vals = unloadCreatableSelect( refs[r] ) ;
newVals[ r ] = vals.map( v => v.label ) ;
} else
newVals[ r ] = refs[r].value.trim() ;
}
if ( newVals.pub_name === "" ) {
@ -184,8 +181,8 @@ export class PublicationSearchResult extends React.Component
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> ) ;
if ( resp.data.warnings )
gAppRef.showWarnings( "The publication was deleted.", resp.data.warnings ) ;
else
gAppRef.showInfoToast( <div> The publication was deleted. </div> ) ;
} )

@ -1,6 +1,6 @@
import React from "react" ;
import { gAppRef } from "./index.js" ;
import { makeOptionalLink, pluralString } from "./utils.js" ;
import { makeOptionalLink, pluralString, applyUpdatedVals } from "./utils.js" ;
const axios = require( "axios" ) ;
@ -27,15 +27,12 @@ export class PublisherSearchResult extends React.Component
.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 ] ;
}
// unload any updated values
applyUpdatedVals( newVals, newVals, resp.data.updated, refs ) ;
// 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> ) ;
if ( resp.data.warnings )
gAppRef.showWarnings( "The new publisher was created OK.", resp.data.warnings ) ;
else
gAppRef.showInfoToast( <div> The new publisher was created OK. </div> ) ;
gAppRef.closeModalForm() ;
@ -55,11 +52,10 @@ export class PublisherSearchResult extends React.Component
// 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] ;
applyUpdatedVals( this.props.data, newVals, resp.data.updated, refs ) ;
this.forceUpdate() ;
if ( resp.data.warning )
gAppRef.showWarningToast( <div> The publisher was updated OK. <p> {resp.data.warning} </p> </div> ) ;
if ( resp.data.warnings )
gAppRef.showWarnings( "The publisher was updated OK.", resp.data.warnings ) ;
else
gAppRef.showInfoToast( <div> The publisher was updated OK. </div> ) ;
gAppRef.closeModalForm() ;
@ -144,8 +140,8 @@ export class PublisherSearchResult extends React.Component
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> ) ;
if ( resp.data.warnings )
gAppRef.showWarnings( "The publisher was deleted.", resp.data.warnings ) ;
else
gAppRef.showInfoToast( <div> The publisher was deleted. </div> ) ;
} )

@ -6,20 +6,31 @@ export function unloadCreatableSelect( sel ) {
// unload the values from a CreatableSelect
if ( ! sel.state.value )
return [] ;
const vals = sel.state.value.map( v => v.label ) ;
const vals = sel.state.value ;
// dedupe the values (trying to preserve order)
let vals2=[], used={} ;
for ( let i=0 ; i < vals.length ; ++i ) {
if ( ! used[ vals[i] ] ) {
vals2.push( vals[i] ) ;
used[ vals[i] ] = true ;
vals.forEach( val => {
if ( ! used[ val.label ] ) {
vals2.push( val ) ;
used[ val.label ] = true ;
}
}
} ) ;
return vals2 ;
}
// --------------------------------------------------------------------
export function applyUpdatedVals( vals, newVals, updated, refs ) {
// NOTE: After the user has edited an object, we send the new values to the server to store in
// the database, but the server will sometimes return modified values back e.g. because unsafe HTML
// was removed, or the ID's of newly-created authors. This function applies these new values back
// into the original table of values.
for ( let r in refs )
vals[ r ] = (updated && updated[r] !== undefined) ? updated[r] : newVals[r] ;
}
// --------------------------------------------------------------------
export function makeOptionalLink( caption, url ) {
let link = <span dangerouslySetInnerHTML={{ __html: caption }} /> ;
if ( url )

Loading…
Cancel
Save