Created linkages between the various objects in the UI.

master
Pacman Ghost 4 years ago
parent ff69969028
commit 19c280d321
  1. 4
      asl_articles/publications.py
  2. 130
      asl_articles/search.py
  3. 173
      asl_articles/tests/test_search.py
  4. 18
      asl_articles/tests/utils.py
  5. 34
      web/src/App.js
  6. 76
      web/src/ArticleSearchResult.js
  7. 52
      web/src/PublicationSearchResult.js
  8. 12
      web/src/PublisherSearchResult.js
  9. 8
      web/src/SearchResults.css
  10. 30
      web/src/utils.js

@ -69,6 +69,10 @@ def get_publication_vals( pub, include_articles, add_type=False ):
vals[ "type" ] = "publication"
return vals
def get_publication_sort_key( pub ):
"""Get a publication's sort key."""
return int( pub.time_created.timestamp() ) if pub.time_created else 0
# ---------------------------------------------------------------------
@app.route( "/publication/create", methods=["POST"] )

@ -8,13 +8,13 @@ import tempfile
import re
import logging
from flask import request, jsonify
from flask import request, jsonify, abort
import asl_articles
from asl_articles import app, db
from asl_articles.models import Publisher, Publication, Article, Author, Scenario, get_model_from_table_name
from asl_articles.publishers import get_publisher_vals
from asl_articles.publications import get_publication_vals
from asl_articles.publications import get_publication_vals, get_publication_sort_key
from asl_articles.articles import get_article_vals
from asl_articles.utils import decode_tags, to_bool
@ -22,7 +22,14 @@ _search_index_path = None
_search_aliases = {}
_logger = logging.getLogger( "search" )
_SQLITE_FTS_SPECIAL_CHARS = "+-#':/."
_SQLITE_FTS_SPECIAL_CHARS = "+-#':/.@$"
_SEARCHABLE_COL_NAMES = [ "name", "name2", "description", "authors", "scenarios", "tags" ]
_get_publisher_vals = lambda p: get_publisher_vals( p, True )
_get_publication_vals = lambda p: get_publication_vals( p, True, True )
_get_article_vals = lambda a: get_article_vals( a, True )
_PASSTHROUGH_REGEXES = set( [
re.compile( r"\bAND\b" ),
re.compile( r"\bOR\b" ),
@ -94,9 +101,77 @@ _FIELD_MAPPINGS = {
@app.route( "/search", methods=["POST"] )
def search():
"""Run a search."""
query_string = request.json.get( "query" ).strip()
return _do_search( query_string, None )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@app.route( "/search/publisher/<int:publ_id>", methods=["POST","GET"] )
def search_publisher( publ_id ):
"""Search for a publisher."""
publ = Publisher.query.get( publ_id )
if not publ:
abort( 404 )
results = [ get_publisher_vals( publ, True ) ]
pubs = sorted( publ.publications, key=get_publication_sort_key, reverse=True )
for pub in pubs:
results.append( get_publication_vals( pub, False, True ) )
return jsonify( results )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@app.route( "/search/publication/<int:pub_id>", methods=["POST","GET"] )
def search_publication( pub_id ):
"""Search for a publication."""
pub = Publication.query.get( pub_id )
if not pub:
abort( 404 )
results = [ get_publication_vals( pub, True, True ) ]
for article in pub.articles:
results.append( get_article_vals( article, True ) )
return jsonify( results )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@app.route( "/search/article/<int:article_id>", methods=["POST","GET"] )
def search_article( article_id ):
"""Search for an article."""
article = Article.query.get( article_id )
if not article:
abort( 404 )
results = [ get_article_vals( article, True ) ]
if article.pub_id:
pub = Publication.query.get( article.pub_id )
if pub:
results.append( get_publication_vals( pub, True, True ) )
return jsonify( results )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@app.route( "/search/author/<int:author_id>", methods=["POST","GET"] )
def search_author( author_id ):
"""Search for an author."""
author = Author.query.get( author_id )
if not author:
abort( 404 )
author_name = '"{}"'.format( author.author_name.replace( '"', '""' ) )
return _do_search( author_name, [ "authors" ] )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@app.route( "/search/tag/<tag>", methods=["POST","GET"] )
def search_tag( tag ):
"""Search for a tag."""
tag = '"{}"'.format( tag.replace( '"', '""' ) )
return _do_search( tag, [ "tags" ] )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _do_search( query_string, col_names ):
"""Run a search."""
try:
return _do_search()
return _do_search2( query_string, col_names )
except Exception as exc: #pylint: disable=broad-except
msg = str( exc )
if isinstance( exc, sqlite3.OperationalError ):
@ -106,22 +181,14 @@ def search():
msg = str( type(exc) )
return jsonify( { "error": msg } )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _do_search(): #pylint: disable=too-many-locals,too-many-statements,too-many-branches
def _do_search2( query_string, col_names ):
"""Run a search."""
# parse the request parameters
query_string = request.json.get( "query" ).strip()
if not query_string:
raise RuntimeError( "Missing query string." )
no_hilite = to_bool( request.json.get( "no_hilite" ) )
_logger.info( "SEARCH REQUEST: %s", query_string )
_get_publisher_vals = lambda p: get_publisher_vals( p, True )
_get_publication_vals = lambda p: get_publication_vals( p, True, True )
_get_article_vals = lambda a: get_article_vals( a, True )
# check for special query terms (for testing porpoises)
results = []
def find_special_term( term ):
@ -150,9 +217,19 @@ def _do_search(): #pylint: disable=too-many-locals,too-many-statements,too-many-
if not query_string:
return jsonify( results )
# prepare the query
# do the search
fts_query_string = _make_fts_query_string( query_string, _search_aliases )
return _do_fts_search( fts_query_string, col_names, results=results )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _do_fts_search( fts_query_string, col_names, results=None ): #pylint: disable=too-many-locals
"""Run an FTS search."""
_logger.debug( "FTS query string: %s", fts_query_string )
if results is None:
results = []
no_hilite = request.json and to_bool( request.json.get( "no_hilite" ) )
# NOTE: We would like to cache the connection, but SQLite connections can only be used
# in the same thread they were created in.
@ -164,14 +241,16 @@ def _do_search(): #pylint: disable=too-many-locals,too-many-statements,too-many-
return "highlight( searchable, {}, '{}', '{}' )".format(
n, hilites[0], hilites[1]
)
sql = "SELECT owner,rank,{}, {}, {}, {}, {}, {} FROM searchable" \
sql = "SELECT owner, rank, {}, {}, {}, {}, {}, {} FROM searchable" \
" WHERE searchable MATCH ?" \
" ORDER BY rank".format(
highlight(1), highlight(2), highlight(3), highlight(4), highlight(5), highlight(6)
)
curs = dbconn.conn.execute( sql,
( "{name name2 description authors scenarios tags}: " + fts_query_string, )
match = "{{ {} }}: {}".format(
" ".join( col_names or _SEARCHABLE_COL_NAMES ),
fts_query_string
)
curs = dbconn.conn.execute( sql, (match,) )
# get the results
for row in curs:
@ -183,7 +262,7 @@ def _do_search(): #pylint: disable=too-many-locals,too-many-statements,too-many-
_logger.debug( "- {} ({:.3f})".format( obj, row[1] ) )
# prepare the result for the front-end
result = locals()[ "_get_{}_vals".format( owner_type ) ]( obj )
result = globals()[ "_get_{}_vals".format( owner_type ) ]( obj )
result[ "type" ] = owner_type
# return highlighted versions of the content to the caller
@ -209,6 +288,8 @@ def _do_search(): #pylint: disable=too-many-locals,too-many-statements,too-many-
return jsonify( results )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _make_fts_query_string( query_string, search_aliases ):
"""Generate the SQLite query string."""
@ -286,7 +367,9 @@ def init_search( session, logger ):
# (nor UNIQUE constraints), so we have to manage this manually :-(
dbconn.conn.execute(
"CREATE VIRTUAL TABLE searchable USING fts5"
" ( owner, name, name2, description, authors, scenarios, tags, tokenize='porter unicode61' )"
" ( owner, {}, tokenize='porter unicode61' )".format(
", ".join( _SEARCHABLE_COL_NAMES )
)
)
# load the searchable content
@ -356,9 +439,12 @@ def _do_add_or_update_searchable( dbconn, owner_type, owner, obj ):
# when search results are presented to the user.
def do_add_or_update( dbconn ):
dbconn.conn.execute( "INSERT INTO searchable"
" ( owner, name, name2, description, authors, scenarios, tags )"
" VALUES (?,?,?,?,?,?,?)", (
sql = "INSERT INTO searchable" \
" ( owner, {} )" \
" VALUES (?,?,?,?,?,?,?)".format(
",".join( _SEARCHABLE_COL_NAMES )
)
dbconn.conn.execute( sql, (
owner,
vals.get("name"), vals.get("name2"), vals.get("description"),
vals.get("authors"), vals.get("scenarios"), vals.get("tags")

@ -1,13 +1,14 @@
""" Test search operations. """
from asl_articles.search import _load_search_aliases, _make_fts_query_string
from asl_articles.search import SEARCH_ALL
from asl_articles.tests.test_publishers import create_publisher, edit_publisher
from asl_articles.tests.test_publications import create_publication, edit_publication
from asl_articles.tests.test_articles import create_article, edit_article
from asl_articles.tests.utils import init_tests, select_sr_menu_option, \
wait_for_elem, find_child, find_children, check_ask_dialog, \
do_search, get_search_result_names, find_search_result
wait_for, wait_for_elem, find_child, find_children, check_ask_dialog, \
do_search, get_search_results, get_search_result_names, find_search_result, get_search_seqno
# ---------------------------------------------------------------------
@ -271,6 +272,174 @@ def test_highlighting( webdriver, flask_app, dbconn ):
# ---------------------------------------------------------------------
def test_publisher_search( webdriver, flask_app, dbconn ):
"""Test searching for publishers."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="search.json" )
def click_on_publisher( sr, expected ):
elem = find_child( ".header .publisher", sr )
assert elem.text == expected
seq_no = get_search_seqno()
elem.click()
wait_for( 2, lambda: get_search_seqno() != seq_no )
assert find_child( "#search-form input.query" ).get_attribute( "value" ) == ""
return get_search_results()
# find a publication and click on its parent publisher
results = do_search( "fantastic" )
assert len(results) == 1
click_on_publisher( results[0], "View From The Trenches" )
assert get_search_result_names() == [
"View From The Trenches", "View From The Trenches (100)"
]
# ---------------------------------------------------------------------
def test_publication_search( webdriver, flask_app, dbconn ):
"""Test searching for publications."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="search.json" )
def click_on_publication( sr, expected ):
classes = sr.get_attribute( "class" ).split()
if "article" in classes:
elem = find_child( ".header .publication", sr )
elif "publisher" in classes:
elems = find_children( ".content .collapsible li", sr )
elem = elems[0] # nb: we just use the first one
else:
assert "publication" in classes
elem = find_child( ".header .name", sr )
assert elem.text == expected
seq_no = get_search_seqno()
elem.click()
wait_for( 2, lambda: get_search_seqno() != seq_no )
assert find_child( "#search-form input.query" ).get_attribute( "value" ) == ""
return get_search_results()
# find a publication and click on it
results = do_search( "vftt" )
sr = find_search_result( "View From The Trenches (100)", results )
click_on_publication( sr, "View From The Trenches (100)" )
assert get_search_result_names() == [
"View From The Trenches (100)", "Jagdpanzer 38(t) Hetzer"
]
# find an article and click on its parent publication
results = do_search( "neutral" )
assert len(results) == 1
click_on_publication( results[0], "ASL Journal (5)" )
assert get_search_result_names() == [
"ASL Journal (5)", "The Jungle Isn't Neutral", "Hunting DUKWs and Buffalos"
]
# find a publisher and click on one of its publications
results = do_search( "mmp" )
assert len(results) == 1
click_on_publication( results[0], "ASL Journal (4)" )
assert get_search_result_names() == [
"ASL Journal (4)", "Hit 'Em High, Or Hit 'Em Low", "'Bolts From Above"
]
# ---------------------------------------------------------------------
def test_article_search( webdriver, flask_app, dbconn ):
"""Test searching for articles."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="search.json" )
def click_on_article( sr, expected ):
elems = find_children( ".content .collapsible li", sr )
elem = elems[0] # nb: we just use the first one
assert elem.text == expected
seq_no = get_search_seqno()
elem.click()
wait_for( 2, lambda: get_search_seqno() != seq_no )
assert find_child( "#search-form input.query" ).get_attribute( "value" ) == ""
return get_search_results()
# find a publication and click on one of its articles
results = do_search( "vftt" )
sr = find_search_result( "View From The Trenches (100)", results )
click_on_article( sr, "Jagdpanzer 38(t) Hetzer" )
assert get_search_result_names() == [
"Jagdpanzer 38(t) Hetzer", "View From The Trenches (100)"
]
# ---------------------------------------------------------------------
def test_author_search( webdriver, flask_app, dbconn ):
"""Test searching for authors."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="search.json" )
def click_on_author( sr, expected ):
authors = find_children( ".authors .author", sr )
assert len(authors) == 1
assert authors[0].text == expected
seq_no = get_search_seqno()
authors[0].click()
wait_for( 2, lambda: get_search_seqno() != seq_no )
assert find_child( "#search-form input.query" ).get_attribute( "value" ) == ""
return get_search_results()
# find an article and click on the author
results = do_search( SEARCH_ALL )
sr = find_search_result( "Jagdpanzer 38(t) Hetzer" )
results = click_on_author( sr, "Michael Davies" )
assert get_search_result_names( results ) == [
"Jagdpanzer 38(t) Hetzer"
]
# ---------------------------------------------------------------------
def test_tag_search( webdriver, flask_app, dbconn ):
"""Test searching for tags."""
# initialize
init_tests( webdriver, flask_app, dbconn, fixtures="search.json" )
def click_on_tag( tag ):
seq_no = get_search_seqno()
tag.click()
wait_for( 2, lambda: get_search_seqno() != seq_no )
assert find_child( "#search-form input.query" ).get_attribute( "value" ) == ""
return get_search_results()
def get_tags( sr ):
return find_children( ".tags .tag", sr )
# find an article and click on the "#aslj" tag
results = do_search( "high low" )
assert len(results) == 1
tags = get_tags( results[0] )
assert [ t.text for t in tags ] == [ "#aslj", "#mortars" ]
results = click_on_tag( tags[0] )
expected = [
"ASL Journal (4)", "ASL Journal (5)",
"'Bolts From Above", "The Jungle Isn't Neutral", "Hunting DUKWs and Buffalos", "Hit 'Em High, Or Hit 'Em Low"
]
assert get_search_result_names( results ) == expected
# click on another "#aslj" tag
tags = get_tags( results[0] )
assert [ t.text for t in tags ] == [ "#aslj" ]
results = click_on_tag( tags[0] )
assert get_search_result_names( results ) == expected
# click on a "#PTO" tag
sr = find_search_result( "The Jungle Isn't Neutral" )
tags = get_tags( sr )
assert [ t.text for t in tags ] == [ "#aslj", "#PTO" ]
results = click_on_tag( tags[1] )
assert get_search_result_names( results ) == [ "The Jungle Isn't Neutral" ]
# ---------------------------------------------------------------------
def test_make_fts_query_string():
"""Test generating FTS query strings."""

@ -93,15 +93,8 @@ def load_fixtures( session, fname ):
def do_search( query ):
"""Run a search."""
# get the current search seq#
def get_seqno():
elem = find_child( "#search-results" )
if elem is None:
return None
return elem.get_attribute( "seqno" )
curr_seqno = get_seqno()
# submit the search query
curr_seqno = get_search_seqno()
form = find_child( "#search-form" )
assert form
elem = find_child( ".query", form )
@ -116,7 +109,7 @@ def do_search( query ):
find_child( "button[type='submit']", form ).click()
# return the results
wait_for( 2, lambda: get_seqno() != curr_seqno )
wait_for( 2, lambda: get_search_seqno() != curr_seqno )
return get_search_results()
def get_search_results():
@ -162,6 +155,13 @@ def check_search_result( sr, check, expected ):
return None # nb: the web page updated while we were checking it
return wait_for( 2, check_sr )
def get_search_seqno():
"""Get the current search seq#."""
elem = find_child( "#search-results" )
if not elem:
return None
return elem.get_attribute( "seqno" )
# ---------------------------------------------------------------------
def do_test_confirm_discard_changes( menu_id, update_fields=None ): #pylint: disable=too-many-statements

@ -44,6 +44,7 @@ export default class App extends React.Component
// initialize
this._searchFormRef = React.createRef() ;
this._searchResultsRef = React.createRef() ;
this._modalFormRef = React.createRef() ;
this._setFocusTo = null ;
@ -91,7 +92,10 @@ export default class App extends React.Component
<SearchForm onSearch={this.onSearch.bind(this)} ref={this._searchFormRef} />
</div>
{menu}
<SearchResults seqNo={this.state.searchSeqNo} searchResults={this.state.searchResults} />
<SearchResults ref={this._searchResultsRef}
seqNo = {this.state.searchSeqNo}
searchResults = {this.state.searchResults}
/>
</div> ) ;
}
return ( <div> {content}
@ -156,17 +160,31 @@ export default class App extends React.Component
onSearch( query ) {
// run the search
query = query.trim() ;
const queryStringRef = this._searchFormRef.current.queryStringRef.current ;
if ( query.length === 0 ) {
this.showErrorMsg( "Please enter something to search for.", queryStringRef )
this.showErrorMsg( "Please enter something to search for.", this._searchFormRef.current.queryStringRef.current )
return ;
}
axios.post( this.makeFlaskUrl( "/search" ), {
query: query,
no_hilite: this._disableSearchResultHighlighting,
} )
this._doSearch( "/search", { query: query } ) ;
}
searchForPublisher( publ_id ) { this._onSpecialSearch( "/search/publisher/" + publ_id ) ; }
searchForPublication( pub_id ) { this._onSpecialSearch( "/search/publication/" + pub_id ) ; }
searchForArticle( article_id ) { this._onSpecialSearch( "/search/article/" + article_id ) ; }
searchForAuthor( author_id ) { this._onSpecialSearch( "/search/author/" + author_id ) ; }
searchForTag( tag ) { this._onSpecialSearch( "/search/tag/" + encodeURIComponent(tag) ) ; }
_onSpecialSearch( url ) {
// run the search
this._searchFormRef.current.setState( { queryString: "" } ) ;
this._doSearch( url, {} ) ;
}
_doSearch( url, args ) {
// do the search
args.no_hilite = this._disableSearchResultHighlighting ;
axios.post(
this.makeFlaskUrl( url ), args
)
.then( resp => {
this._setFocusTo = queryStringRef ;
ReactDOM.findDOMNode( this._searchResultsRef.current ).scrollTo( 0, 0 ) ;
this._setFocusTo = this._searchFormRef.current.queryStringRef.current ;
this.setState( { searchResults: resp.data, searchSeqNo: this.state.searchSeqNo+1 } ) ;
} )
.catch( err => {

@ -23,32 +23,44 @@ export class ArticleSearchResult extends React.Component
const image_url = gAppRef.makeFlaskImageUrl( "article", this.props.data.article_image_id, true ) ;
// prepare the authors
let authors ;
let authors = [] ;
if ( this.props.data[ "authors!" ] ) {
// the backend has provided us with a list of author names (possibly highlighted) - use them directly
authors = makeCommaList( this.props.data["authors!"],
(a) => <span className="author" key={a} dangerouslySetInnerHTML={{__html: a}} />
) ;
for ( let i=0 ; i < this.props.data["authors!"].length ; ++i ) {
const author_id = this.props.data.article_authors[ i ] ;
authors.push( <span key={i} className="author"
dangerouslySetInnerHTML = {{ __html: this.props.data["authors!"][i] }}
onClick = { () => gAppRef.searchForAuthor( author_id ) }
title = "Show articles from this author."
/> ) ;
}
} else {
// we only have a list of author ID's (the normal case) - figure out what the corresponding names are
authors = makeCommaList( this.props.data.article_authors,
(a) => <span className="author" key={a} dangerouslySetInnerHTML={{__html: gAppRef.caches.authors[a].author_name}} />
) ;
for ( let i=0 ; i < this.props.data.article_authors.length ; ++i ) {
const author_id = this.props.data.article_authors[ i ] ;
authors.push( <span key={i} className="author"
dangerouslySetInnerHTML = {{ __html: gAppRef.caches.authors[ author_id ].author_name }}
onClick = { () => gAppRef.searchForAuthor( author_id ) }
title = "Show articles from this author."
/> ) ;
}
}
// prepare the scenarios
let scenarios ;
let scenarios = [] ;
if ( this.props.data[ "scenarios!" ] ) {
// the backend has provided us with a list of scenarios (possibly highlighted) - use them directly
let scenarios2 = [];
this.props.data["scenarios!"].forEach( s => scenarios2.push( makeScenarioDisplayName(s) ) ) ;
scenarios = makeCommaList( scenarios2,
(s) => <span className="scenario" key={s} dangerouslySetInnerHTML={{__html: s}} />
this.props.data[ "scenarios!" ].forEach( (scenario,i) =>
scenarios.push( <span key={i} className="scenario"
dangerouslySetInnerHTML = {{ __html: makeScenarioDisplayName( scenario ) }}
/> )
) ;
} else {
// we only have a list of scenario ID's (the normal case) - figure out what the corresponding names are
scenarios = makeCommaList( this.props.data.article_scenarios,
(s) => <span className="scenario" key={s} dangerouslySetInnerHTML={{__html: makeScenarioDisplayName(gAppRef.caches.scenarios[s])}} />
this.props.data.article_scenarios.forEach( (scenario,i) =>
scenarios.push( <span key={i} className="scenario"
dangerouslySetInnerHTML = {{ __html: makeScenarioDisplayName( gAppRef.caches.scenarios[scenario] ) }}
/> )
) ;
}
@ -59,14 +71,22 @@ export class ArticleSearchResult extends React.Component
// NOTE: We don't normally show HTML in tags, but in this case we need to, in order to be able to highlight
// matching search terms. This will have the side-effect of rendering any HTML that may be in the tag,
// but we can live with that.
this.props.data[ "tags!" ].map(
t => tags.push( <div key={t} className="tag" dangerouslySetInnerHTML={{__html: t}} /> )
) ;
for ( let i=0 ; i < this.props.data["tags!"].length ; ++i ) {
const tag = this.props.data.article_tags[ i ] ; // nb: this is the actual tag (without highlights)
tags.push( <div key={tag} className="tag"
dangerouslySetInnerHTML = {{ __html: this.props.data["tags!"][i] }}
onClick = { () => gAppRef.searchForTag( tag ) }
title = "Search for this tag."
/> ) ;
}
} else {
if ( this.props.data.article_tags ) {
this.props.data.article_tags.map(
t => tags.push( <div key={t} className="tag"> {t} </div> )
) ;
tag => tags.push( <div key={tag} className="tag"
onClick = { () => gAppRef.searchForTag( tag ) }
title = "Search for this tag."
> {tag} </div>
) ) ;
}
}
@ -91,9 +111,19 @@ export class ArticleSearchResult extends React.Component
>
<div className="header">
{menu}
{ pub_display_name && <span className="publication"> {pub_display_name} </span> }
{ pub_display_name &&
<span className="publication"
onClick = { () => gAppRef.searchForPublication( this.props.data.pub_id ) }
title = "Show this publication."
> {pub_display_name}
</span>
}
<span className="title name" dangerouslySetInnerHTML={{ __html: display_title }} />
{ this.props.data.article_url && <a href={this.props.data.article_url} className="open-link" target="_blank" rel="noopener noreferrer"><img src="/images/open-link.png" alt="Open article." title="Open this article." /></a> }
{ this.props.data.article_url &&
<a href={this.props.data.article_url} className="open-link" target="_blank" rel="noopener noreferrer">
<img src="/images/open-link.png" alt="Open article." title="Open this article." />
</a>
}
{ display_subtitle && <div className="subtitle" dangerouslySetInnerHTML={{ __html: display_subtitle }} /> }
</div>
<div className="content">
@ -101,8 +131,8 @@ export class ArticleSearchResult extends React.Component
<div className="snippet" dangerouslySetInnerHTML={{__html: display_snippet}} />
</div>
<div className="footer">
{ authors.length > 0 && <div className="authors"> By {authors} </div> }
{ scenarios.length > 0 && <div className="scenarios"> Scenarios: {scenarios} </div> }
{ authors.length > 0 && <div className="authors"> By {makeCommaList(authors)} </div> }
{ scenarios.length > 0 && <div className="scenarios"> Scenarios: {makeCommaList(scenarios)} </div> }
{ tags.length > 0 && <div className="tags"> Tags: {tags} </div> }
</div>
</div> ) ;

@ -27,21 +27,37 @@ export class PublicationSearchResult extends React.Component
// NOTE: We don't normally show HTML in tags, but in this case we need to, in order to be able to highlight
// matching search terms. This will have the side-effect of rendering any HTML that may be in the tag,
// but we can live with that.
this.props.data[ "tags!" ].map(
t => tags.push( <div key={t} className="tag" dangerouslySetInnerHTML={{__html: t}} /> )
) ;
for ( let i=0 ; i < this.props.data["tags!"].length ; ++i ) {
const tag = this.props.data.pub_tags[ i ] ; // nb: this is the actual tag (without highlights)
tags.push( <div key={tag} className="tag"
dangerouslySetInnerHTML = {{ __html: this.props.data["tags!"][i] }}
onClick = { () => gAppRef.searchForTag( tag ) }
title = "Search for this tag."
/> ) ;
}
} else {
if ( this.props.data.pub_tags ) {
this.props.data.pub_tags.map(
t => tags.push( <div key={t} className="tag"> {t} </div> )
) ;
tag => tags.push( <div key={tag} className="tag"
onClick = { () => gAppRef.searchForTag( tag ) }
title = "Search for this tag."
> {tag} </div>
) ) ;
}
}
// prepare the articles
let articles = null ;
if ( this.props.data.articles )
articles = this.props.data.articles.map( a => a.article_title ) ;
let articles = [] ;
if ( this.props.data.articles ) {
for ( let i=0 ; i < this.props.data.articles.length ; ++i ) {
const article = this.props.data.articles[ i ] ;
articles.push( <span
dangerouslySetInnerHTML = {{ __html: article.article_title }}
onClick = { () => gAppRef.searchForArticle( article.article_id ) }
title = "Show this article."
/> ) ;
}
}
// prepare the menu
const menu = ( <Menu>
@ -61,9 +77,23 @@ export class PublicationSearchResult extends React.Component
>
<div className="header">
{menu}
{ publ && <span className="publisher"> {publ.publ_name} </span> }
<span className="name" dangerouslySetInnerHTML={{ __html: this._makeDisplayName(true) }} />
{ this.props.data.pub_url && <a href={this.props.data.pub_url} className="open-link" target="_blank" rel="noopener noreferrer"><img src="/images/open-link.png" alt="Open publication." title="Open this publication." /></a> }
{ publ &&
<span className="publisher"
onClick={ () => gAppRef.searchForPublisher( this.props.data.publ_id ) }
title = "Show this publisher."
> {publ.publ_name}
</span>
}
<span className="name"
dangerouslySetInnerHTML = {{ __html: this._makeDisplayName( true ) }}
onClick = { () => gAppRef.searchForPublication( this.props.data.pub_id ) }
title = "Show this publication."
/>
{ this.props.data.pub_url &&
<a href={this.props.data.pub_url} className="open-link" target="_blank" rel="noopener noreferrer">
<img src="/images/open-link.png" alt="Open publication." title="Open this publication." />
</a>
}
</div>
<div className="content">
{ image_url && <img src={image_url} className="image" alt="Publication." /> }

@ -28,7 +28,11 @@ export class PublisherSearchResult extends React.Component
pubs.push( pub[1] ) ;
}
pubs.sort( (lhs,rhs) => rhs.time_created - lhs.time_created ) ;
pubs = pubs.map( p => PublicationSearchResult.makeDisplayName(p) ) ;
pubs = pubs.map( p => <span
dangerouslySetInnerHTML = {{ __html: PublicationSearchResult.makeDisplayName(p) }}
onClick = { () => gAppRef.searchForPublication( p.pub_id ) }
title = "Show this publication."
/> ) ;
// prepare the menu
const menu = ( <Menu>
@ -49,7 +53,11 @@ export class PublisherSearchResult extends React.Component
<div className="header">
{menu}
<span className="name" dangerouslySetInnerHTML={{ __html: display_name }} />
{ this.props.data.publ_url && <a href={this.props.data.publ_url} className="open-link" target="_blank" rel="noopener noreferrer"><img src="/images/open-link.png" alt="Open publisher." title="Go to this publisher." /></a> }
{ this.props.data.publ_url &&
<a href={this.props.data.publ_url} className="open-link" target="_blank" rel="noopener noreferrer">
<img src="/images/open-link.png" alt="Open publisher." title="Go to this publisher." />
</a>
}
</div>
<div className="content">
{ image_url && <img src={image_url} className="image" alt="Publisher." /> }

@ -16,8 +16,11 @@
.search-result.publisher .header { border: 1px solid #c0c0c0 ; background: #eabe51 ; }
.search-result.publication .header { border: 1px solid #c0c0c0 ; background: #e5f700 ; }
.search-result.publication .header .name { cursor: pointer ; }
.search-result.publication .header .publisher { cursor: pointer ; }
.search-result.article .header { border: 1px solid #c0c0c0 ; background: #d3edfc ; }
.search-result.article .header .subtitle { font-size: 80% ; font-style: italic ; color: #333 ; }
.search-result.article .header .publication { cursor: pointer ; }
.search-result.publication .header .publisher , .search-result.article .header .publication {
float: right ; margin-right: 0.5em ; font-size: 80% ; font-style: italic ; color: #444 ;
@ -30,9 +33,12 @@
.search-result .content .collapsible { margin-top:0.5em ; font-size: 90% ; color: #444 ; }
.search-result .content .collapsible .caption img { height: 0.75em ; margin-left: 0.25em ; cursor: pointer ; }
.search-result .content .collapsible ul { margin: 0 0 0 1em ; }
.search-result.publisher .content .collapsible li { cursor: pointer ; }
.search-result.publication .content .collapsible li { cursor: pointer ; }
.search-result .footer { clear: both ; padding: 0 5px ; font-size: 80% ; font-style: italic ; color: #666 ; }
.search-result .footer .tag { display: inline ; margin-right: 0.25em ; padding: 0 2px ; border: 1px solid #ccc ; background: #f0f0f0 ; }
.search-result .footer .tag { display: inline ; margin-right: 0.25em ; padding: 0 2px ; border: 1px solid #ccc ; background: #f0f0f0 ; cursor: pointer ; }
.search-result.article .footer .author { cursor: pointer ; }
.search-result .hilite { padding: 0 2px ; background: #ffffa0 ; }
.search-result.publisher .header .hilite { background: #e0a040 ; }

@ -174,23 +174,19 @@ export function makeCollapsibleList( caption, vals, maxItems, style ) {
if ( ! vals || vals.length === 0 )
return null ;
let items=[], excessItems=[] ;
let excessItemRefs={}, flipButtonRef=null ;
let excessItemsRef=null, flipButtonRef=null ;
for ( let i=0 ; i < vals.length ; ++i ) {
if ( i < maxItems ) {
items.push(
<li key={i} dangerouslySetInnerHTML={{ __html: vals[i] }} />
) ;
} else {
excessItems.push(
<li key={i} dangerouslySetInnerHTML={{ __html: vals[i] }} style={{display:"none"}} ref={r => excessItemRefs[i]=r} />
) ;
}
let item ;
if ( typeof vals[i] === "string" )
item = <li key={i} dangerouslySetInnerHTML={{ __html: vals[i] }} /> ;
else
item = <li key={i}> {vals[i]} </li> ; // nb: we assume we were given JSX
( i < maxItems ? items : excessItems ).push( item ) ;
}
function flipExcessItems() {
const pos = flipButtonRef.src.lastIndexOf( "/" ) ;
const show = flipButtonRef.src.substr( pos ) === "/collapsible-down.png" ;
for ( let r in excessItemRefs )
excessItemRefs[r].style.display = show ? "list-item" : "none" ;
excessItemsRef.style.display = show ? "block" : "none" ;
flipButtonRef.src = flipButtonRef.src.substr( 0, pos ) + (show ? "/collapsible-up.png" : "/collapsible-down.png") ;
}
return ( <div className="collapsible" style={style}>
@ -198,15 +194,19 @@ export function makeCollapsibleList( caption, vals, maxItems, style ) {
{ excessItems.length > 0 && <img src="images/collapsible-down.png" onClick={flipExcessItems} ref={r => flipButtonRef=r} alt="Show/hide extra items." /> }
</div>
<ul> {items} </ul>
{ excessItems.length > 0 && <ul> {excessItems} </ul> }
{ excessItems.length > 0 &&
<ul className="excess" ref={r => excessItemsRef=r} style={{display:"none"}}>
{excessItems}
</ul>
}
</div> ) ;
}
export function makeCommaList( vals, extract ) {
export function makeCommaList( vals ) {
let result = [] ;
if ( vals ) {
for ( let i=0 ; i < vals.length ; ++i ) {
result.push( extract( vals[i] ) ) ;
result.push( vals[i] ) ;
if ( i < vals.length-1 )
result.push( ", " ) ;
}

Loading…
Cancel
Save