Manage ASL magazines and their articles.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
asl-articles/web/src/App.js

583 lines
26 KiB

import React from "react" ;
import ReactDOM from "react-dom" ;
import ReactDOMServer from "react-dom/server" ;
import { Menu, MenuList, MenuButton, MenuItem } from "@reach/menu-button" ;
import "@reach/menu-button/styles.css" ;
import { ToastContainer, toast } from "react-toastify" ;
import "react-toastify/dist/ReactToastify.min.css" ;
import SearchForm from "./SearchForm" ;
import { SearchResults } from "./SearchResults" ;
import { PublisherSearchResult } from "./PublisherSearchResult" ;
import { PublicationSearchResult } from "./PublicationSearchResult" ;
import { ArticleSearchResult } from "./ArticleSearchResult" ;
import { DbReport } from "./DbReport";
import ModalForm from "./ModalForm";
import AskDialog from "./AskDialog" ;
import { DataCache } from "./DataCache" ;
import { PreviewableImage } from "./PreviewableImage" ;
import { makeSmartBulletList, isLink } from "./utils.js" ;
import { APP_NAME } from "./constants.js" ;
import "./App.css" ;
const axios = require( "axios" ) ;
const queryString = require( "query-string" ) ;
window.$ = window.jQuery = require( "jquery" ) ;
export let gAppRef = null ;
// --------------------------------------------------------------------
export class App extends React.Component
{
constructor( props ) {
// initialize the App
super( props ) ;
this.state = {
searchResults: [],
searchSeqNo: 0,
showDbReport: false,
modalForm: null,
askDialog: null,
startupTasks: [ "dummy" ], // FUDGE! We need at least one startup task.
} ;
gAppRef = this ;
this.setWindowTitle( null ) ;
// initialize the data cache
this.dataCache = new DataCache() ;
// initialize
this.args = queryString.parse( window.location.search ) ;
this._storeMsgs = this.isTestMode() && this.args.store_msgs ;
this._disableSearchResultHighlighting = this.isTestMode() && this.args.no_sr_hilite ;
this._disableConstraints = this.isTestMode() && this.args.disable_constraints ;
this._disableConfirmDiscardChanges = this.isTestMode() && this.args.disable_confirm_discard_changes ;
this._fakeUploads = this.isTestMode() && this.args.fake_uploads ;
// initialize
this._searchFormRef = React.createRef() ;
this._searchResultsRef = React.createRef() ;
this._modalFormRef = React.createRef() ;
this._setFocusTo = null ;
// figure out the base URL of the Flask backend server
// NOTE: We allow the caller to do this since the test suite will usually spin up
// it's own Flask server, but talks to an existing React server, so we need some way
// for pytest to change which Flask server the React frontend code should tak to.
this._flaskBaseUrl = this.isTestMode() ? this.args._flask : null ;
if ( ! this._flaskBaseUrl ) {
// NOTE: We used to use process.env.REACT_APP_FLASK_URL here, but this means that the client
// needs to have access to the Flask backend server. We now proxy all backend requests via
// "/api/..." endpoints, which we handle ourself (by setupProxy.js for the dev environment,
// and nginx proxying for production), so the client only needs access to the React front-end.
// This also has the nice side-effect of removing CORS issues :-/
this._flaskBaseUrl = "/api" ;
}
// NOTE: Managing publisher/publication/article images is a bit tricky, since they are accessed via a URL
// such as "/articles/images/123", so if the user uploads a new image, the browser has no way of knowing
// that it can't use what's in its cache and must get a new one. We can add something to the URL to force
// a reload (e.g. "?foo=" + Math.random()), but this forces the image to be reloaded *every* time, which is
// pretty inefficient.
// Instead, we track a unique cache-busting value for each image URL, and change it when necessary.
this._flaskImageUrlVersions = {} ;
}
render() {
let content ;
if ( this.state.startupTasks.length > 0 ) {
// we are still starting up
content = <div id="loading"> <img id="loading" src="/images/loading.gif" alt="Loading..." /> </div> ;
} else {
// generate the menu
const menu = ( <Menu id="app">
<MenuButton />
<MenuList>
<MenuItem id="menu-show-publishers" onSelect={ () => this._showPublishers(true) } >
<img src="/images/menu/publishers.png" alt="Show publishers." /> Show publishers
</MenuItem>
<MenuItem id="menu-search-technique" onSelect={ () => this._showTechniqueArticles(true) } >
<img src="/images/menu/technique.png" alt="Show technique articles." /> Show technique
</MenuItem>
<MenuItem id="menu-search-tips" onSelect={ () => this._showTipsArticles(true) } >
<img src="/images/menu/tips.png" alt="Show tip articles." /> Show tips
</MenuItem>
<div className="divider" />
<MenuItem id="menu-new-publisher" onSelect={PublisherSearchResult.onNewPublisher} >
<img src="/images/menu/publisher.png" alt="New publisher." /> New publisher
</MenuItem>
<MenuItem id="menu-new-publication" onSelect={PublicationSearchResult.onNewPublication} >
<img src="/images/menu/publication.png" alt="New publication." /> New publication
</MenuItem>
<MenuItem id="menu-new-article" onSelect={ArticleSearchResult.onNewArticle} >
<img src="/images/menu/article.png" alt="New article." /> New article
</MenuItem>
<div className="divider" />
<MenuItem id="menu-db-report" onSelect={ () => this._showDbReport(true) } >
<img src="/images/menu/db-report.png" alt="Database report." /> DB report
</MenuItem>
</MenuList>
</Menu> ) ;
// generate the main content
content = ( <div>
<div id="header">
<a href={gAppRef.makeAppUrl("/")} title="Home page.">
<img className="logo" src="/images/app.png" alt="Logo" />
</a>
<div className="app-name"> {APP_NAME} </div>
<SearchForm onSearch={this.onSearch.bind(this)} ref={this._searchFormRef} />
</div>
{menu}
{ this.state.showDbReport
? <DbReport />
: <SearchResults ref={this._searchResultsRef}
seqNo = {this.state.searchSeqNo}
searchResults = {this.state.searchResults}
/>
}
</div> ) ;
}
return ( <div> {content}
{ this.state.modalForm !== null &&
<ModalForm show={true} formId={this.state.modalForm.formId}
{...this.state.modalForm}
ref = {this._modalFormRef}
/>
}
{ this.state.askDialog !== null &&
<AskDialog show={true} {...this.state.askDialog} />
}
<ToastContainer position="bottom-right" hideProgressBar={true} />
{ this._storeMsgs && <div>
<textarea id="_stored_msg-info_toast_" ref="_stored_msg-info_toast_" defaultValue="" hidden={true} />
<textarea id="_stored_msg-warning_toast_" ref="_stored_msg-warning_toast_" defaultValue="" hidden={true} />
<textarea id="_stored_msg-error_toast_" ref="_stored_msg-error_toast_" defaultValue="" hidden={true} />
</div> }
{ this._fakeUploads && <div>
<textarea id="_stored_msg-upload_" ref="_stored_msg-upload_" defaultValue="" hidden={true} />
</div> }
<div id="watermark" ref="watermark"> <img src="/images/watermark.png" alt="watermark" /> </div>
</div> ) ;
}
componentDidMount() {
// initialize
PreviewableImage.initPreviewableImages() ;
window.addEventListener( "keydown", this.onKeyDown.bind( this ) ) ;
// check if the server started up OK
let on_startup_ok = () => {
// the backend server started up OK, continue our startup process
this._onStartupTask( "dummy" ) ;
}
let on_startup_failure = () => {
// the backend server had problems during startup; we hide the spinner
// and leave the error message(s) on-screen.
document.getElementById( "loading" ).style.display = "none" ;
}
axios.get(
this.makeFlaskUrl( "/startup-messages" )
).then( resp => {
// show any messages logged by the backend server as it started up
[ "info", "warning", "error" ].forEach( msgType => {
if ( resp.data[ msgType ] ) {
resp.data[ msgType ].forEach( msg => {
const pos = msg.indexOf( ":\n" ) ;
if ( pos !== -1 ) {
msg = ( <div> {msg.substr(0,pos+1)}
<div className="monospace"> {msg.substr(pos+2)} </div>
</div> ) ;
}
const funcName = "show" + msgType[0].toUpperCase() + msgType.substr(1) + "Toast" ;
this[ funcName ]( msg ) ;
} ) ;
}
} ) ;
if ( resp.data.error && resp.data.error.length > 0 )
on_startup_failure() ;
else
on_startup_ok() ;
} ).catch( err => {
let errorMsg = err.toString() ;
if ( errorMsg.indexOf( "502" ) !== -1 || errorMsg.indexOf( "504" ) !== -1 )
this.showErrorToast( <div> Couldn't connect to the backend Flask server. </div> ) ;
else
this.showErrorToast( <div> Couldn't get the startup messages: <div className="monospace"> {errorMsg} </div> </div> ) ;
on_startup_failure() ;
} ) ;
}
componentDidUpdate() {
// we've finished rendering the page, check if we should set focus
if ( this._setFocusTo ) {
// yup - set focus to the requested control
if ( this._setFocusTo.current )
this._setFocusTo.current.focus() ;
else
this._setFocusTo.focus() ;
}
else {
// nope - set focus to the search results (so that Page Up/Down et.al. will work)
if ( ! this._modalFormRef.current ) {
let elem = document.getElementById( "search-results" ) ;
if ( elem )
setTimeout( () => elem.focus(), 10 ) ;
}
}
this._setFocusTo = null ;
}
componentWillUnmount() {
// clean up
window.removeEventListener( "keydown", this.onKeyDown ) ;
}
onSearch( query ) {
// run the search
query = query.trim() ;
if ( query.length === 0 ) {
this.showErrorMsg( "Please enter something to search for.", this._searchFormRef.current.queryStringRef.current )
return ;
}
this._doSearch( "/search", { query: query } ) ;
}
_doSearch( url, args, onDone ) {
// do the search
this.setWindowTitle( null ) ;
this.setState( { searchResults: "(loading)", showDbReport: false } ) ;
args.no_hilite = this._disableSearchResultHighlighting ;
axios.post(
this.makeFlaskUrl( url ), args
).then( resp => {
ReactDOM.findDOMNode( this._searchResultsRef.current ).scrollTo( 0, 0 ) ;
this.setState( { searchResults: resp.data, searchSeqNo: this.state.searchSeqNo+1 } ) ;
if ( onDone )
onDone() ;
} ).catch( err => {
this.showErrorResponse( "The search query failed", err ) ;
this.setState( { searchResults: null, searchSeqNo: this.state.searchSeqNo+1 } ) ;
} ) ;
}
runSpecialSearch( url, args, onDone ) {
// run the search
this._searchFormRef.current.setState( { queryString: "" } ) ;
if ( ! args )
args = {} ;
this._doSearch( url, args, onDone ) ;
}
_showPublishers( pushState ) {
this.runSpecialSearch( "/search/publishers", null, () => {
this.setWindowTitle( "All publishers" ) ;
if ( pushState )
window.history.pushState( null, document.title, "/publishers"+window.location.search ) ;
} )
}
_showTechniqueArticles( pushState ) {
this.runSpecialSearch( "/search/tag/technique", {randomize:1}, () => {
this.setWindowTitle( "Technique" ) ;
if ( pushState )
window.history.pushState( null, document.title, "/technique"+window.location.search ) ;
} )
}
_showTipsArticles( pushState ) {
this.runSpecialSearch( "/search/tag/tips", {randomize:1}, () => {
this.setWindowTitle( "Tips" ) ;
if ( pushState )
window.history.pushState( null, document.title, "/tips"+window.location.search ) ;
} )
}
_showDbReport( pushState ) {
this.setState( { showDbReport: true, searchResults: [] } ) ;
this._searchFormRef.current.setState( { queryString: "" } ) ;
this.setWindowTitle( "Database report" ) ;
if ( pushState )
window.history.pushState( null, document.title, "/report"+window.location.search ) ;
}
prependSearchResult( sr ) {
// add a new entry to the start of the search results
// NOTE: We do this after creating a new object, and while it isn't really the right thing
// to do (since the new object might not actually be a result for the current search), it's nice
// to give the user some visual feedback.
let newSearchResults = [ sr ] ;
newSearchResults.push( ...this.state.searchResults ) ;
this.setState( { searchResults: newSearchResults } ) ;
}
updatePublisher( publ_id ) {
// update the specified publisher in the UI
this._doUpdateSearchResult(
(sr) => ( sr._type === "publisher" && sr.publ_id === publ_id ),
this.makeFlaskUrl( "/publisher/" + publ_id, {include_pubs:1,include_articles:1} )
) ;
this.forceFlaskImageReload( "publisher", publ_id ) ;
}
updatePublication( pub_id ) {
// update the specified publication in the UI
this._doUpdateSearchResult(
(sr) => ( sr._type === "publication" && sr.pub_id === pub_id ),
this.makeFlaskUrl( "/publication/" + pub_id, {include_articles:1,deep:1} )
) ;
this.forceFlaskImageReload( "publication", pub_id ) ;
}
_doUpdateSearchResult( srCheck, url ) {
// find the target search result in the UI
let newSearchResults = this.state.searchResults ;
for ( let i=0 ; i < newSearchResults.length ; ++i ) {
if ( srCheck( newSearchResults[i] ) ) {
// found it - get the latest details from the backend
axios.get( url ).then( resp => {
newSearchResults[i] = resp.data ;
this.setState( { searchResults: newSearchResults } ) ;
} ).catch( err => {
this.showErrorResponse( "Can't get the updated search result details", err ) ;
} ) ;
break ; // nb: we assume there's only 1 instance
}
}
}
showModalForm( formId, title, titleColor, content, buttons ) {
// prepare the buttons
let buttons2 = [] ;
for ( let b in buttons ) {
let notify = buttons[ b ] ;
buttons2[ b ] = () => {
// a button was clicked - notify the caller
if ( notify )
notify() ;
// NOTE: We don't automatically dismiss the dialog here, since the form might not want to close
// e.g. if it had problems updating something on the server. The form must dismiss the dialog manually.
} ;
}
// show the dialog
this.setState( {
modalForm: { formId: formId, title: title, titleColor: titleColor, content: content, buttons: buttons2 },
} ) ;
}
closeModalForm() {
this.setState( { modalForm: null } ) ;
}
showInfoToast( msg ) { this._doShowToast( "info", msg, 5*1000 ) ; }
showWarningToast( msg ) { this._doShowToast( "warning", msg, 15*1000 ) ; }
showErrorToast( msg ) { this._doShowToast( "error", msg, false ) ; }
_doShowToast( type, msg, autoClose ) {
if ( this._storeMsgs ) {
// save the message for the test suite to retrieve (nb: we also don't show the toast itself
// since these build up when tests are running at high speed, and obscure elements that
// we want to click on :-/
this.setStoredMsg( type+"_toast", ReactDOMServer.renderToStaticMarkup(msg) ) ;
return ;
}
toast( msg, { type: type, autoClose: autoClose } ) ;
}
setStoredMsg( msgType, msgData ) { this.refs[ "_stored_msg-" + msgType + "_" ].value = msgData ; }
getStoredMsg( msgType ) { return this.refs[ "_stored_msg-" + msgType + "_" ].value }
showErrorResponse( caption, err ) {
let content ;
if ( ! err.response )
content = <div className="monospace"> {err.toString()} </div> ;
else {
if ( err.response.data.indexOf( "<!DOCTYPE" ) !== -1 || err.response.data.indexOf( "<html" ) !== -1 )
content = <iframe title="error-response" srcDoc={err.response.data} /> ;
else
content = <div className="monospace"> {err.response.data} </div> ;
}
const msg = err.response ? err.response.statusText : err ;
const buttons = { Close: () => this.closeModalForm() } ;
this.showModalForm( "error-response", msg, "red",
<div> {caption}: {content} </div>,
buttons
) ;
}
showErrorMsg( content, setFocusTo ) {
// show the error message in a modal dialog
this.ask( content, "error",
{ "OK": null },
setFocusTo
) ;
}
showWarnings( caption, warnings ) {
this.showWarningToast( makeSmartBulletList( caption, warnings ) ) ;
}
ask( content, iconType, buttons, setFocusTo ) {
// prepare the buttons
let buttons2 = [] ;
for ( let b in buttons ) {
let notify = buttons[ b ] ;
buttons2[ b ] = () => {
// a button was clicked - notify the caller
if ( notify )
notify() ;
// dismiss the dialog
this._setFocusTo = setFocusTo ;
this.setState( { askDialog: null } ) ;
} ;
}
// show the dialog
this.setState( { askDialog: {
content: <div> <img src={"/images/"+iconType+".png"} className="icon" alt={iconType+" icon"} /> {content} </div>,
buttons: buttons2
} } ) ;
}
onKeyDown( evt ) {
// check if a modal dialog is open and Ctrl-Enter was pressed
if ( this._modalFormRef.current && evt.keyCode === 13 && evt.ctrlKey ) {
let dlg = ReactDOM.findDOMNode( this._modalFormRef.current ) ;
if ( dlg ) {
// yup - accept the dialog
let btn = dlg.querySelector( ".MuiButton-root.ok" ) ;
if ( btn )
btn.click() ;
else
console.log( "ERROR: Can't find default button." ) ;
}
}
// check for other shortcuts
if ( ! this._modalFormRef.current ) {
// Alt-R: set focus to query string
if ( evt.key === "r" && evt.altKey ) {
if ( this._searchFormRef.current ) {
let elem = this._searchFormRef.current.queryStringRef.current ;
if ( elem ) {
elem.focus() ;
elem.select() ;
}
}
}
}
}
logInternalError( msg, detail ) {
// log an internal error
this.showErrorToast( <div>
INTERNAL ERROR! <div>{msg}</div>
{detail && <div className="monospace">{detail}</div>}
</div> ) ;
console.log( "INTERNAL ERROR: " + msg ) ;
if ( detail )
console.log( " " + detail ) ;
}
makeAppUrl( url ) {
// FUDGE! The test suite needs any URL parameters to passed on to the next page if a link is clicked.
if ( this.isTestMode() )
url += window.location.search ;
return url ;
}
makeFlaskUrl( url, args ) {
// generate a URL for the Flask backend server
url = this._flaskBaseUrl + url ;
if ( args ) {
let args2 = [] ;
for ( let a in args )
args2.push( a + "=" + encodeURIComponent( args[a] ) ) ;
url = url + "?" + args2.join("&") ;
}
return url ;
}
makeExternalDocUrl( url ) {
// generate a URL for an external document
if ( isLink( url ) )
return url ;
if ( url.substr( 0, 2 ) === "$/" )
url = url.substr( 2 ) ;
if ( url[0] === "/" )
url = url.substr( 1 ) ;
return this.makeFlaskUrl( "/docs/" + encodeURIComponent( url ) ) ;
}
makeFlaskImageUrl( type, imageId ) {
// generate an image URL for the Flask backend server
if ( ! imageId )
return null ;
let url = this.makeFlaskUrl( "/images/" + type + "/" + imageId ) ;
const key = this._makeFlaskImageKey( type, imageId ) ;
if ( ! this._flaskImageUrlVersions[ key ] ) {
// NOTE: It would be nice to only add this if necessary (i.e. the user has changed
// the image, thus requiring us to fetch the new image), but not doing so causes problems
// in a dev environment, since we are constantly changing things in the database
// outside the app (e.g. in tests) and the browser cache will get out of sync.
this.forceFlaskImageReload( type, imageId ) ;
}
url += "?v=" + this._flaskImageUrlVersions[key] ;
return url ;
}
forceFlaskImageReload( type, imageId ) {
// bump the image's version#, which will force a new URL the next time makeFlaskImageUrl() is called
const key = this._makeFlaskImageKey( type, imageId ) ;
const version = this._flaskImageUrlVersions[ key ] ;
// NOTE: It would be nice to start at 1, but this causes problems in a dev environment, since
// we are constantly changing things in the database, and the browser cache will get out of sync.
this._flaskImageUrlVersions[ key ] = version ? version+1 : Math.floor(Date.now()/1000) ;
}
_makeFlaskImageKey( type, imageId ) { return type + ":" + imageId ; }
_onStartupTask( taskId ) {
// flag that the specified startup task has completed
let pos = this.state.startupTasks.indexOf( taskId ) ;
if ( pos === -1 ) {
this.logInternalError( "Unknown startup task.", "taskId = "+taskId ) ;
return ;
}
this.state.startupTasks.splice( pos, 1 ) ;
this.setState( { startupTasks: this.state.startupTasks } ) ;
if ( this.state.startupTasks.length === 0 )
this._onStartupComplete() ;
}
_onStartupComplete() {
// startup has completed, we're ready to go
this.refs.watermark.style.opacity = 0.2 ;
if ( this.props.warning )
this.showWarningToast( this.props.warning ) ;
if ( this.props.doSearch )
this.props.doSearch() ;
else if ( this.props.type === "report" )
this._showDbReport() ;
// NOTE: We could preload the DataCache here (i.e. where it won't affect startup time),
// but it will happen on every page load (e.g. /article/NNN or /publication/NNN),
// which would probably hurt more than it helps (since the data isn't needed if the user
// is only searching for stuff i.e. most of the time).
}
setWindowTitleFromSearchResults( srType, idField, idVal, nameField ) {
for ( let sr of Object.entries( this.state.searchResults ) ) {
if ( sr[1]._type === srType && String(sr[1][idField]) === idVal ) {
this.setWindowTitle( typeof nameField === "function" ? nameField(sr[1]) : sr[1][nameField] ) ;
return ;
}
}
this.setWindowTitle( null ) ;
}
setWindowTitle( caption ) {
if ( caption ) {
let doc = new DOMParser().parseFromString( caption, "text/html" ) ;
caption = doc.body.textContent ;
}
document.title = caption ? APP_NAME + " - " + caption : APP_NAME ;
}
isTestMode() { return process.env.REACT_APP_TEST_MODE ; }
isDisableConstraints() { return this._disableConstraints ; }
isDisableConfirmDiscardChanges() { return this._disableConfirmDiscardChanges ; }
isFakeUploads() { return this._fakeUploads ; }
setTestAttribute( obj, attrName, attrVal ) {
// set an attribute on an element (for testing porpoises)
if ( obj && this.isTestMode() )
obj.setAttribute( "testing--"+attrName, attrVal ) ;
}
}