Implemented the basic webapp functionality.

master
Pacman Ghost 3 years ago
parent 4b3f974b56
commit 9d2495aa64
  1. 22
      .eslintrc.json
  2. 4
      .gitignore
  3. 4
      asl_rulebook2/webapp/__init__.py
  4. 79
      asl_rulebook2/webapp/content.py
  5. 30
      asl_rulebook2/webapp/globvars.py
  6. 12
      asl_rulebook2/webapp/main.py
  7. 21
      asl_rulebook2/webapp/run_server.py
  8. 40
      asl_rulebook2/webapp/static/ContentPane.js
  9. 70
      asl_rulebook2/webapp/static/MainApp.js
  10. 23
      asl_rulebook2/webapp/static/NavPane.js
  11. 83
      asl_rulebook2/webapp/static/SearchPane.js
  12. 23
      asl_rulebook2/webapp/static/SearchResult.js
  13. 80
      asl_rulebook2/webapp/static/TabbedPages.js
  14. 10
      asl_rulebook2/webapp/static/css/ContentPane.css
  15. 6
      asl_rulebook2/webapp/static/css/MainApp.css
  16. 3
      asl_rulebook2/webapp/static/css/NavPane.css
  17. 8
      asl_rulebook2/webapp/static/css/SearchPane.css
  18. 1
      asl_rulebook2/webapp/static/css/SearchResult.css
  19. 4
      asl_rulebook2/webapp/static/css/TabbedPages.css
  20. 15
      asl_rulebook2/webapp/static/css/global.css
  21. 96
      asl_rulebook2/webapp/static/growl/jquery.growl.css
  22. 311
      asl_rulebook2/webapp/static/growl/jquery.growl.js
  23. 769
      asl_rulebook2/webapp/static/split/split.js
  24. 3
      asl_rulebook2/webapp/static/split/split.min.js
  25. 16
      asl_rulebook2/webapp/static/src/MainApp.js
  26. 71
      asl_rulebook2/webapp/static/tinyemitter/tinyemitter.js
  27. 1
      asl_rulebook2/webapp/static/tinyemitter/tinyemitter.min.js
  28. 17
      asl_rulebook2/webapp/static/utils.js
  29. 30
      asl_rulebook2/webapp/templates/index.html
  30. 10
      asl_rulebook2/webapp/tests/control_tests.py
  31. 24
      asl_rulebook2/webapp/tests/control_tests_servicer.py
  32. BIN
      asl_rulebook2/webapp/tests/fixtures/simple/simple.docx
  33. 76
      asl_rulebook2/webapp/tests/fixtures/simple/simple.index
  34. BIN
      asl_rulebook2/webapp/tests/fixtures/simple/simple.pdf
  35. 15
      asl_rulebook2/webapp/tests/fixtures/simple/simple.targets
  36. 8
      asl_rulebook2/webapp/tests/proto/control_tests.proto
  37. 56
      asl_rulebook2/webapp/tests/proto/generated/control_tests_pb2.py
  38. 34
      asl_rulebook2/webapp/tests/proto/generated/control_tests_pb2_grpc.py
  39. 12
      asl_rulebook2/webapp/tests/test_basic.py
  40. 35
      asl_rulebook2/webapp/tests/test_eslint.py
  41. 37
      asl_rulebook2/webapp/tests/utils.py
  42. 18
      asl_rulebook2/webapp/utils.py

@ -0,0 +1,22 @@
{
"env": {
"browser": true,
"jquery": true
},
"extends": [
"eslint:recommended",
"plugin:vue/essential"
],
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module"
},
"plugins": [
"vue"
],
"globals": {
"Vue": "readable"
},
"rules": {
}
}

4
.gitignore vendored

@ -1,3 +1,7 @@
package.json
package-lock.json
node_modules/
_work_/ _work_/
.vscode .vscode

@ -35,7 +35,6 @@ def _on_sigint( signum, stack ): #pylint: disable=unused-argument
shutdown_event.set() shutdown_event.set()
# call any registered cleanup handlers # call any registered cleanup handlers
from asl_rulebook2.webapp import globvars #pylint: disable=cyclic-import
for handler in globvars.cleanup_handlers: for handler in globvars.cleanup_handlers:
handler() handler()
@ -78,6 +77,9 @@ else:
# load the application # load the application
import asl_rulebook2.webapp.main #pylint: disable=wrong-import-position,cyclic-import import asl_rulebook2.webapp.main #pylint: disable=wrong-import-position,cyclic-import
import asl_rulebook2.webapp.content #pylint: disable=wrong-import-position,cyclic-import
from asl_rulebook2.webapp import globvars #pylint: disable=wrong-import-position,cyclic-import
app.before_request( globvars.on_request )
# install our signal handler # install our signal handler
signal.signal( signal.SIGINT, _on_sigint ) signal.signal( signal.SIGINT, _on_sigint )

@ -0,0 +1,79 @@
""" Manage the content documents. """
import os
import io
import glob
from flask import jsonify, send_file, url_for, abort
from asl_rulebook2.webapp import app
from asl_rulebook2.webapp.utils import change_extn, slugify
content_docs = None
# ---------------------------------------------------------------------
def load_content_docs():
"""Load the content documents from the data directory."""
# initialize
global content_docs
content_docs = {}
dname = app.config.get( "DATA_DIR" )
if not dname:
return
if not os.path.dirname( dname ):
raise RuntimeError( "Invalid data directory: {}".format( dname ) )
def get_doc( content_doc, key, fname, binary=False ):
fname = os.path.join( dname, fname )
if not os.path.isfile( fname ):
return
kwargs = {}
kwargs["mode"] = "rb" if binary else "r"
if not binary:
kwargs["encoding"] = "utf-8"
with open( fname, **kwargs ) as fp:
content_doc[ key ] = fp.read()
# load each content doc
fspec = os.path.join( dname, "*.index" )
for fname in glob.glob( fspec ):
fname = os.path.basename( fname )
title = os.path.splitext( fname )[0]
content_doc = {
"doc_id": slugify( title ),
"title": title,
}
get_doc( content_doc, "index", fname )
get_doc( content_doc, "targets", change_extn(fname,".targets") )
get_doc( content_doc, "footnotes", change_extn(fname,".footnotes") )
get_doc( content_doc, "content", change_extn(fname,".pdf"), binary=True )
content_docs[ content_doc["doc_id"] ] = content_doc
# ---------------------------------------------------------------------
@app.route( "/content-docs" )
def get_content_docs():
"""Return the available content docs."""
resp = {}
for cdoc in content_docs.values():
cdoc2 = {
"docId": cdoc["doc_id"],
"title": cdoc["title"],
}
if "content" in cdoc:
cdoc2["url"] = url_for( "get_content", doc_id=cdoc["doc_id"] )
resp[ cdoc["doc_id"] ] = cdoc2
return jsonify( resp )
# ---------------------------------------------------------------------
@app.route( "/content/<doc_id>" )
def get_content( doc_id ):
"""Return the content for the specified document."""
cdoc = content_docs.get( doc_id )
if not cdoc or "content" not in cdoc:
abort( 404 )
buf = io.BytesIO( cdoc["content"] )
return send_file( buf, mimetype="application/pdf" )

@ -1,4 +1,8 @@
""" Global variables. """ """ Global definitions. """
import threading
from flask import request
from asl_rulebook2.webapp import app from asl_rulebook2.webapp import app
from asl_rulebook2.webapp.config.constants import APP_NAME, APP_VERSION from asl_rulebook2.webapp.config.constants import APP_NAME, APP_VERSION
@ -7,6 +11,30 @@ cleanup_handlers = []
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
_init_lock = threading.Lock()
_init_done = False
def on_request():
"""Called before each request."""
if request.path == "/control-tests":
# NOTE: The test suite calls $/control-tests to find out which port the gRPC test control service
# is running on, which is nice since we don't need to configure both ends with a predefined port.
# However, we don't want this call to trigger initialization, since the tests will often want to
# configure the remote webapp before loading the main page.
return
with _init_lock:
global _init_done
if not _init_done or (request.path == "/" and request.args.get("reload")):
try:
from asl_rulebook2.webapp.main import init_webapp
init_webapp()
finally:
# NOTE: It's important to set this, even if initialization failed, so we don't
# try to initialize again.
_init_done = True
# ---------------------------------------------------------------------
@app.context_processor @app.context_processor
def inject_template_params(): def inject_template_params():
"""Inject template parameters into Jinja2.""" """Inject template parameters into Jinja2."""

@ -8,10 +8,22 @@ import logging
from flask import render_template, jsonify, abort from flask import render_template, jsonify, abort
from asl_rulebook2.webapp import app, globvars, shutdown_event from asl_rulebook2.webapp import app, globvars, shutdown_event
from asl_rulebook2.webapp.content import load_content_docs
from asl_rulebook2.webapp.utils import parse_int from asl_rulebook2.webapp.utils import parse_int
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
def init_webapp():
"""Initialize the webapp.
IMPORTANT: This is called on the first Flask request, but can also be called multiple times
after that by the test suite, to reset the webapp before each test.
"""
# initialize the webapp
load_content_docs()
# ---------------------------------------------------------------------
@app.route( "/" ) @app.route( "/" )
def main(): def main():
"""Return the main page.""" """Return the main page."""

@ -2,6 +2,9 @@
""" Run the webapp server. """ """ Run the webapp server. """
import os import os
import threading
import urllib.request
import time
import glob import glob
import click import click
@ -12,8 +15,10 @@ from asl_rulebook2.webapp import app
@click.command() @click.command()
@click.option( "--addr","-a","bind_addr", help="Webapp server address (host:port)." ) @click.option( "--addr","-a","bind_addr", help="Webapp server address (host:port)." )
@click.option( "--data","-d","data_dir", help="Data directory." )
@click.option( "--force-init-delay", default=0, help="Force the webapp to initialize (#seconds delay)." )
@click.option( "--debug","flask_debug", is_flag=True, default=False, help="Run Flask in debug mode." ) @click.option( "--debug","flask_debug", is_flag=True, default=False, help="Run Flask in debug mode." )
def main( bind_addr, flask_debug ): def main( bind_addr, data_dir, force_init_delay, flask_debug ):
"""Run the webapp server.""" """Run the webapp server."""
# initialize # initialize
@ -30,6 +35,12 @@ def main( bind_addr, flask_debug ):
if not flask_debug: if not flask_debug:
flask_debug = app.config.get( "FLASK_DEBUG", False ) flask_debug = app.config.get( "FLASK_DEBUG", False )
# initialize
if data_dir:
if not os.path.isdir( data_dir ):
raise RuntimeError( "Invalid data directory: {}".format( data_dir ) )
app.config["DATA_DIR"] = data_dir
# validate the configuration # validate the configuration
if not host: if not host:
raise RuntimeError( "The server host was not set." ) raise RuntimeError( "The server host was not set." )
@ -51,6 +62,14 @@ def main( bind_addr, flask_debug ):
files = glob.glob( fspec ) files = glob.glob( fspec )
extra_files.extend( files ) extra_files.extend( files )
# check if we should force webapp initialization
if force_init_delay > 0:
def _start_server():
time.sleep( force_init_delay )
url = "http://{}:{}/ping".format( host, port )
_ = urllib.request.urlopen( url )
threading.Thread( target=_start_server, daemon=True ).start()
# run the server # run the server
app.run( host=host, port=port, debug=flask_debug, app.run( host=host, port=port, debug=flask_debug,
extra_files = extra_files extra_files = extra_files

@ -0,0 +1,40 @@
import { gMainApp, gEventBus, gUrlParams } from "./MainApp.js" ;
// --------------------------------------------------------------------
gMainApp.component( "content-pane", {
props: [ "contentDocs" ],
template: `
<tabbed-pages ref="tabbedPages">
<tabbed-page v-for="doc in contentDocs" :tabId=doc.docId :caption=doc.title >
<content-doc :doc=doc />
</tabbed-page>
</tabbed-pages>`,
mounted() {
gEventBus.on( "show-content-doc", (docId) => {
this.$refs.tabbedPages.activateTab( docId ) ; // nb: tabId == docId
} ) ;
},
} ) ;
// --------------------------------------------------------------------
gMainApp.component( "content-doc", {
props: [ "doc" ],
data() { return {
noContent: gUrlParams.get( "no-content" ),
} ; },
template: `
<div class="content-doc">
<div v-if=noContent class="disabled"> Content disabled. </div>
<iframe v-else-if=doc.url :src=doc.url />
<div v-else class="disabled"> No content. </div>
</div>`,
} ) ;

@ -0,0 +1,70 @@
import { showErrorMsg } from "./utils.js" ;
// parse any URL parameters
export let gUrlParams = new URLSearchParams( window.location.search.substring(1) ) ;
// create the main application
export const gMainApp = Vue.createApp( { //eslint-disable-line no-undef
template: "<main-app />",
} ) ;
export const gEventBus = new TinyEmitter() ; //eslint-disable-line no-undef
$(document).ready( () => {
gMainApp.mount( "#main-app" ) ;
} ) ;
// --------------------------------------------------------------------
gMainApp.component( "main-app", {
data() { return {
contentDocs: [],
isLoaded: false,
} ; },
template: `
<nav-pane id="nav" />
<content-pane id="content" :contentDocs=contentDocs />
<div v-if=isLoaded id="_mainapp-loaded_" />
`,
mounted() {
// initialize the splitter
Split( [ "#nav", "#content" ], { //eslint-disable-line no-undef
direction: "horizontal",
sizes: [ 25, 75 ],
gutterSize: 2,
} ) ;
// initialze the webapp
// NOTE: We don't provide a catch handler, since each individual Promise should report
// their own errors i.e. what could we do here, other than show a generic "startup failed" error?
Promise.all( [
this.getContentDocs( this ),
] ).then( () => {
this.isLoaded = true ;
$( "#query-string" ).focus() ; // nb: because autofocus on the <input> doesn't work :-/
} ) ;
},
methods: {
getContentDocs: (self) => new Promise( (resolve, reject) => {
// get the content docs
$.getJSON( gGetContentDocsUrl, (resp) => { //eslint-disable-line no-undef
self.contentDocs = resp ;
let docIds = Object.keys( resp ) ;
if ( docIds.length > 0 ) {
Vue.nextTick( () => {
gEventBus.emit( "show-content-doc", docIds[0] ) ; // FIXME! which one do we choose?
} ) ;
}
resolve() ;
} ).fail( (xhr, status, errorMsg) => {
const msg = "Couldn't get the content docs." ;
showErrorMsg( msg + " <div class='pre'>" + errorMsg + "</div>" ) ;
reject( msg )
} ) ;
} ),
},
} ) ;

@ -0,0 +1,23 @@
import { gMainApp, gEventBus } from "./MainApp.js" ;
// --------------------------------------------------------------------
gMainApp.component( "nav-pane", {
template: `
<tabbed-pages>
<tabbed-page tabId="search" caption="Search" data-display="flex" >
<search-box id="search-box" @search=onSearch />
<search-results id="search-results" />
</tabbed-page>
</tabbed-pages>`,
methods: {
onSearch: (queryString) => {
gEventBus.emit( "search", queryString ) ;
},
},
} ) ;

@ -0,0 +1,83 @@
import { gMainApp, gEventBus } from "./MainApp.js" ;
import { IndexSearchResult } from "./SearchResult.js" ;
// --------------------------------------------------------------------
gMainApp.component( "search-panel", {
template: "<search-box /> <search-results />",
} ) ;
// --------------------------------------------------------------------
gMainApp.component( "search-box", {
data: function() { return {
queryString: "",
} ; },
template: `
<div>
<input type="text" id="query-string" @keyup=onKeyUp v-model.trim="queryString" ref="queryString" autofocus >
<button @click="$emit('search',this.queryString)" ref=submit> Go </button>
</div>`,
mounted: function() {
// initialize
$( this.$refs.queryString ).addClass( "ui-widget ui-state-default ui-corner-all" ) ;
$( this.$refs.submit ).button() ;
},
methods: {
onKeyUp: function( evt ) {
if ( evt.keyCode == 13 )
this.$refs["submit"].click() ;
}
},
} ) ;
// --------------------------------------------------------------------
gMainApp.component( "search-results", {
data() { return {
searchResults: [],
} ; },
template: `<div>
<div v-for="sr in searchResults" :key=sr.key >
<index-sr v-if="sr.srType == 'index'" :sr=sr />
<div v-else> ??? </div>
</div>
</div>`,
mounted() {
gEventBus.on( "search", this.onSearch ) ;
},
methods: {
onSearch( queryString ) {
// generate some dummy search results
let searchResults = [] ;
for ( let i=0 ; i < queryString.length ; ++i ) {
let buf = [ "Search result #" + (1+i) ] ;
let nItems = Math.floor( Math.sqrt( 100 * Math.random() ) ) - 1 ;
if ( nItems > 0 ) {
buf.push( "<ul style='padding-left:1em;'>" ) ;
for ( let j=0 ; j < nItems ; ++j )
buf.push( "<li> item " + (1+j) ) ;
buf.push( "</ul>" ) ;
}
searchResults.push(
new IndexSearchResult( i, buf.join("") )
) ;
}
this.searchResults = searchResults ;
},
},
} ) ;

@ -0,0 +1,23 @@
import { gMainApp } from "./MainApp.js" ;
// --------------------------------------------------------------------
export class IndexSearchResult {
constructor( key, content ) {
this.key = key ;
this.srType = "index" ;
this.content = content ;
}
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
gMainApp.component( "index-sr", {
props: [ "sr" ],
template: `
<div class="sr index-sr" v-html=sr.content />
`,
} ) ;

@ -0,0 +1,80 @@
import { gMainApp, gEventBus } from "./MainApp.js" ;
// --------------------------------------------------------------------
gMainApp.component( "tabbed-pages", {
data: function() { return {
tabs: [],
activeTabId: null,
} ; },
template: `
<div class="tabbed-pages">
<slot />
<div class="tab-strip">
<div v-for="tab in tabs" :data-tabid=tab.tabId @click=onTabClicked class="tab" v-bind:class="{'active': tab.tabId == activeTabId}" >
{{tab.caption}}
</div>
</div>
</div>`,
created() {
// FUDGE! It's nice to have the parent manage the TabbedPage's, and we just show the content
// as a slot, but that makes it tricky for us to create the tab strip, since we don't know anything
// about the tabs themselves. We work around this by having each TabbedPage emit an event when
// they mount, but since we ourself mount only after all our children have mounted, it's tricky
// for us to catch these events ($on() was remove in Vue 3 :-/). So, we emit the event on the
// global event bus, and check if they're for one of our TabbedPage's when we receive them.
gEventBus.on( "tab-loaded", (tabbedPage) => {
if ( ! tabbedPage.$el.parentNode.isSameNode( this.$el ) )
return ;
// one of our TabbedPage's has just mounted - show it in our tab strip
this.tabs.push( {
tabId: tabbedPage.tabId, caption: tabbedPage.caption
} ) ;
} ) ;
},
mounted() {
// start with the first tab activated
if ( this.tabs.length > 0 )
this.activateTab( this.tabs[0].tabId ) ;
},
methods: {
onTabClicked: function( evt ) {
// activate the selected tab
this.activateTab( evt.target.dataset.tabid ) ;
},
activateTab: function( tabId ) {
// activate the specified tab
this.activeTabId = tabId ;
$( this.$el ).find( ".tabbed-page" ).each( function() {
let displayStyle = $(this).data("display") || "block" ;
$(this).css( "display", ($(this).data("tabid") == tabId) ? displayStyle : "none" ) ;
} ) ;
},
},
} ) ;
// --------------------------------------------------------------------
gMainApp.component( "tabbed-page", {
props: [ "tabId", "caption", "isActive" ],
template: `
<div :data-tabid=tabId v-show=isActive class="tabbed-page" >
<slot />
</div>`,
mounted() {
gEventBus.emit( "tab-loaded", this ) ;
},
} ) ;

@ -0,0 +1,10 @@
#content { position: relative ; }
#content .tabbed-page { border-bottom: 1px solid #aaa ; }
#content .tab-strip { padding: 0 0.5em ; }
#content .tab-strip .tab { margin-top: -1px ; z-index: 5 ; border-radius: 0 0 5px 5px ; }
#content .tab-strip .tab { background: #f0f0f0 ; color: #808080 ; }
#content .tab-strip .tab.active { background: white ; color: #444 ; border-top: none ; }
#content .content-doc iframe { position: absolute ; width: 100% ; height: calc(100% - 35px) ; border: none ; }
#content .content-doc .disabled { margin-top: 1em ; text-align: center ; font-style: italic ; color: #888 ; }

@ -0,0 +1,6 @@
#main-app { width: 100% ; height: 100% ; display: flex ; }
/* splitter */
#nav { min-width: 300px ; }
#content { flex-grow: 1 ; min-width: 500px ; }
#main-app .gutter.gutter-horizontal { cursor: ew-resize ; background: #ccc ; }

@ -0,0 +1,3 @@
#nav { padding: 5px ; }
#nav .tabbed-page { height: calc(100% - 25px) ; padding: 2px ; }

@ -0,0 +1,8 @@
#nav .tabbed-page[data-tabid="search"] { display: flex ; flex-direction: column ; }
/* search box */
#search-box { display: flex ; }
#search-box input#query-string { margin-right: 5px ; flex-grow: 1 ; }
/* search results */
#search-results { flex-grow: -1 ; margin: 8px 0 2px 0 ; overflow-y: auto ; }

@ -0,0 +1 @@
#search-results .sr { margin: 0 10px 2px 0 ; border: 1px dotted #666 ; padding: 5px ; }

@ -0,0 +1,4 @@
.tabbed-pages { display: flex ; flex-direction: column ; }
.tabbed-pages .tabbed-page { height: calc(100% - 24px) ; }
.tabbed-pages .tab-strip { height: 24px ; display: flex ; font-size: 10pt ; font-style: italic ; }
.tabbed-pages .tab-strip .tab { border: 1px solid #aaa ; padding: 2px 5px ; margin-right: 5px ; }

@ -0,0 +1,15 @@
*/* global reset */
* { margin: 0 ; padding: 0 }
html { height: 100% ; }
body { height: 100% ; overflow: hidden ; }
/* general styling */
body { font-family: Arial, Helvetica, sans-serif ; font-size: 16px ; }
input[type="text"] { height: 22px ; padding: 0 5px ; }
button { height: 24px ; padding: 0 5px !important ; }
/* notification balloons */
.growl .growl-close { position: absolute ; top: 0 ; right: 6px ; }
.growl .growl-title { display: none ; }
.growl .pre { font-family: monospace ; }
.growl div.pre { margin: 0 0 15px 15px ; font-size: 80% ; }

@ -0,0 +1,96 @@
/* jQuery Growl
* Copyright 2015 Kevin Sylvestre
* 1.3.5
*/
.ontop, #growls-default, #growls-tl, #growls-tr, #growls-bl, #growls-br, #growls-tc, #growls-bc, #growls-cc, #growls-cl, #growls-cr {
z-index: 50000;
position: fixed; }
#growls-default {
top: 10px;
right: 10px; }
#growls-tl {
top: 10px;
left: 10px; }
#growls-tr {
top: 10px;
right: 10px; }
#growls-bl {
bottom: 10px;
left: 10px; }
#growls-br {
bottom: 10px;
right: 10px; }
#growls-tc {
top: 10px;
right: 10px;
left: 10px; }
#growls-bc {
bottom: 10px;
right: 10px;
left: 10px; }
#growls-cc {
top: 50%;
left: 50%;
margin-left: -125px; }
#growls-cl {
top: 50%;
left: 10px; }
#growls-cr {
top: 50%;
right: 10px; }
#growls-tc .growl, #growls-bc .growl {
margin-left: auto;
margin-right: auto; }
.growl {
opacity: 0.8;
filter: alpha(opacity=80);
position: relative;
border-radius: 4px;
-webkit-transition: all 0.4s ease-in-out;
-moz-transition: all 0.4s ease-in-out;
transition: all 0.4s ease-in-out; }
.growl.growl-incoming {
opacity: 0;
filter: alpha(opacity=0); }
.growl.growl-outgoing {
opacity: 0;
filter: alpha(opacity=0); }
.growl.growl-small {
width: 200px;
padding: 5px;
margin: 5px; }
.growl.growl-medium {
width: 250px;
padding: 10px;
margin: 10px; }
.growl.growl-large {
width: 300px;
padding: 15px;
margin: 15px; }
.growl.growl-default {
color: #FFF;
background: #7f8c8d; }
.growl.growl-error {
color: #FFF;
background: #C0392B; }
.growl.growl-notice {
color: #FFF;
background: #2ECC71; }
.growl.growl-warning {
color: #FFF;
background: #F39C12; }
.growl .growl-close {
cursor: pointer;
float: right;
font-size: 14px;
line-height: 18px;
font-weight: normal;
font-family: helvetica, verdana, sans-serif; }
.growl .growl-title {
font-size: 18px;
line-height: 24px; }
.growl .growl-message {
font-size: 14px;
line-height: 18px; }

@ -0,0 +1,311 @@
var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
// Generated by CoffeeScript 2.1.0
(function () {
/*
jQuery Growl
Copyright 2015 Kevin Sylvestre
1.3.5
*/
"use strict";
var $, Animation, Growl;
$ = jQuery;
Animation = function () {
var Animation = function () {
function Animation() {
_classCallCheck(this, Animation);
}
_createClass(Animation, null, [{
key: "transition",
value: function transition($el) {
var el, ref, result, type;
el = $el[0];
ref = this.transitions;
for (type in ref) {
result = ref[type];
if (el.style[type] != null) {
return result;
}
}
}
}]);
return Animation;
}();
;
Animation.transitions = {
"webkitTransition": "webkitTransitionEnd",
"mozTransition": "mozTransitionEnd",
"oTransition": "oTransitionEnd",
"transition": "transitionend"
};
return Animation;
}();
Growl = function () {
var Growl = function () {
_createClass(Growl, null, [{
key: "growl",
value: function growl() {
var settings = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
return new Growl(settings);
}
}]);
function Growl() {
var settings = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
_classCallCheck(this, Growl);
this.render = this.render.bind(this);
this.bind = this.bind.bind(this);
this.unbind = this.unbind.bind(this);
this.mouseEnter = this.mouseEnter.bind(this);
this.mouseLeave = this.mouseLeave.bind(this);
this.click = this.click.bind(this);
this.close = this.close.bind(this);
this.cycle = this.cycle.bind(this);
this.waitAndDismiss = this.waitAndDismiss.bind(this);
this.present = this.present.bind(this);
this.dismiss = this.dismiss.bind(this);
this.remove = this.remove.bind(this);
this.animate = this.animate.bind(this);
this.$growls = this.$growls.bind(this);
this.$growl = this.$growl.bind(this);
this.html = this.html.bind(this);
this.content = this.content.bind(this);
this.container = this.container.bind(this);
this.settings = $.extend({}, Growl.settings, settings);
this.initialize(this.settings.location);
this.render();
}
_createClass(Growl, [{
key: "initialize",
value: function initialize(location) {
var id;
id = 'growls-' + location;
return $('body:not(:has(#' + id + '))').append('<div id="' + id + '" />');
}
}, {
key: "render",
value: function render() {
var $growl;
$growl = this.$growl();
this.$growls(this.settings.location).append($growl);
if (this.settings.fixed) {
this.present();
} else {
this.cycle();
}
}
}, {
key: "bind",
value: function bind() {
var $growl = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.$growl();
$growl.on("click", this.click);
if (this.settings.delayOnHover) {
$growl.on("mouseenter", this.mouseEnter);
$growl.on("mouseleave", this.mouseLeave);
}
return $growl.on("contextmenu", this.close).find("." + this.settings.namespace + "-close").on("click", this.close);
}
}, {
key: "unbind",
value: function unbind() {
var $growl = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.$growl();
$growl.off("click", this.click);
if (this.settings.delayOnHover) {
$growl.off("mouseenter", this.mouseEnter);
$growl.off("mouseleave", this.mouseLeave);
}
return $growl.off("contextmenu", this.close).find("." + this.settings.namespace + "-close").off("click", this.close);
}
}, {
key: "mouseEnter",
value: function mouseEnter(event) {
var $growl;
$growl = this.$growl();
return $growl.stop(true, true);
}
}, {
key: "mouseLeave",
value: function mouseLeave(event) {
return this.waitAndDismiss();
}
}, {
key: "click",
value: function click(event) {
if (this.settings.url != null) {
event.preventDefault();
event.stopPropagation();
return window.open(this.settings.url);
}
}
}, {
key: "close",
value: function close(event) {
var $growl;
event.preventDefault();
event.stopPropagation();
$growl = this.$growl();
return $growl.stop().queue(this.dismiss).queue(this.remove);
}
}, {
key: "cycle",
value: function cycle() {
var $growl;
$growl = this.$growl();
return $growl.queue(this.present).queue(this.waitAndDismiss());
}
}, {
key: "waitAndDismiss",
value: function waitAndDismiss() {
var $growl;
$growl = this.$growl();
return $growl.delay(this.settings.duration).queue(this.dismiss).queue(this.remove);
}
}, {
key: "present",
value: function present(callback) {
var $growl;
$growl = this.$growl();
this.bind($growl);
return this.animate($growl, this.settings.namespace + "-incoming", 'out', callback);
}
}, {
key: "dismiss",
value: function dismiss(callback) {
var $growl;
$growl = this.$growl();
this.unbind($growl);
return this.animate($growl, this.settings.namespace + "-outgoing", 'in', callback);
}
}, {
key: "remove",
value: function remove(callback) {
this.$growl().remove();
return typeof callback === "function" ? callback() : void 0;
}
}, {
key: "animate",
value: function animate($element, name) {
var direction = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'in';
var callback = arguments[3];
var transition;
transition = Animation.transition($element);
$element[direction === 'in' ? 'removeClass' : 'addClass'](name);
$element.offset().position;
$element[direction === 'in' ? 'addClass' : 'removeClass'](name);
if (callback == null) {
return;
}
if (transition != null) {
$element.one(transition, callback);
} else {
callback();
}
}
}, {
key: "$growls",
value: function $growls(location) {
var base;
if (this.$_growls == null) {
this.$_growls = [];
}
return (base = this.$_growls)[location] != null ? base[location] : base[location] = $('#growls-' + location);
}
}, {
key: "$growl",
value: function $growl() {
return this.$_growl != null ? this.$_growl : this.$_growl = $(this.html());
}
}, {
key: "html",
value: function html() {
return this.container(this.content());
}
}, {
key: "content",
value: function content() {
return "<div class='" + this.settings.namespace + "-close'>" + this.settings.close + "</div>\n<div class='" + this.settings.namespace + "-title'>" + this.settings.title + "</div>\n<div class='" + this.settings.namespace + "-message'>" + this.settings.message + "</div>";
}
}, {
key: "container",
value: function container(content) {
return "<div class='" + this.settings.namespace + " " + this.settings.namespace + "-" + this.settings.style + " " + this.settings.namespace + "-" + this.settings.size + "'>\n " + content + "\n</div>";
}
}]);
return Growl;
}();
;
Growl.settings = {
namespace: 'growl',
duration: 3200,
close: "&#215;",
location: "default",
style: "default",
size: "medium",
delayOnHover: true
};
return Growl;
}();
this.Growl = Growl;
$.growl = function () {
var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
return Growl.growl(options);
};
$.growl.error = function () {
var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
var settings;
settings = {
title: "Error!",
style: "error"
};
return $.growl($.extend(settings, options));
};
$.growl.notice = function () {
var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
var settings;
settings = {
title: "Notice!",
style: "notice"
};
return $.growl($.extend(settings, options));
};
$.growl.warning = function () {
var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
var settings;
settings = {
title: "Warning!",
style: "warning"
};
return $.growl($.extend(settings, options));
};
}).call(this);

@ -0,0 +1,769 @@
/*! Split.js - v1.6.2 */
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.Split = factory());
}(this, (function () { 'use strict';
// The programming goals of Split.js are to deliver readable, understandable and
// maintainable code, while at the same time manually optimizing for tiny minified file size,
// browser compatibility without additional requirements
// and very few assumptions about the user's page layout.
var global = typeof window !== 'undefined' ? window : null;
var ssr = global === null;
var document = !ssr ? global.document : undefined;
// Save a couple long function names that are used frequently.
// This optimization saves around 400 bytes.
var addEventListener = 'addEventListener';
var removeEventListener = 'removeEventListener';
var getBoundingClientRect = 'getBoundingClientRect';
var gutterStartDragging = '_a';
var aGutterSize = '_b';
var bGutterSize = '_c';
var HORIZONTAL = 'horizontal';
var NOOP = function () { return false; };
// Helper function determines which prefixes of CSS calc we need.
// We only need to do this once on startup, when this anonymous function is called.
//
// Tests -webkit, -moz and -o prefixes. Modified from StackOverflow:
// http://stackoverflow.com/questions/16625140/js-feature-detection-to-detect-the-usage-of-webkit-calc-over-calc/16625167#16625167
var calc = ssr
? 'calc'
: ((['', '-webkit-', '-moz-', '-o-']
.filter(function (prefix) {
var el = document.createElement('div');
el.style.cssText = "width:" + prefix + "calc(9px)";
return !!el.style.length
})
.shift()) + "calc");
// Helper function checks if its argument is a string-like type
var isString = function (v) { return typeof v === 'string' || v instanceof String; };
// Helper function allows elements and string selectors to be used
// interchangeably. In either case an element is returned. This allows us to
// do `Split([elem1, elem2])` as well as `Split(['#id1', '#id2'])`.
var elementOrSelector = function (el) {
if (isString(el)) {
var ele = document.querySelector(el);
if (!ele) {
throw new Error(("Selector " + el + " did not match a DOM element"))
}
return ele
}
return el
};
// Helper function gets a property from the properties object, with a default fallback
var getOption = function (options, propName, def) {
var value = options[propName];
if (value !== undefined) {
return value
}
return def
};
var getGutterSize = function (gutterSize, isFirst, isLast, gutterAlign) {
if (isFirst) {
if (gutterAlign === 'end') {
return 0
}
if (gutterAlign === 'center') {
return gutterSize / 2
}
} else if (isLast) {
if (gutterAlign === 'start') {
return 0
}
if (gutterAlign === 'center') {
return gutterSize / 2
}
}
return gutterSize
};
// Default options
var defaultGutterFn = function (i, gutterDirection) {
var gut = document.createElement('div');
gut.className = "gutter gutter-" + gutterDirection;
return gut
};
var defaultElementStyleFn = function (dim, size, gutSize) {
var style = {};
if (!isString(size)) {
style[dim] = calc + "(" + size + "% - " + gutSize + "px)";
} else {
style[dim] = size;
}
return style
};
var defaultGutterStyleFn = function (dim, gutSize) {
var obj;
return (( obj = {}, obj[dim] = (gutSize + "px"), obj ));
};
// The main function to initialize a split. Split.js thinks about each pair
// of elements as an independant pair. Dragging the gutter between two elements
// only changes the dimensions of elements in that pair. This is key to understanding
// how the following functions operate, since each function is bound to a pair.
//
// A pair object is shaped like this:
//
// {
// a: DOM element,
// b: DOM element,
// aMin: Number,
// bMin: Number,
// dragging: Boolean,
// parent: DOM element,
// direction: 'horizontal' | 'vertical'
// }
//
// The basic sequence:
//
// 1. Set defaults to something sane. `options` doesn't have to be passed at all.
// 2. Initialize a bunch of strings based on the direction we're splitting.
// A lot of the behavior in the rest of the library is paramatized down to
// rely on CSS strings and classes.
// 3. Define the dragging helper functions, and a few helpers to go with them.
// 4. Loop through the elements while pairing them off. Every pair gets an
// `pair` object and a gutter.
// 5. Actually size the pair elements, insert gutters and attach event listeners.
var Split = function (idsOption, options) {
if ( options === void 0 ) options = {};
if (ssr) { return {} }
var ids = idsOption;
var dimension;
var clientAxis;
var position;
var positionEnd;
var clientSize;
var elements;
// Allow HTMLCollection to be used as an argument when supported
if (Array.from) {
ids = Array.from(ids);
}
// All DOM elements in the split should have a common parent. We can grab
// the first elements parent and hope users read the docs because the
// behavior will be whacky otherwise.
var firstElement = elementOrSelector(ids[0]);
var parent = firstElement.parentNode;
var parentStyle = getComputedStyle ? getComputedStyle(parent) : null;
var parentFlexDirection = parentStyle ? parentStyle.flexDirection : null;
// Set default options.sizes to equal percentages of the parent element.
var sizes = getOption(options, 'sizes') || ids.map(function () { return 100 / ids.length; });
// Standardize minSize to an array if it isn't already. This allows minSize
// to be passed as a number.
var minSize = getOption(options, 'minSize', 100);
var minSizes = Array.isArray(minSize) ? minSize : ids.map(function () { return minSize; });
// Get other options
var expandToMin = getOption(options, 'expandToMin', false);
var gutterSize = getOption(options, 'gutterSize', 10);
var gutterAlign = getOption(options, 'gutterAlign', 'center');
var snapOffset = getOption(options, 'snapOffset', 30);
var dragInterval = getOption(options, 'dragInterval', 1);
var direction = getOption(options, 'direction', HORIZONTAL);
var cursor = getOption(
options,
'cursor',
direction === HORIZONTAL ? 'col-resize' : 'row-resize'
);
var gutter = getOption(options, 'gutter', defaultGutterFn);
var elementStyle = getOption(
options,
'elementStyle',
defaultElementStyleFn
);
var gutterStyle = getOption(options, 'gutterStyle', defaultGutterStyleFn);
// 2. Initialize a bunch of strings based on the direction we're splitting.
// A lot of the behavior in the rest of the library is paramatized down to
// rely on CSS strings and classes.
if (direction === HORIZONTAL) {
dimension = 'width';
clientAxis = 'clientX';
position = 'left';
positionEnd = 'right';
clientSize = 'clientWidth';
} else if (direction === 'vertical') {
dimension = 'height';
clientAxis = 'clientY';
position = 'top';
positionEnd = 'bottom';
clientSize = 'clientHeight';
}
// 3. Define the dragging helper functions, and a few helpers to go with them.
// Each helper is bound to a pair object that contains its metadata. This
// also makes it easy to store references to listeners that that will be
// added and removed.
//
// Even though there are no other functions contained in them, aliasing
// this to self saves 50 bytes or so since it's used so frequently.
//
// The pair object saves metadata like dragging state, position and
// event listener references.
function setElementSize(el, size, gutSize, i) {
// Split.js allows setting sizes via numbers (ideally), or if you must,
// by string, like '300px'. This is less than ideal, because it breaks
// the fluid layout that `calc(% - px)` provides. You're on your own if you do that,
// make sure you calculate the gutter size by hand.
var style = elementStyle(dimension, size, gutSize, i);
Object.keys(style).forEach(function (prop) {
// eslint-disable-next-line no-param-reassign
el.style[prop] = style[prop];
});
}
function setGutterSize(gutterElement, gutSize, i) {
var style = gutterStyle(dimension, gutSize, i);
Object.keys(style).forEach(function (prop) {
// eslint-disable-next-line no-param-reassign
gutterElement.style[prop] = style[prop];
});
}
function getSizes() {
return elements.map(function (element) { return element.size; })
}
// Supports touch events, but not multitouch, so only the first
// finger `touches[0]` is counted.
function getMousePosition(e) {
if ('touches' in e) { return e.touches[0][clientAxis] }
return e[clientAxis]
}
// Actually adjust the size of elements `a` and `b` to `offset` while dragging.
// calc is used to allow calc(percentage + gutterpx) on the whole split instance,
// which allows the viewport to be resized without additional logic.
// Element a's size is the same as offset. b's size is total size - a size.
// Both sizes are calculated from the initial parent percentage,
// then the gutter size is subtracted.
function adjust(offset) {
var a = elements[this.a];
var b = elements[this.b];
var percentage = a.size + b.size;
a.size = (offset / this.size) * percentage;
b.size = percentage - (offset / this.size) * percentage;
setElementSize(a.element, a.size, this[aGutterSize], a.i);
setElementSize(b.element, b.size, this[bGutterSize], b.i);
}
// drag, where all the magic happens. The logic is really quite simple:
//
// 1. Ignore if the pair is not dragging.
// 2. Get the offset of the event.
// 3. Snap offset to min if within snappable range (within min + snapOffset).
// 4. Actually adjust each element in the pair to offset.
//
// ---------------------------------------------------------------------
// | | <- a.minSize || b.minSize -> | |
// | | | <- this.snapOffset || this.snapOffset -> | | |
// | | | || | | |
// | | | || | | |
// ---------------------------------------------------------------------
// | <- this.start this.size -> |
function drag(e) {
var offset;
var a = elements[this.a];
var b = elements[this.b];
if (!this.dragging) { return }
// Get the offset of the event from the first side of the
// pair `this.start`. Then offset by the initial position of the
// mouse compared to the gutter size.
offset =
getMousePosition(e) -
this.start +
(this[aGutterSize] - this.dragOffset);
if (dragInterval > 1) {
offset = Math.round(offset / dragInterval) * dragInterval;
}
// If within snapOffset of min or max, set offset to min or max.
// snapOffset buffers a.minSize and b.minSize, so logic is opposite for both.
// Include the appropriate gutter sizes to prevent overflows.
if (offset <= a.minSize + snapOffset + this[aGutterSize]) {
offset = a.minSize + this[aGutterSize];
} else if (
offset >=
this.size - (b.minSize + snapOffset + this[bGutterSize])
) {
offset = this.size - (b.minSize + this[bGutterSize]);
}
// Actually adjust the size.
adjust.call(this, offset);
// Call the drag callback continously. Don't do anything too intensive
// in this callback.
getOption(options, 'onDrag', NOOP)(getSizes());
}
// Cache some important sizes when drag starts, so we don't have to do that
// continously:
//
// `size`: The total size of the pair. First + second + first gutter + second gutter.
// `start`: The leading side of the first element.
//
// ------------------------------------------------
// | aGutterSize -> ||| |
// | ||| |
// | ||| |
// | ||| <- bGutterSize |
// ------------------------------------------------
// | <- start size -> |
function calculateSizes() {
// Figure out the parent size minus padding.
var a = elements[this.a].element;
var b = elements[this.b].element;
var aBounds = a[getBoundingClientRect]();
var bBounds = b[getBoundingClientRect]();
this.size =
aBounds[dimension] +
bBounds[dimension] +
this[aGutterSize] +
this[bGutterSize];
this.start = aBounds[position];
this.end = aBounds[positionEnd];
}
function innerSize(element) {
// Return nothing if getComputedStyle is not supported (< IE9)
// Or if parent element has no layout yet
if (!getComputedStyle) { return null }
var computedStyle = getComputedStyle(element);
if (!computedStyle) { return null }
var size = element[clientSize];
if (size === 0) { return null }
if (direction === HORIZONTAL) {
size -=
parseFloat(computedStyle.paddingLeft) +
parseFloat(computedStyle.paddingRight);
} else {
size -=
parseFloat(computedStyle.paddingTop) +
parseFloat(computedStyle.paddingBottom);
}
return size
}
// When specifying percentage sizes that are less than the computed
// size of the element minus the gutter, the lesser percentages must be increased
// (and decreased from the other elements) to make space for the pixels
// subtracted by the gutters.
function trimToMin(sizesToTrim) {
// Try to get inner size of parent element.
// If it's no supported, return original sizes.
var parentSize = innerSize(parent);
if (parentSize === null) {
return sizesToTrim
}
if (minSizes.reduce(function (a, b) { return a + b; }, 0) > parentSize) {
return sizesToTrim
}
// Keep track of the excess pixels, the amount of pixels over the desired percentage
// Also keep track of the elements with pixels to spare, to decrease after if needed
var excessPixels = 0;
var toSpare = [];
var pixelSizes = sizesToTrim.map(function (size, i) {
// Convert requested percentages to pixel sizes
var pixelSize = (parentSize * size) / 100;
var elementGutterSize = getGutterSize(
gutterSize,
i === 0,
i === sizesToTrim.length - 1,
gutterAlign
);
var elementMinSize = minSizes[i] + elementGutterSize;
// If element is too smal, increase excess pixels by the difference
// and mark that it has no pixels to spare
if (pixelSize < elementMinSize) {
excessPixels += elementMinSize - pixelSize;
toSpare.push(0);
return elementMinSize
}
// Otherwise, mark the pixels it has to spare and return it's original size
toSpare.push(pixelSize - elementMinSize);
return pixelSize
});
// If nothing was adjusted, return the original sizes
if (excessPixels === 0) {
return sizesToTrim
}
return pixelSizes.map(function (pixelSize, i) {
var newPixelSize = pixelSize;
// While there's still pixels to take, and there's enough pixels to spare,
// take as many as possible up to the total excess pixels
if (excessPixels > 0 && toSpare[i] - excessPixels > 0) {
var takenPixels = Math.min(
excessPixels,
toSpare[i] - excessPixels
);
// Subtract the amount taken for the next iteration
excessPixels -= takenPixels;
newPixelSize = pixelSize - takenPixels;
}
// Return the pixel size adjusted as a percentage
return (newPixelSize / parentSize) * 100
})
}
// stopDragging is very similar to startDragging in reverse.
function stopDragging() {
var self = this;
var a = elements[self.a].element;
var b = elements[self.b].element;
if (self.dragging) {
getOption(options, 'onDragEnd', NOOP)(getSizes());
}
self.dragging = false;
// Remove the stored event listeners. This is why we store them.
global[removeEventListener]('mouseup', self.stop);
global[removeEventListener]('touchend', self.stop);
global[removeEventListener]('touchcancel', self.stop);
global[removeEventListener]('mousemove', self.move);
global[removeEventListener]('touchmove', self.move);
// Clear bound function references
self.stop = null;
self.move = null;
a[removeEventListener]('selectstart', NOOP);
a[removeEventListener]('dragstart', NOOP);
b[removeEventListener]('selectstart', NOOP);
b[removeEventListener]('dragstart', NOOP);
a.style.userSelect = '';
a.style.webkitUserSelect = '';
a.style.MozUserSelect = '';
a.style.pointerEvents = '';
b.style.userSelect = '';
b.style.webkitUserSelect = '';
b.style.MozUserSelect = '';
b.style.pointerEvents = '';
self.gutter.style.cursor = '';
self.parent.style.cursor = '';
document.body.style.cursor = '';
}
// startDragging calls `calculateSizes` to store the inital size in the pair object.
// It also adds event listeners for mouse/touch events,
// and prevents selection while dragging so avoid the selecting text.
function startDragging(e) {
// Right-clicking can't start dragging.
if ('button' in e && e.button !== 0) {
return
}
// Alias frequently used variables to save space. 200 bytes.
var self = this;
var a = elements[self.a].element;
var b = elements[self.b].element;
// Call the onDragStart callback.
if (!self.dragging) {
getOption(options, 'onDragStart', NOOP)(getSizes());
}
// Don't actually drag the element. We emulate that in the drag function.
e.preventDefault();
// Set the dragging property of the pair object.
self.dragging = true;
// Create two event listeners bound to the same pair object and store
// them in the pair object.
self.move = drag.bind(self);
self.stop = stopDragging.bind(self);
// All the binding. `window` gets the stop events in case we drag out of the elements.
global[addEventListener]('mouseup', self.stop);
global[addEventListener]('touchend', self.stop);
global[addEventListener]('touchcancel', self.stop);
global[addEventListener]('mousemove', self.move);
global[addEventListener]('touchmove', self.move);
// Disable selection. Disable!
a[addEventListener]('selectstart', NOOP);
a[addEventListener]('dragstart', NOOP);
b[addEventListener]('selectstart', NOOP);
b[addEventListener]('dragstart', NOOP);
a.style.userSelect = 'none';
a.style.webkitUserSelect = 'none';
a.style.MozUserSelect = 'none';
a.style.pointerEvents = 'none';
b.style.userSelect = 'none';
b.style.webkitUserSelect = 'none';
b.style.MozUserSelect = 'none';
b.style.pointerEvents = 'none';
// Set the cursor at multiple levels
self.gutter.style.cursor = cursor;
self.parent.style.cursor = cursor;
document.body.style.cursor = cursor;
// Cache the initial sizes of the pair.
calculateSizes.call(self);
// Determine the position of the mouse compared to the gutter
self.dragOffset = getMousePosition(e) - self.end;
}
// adjust sizes to ensure percentage is within min size and gutter.
sizes = trimToMin(sizes);
// 5. Create pair and element objects. Each pair has an index reference to
// elements `a` and `b` of the pair (first and second elements).
// Loop through the elements while pairing them off. Every pair gets a
// `pair` object and a gutter.
//
// Basic logic:
//
// - Starting with the second element `i > 0`, create `pair` objects with
// `a = i - 1` and `b = i`
// - Set gutter sizes based on the _pair_ being first/last. The first and last
// pair have gutterSize / 2, since they only have one half gutter, and not two.
// - Create gutter elements and add event listeners.
// - Set the size of the elements, minus the gutter sizes.
//
// -----------------------------------------------------------------------
// | i=0 | i=1 | i=2 | i=3 |
// | | | | |
// | pair 0 pair 1 pair 2 |
// | | | | |
// -----------------------------------------------------------------------
var pairs = [];
elements = ids.map(function (id, i) {
// Create the element object.
var element = {
element: elementOrSelector(id),
size: sizes[i],
minSize: minSizes[i],
i: i,
};
var pair;
if (i > 0) {
// Create the pair object with its metadata.
pair = {
a: i - 1,
b: i,
dragging: false,
direction: direction,
parent: parent,
};
pair[aGutterSize] = getGutterSize(
gutterSize,
i - 1 === 0,
false,
gutterAlign
);
pair[bGutterSize] = getGutterSize(
gutterSize,
false,
i === ids.length - 1,
gutterAlign
);
// if the parent has a reverse flex-direction, switch the pair elements.
if (
parentFlexDirection === 'row-reverse' ||
parentFlexDirection === 'column-reverse'
) {
var temp = pair.a;
pair.a = pair.b;
pair.b = temp;
}
}
// Determine the size of the current element. IE8 is supported by
// staticly assigning sizes without draggable gutters. Assigns a string
// to `size`.
//
// Create gutter elements for each pair.
if (i > 0) {
var gutterElement = gutter(i, direction, element.element);
setGutterSize(gutterElement, gutterSize, i);
// Save bound event listener for removal later
pair[gutterStartDragging] = startDragging.bind(pair);
// Attach bound event listener
gutterElement[addEventListener](
'mousedown',
pair[gutterStartDragging]
);
gutterElement[addEventListener](
'touchstart',
pair[gutterStartDragging]
);
parent.insertBefore(gutterElement, element.element);
pair.gutter = gutterElement;
}
setElementSize(
element.element,
element.size,
getGutterSize(
gutterSize,
i === 0,
i === ids.length - 1,
gutterAlign
),
i
);
// After the first iteration, and we have a pair object, append it to the
// list of pairs.
if (i > 0) {
pairs.push(pair);
}
return element
});
function adjustToMin(element) {
var isLast = element.i === pairs.length;
var pair = isLast ? pairs[element.i - 1] : pairs[element.i];
calculateSizes.call(pair);
var size = isLast
? pair.size - element.minSize - pair[bGutterSize]
: element.minSize + pair[aGutterSize];
adjust.call(pair, size);
}
elements.forEach(function (element) {
var computedSize = element.element[getBoundingClientRect]()[dimension];
if (computedSize < element.minSize) {
if (expandToMin) {
adjustToMin(element);
} else {
// eslint-disable-next-line no-param-reassign
element.minSize = computedSize;
}
}
});
function setSizes(newSizes) {
var trimmed = trimToMin(newSizes);
trimmed.forEach(function (newSize, i) {
if (i > 0) {
var pair = pairs[i - 1];
var a = elements[pair.a];
var b = elements[pair.b];
a.size = trimmed[i - 1];
b.size = newSize;
setElementSize(a.element, a.size, pair[aGutterSize], a.i);
setElementSize(b.element, b.size, pair[bGutterSize], b.i);
}
});
}
function destroy(preserveStyles, preserveGutter) {
pairs.forEach(function (pair) {
if (preserveGutter !== true) {
pair.parent.removeChild(pair.gutter);
} else {
pair.gutter[removeEventListener](
'mousedown',
pair[gutterStartDragging]
);
pair.gutter[removeEventListener](
'touchstart',
pair[gutterStartDragging]
);
}
if (preserveStyles !== true) {
var style = elementStyle(
dimension,
pair.a.size,
pair[aGutterSize]
);
Object.keys(style).forEach(function (prop) {
elements[pair.a].element.style[prop] = '';
elements[pair.b].element.style[prop] = '';
});
}
});
}
return {
setSizes: setSizes,
getSizes: getSizes,
collapse: function collapse(i) {
adjustToMin(elements[i]);
},
destroy: destroy,
parent: parent,
pairs: pairs,
}
};
return Split;
})));

File diff suppressed because one or more lines are too long

@ -1,16 +0,0 @@
gMainApp.component( "main-app", {
data() { return {
isLoaded: false,
} ; },
template: `
<div> Hello, world! </div>
<div v-if="isLoaded" id="_mainapp-loaded_" />
`,
mounted() {
this.isLoaded = true ;
},
} ) ;

@ -0,0 +1,71 @@
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.TinyEmitter = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
function E () {
// Keep this empty so it's easier to inherit from
// (via https://github.com/lipsmack from https://github.com/scottcorgan/tiny-emitter/issues/3)
}
E.prototype = {
on: function (name, callback, ctx) {
var e = this.e || (this.e = {});
(e[name] || (e[name] = [])).push({
fn: callback,
ctx: ctx
});
return this;
},
once: function (name, callback, ctx) {
var self = this;
function listener () {
self.off(name, listener);
callback.apply(ctx, arguments);
};
listener._ = callback
return this.on(name, listener, ctx);
},
emit: function (name) {
var data = [].slice.call(arguments, 1);
var evtArr = ((this.e || (this.e = {}))[name] || []).slice();
var i = 0;
var len = evtArr.length;
for (i; i < len; i++) {
evtArr[i].fn.apply(evtArr[i].ctx, data);
}
return this;
},
off: function (name, callback) {
var e = this.e || (this.e = {});
var evts = e[name];
var liveEvents = [];
if (evts && callback) {
for (var i = 0, len = evts.length; i < len; i++) {
if (evts[i].fn !== callback && evts[i].fn._ !== callback)
liveEvents.push(evts[i]);
}
}
// Remove event from queue to prevent memory leak
// Suggested by https://github.com/lazd
// Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910
(liveEvents.length)
? e[name] = liveEvents
: delete e[name];
return this;
}
};
module.exports = E;
module.exports.TinyEmitter = E;
},{}]},{},[1])(1)
});

@ -0,0 +1 @@
(function(e){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=e()}else if(typeof define==="function"&&define.amd){define([],e)}else{var n;if(typeof window!=="undefined"){n=window}else if(typeof global!=="undefined"){n=global}else if(typeof self!=="undefined"){n=self}else{n=this}n.TinyEmitter=e()}})(function(){var e,n,t;return function r(e,n,t){function i(o,u){if(!n[o]){if(!e[o]){var s=typeof require=="function"&&require;if(!u&&s)return s(o,!0);if(f)return f(o,!0);var a=new Error("Cannot find module '"+o+"'");throw a.code="MODULE_NOT_FOUND",a}var l=n[o]={exports:{}};e[o][0].call(l.exports,function(n){var t=e[o][1][n];return i(t?t:n)},l,l.exports,r,e,n,t)}return n[o].exports}var f=typeof require=="function"&&require;for(var o=0;o<t.length;o++)i(t[o]);return i}({1:[function(e,n,t){function r(){}r.prototype={on:function(e,n,t){var r=this.e||(this.e={});(r[e]||(r[e]=[])).push({fn:n,ctx:t});return this},once:function(e,n,t){var r=this;function i(){r.off(e,i);n.apply(t,arguments)}i._=n;return this.on(e,i,t)},emit:function(e){var n=[].slice.call(arguments,1);var t=((this.e||(this.e={}))[e]||[]).slice();var r=0;var i=t.length;for(r;r<i;r++){t[r].fn.apply(t[r].ctx,n)}return this},off:function(e,n){var t=this.e||(this.e={});var r=t[e];var i=[];if(r&&n){for(var f=0,o=r.length;f<o;f++){if(r[f].fn!==n&&r[f].fn._!==n)i.push(r[f])}}i.length?t[e]=i:delete t[e];return this}};n.exports=r;n.exports.TinyEmitter=r},{}]},{},[1])(1)});

@ -0,0 +1,17 @@
export function showInfoMsg( msg ) { _doShowNotificationMsg( "notice", msg ) ; }
export function showWarningMsg( msg ) { _doShowNotificationMsg( "warning", msg ) ; }
export function showErrorMsg( msg ) { _doShowNotificationMsg( "error", msg ) ; }
function _doShowNotificationMsg( msgType, msg )
{
// show the notification message
$.growl( {
style: msgType,
title: null,
message: msg,
location: "br",
duration: (msgType == "warning") ? 15*1000 : 5*1000,
fixed: (msgType == "error"),
} ) ;
}

@ -8,6 +8,14 @@
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', <link rel="stylesheet" type="text/css" href="{{ url_for( 'static',
filename = 'jquery-ui/jquery-ui' + WEB_DEBUG_MIN + '.css' filename = 'jquery-ui/jquery-ui' + WEB_DEBUG_MIN + '.css'
) }}" /> ) }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='growl/jquery.growl.css' ) }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='css/global.css' ) }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='css/MainApp.css' ) }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='css/NavPane.css' ) }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='css/SearchPane.css' ) }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='css/SearchResult.css' ) }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='css/ContentPane.css' ) }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='css/TabbedPages.css' ) }}" />
</head> </head>
<body> <body>
@ -25,14 +33,24 @@
<script src="{{ url_for( 'static', <script src="{{ url_for( 'static',
filename = 'jquery-ui/jquery-ui' + WEB_DEBUG_MIN + '.js' filename = 'jquery-ui/jquery-ui' + WEB_DEBUG_MIN + '.js'
) }}"></script> ) }}"></script>
<script src="{{ url_for( 'static',
filename = 'split/split' + WEB_DEBUG_MIN + '.js'
) }}"></script>
<script src="{{ url_for( 'static',
filename = 'tinyemitter/tinyemitter' + WEB_DEBUG_MIN + '.js'
) }}"></script>
<script src="{{ url_for( 'static', filename='growl/jquery.growl.js' ) }}"></script>
<script> <script>
// create the main application gGetContentDocsUrl = "{{ url_for( 'get_content_docs') }}" ;
gMainApp = Vue.createApp( {
template: "<main-app />",
} ) ;
</script> </script>
<script src="{{url_for('static',filename='src/MainApp.js')}}"></script>
<script> gMainApp.mount( "#main-app" ) ; </script> <script type="module" src="{{ url_for( 'static', filename='MainApp.js' ) }}"></script>
<script type="module" src="{{ url_for( 'static', filename='NavPane.js' ) }}"></script>
<script type="module" src="{{ url_for( 'static', filename='SearchPane.js' ) }}"></script>
<script type="module" src="{{ url_for( 'static', filename='SearchResult.js' ) }}"></script>
<script type="module" src="{{ url_for( 'static', filename='ContentPane.js' ) }}"></script>
<script type="module" src="{{ url_for( 'static', filename='TabbedPages.js' ) }}"></script>
<script type="module" src="{{ url_for( 'static', filename='utils.js' ) }}"></script>
</html> </html>

@ -5,6 +5,9 @@ from google.protobuf.empty_pb2 import Empty
from asl_rulebook2.webapp.tests.proto.generated.control_tests_pb2_grpc import ControlTestsStub from asl_rulebook2.webapp.tests.proto.generated.control_tests_pb2_grpc import ControlTestsStub
from asl_rulebook2.webapp.tests.proto.generated.control_tests_pb2 import \
SetDataDirRequest
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# NOTE: The API for this class should be kept in sync with ControlTestsServicer. # NOTE: The API for this class should be kept in sync with ControlTestsServicer.
@ -25,3 +28,10 @@ class ControlTests:
def end_tests( self ): def end_tests( self ):
"""End a test run.""" """End a test run."""
self._stub.endTests( Empty() ) self._stub.endTests( Empty() )
def set_data_dir( self, fixtures_dname ):
"""Set the data directory."""
self._stub.setDataDir(
SetDataDirRequest( fixturesDirName = fixtures_dname )
)
return self

@ -1,5 +1,6 @@
""" gRPC servicer that allows the webapp server to be controlled. """ """ gRPC servicer that allows the webapp server to be controlled. """
import os
import inspect import inspect
import logging import logging
@ -7,6 +8,8 @@ from google.protobuf.empty_pb2 import Empty
from asl_rulebook2.webapp.tests.proto.generated.control_tests_pb2_grpc \ from asl_rulebook2.webapp.tests.proto.generated.control_tests_pb2_grpc \
import ControlTestsServicer as BaseControlTestsServicer import ControlTestsServicer as BaseControlTestsServicer
from asl_rulebook2.webapp.tests.proto.generated.control_tests_pb2 import \
SetDataDirRequest
_logger = logging.getLogger( "control_tests" ) _logger = logging.getLogger( "control_tests" )
@ -20,6 +23,7 @@ class ControlTestsServicer( BaseControlTestsServicer ):
def __init__( self, webapp ): def __init__( self, webapp ):
# initialize # initialize
self._webapp = webapp self._webapp = webapp
self._fixtures_dir = os.path.join( os.path.dirname(__file__), "fixtures/" )
def __del__( self ): def __del__( self ):
# clean up # clean up
@ -32,6 +36,11 @@ class ControlTestsServicer( BaseControlTestsServicer ):
def startTests( self, request, context ): def startTests( self, request, context ):
"""Start a new test run.""" """Start a new test run."""
self._log_request( request, context ) self._log_request( request, context )
# reset the webapp
ctx = None
self.setDataDir( SetDataDirRequest( fixturesDirName=None ), ctx )
# NOTE: The webapp has now been reset, but the client must reloaed the home page
# with "?reload=1", to force it to reload with the new settings.
return Empty() return Empty()
def endTests( self, request, context ): def endTests( self, request, context ):
@ -40,9 +49,24 @@ class ControlTestsServicer( BaseControlTestsServicer ):
self.cleanup() self.cleanup()
return Empty() return Empty()
def setDataDir( self, request, context ):
"""Set the data directory."""
self._log_request( request, context )
dname = request.fixturesDirName
# set the data directory
_logger.debug( "- Setting data directory: %s", dname )
if dname:
self._webapp.config[ "DATA_DIR" ] = os.path.join( self._fixtures_dir, dname )
_logger.warning( os.path.join( self._fixtures_dir, dname ) )
else:
self._webapp.config.pop( "DATA_DIR", None )
return Empty()
@staticmethod @staticmethod
def _log_request( req, ctx ): #pylint: disable=unused-argument def _log_request( req, ctx ): #pylint: disable=unused-argument
"""Log a request.""" """Log a request."""
if ctx is None:
return # nb: we don't log internal calls
# get the entry-point name # get the entry-point name
msg = "{}()".format( inspect.currentframe().f_back.f_code.co_name ) msg = "{}()".format( inspect.currentframe().f_back.f_code.co_name )
# log the message # log the message

@ -0,0 +1,76 @@
[
{ "title": "a",
"content": "amphibious"
},
{ "title": "Advance",
"ruleids": [ "A4.7" ]
},
{ "title": "Backblast",
"ruleids": [ "C13.8" ],
"rulerefs": [
{ "caption": "Huts", "ruleids": [ "G5.62" ] },
{ "caption": "RCL", "ruleids": [ "C12.3-.4" ] }
]
},
{ "title": "CCPh",
"subtitle": "Close Combat Phase",
"ruleids": [ "A3.8" ],
"rulerefs": [
{ "caption": "ENEMY Attacks", "ruleids": [ "S11.5" ] },
{ "caption": "dropping SW before CC", "ruleids": [ "A4.43" ] }
]
},
{ "title": "Double Time",
"ruleids": [ "A4.5-.51", "S6.222" ],
"see_also": [ "CX" ],
"content": "Also known as \"running <em>really</em> fast.\"",
"rulerefs": [
{ "caption": "ENEMY Guard Automatic Action", "ruleids": [ "S6.303" ] },
{ "caption": "Manhandling", "ruleids": [ "C10.3" ] },
{ "caption": "NA for Pathfinders", "ruleids": [ "T1.2" ] },
{ "caption": "S? NA", "ruleids": [ "S3.321" ] },
{ "caption": "Water Shortage", "ruleids": [ "RCG21" ] },
{ "caption": "Wire NA", "ruleids": [ "B26.46" ] }
]
},
{ "title": "ELR",
"subtitle": "Experience Level Rating",
"ruleids": [ "A19.1" ],
"rulerefs": [
{ "caption": "BRT", "ruleids": [ "TCG17" ] },
{ "caption": "Loss", "ruleids": [ "A16.2" ] },
{ "caption": "Massacre", "ruleids": [ "A20.4" ] },
{ "caption": "Night", "ruleids": [ "E1.22" ] },
{ "caption": "in PB", "ruleids": [ "SSR PB12" ] },
{ "caption": "Regaining", "ruleids": [ "A16.3" ] },
{ "caption": "RePh", "ruleids": [ "O11.617", "PCG4", "QCG3", "R9.6202" ] }
]
},
{ "title": "Firepower",
"ruleids": [ "A1.21" ],
"see_also": [ "FP" ]
},
{ "title": "Gaps, Convoy",
"ruleids": [ "E11.21" ]
},
{ "title": "H#",
"subtitle": "HEAT Depletion Number; the number is the Depletion Number, and the superscript following it indicates the first year it applies and a letter indicates the month of that year [EX: A superscript of \"4\" means the vehicle/ordnance has that ammo starting in 1944]",
"ruleids": [ "C8.3" ],
"see_also": [ "HEAT" ]
},
{ "title": "Identity, Vehicular",
"ruleids": [ "D1.4" ]
}
]

@ -0,0 +1,15 @@
{
"A4.7": { "caption": "ADVANCE PHASE", "page_no": 1, "pos": [72,702] },
"C13.8": { "caption": "BACKBLAST", "page_no": 1, "pos": [72,404] },
"A3.8": { "caption": "CLOSE COMBAT PHASE (CCPh)", "page_no": 1, "pos": [72.97] },
"A4.5": { "caption": "DOUBLE TIME", "page_no": 2, "pos": [72,702] },
"A19.1": { "caption": "EXPERIENCE LEVEL RATING (ELR)", "page_no": 2, "pos": [72.404] },
"A1.21": { "caption": "FIREPOWER (FP)", "page_no": 2, "pos": [72,97] },
"A1.21": { "caption": "FIREPOWER (FP)", "page_no": 3, "pos": [72,702] },
"E11.21": { "caption": "GAPS", "page_no": 3, "pos":[72,404] },
"C8.3": { "caption": "HEAT (H)", "page_no": 3, "pos": [72,97] }
}

@ -4,8 +4,16 @@ import "google/protobuf/empty.proto" ;
// -------------------------------------------------------------------- // --------------------------------------------------------------------
message SetDataDirRequest {
string fixturesDirName = 1 ;
}
// --------------------------------------------------------------------
service ControlTests service ControlTests
{ {
rpc startTests( google.protobuf.Empty ) returns ( google.protobuf.Empty ) ; rpc startTests( google.protobuf.Empty ) returns ( google.protobuf.Empty ) ;
rpc endTests( google.protobuf.Empty ) returns ( google.protobuf.Empty ) ; rpc endTests( google.protobuf.Empty ) returns ( google.protobuf.Empty ) ;
rpc setDataDir( SetDataDirRequest ) returns ( google.protobuf.Empty ) ;
} }

@ -20,14 +20,54 @@ DESCRIPTOR = _descriptor.FileDescriptor(
syntax='proto3', syntax='proto3',
serialized_options=None, serialized_options=None,
create_key=_descriptor._internal_create_key, create_key=_descriptor._internal_create_key,
serialized_pb=b'\n\x13\x63ontrol_tests.proto\x1a\x1bgoogle/protobuf/empty.proto2\x88\x01\n\x0c\x43ontrolTests\x12<\n\nstartTests\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\x12:\n\x08\x65ndTests\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Emptyb\x06proto3' serialized_pb=b'\n\x13\x63ontrol_tests.proto\x1a\x1bgoogle/protobuf/empty.proto\",\n\x11SetDataDirRequest\x12\x17\n\x0f\x66ixturesDirName\x18\x01 \x01(\t2\xc2\x01\n\x0c\x43ontrolTests\x12<\n\nstartTests\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\x12:\n\x08\x65ndTests\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\x12\x38\n\nsetDataDir\x12\x12.SetDataDirRequest\x1a\x16.google.protobuf.Emptyb\x06proto3'
, ,
dependencies=[google_dot_protobuf_dot_empty__pb2.DESCRIPTOR,]) dependencies=[google_dot_protobuf_dot_empty__pb2.DESCRIPTOR,])
_SETDATADIRREQUEST = _descriptor.Descriptor(
name='SetDataDirRequest',
full_name='SetDataDirRequest',
filename=None,
file=DESCRIPTOR,
containing_type=None,
create_key=_descriptor._internal_create_key,
fields=[
_descriptor.FieldDescriptor(
name='fixturesDirName', full_name='SetDataDirRequest.fixturesDirName', index=0,
number=1, type=9, cpp_type=9, label=1,
has_default_value=False, default_value=b"".decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
],
extensions=[
],
nested_types=[],
enum_types=[
],
serialized_options=None,
is_extendable=False,
syntax='proto3',
extension_ranges=[],
oneofs=[
],
serialized_start=52,
serialized_end=96,
)
DESCRIPTOR.message_types_by_name['SetDataDirRequest'] = _SETDATADIRREQUEST
_sym_db.RegisterFileDescriptor(DESCRIPTOR) _sym_db.RegisterFileDescriptor(DESCRIPTOR)
SetDataDirRequest = _reflection.GeneratedProtocolMessageType('SetDataDirRequest', (_message.Message,), {
'DESCRIPTOR' : _SETDATADIRREQUEST,
'__module__' : 'control_tests_pb2'
# @@protoc_insertion_point(class_scope:SetDataDirRequest)
})
_sym_db.RegisterMessage(SetDataDirRequest)
_CONTROLTESTS = _descriptor.ServiceDescriptor( _CONTROLTESTS = _descriptor.ServiceDescriptor(
@ -37,8 +77,8 @@ _CONTROLTESTS = _descriptor.ServiceDescriptor(
index=0, index=0,
serialized_options=None, serialized_options=None,
create_key=_descriptor._internal_create_key, create_key=_descriptor._internal_create_key,
serialized_start=53, serialized_start=99,
serialized_end=189, serialized_end=293,
methods=[ methods=[
_descriptor.MethodDescriptor( _descriptor.MethodDescriptor(
name='startTests', name='startTests',
@ -60,6 +100,16 @@ _CONTROLTESTS = _descriptor.ServiceDescriptor(
serialized_options=None, serialized_options=None,
create_key=_descriptor._internal_create_key, create_key=_descriptor._internal_create_key,
), ),
_descriptor.MethodDescriptor(
name='setDataDir',
full_name='ControlTests.setDataDir',
index=2,
containing_service=None,
input_type=_SETDATADIRREQUEST,
output_type=google_dot_protobuf_dot_empty__pb2._EMPTY,
serialized_options=None,
create_key=_descriptor._internal_create_key,
),
]) ])
_sym_db.RegisterServiceDescriptor(_CONTROLTESTS) _sym_db.RegisterServiceDescriptor(_CONTROLTESTS)

@ -2,6 +2,7 @@
"""Client and server classes corresponding to protobuf-defined services.""" """Client and server classes corresponding to protobuf-defined services."""
import grpc import grpc
import asl_rulebook2.webapp.tests.proto.generated.control_tests_pb2 as control__tests__pb2
from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2
@ -26,6 +27,11 @@ class ControlTestsStub(object):
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
) )
self.setDataDir = channel.unary_unary(
'/ControlTests/setDataDir',
request_serializer=control__tests__pb2.SetDataDirRequest.SerializeToString,
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
)
class ControlTestsServicer(object): class ControlTestsServicer(object):
@ -45,6 +51,12 @@ class ControlTestsServicer(object):
context.set_details('Method not implemented!') context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!') raise NotImplementedError('Method not implemented!')
def setDataDir(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_ControlTestsServicer_to_server(servicer, server): def add_ControlTestsServicer_to_server(servicer, server):
rpc_method_handlers = { rpc_method_handlers = {
@ -58,6 +70,11 @@ def add_ControlTestsServicer_to_server(servicer, server):
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
), ),
'setDataDir': grpc.unary_unary_rpc_method_handler(
servicer.setDataDir,
request_deserializer=control__tests__pb2.SetDataDirRequest.FromString,
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
),
} }
generic_handler = grpc.method_handlers_generic_handler( generic_handler = grpc.method_handlers_generic_handler(
'ControlTests', rpc_method_handlers) 'ControlTests', rpc_method_handlers)
@ -103,3 +120,20 @@ class ControlTests(object):
google_dot_protobuf_dot_empty__pb2.Empty.FromString, google_dot_protobuf_dot_empty__pb2.Empty.FromString,
options, channel_credentials, options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata) insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def setDataDir(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(request, target, '/ControlTests/setDataDir',
control__tests__pb2.SetDataDirRequest.SerializeToString,
google_dot_protobuf_dot_empty__pb2.Empty.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)

@ -1,10 +1,20 @@
""" Test basic functionality. """ """ Test basic functionality. """
from asl_rulebook2.webapp.tests.utils import init_webapp from asl_rulebook2.webapp.tests.utils import init_webapp, get_nav_panels, get_content_docs
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
def test_hello( webapp, webdriver ): def test_hello( webapp, webdriver ):
"""Test basic functionality.""" """Test basic functionality."""
# initialize
webapp.control_tests.set_data_dir( "simple" )
init_webapp( webapp, webdriver ) init_webapp( webapp, webdriver )
# check that the nav panel loaded correctly
nav_panels = get_nav_panels()
assert nav_panels == [ "search" ]
# check that the content docs loaded correctly
content_docs = get_content_docs()
assert content_docs == [ "simple" ]

@ -0,0 +1,35 @@
""" Run ESLint over the Javascript files. """
import os.path
import subprocess
import pytest
# ---------------------------------------------------------------------
@pytest.mark.skipif( not os.environ.get("ESLINT"), reason="ESLINT not configured." )
def test_eslint():
"""Run ESLint over the Javascript files."""
# initialize
eslint = os.environ[ "ESLINT" ]
# check each Javascript file
dname = os.path.join( os.path.dirname(__file__), "../static/" )
for fname in os.listdir( dname ):
if os.path.splitext( fname )[1] != ".js":
continue
# run ESLint for the next file
proc = subprocess.run(
[ eslint, os.path.join(dname,fname) ],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8",
check=False
)
if proc.stdout or proc.stderr:
print( "=== ESLint failed: {} ===".format( fname ) )
if proc.stdout:
print( proc.stdout )
if proc.stderr:
print( proc.stderr )
assert False

@ -3,6 +3,8 @@
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.common.exceptions import NoSuchElementException from selenium.common.exceptions import NoSuchElementException
from asl_rulebook2.webapp import tests as webapp_tests
_webapp = None _webapp = None
_webdriver = None _webdriver = None
@ -17,6 +19,11 @@ def init_webapp( webapp, webdriver, **options ):
_webdriver = webdriver _webdriver = webdriver
# load the webapp # load the webapp
if get_pytest_option("webdriver") == "chrome" and get_pytest_option("headless"):
# FUDGE! Headless Chrome doesn't want to show the PDF in the browser,
# it downloads the file and saves it in the current directory :wtf:
options["no-content"] = 1
options["reload"] = 1 # nb: force the webapp to reload
webdriver.get( webapp.url_for( "main", **options ) ) webdriver.get( webapp.url_for( "main", **options ) )
_wait_for_webapp() _wait_for_webapp()
@ -32,6 +39,21 @@ def _wait_for_webapp():
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
def get_nav_panels():
"""Get the available nav panels."""
return _get_tab_ids( "#nav .tab-strip" )
def get_content_docs():
"""Get the available content docs."""
return _get_tab_ids( "#content .tab-strip" )
def _get_tab_ids( sel ):
"""Get the tabs in a tab-strip."""
tabs = find_children( "{} .tab".format( sel ) )
return [ tab.get_attribute( "data-tabid" ) for tab in tabs ]
# ---------------------------------------------------------------------
def find_child( sel, parent=None ): def find_child( sel, parent=None ):
"""Find a single child element.""" """Find a single child element."""
try: try:
@ -41,6 +63,15 @@ def find_child( sel, parent=None ):
except NoSuchElementException: except NoSuchElementException:
return None return None
def find_children( sel, parent=None ):
"""Find child elements."""
try:
if parent is None:
parent = _webdriver
return parent.find_elements_by_css_selector( sel )
except NoSuchElementException:
return None
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
def wait_for( timeout, func ): def wait_for( timeout, func ):
@ -48,3 +79,9 @@ def wait_for( timeout, func ):
WebDriverWait( _webdriver, timeout, poll_frequency=0.1 ).until( WebDriverWait( _webdriver, timeout, poll_frequency=0.1 ).until(
lambda driver: func() lambda driver: func()
) )
# ---------------------------------------------------------------------
def get_pytest_option( opt ):
"""Get a pytest configuration option."""
return getattr( webapp_tests.pytest_options, opt )

@ -1,7 +1,25 @@
"""Helper functions.""" """Helper functions."""
import pathlib
import re
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
def change_extn( fname, extn ):
"""Change a filename's extension."""
return pathlib.Path( fname ).with_suffix( extn )
def slugify( val ):
"""Convert a string to a slug."""
val = re.sub( r"\s+", " ", val ).lower()
def fix( ch ):
if ch.isalnum() or ch == "-":
return ch
if ch in " _":
return "-"
return "_"
return "".join( fix(ch) for ch in val )
def parse_int( val, default=None ): def parse_int( val, default=None ):
"""Parse an integer.""" """Parse an integer."""
try: try:

Loading…
Cancel
Save