diff --git a/asl_rulebook2/webapp/content.py b/asl_rulebook2/webapp/content.py index 97bb6c6..7610193 100644 --- a/asl_rulebook2/webapp/content.py +++ b/asl_rulebook2/webapp/content.py @@ -10,6 +10,7 @@ from asl_rulebook2.webapp import app from asl_rulebook2.webapp.utils import load_data_file, slugify _content_sets = None +_footnote_index = None _chapter_resources = None # --------------------------------------------------------------------- @@ -27,8 +28,8 @@ def load_content_sets( startup_msgs, logger ): # in the MMP eASLRB index, and have their own index. # initialize - global _content_sets, _chapter_resources - _content_sets = {} + global _content_sets, _footnote_index, _chapter_resources + _content_sets, _footnote_index = {}, {} _chapter_resources = { "background": {}, "icon": {} } # get the data directory @@ -47,12 +48,25 @@ def load_content_sets( startup_msgs, logger ): return fname2 return None - def load_content_doc( fname_stem, title ): + def load_content_doc( fname_stem, title, cdoc_id ): # load the content doc files - content_doc = { "title": title } + content_doc = { "cdoc_id": cdoc_id, "title": title } load_file( fname_stem+".targets", content_doc, "targets", startup_msgs.warning ) load_file( fname_stem+".chapters", content_doc, "chapters", startup_msgs.warning ) - load_file( fname_stem+".footnotes", content_doc, "footnotes", startup_msgs.warning ) + if load_file( fname_stem+".footnotes", content_doc, "footnotes", startup_msgs.warning ): + # update the footnote index + # NOTE: The front-end doesn't care about what chapter a footnote belongs to, + # and we rework things a bit to make it easier to map ruleid's to footnotes. + if cdoc_id not in _footnote_index: + _footnote_index[ cdoc_id ] = {} + for chapter_id, footnotes in content_doc.get( "footnotes", {} ).items(): + for footnote_id, footnote in footnotes.items(): + for caption in footnote.get( "captions", [] ): + footnote[ "display_name" ] = "{}{}".format( chapter_id, footnote_id ) + ruleid = caption[ "ruleid" ] + if ruleid not in _footnote_index[ cdoc_id ]: + _footnote_index[ cdoc_id ][ ruleid ] = [] + _footnote_index[ cdoc_id ][ ruleid ].append( footnote ) load_file( fname_stem+".pdf", content_doc, "content", startup_msgs.warning, binary=True ) # locate any chapter backgrounds and icons resource_dirs = [ @@ -112,19 +126,18 @@ def load_content_sets( startup_msgs, logger ): continue # nb: we can't do anything without an index file # load the main content doc fname_stem = os.path.splitext( fname2 )[0] - content_doc = load_content_doc( fname_stem, fname_stem ) cdoc_id = cset_id # nb: because this the main content document - content_doc[ "cdoc_id" ] = cdoc_id + content_doc = load_content_doc( fname_stem, fname_stem, cdoc_id ) content_set[ "content_docs" ][ cdoc_id ] = content_doc # load any associated content docs for fname_stem2 in find_assoc_cdocs( fname_stem ): # nb: we assume there's only one space between the two filename stems :-/ + cdoc_id2 = "{}!{}".format( cdoc_id, slugify(fname_stem2) ) content_doc = load_content_doc( "{} ({})".format( fname_stem, fname_stem2 ), - fname_stem2 + fname_stem2, + cdoc_id2 ) - cdoc_id2 = "{}!{}".format( cdoc_id, slugify(fname_stem2) ) - content_doc[ "cdoc_id" ] = cdoc_id2 content_set[ "content_docs" ][ cdoc_id2 ] = content_doc # save the new content set _content_sets[ content_set["cset_id"] ] = content_set @@ -179,6 +192,13 @@ def get_content( cdoc_id ): # --------------------------------------------------------------------- +@app.route( "/footnotes" ) +def get_footnotes(): + """Return the footnote index.""" + return jsonify( _footnote_index ) + +# --------------------------------------------------------------------- + @app.route( "/chapter//" ) def get_chapter_resource( chapter_id, rtype ): """Return a chapter resource.""" diff --git a/asl_rulebook2/webapp/static/ContentPane.js b/asl_rulebook2/webapp/static/ContentPane.js index 99f1735..c6cd2a1 100644 --- a/asl_rulebook2/webapp/static/ContentPane.js +++ b/asl_rulebook2/webapp/static/ContentPane.js @@ -1,5 +1,5 @@ -import { gMainApp, gEventBus, gUrlParams } from "./MainApp.js" ; -import { findTargets, showErrorMsg } from "./utils.js" ; +import { gMainApp, gFootnoteIndex, gEventBus, gUrlParams } from "./MainApp.js" ; +import { findTargets, showErrorMsg, showNotificationMsg } from "./utils.js" ; // -------------------------------------------------------------------- @@ -14,6 +14,21 @@ gMainApp.component( "content-pane", { `, + created() { + + gEventBus.on( "show-target", (cdocId, ruleid) => { + // check if the target has footnote(s) associated with it + if ( gFootnoteIndex[ cdocId ] ) { + let footnotes = gFootnoteIndex[ cdocId ][ ruleid ] ; + if ( footnotes ) { + // yup - show them to the user + this.showFootnotes( footnotes ) ; + } + } + } ) ; + + }, + mounted() { const showContentDoc = (cdocId) => { this.$refs.tabbedPages.activateTab( cdocId ) ; // nb: tabId == cdocId @@ -26,6 +41,76 @@ gMainApp.component( "content-pane", { } ) ; }, + methods: { + + showFootnotes( footnotes ) { + + // show the footnote in a notification balloon + let msg = this.makeFootnoteContent( footnotes ) ; + let $growl = showNotificationMsg( "footnote", msg ) ; + + // adjust the width of the balloon (based on the available width) + // NOTE: The longest footnote is ~7K (A25.8), so we try to hit the max width at ~3K. + let $contentPane = $( this.$el ) ; + let width = Math.min( 50 + 30 * (msg.length / 3000), 80 ) ; + width *= $contentPane.width() / 100 ; + $growl.css( "width", Math.floor(width)+"px" ) ; + + // FUDGE! We want to limit how tall the notification balloon can get, and show a v-scrollbar + // for the content if there's too much. However, max-height only works if one of the parent elements + // has a specific height set for it, so we set a timer to configure the height of the balloon + // to whatever it is after it has appeared on-screen. Sigh... + setTimeout( () => { + let height = $growl.height() ; + let maxHeight = $contentPane.height() * 0.4 ; // nb: CSS max-height of #growls-br is 40% + if ( height > maxHeight ) + height = maxHeight - 40 ; + // FIXME! This is really jerky, but I can't get it to animate :-/ But it's only a problem + // when the v-scrollbar comes into play, and there's stuff moving around as that happens, + // so it's a bit less visually annoying. The footnote balloons are on a light background, + // which also helps. + $growl.css( "height", Math.floor(height)+"px" ) ; + }, 500 ) ; // nb: yes, this needs to be this large :-/ + }, + + makeFootnoteContent( footnotes ) { + + let buf = [] ; + function addCaption( footnote, caption, style ) { + buf.push( "
", + "", caption.caption, " ("+caption.ruleid+")", "", " ", + "", "["+footnote.display_name+"]", "", + "
" ) ; + } + + if ( footnotes.length == 1 ) { + // there is only 1 footnote - we make only its content v-scrollable + let footnote = footnotes[0] ; + buf.push( "
" ) ; + footnote.captions.forEach( (caption) => { + addCaption( footnote, caption, "padding: 0 5px;" ) ; + } ) ; + buf.push( "
", footnote.content, "
" ) ; + buf.push( "
" ) ; + } else { + // there are multiple footnotes - we make the entire content scrollable + buf.push( "
" ) ; + footnotes.forEach( (footnote) => { + buf.push( "
" ) ; + footnote.captions.forEach( (caption) => { + addCaption( footnote, caption ) ; + } ) ; + buf.push( footnote.content ) ; + buf.push( "
" ) ; + } ) ; + buf.push( "
" ) ; + } + + return buf.join( "" ) ; + }, + + }, + } ) ; // -------------------------------------------------------------------- diff --git a/asl_rulebook2/webapp/static/MainApp.js b/asl_rulebook2/webapp/static/MainApp.js index 30bf61e..623023c 100644 --- a/asl_rulebook2/webapp/static/MainApp.js +++ b/asl_rulebook2/webapp/static/MainApp.js @@ -17,6 +17,7 @@ $(document).ready( () => { export let gAppConfig = null ; export let gContentDocs = null ; export let gTargetIndex = null ; +export let gFootnoteIndex = null ; export let gChapterResources = null ; // -------------------------------------------------------------------- @@ -45,8 +46,9 @@ gMainApp.component( "main-app", { // initialze the webapp Promise.all( [ - this.getAppConfig( this ), + this.getAppConfig(), this.getContentDocs( this ), + this.getFootnoteIndex(), ] ).then( () => { this.isLoaded = true ; gEventBus.emit( "app-loaded" ) ; @@ -62,6 +64,11 @@ gMainApp.component( "main-app", { if ( evt.keyCode == 27 ) { if ( $(".jquery-image-zoom").length >= 1 ) return ; // an image is zoomed - ignore the Escape + // close any notification balloons + $( ".growl-close" ).each( function() { + $(this).trigger( "click" ) ; + } ) ; + // notify the rest of the application gEventBus.emit( "escape-pressed" ) ; } } ) ; @@ -107,6 +114,20 @@ gMainApp.component( "main-app", { } ) ; }, + getFootnoteIndex() { + return new Promise( (resolve, reject) => { + // get the footnotes + $.getJSON( gGetFootnotesUrl, (resp) => { //eslint-disable-line no-undef + gFootnoteIndex = resp ; + resolve() ; + } ).fail( (xhr, status, errorMsg) => { + let msg = "Couldn't get the footnote index." ; + showErrorMsg( msg + "
" + errorMsg + "
" ) ; + reject( msg ) + } ) ; + } ) ; + }, + installContentDocs( contentDocs ) { // install the content docs gContentDocs = contentDocs ; diff --git a/asl_rulebook2/webapp/static/NavPane.js b/asl_rulebook2/webapp/static/NavPane.js index 9a865b2..45a159a 100644 --- a/asl_rulebook2/webapp/static/NavPane.js +++ b/asl_rulebook2/webapp/static/NavPane.js @@ -24,11 +24,10 @@ gMainApp.component( "nav-pane", { created() { - // show any Q+A and annotations when a target is opened gEventBus.on( "show-target", (cdocId, ruleid) => { if ( gAppConfig.DISABLE_AUTO_SHOW_RULE_INFO ) return ; - // get the rule info for the target being opened + // get the Q+A and annotations for the target being opened // NOTE: Targets are associated with a content set, but the Q+A is global, which is not quite // the right thing to do - what if there is a ruleid that exists in multiple content set, // but is referenced in the Q+A? Hopefully, this will never happen... :-/ @@ -44,7 +43,11 @@ gMainApp.component( "nav-pane", { } ) ; // close the rule info popup if Escape is pressed - gEventBus.on( "escape-pressed", this.closeRuleInfo ) ; + gEventBus.on( "escape-pressed", () => { + if ( $( ".growl-footnote" ).length > 0 ) + return ; // nb: unless a footnote on-screen (let the Escape close that instead) + this.closeRuleInfo() ; + } ) ; }, diff --git a/asl_rulebook2/webapp/static/SearchPane.js b/asl_rulebook2/webapp/static/SearchPane.js index b085512..e2a5ecc 100644 --- a/asl_rulebook2/webapp/static/SearchPane.js +++ b/asl_rulebook2/webapp/static/SearchPane.js @@ -1,5 +1,5 @@ import { gMainApp, gAppConfig, gEventBus } from "./MainApp.js" ; -import { findTargets, getPrimaryTarget, fixupSearchHilites } from "./utils.js" ; +import { findTargets, getPrimaryTarget, fixupSearchHilites, hideFootnotes } from "./utils.js" ; // -------------------------------------------------------------------- @@ -82,6 +82,7 @@ gMainApp.component( "search-results", { // initialize this.errorMsg = null ; + hideFootnotes() ; function onSearchDone() { Vue.nextTick( () => { gEventBus.emit( "search-done" ) ; } ) ; } diff --git a/asl_rulebook2/webapp/static/SearchResult.js b/asl_rulebook2/webapp/static/SearchResult.js index e990efe..139641e 100644 --- a/asl_rulebook2/webapp/static/SearchResult.js +++ b/asl_rulebook2/webapp/static/SearchResult.js @@ -1,5 +1,5 @@ import { gMainApp, gEventBus, gUrlParams } from "./MainApp.js" ; -import { findTargets, getPrimaryTarget, isRuleid, getChapterResource, fixupSearchHilites, hasHilite } from "./utils.js" ; +import { findTargets, getPrimaryTarget, isRuleid, getChapterResource, fixupSearchHilites, hasHilite, hideFootnotes } from "./utils.js" ; // -------------------------------------------------------------------- @@ -164,6 +164,7 @@ gMainApp.component( "ruleid", { methods: { onClick() { // show the target + hideFootnotes() ; gEventBus.emit( "show-target", this.cdocId, this.ruleid ) ; }, }, diff --git a/asl_rulebook2/webapp/static/css/ContentPane.css b/asl_rulebook2/webapp/static/css/ContentPane.css index 8e95a10..4d76a73 100644 --- a/asl_rulebook2/webapp/static/css/ContentPane.css +++ b/asl_rulebook2/webapp/static/css/ContentPane.css @@ -8,3 +8,14 @@ #content .content-doc iframe { position: absolute ; width: 100% ; height: calc(100% - 40px) ; border: none ; } #content .content-doc .disabled { margin-top: 1em ; text-align: center ; font-style: italic ; color: #888 ; } + +#growls-br { max-height: 40% ;} +.growl-footnote { background: #f0f0d0 ; border: 1px solid #ccc ; color: black ; width: 120px ; } +/* FUDGE! We would like to set the opacity to 1 on :hover, but it breaks the click handler!?!? :wtf: */ +.growl-footnote { opacity: 1 ; } +.growl-footnote .footnote { margin-top: 10px ; height: 100% ; display: flex ; flex-direction: column ; } +.growl-footnote .footnote:first-of-type { margin-top: 0 ; } +.growl-footnote .header { font-weight: bold ; } +.growl-footnote .header .footnote-id { font-size: 90% ; font-style: italic ; color: #666 ; } +.growl-footnote .growl-message { height: 100% ; display: flex ; flex-direction: column ; } +.growl-footnote .growl-message .content { flex-grow: 1 ; overflow-y: auto ; padding: 0 5px 5px 5px ; text-align: justify ; } diff --git a/asl_rulebook2/webapp/static/css/global.css b/asl_rulebook2/webapp/static/css/global.css index 8648c77..a5f466f 100644 --- a/asl_rulebook2/webapp/static/css/global.css +++ b/asl_rulebook2/webapp/static/css/global.css @@ -9,6 +9,7 @@ button { height: 24px ; padding: 0 5px !important ; } /* notification balloons */ +.growl { cursor: pointer ; } .growl .growl-close { position: absolute ; top: 0 ; right: 6px ; } .growl .growl-title { display: none ; } .growl .pre { font-family: monospace ; } diff --git a/asl_rulebook2/webapp/static/utils.js b/asl_rulebook2/webapp/static/utils.js index 176146e..b8dc0b2 100644 --- a/asl_rulebook2/webapp/static/utils.js +++ b/asl_rulebook2/webapp/static/utils.js @@ -96,14 +96,26 @@ export function showNotificationMsg( msgType, msg ) } // show the notification message - $.growl( { + let $growl = $.growl( { style: (msgType == "info") ? "notice" : msgType, title: null, message: msg, location: "br", - duration: (msgType == "warning") ? 15*1000 : 5*1000, + duration: (msgType == "warning" || msgType == "footnote") ? 15*1000 : 5*1000, fixed: (msgType == "error"), - } ) ; + } ).$growl() ; + function onClick() { + $growl.off( "click", onClick ) ; + $(this).find( ".growl-close" ).click() ; + } + $growl.on( "click", onClick ) ; + return $growl ; +} + +export function hideFootnotes() +{ + // hide the footnotes balloon + $( ".growl-footnote" ).find( ".growl-close" ).click() ; } // -------------------------------------------------------------------- diff --git a/asl_rulebook2/webapp/templates/index.html b/asl_rulebook2/webapp/templates/index.html index 48f98ba..9e5fa6b 100644 --- a/asl_rulebook2/webapp/templates/index.html +++ b/asl_rulebook2/webapp/templates/index.html @@ -55,6 +55,7 @@ gGetStartupMsgsUrl = "{{ url_for( 'get_startup_msgs' ) }}" ; gSearchUrl = "{{ url_for( 'search' ) }}" ; gGetRuleInfoUrl = "{{ url_for( 'get_rule_info', ruleid='RULEID' ) }}" ; gGetQAImageUrl = "{{ url_for( 'get_qa_image', fname='FNAME' ) }}" ; +gGetFootnotesUrl = "{{ url_for( 'get_footnotes' ) }}" ; diff --git a/asl_rulebook2/webapp/templates/testing.html b/asl_rulebook2/webapp/templates/testing.html index 98975a0..ec7aa14 100644 --- a/asl_rulebook2/webapp/templates/testing.html +++ b/asl_rulebook2/webapp/templates/testing.html @@ -2,3 +2,4 @@ + diff --git a/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 1.footnotes b/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 1.footnotes new file mode 100644 index 0000000..3e577c3 --- /dev/null +++ b/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 1.footnotes @@ -0,0 +1,29 @@ +{ + +"X": { + +"1": { + "captions": [ { "caption": "Alpha", "ruleid": "1a.1" } ], + "content": "This footnote is for ruleid 1a.1." +}, +"2": { + "captions": [ { "caption": "Bravo", "ruleid": "1a.2" } ], + "content": "This footnote is for ruleid 1a.2." +}, + +"10": { + "captions": [ { "caption": "Charlie", "ruleid": "1b.1" } ], + "content": "This footnote is for ruleid 1b1.1." +}, +"11": { + "captions": [ { "caption": "Delta", "ruleid": "1b.1" } ], + "content": "This footnote is also for ruleid 1b1.1." +}, + +"20": { + "captions": [ { "ruleid": "1b.2" } ] +} + +} + +} diff --git a/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 1.index b/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 1.index new file mode 100644 index 0000000..03ce5b6 --- /dev/null +++ b/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 1.index @@ -0,0 +1,16 @@ +[ + +{ "title": "Content Set 1", + "rulerefs": [ + { "caption": "Main document", "ruleids": [ + "1a.1", "1a.2" + ] + }, + { "caption": "Linked document", "ruleids": [ + "1b.1", "1b.2" + ] + } + ] +} + +] diff --git a/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 1.targets b/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 1.targets new file mode 100644 index 0000000..1afb38a --- /dev/null +++ b/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 1.targets @@ -0,0 +1,9 @@ +{ + +"1a.1": { "caption": "Item 1a.1" }, +"1a.2": { "caption": "Item 1a.2" }, + +"1b.1": { "caption": "Item 1b.1" }, +"1b.2": { "caption": "Item 1b.2" } + +} diff --git a/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 2.footnotes b/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 2.footnotes new file mode 100644 index 0000000..18f2f39 --- /dev/null +++ b/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 2.footnotes @@ -0,0 +1,16 @@ +{ + +"Y": { + +"1": { + "captions": [ { "caption": "Mike", "ruleid": "2.1" } ], + "content": "This footnote is for ruleid 2.1." +}, +"2": { + "captions": [ { "caption": "November", "ruleid": "2.2" } ], + "content": "This footnote is for ruleid 2.2." +} + +} + +} diff --git a/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 2.index b/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 2.index new file mode 100644 index 0000000..c2e3ec2 --- /dev/null +++ b/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 2.index @@ -0,0 +1,12 @@ +[ + +{ "title": "Content Set 2", + "rulerefs": [ + { "caption": "The only document", "ruleids": [ + "2.1", "2.2" + ] + } + ] +} + +] diff --git a/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 2.targets b/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 2.targets new file mode 100644 index 0000000..108a3d6 --- /dev/null +++ b/asl_rulebook2/webapp/tests/fixtures/footnotes/content set 2.targets @@ -0,0 +1,6 @@ +{ + +"2.1": { "caption": "Item 2.1" }, +"2.2": { "caption": "Item 2.2" } + +} diff --git a/asl_rulebook2/webapp/tests/test_footnotes.py b/asl_rulebook2/webapp/tests/test_footnotes.py new file mode 100644 index 0000000..35168ab --- /dev/null +++ b/asl_rulebook2/webapp/tests/test_footnotes.py @@ -0,0 +1,83 @@ +""" Test footnotes. """ + +import lxml.html + +from asl_rulebook2.webapp.tests.utils import init_webapp, \ + find_children, wait_for, set_stored_msg_marker, get_last_footnote_msg +from asl_rulebook2.webapp.tests.test_search import do_search + +# --------------------------------------------------------------------- + +def test_footnotes( webdriver, webapp ): + """Test footnotes.""" + + # initialize + webapp.control_tests.set_data_dir( "footnotes" ) + init_webapp( webapp, webdriver ) + + # bring up the ruleid's and locate the ruleid links + do_search( "document" ) + def remove_brackets( val ): + assert val.startswith( "[" ) and val.endswith( "]" ) + return val[1:-1] + ruleid_elems = { + remove_brackets( c.text ): c + for c in find_children( "#search-results .ruleid" ) + } + + def do_test( ruleid, expected ): + # click on the specified ruleid and wait for the footnote to appear + marker = set_stored_msg_marker( "footnote" ) + ruleid_elems[ ruleid ].click() + wait_for( 2, lambda: get_last_footnote_msg() != marker ) + # locate the footnote(s) + footnotes = [] + root = lxml.html.fragment_fromstring( get_last_footnote_msg() ) + if root.attrib["class"] == "footnote": + # this is a single footnote + footnote_elems = [ root ] + get_content = lambda fnote: fnote.find( "div[@class='content']" ).text + else: + # there are multiple footnotes + footnote_elems = root.findall( "div[@class='footnote']" ) + get_content = lambda fnote: "".join( fnote.xpath( "text()" ) ) + # extract content from each footnote + for footnote in footnote_elems: + header = footnote.find( "div[@class='header']" ) + footnotes.append( { + "caption": header.find( "span[@class='caption']" ).text, + "footnote_id": remove_brackets( header.find( "span[@class='footnote-id']" ).text ), + "content": get_content( footnote ) + } ) + assert footnotes == expected + + # do the tests (content set 1) + do_test( "1a.1", [ { + "caption": "Alpha (1a.1)", "footnote_id": "X1", + "content": "This footnote is for ruleid 1a.1." + } ] ) + do_test( "1a.2", [ { + "caption": "Bravo (1a.2)", "footnote_id": "X2", + "content": "This footnote is for ruleid 1a.2." + } ] ) + do_test( "1b.1", [ { + "caption": "Charlie (1b.1)", "footnote_id": "X10", + "content": "This footnote is for ruleid 1b1.1." + }, { + "caption": "Delta (1b.1)", "footnote_id": "X11", + "content": "This footnote is also for ruleid 1b1.1." + } ] ) + do_test( "1b.2", [ { + "caption": " (1b.2)", "footnote_id": "X20", + "content": None + } ] ) + + # do the tests (content set 2) + do_test( "2.1", [ { + "caption": "Mike (2.1)", "footnote_id": "Y1", + "content": "This footnote is for ruleid 2.1." + } ] ) + do_test( "2.2", [ { + "caption": "November (2.2)", "footnote_id": "Y2", + "content": "This footnote is for ruleid 2.2." + } ] ) diff --git a/asl_rulebook2/webapp/tests/utils.py b/asl_rulebook2/webapp/tests/utils.py index a586e0b..e7b0d23 100644 --- a/asl_rulebook2/webapp/tests/utils.py +++ b/asl_rulebook2/webapp/tests/utils.py @@ -92,6 +92,7 @@ def get_curr_target(): def get_last_info(): return get_stored_msg( "info" ) def get_last_warning_msg(): return get_stored_msg( "warning" ) def get_last_error_msg(): return get_stored_msg( "error" ) +def get_last_footnote_msg(): return get_stored_msg( "footnote" ) #pylint: enable=multiple-statements,missing-function-docstring def get_stored_msg( msg_type ):