whimsical-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From Sam Ruby <ru...@apache.org>
Subject [whimsy.git] [30/50] Commit ab01564: cleanup
Date Fri, 22 Jan 2016 02:40:56 GMT
Commit ab01564defb4a4a2464da80e186176f4915c917c:
    cleanup


Branch: refs/heads/master
Author: Sam Ruby <rubys@intertwingly.net>
Committer: Sam Ruby <rubys@intertwingly.net>
Pusher: rubys <rubys@apache.org>

------------------------------------------------------------
README.md                                                    | ++++++++++++ 
Rakefile                                                     | ++ 
main.rb                                                      | ++ -------------
routes.rb                                                    | +++++++++++++ 
views/add-comment.rb                                         | ++++++ ---
views/app.js.rb                                              | ++++ 
views/header.js.rb                                           | ++++++++ -
views/index.js.rb                                            | +++++++ ------
views/main.js.rb                                             | +++++++++++ ----
views/modal-dialog.js.rb                                     | ++++++++ 
views/search.js.rb                                           | +++++++++ --
views/utils.js.rb                                            | +++ 
------------------------------------------------------------
510 changes: 354 additions, 156 deletions.
------------------------------------------------------------


diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0ae0c3b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,47 @@
+Preparation
+---
+
+This has been tested to work on Mac OSX and Linux.  It may not work yet on
+Windows.
+
+For a partial installation, all you need is Ruby and Node.js.
+
+For planning purposes, prereqs for a _full_ installation will require:
+
+ * A SVN checkout of
+  [board](https://svn.apache.org/repos/private/foundation/board).
+
+ * A directory, preferably empty, for work files containing such things as
+  uncommitted comments.
+
+ * The following software installed:
+     * Subversion
+     * Ruby 1.9.3 or greater
+     * Node.js
+     * PhantomJS 2.0
+         * Mac OS/X Yosemite users may need to get the binary from comments
+           on [12900](https://github.com/ariya/phantomjs/issues/12900).
+
+Kicking the tires:
+---
+
+```
+sudo gem install bundler
+git clone ...
+cd ...
+bundle install
+rake
+RACK_ENV=test puma
+```
+
+Visit http://localhost:9292/ in your favorite browser.
+
+Notes:
+
+ * If you don't have PhantomJS installed, or have a version of PhantomJS
+   prior to version 2.0 installed, one test will fail. 
+
+ * The data you see is a sanitized version of actual agendas that have
+   been included in the repository for test purposes.
+
+
diff --git a/Rakefile b/Rakefile
index 1c58b8c..b8931c0 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,3 +1,5 @@
+require 'whimsy/asf/config'
+
 require 'rspec/core/rake_task'
 RSpec::Core::RakeTask.new(:spec)
 task :default => :spec
diff --git a/main.rb b/main.rb
index ec3a94d..fd437ca 100755
--- a/main.rb
+++ b/main.rb
@@ -1,74 +1,37 @@
 #!/usr/bin/ruby
 
 #
-# Server side router/controllers
+# Server side setup
 #
 
 require 'whimsy/asf/agenda'
 
 require 'wunderbar/sinatra'
-require 'wunderbar/bootstrap/theme'
 require 'wunderbar/react'
+require 'wunderbar/bootstrap/theme'
 require 'ruby2js/filter/functions'
 require 'ruby2js/filter/require'
 
 require 'yaml'
 
+require_relative './routes'
+
+# determine where relevant data can be found
 if ENV['RACK_ENV'] == 'test'
   FOUNDATION_BOARD = File.expand_path('test/work/board').untaint
-  MINUTES_WORK = File.expand_path('test/work/data').untaint
+  AGENDA_WORK = File.expand_path('test/work/data').untaint
 else
   FOUNDATION_BOARD = ASF::SVN['private/foundation/board']
-  MINUTES_WORK = '/var/tools/data'
+  AGENDA_WORK = ASF::Config.get(:agenda_work) || '/var/tools/data'
+  STDERR.puts "* SVN board  : #{FOUNDATION_BOARD}"
+  STDERR.puts "* Agenda work: #{AGENDA_WORK}"
 end
 
+# get a directory listing given a pattern and a base directory
 def dir(pattern, base=FOUNDATION_BOARD)
   Dir[File.join(base, pattern)].map {|name| File.basename name}
 end
 
-get '/' do
-  agenda = dir('board_agenda_*.txt').sort.last
-  redirect to("/#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}/")
-end
-
-get %r{/(\d\d\d\d-\d\d-\d\d)/(.*)} do |date, path|
-  @agendas = dir('board_agenda_*.txt').sort
-  @drafts = dir('board_minutes_*.txt').sort
-  @base = env['PATH_INFO'].chomp(path).untaint
-  @path = path
-  @query = params['q']
-  @agenda = "board_agenda_#{date.gsub('-','_')}.txt"
-  pass unless File.exist? File.join(FOUNDATION_BOARD, @agenda)
-
-  if AGENDA_CACHE[@agenda][:mtime] == 0
-    AGENDA_CACHE.parse(@agenda, true)
-  end
-
-  @parsed = AGENDA_CACHE[@agenda][:parsed]
-  @etag = AGENDA_CACHE[@agenda][:etag]
-  @etag = nil unless AGENDA_CACHE[@agenda][:mtime].to_i > 0
-
-  _html :'main'
-end
-
-get '/json/jira' do
-  _json :'jira'
-end
-
-get '/json/pending' do
-  _json do
-    Pending.get(env.user)
-  end
-end
-
-get '/json/secretary_todos/:file' do
-  _json :'json/todos'
-end
-
-post '/json/:file' do
-  _json :"json/#{params[:file]}"
-end
-
 # aggressively cache agenda
 AGENDA_CACHE = Hash.new(mtime: 0)
 def AGENDA_CACHE.parse(file, quick=false)
@@ -79,65 +42,12 @@ def AGENDA_CACHE.parse(file, quick=false)
   }
 end
 
-get %r{(\d\d\d\d-\d\d-\d\d).json} do |file|
-  file = "board_agenda_#{file.gsub('-','_')}.txt"
-  path = File.expand_path(file, FOUNDATION_BOARD).untaint
-  pass unless File.exist? path
-
-  response = _json do
-    file = file.dup.untaint
-
-    if AGENDA_CACHE[file][:mtime] != File.mtime(path)
-      AGENDA_CACHE.parse file
-    end
-
-    last_modified AGENDA_CACHE[file][:mtime]
-    AGENDA_CACHE[file][:parsed]
-  end
-
-  AGENDA_CACHE[file][:etag] = headers['ETag']
-  response
-end
-
 # aggressively cache minutes
 MINUTE_CACHE = Hash.new(mtime: 0)
 def MINUTE_CACHE.parse(file)
-  path = File.expand_path(file, MINUTES_WORK).untaint
+  path = File.expand_path(file, AGENDA_WORK).untaint
   self[file] = {
     mtime: File.mtime(path),
     parsed: YAML.load_file(path)
   }
 end
-
-get '/json/minutes/:file' do |file|
-  file = "board_minutes_#{file.gsub('-','_')}.yml"
-  path = File.expand_path(file, MINUTES_WORK).untaint
-  pass unless File.exits? path
-
-  _json do
-    last_modified File.mtime(path)
-    MINUTE_CACHE.parse(file)[:parsed]
-  end
-end
-
-get '/text/minutes/:file' do |file|
-  file = "board_minutes_#{file.gsub('-','_')}.txt".untaint
-  pass unless dir('board_minutes_*.txt').include? file
-  path = File.expand_path(file, FOUNDATION_BOARD).untaint
-
-  _text do
-    last_modified File.mtime(path)
-    File.read(path)
-  end
-end
-
-get '/text/draft/:file' do |file|
-  agenda = "board_agenda_#{file.gsub('-','_')}.txt".untaint
-  minutes = MINUTES_WORK + '/' + 
-    agenda.sub('_agenda_','_minutes_').sub('.txt','.yml')
-  pass unless dir('board_agenda_*.txt').include?(agenda) and File.exist? minutes
-
-  _text do
-    Minutes.draft(agenda, minutes)
-  end
-end
diff --git a/routes.rb b/routes.rb
new file mode 100755
index 0000000..23c2d2c
--- /dev/null
+++ b/routes.rb
@@ -0,0 +1,99 @@
+#
+# Server side Sinatra routes
+#
+
+get '/' do
+  agenda = dir('board_agenda_*.txt').sort.last
+  redirect to("/#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}/")
+end
+
+get %r{/(\d\d\d\d-\d\d-\d\d)/(.*)} do |date, path|
+  @agendas = dir('board_agenda_*.txt').sort
+  @drafts = dir('board_minutes_*.txt').sort
+  @base = env['PATH_INFO'].chomp(path).untaint
+  @path = path
+  @query = params['q']
+  @agenda = "board_agenda_#{date.gsub('-','_')}.txt"
+  pass unless File.exist? File.join(FOUNDATION_BOARD, @agenda)
+
+  if AGENDA_CACHE[@agenda][:mtime] == 0
+    AGENDA_CACHE.parse(@agenda, true)
+  end
+
+  @parsed = AGENDA_CACHE[@agenda][:parsed]
+  @etag = AGENDA_CACHE[@agenda][:etag]
+  @etag = nil unless AGENDA_CACHE[@agenda][:mtime].to_i > 0
+
+  _html :'main'
+end
+
+get '/json/jira' do
+  _json :'jira'
+end
+
+get '/json/pending' do
+  _json do
+    Pending.get(env.user)
+  end
+end
+
+get '/json/secretary_todos/:file' do
+  _json :'json/todos'
+end
+
+post '/json/:file' do
+  _json :"json/#{params[:file]}"
+end
+
+get %r{(\d\d\d\d-\d\d-\d\d).json} do |file|
+  file = "board_agenda_#{file.gsub('-','_')}.txt"
+  path = File.expand_path(file, FOUNDATION_BOARD).untaint
+  pass unless File.exist? path
+
+  response = _json do
+    file = file.dup.untaint
+
+    if AGENDA_CACHE[file][:mtime] != File.mtime(path)
+      AGENDA_CACHE.parse file
+    end
+
+    last_modified AGENDA_CACHE[file][:mtime]
+    AGENDA_CACHE[file][:parsed]
+  end
+
+  AGENDA_CACHE[file][:etag] = headers['ETag']
+  response
+end
+
+get '/json/minutes/:file' do |file|
+  file = "board_minutes_#{file.gsub('-','_')}.yml"
+  path = File.expand_path(file, AGENDA_WORK).untaint
+  pass unless File.exits? path
+
+  _json do
+    last_modified File.mtime(path)
+    MINUTE_CACHE.parse(file)[:parsed]
+  end
+end
+
+get '/text/minutes/:file' do |file|
+  file = "board_minutes_#{file.gsub('-','_')}.txt".untaint
+  pass unless dir('board_minutes_*.txt').include? file
+  path = File.expand_path(file, FOUNDATION_BOARD).untaint
+
+  _text do
+    last_modified File.mtime(path)
+    File.read(path)
+  end
+end
+
+get '/text/draft/:file' do |file|
+  agenda = "board_agenda_#{file.gsub('-','_')}.txt".untaint
+  minutes = AGENDA_WORK + '/' + 
+    agenda.sub('_agenda_','_minutes_').sub('.txt','.yml')
+  pass unless dir('board_agenda_*.txt').include?(agenda) and File.exist? minutes
+
+  _text do
+    Minutes.draft(agenda, minutes)
+  end
+end
diff --git a/views/add-comment.rb b/views/add-comment.rb
index 4f810b6..03c0c11 100644
--- a/views/add-comment.rb
+++ b/views/add-comment.rb
@@ -1,32 +1,51 @@
 class AddComment < React
+  def initialize
+    @save_disabled = true
+  end
+
   def render
+    # comment form button
     _button.btn.btn_primary 'add comment', type: 'button', 
       data_toggle: 'modal', data_target: '#comment-form'
 
-    _div.modal.fade.comment_form! do
-      _div.modal_dialog do
-        _div.modal_content do
-          _div.modal_header.commented do
-            _button.close 'x', type: 'button', data_dismiss: 'modal'
-            _h4.modal_title 'Enter a comment'
-          end
-          _div.modal_body do
-            _div.form_group do
-              _label 'Initials', for: 'comment-initials'
-              _input.comment_initials!.form_control label: 'Initials',
-                placeholder: 'initials'
-            end
-            _div.form_group do
-              _label 'Comment', for: 'comment-text'
-              _textarea.comment_text!.form_control label: 'Comment',
-                placeholder: 'comment', rows: 5
-            end
-          end
-          _div.modal_footer do
-            _button.btn.btn_default 'Cancel'
-          end
-        end
+    _ModalDialog.comment_form! color: 'commented' do
+      # header
+      _h4 'Enter a comment'
+
+      #input field: initials
+      _div.form_group do
+        _label 'Initials', for: 'comment-initials'
+        _input.comment_initials!.form_control label: 'Initials',
+          placeholder: 'initials'
       end
+
+      #input field: comment text
+      _div.form_group do
+        _label 'Comment', for: 'comment-text'
+        _textarea.comment_text!.form_control label: 'Comment',
+          placeholder: 'comment', rows: 5, onInput: self.input
+      end
+
+      # footer buttons
+      _button.btn_default 'Cancel', data_dismiss: 'modal'
+      _button.btn_primary 'Save', disabled: @save_disabled, onClick: self.save
+    end
+  end
+
+  # enable/disable save when input changes
+  def input(event)
+    @save_disabled = ( event.target.value.length == 0 )
+  end
+
+  def save(event)
+    data = {
+      initials: ~'#comment_initials'.value,
+      text: ~'#comment_text'.value
+    }
+
+    post 'add-comment', data do |pending|
+      Pending.load pending
+      ~'#comment-form'.modal(:hide)
     end
   end
 end
diff --git a/views/app.js.rb b/views/app.js.rb
index 62fa318..4c5d586 100644
--- a/views/app.js.rb
+++ b/views/app.js.rb
@@ -14,6 +14,10 @@
 
 # Common elements
 require_relative 'link'
+require_relative 'modal-dialog'
 
 # Model
 require_relative 'agenda'
+
+# Utility functions
+require_relative 'utils'
diff --git a/views/header.js.rb b/views/header.js.rb
index 4ac44b5..4249915 100644
--- a/views/header.js.rb
+++ b/views/header.js.rb
@@ -1,5 +1,7 @@
 #
 # Header: title on the left, dropdowns on the right
+#
+# Also keeps the window/tab title in sync with the header title
 
 class Header < React
   def render
@@ -77,11 +79,16 @@ def render
     end
   end
 
+  # set title on initial rendering
   def componentDidMount()
     self.componentDidUpdate()
   end
 
+  # update title to match the item title whenever page changes
   def componentDidUpdate()
-    document.getElementsByTagName('title')[0].textContent = @@item.title
+    title = ~'title'
+    if title.textContent != @@item.title
+      title.textContent = @@item.title
+    end
   end
 end
diff --git a/views/index.js.rb b/views/index.js.rb
index 96d0850..0c65f7e 100644
--- a/views/index.js.rb
+++ b/views/index.js.rb
@@ -1,3 +1,7 @@
+#
+# Overall Agenda page: simple table with one row for each item in the index
+#
+
 class Index < React
   def render
     _header do
@@ -6,21 +10,19 @@ def render
 
     _table.table_bordered do
       _thead do
-	_th 'Attach'
-	_th 'Title'
-	_th 'Owner'
-	_th 'Shepherd'
+	      _th 'Attach'
+	      _th 'Title'
+	      _th 'Owner'
+	      _th 'Shepherd'
       end
 
       _tbody Agenda.index do |row|
-	_tr class: row.color do
-	  _td row.attach
-	  _td do
-	    _Link text: row.title, href: row.href
-	  end
-	  _td row.owner
-	  _td row.shepherd
-	end
+	      _tr class: row.color do
+	        _td row.attach
+	        _td { _Link text: row.title, href: row.href }
+	        _td row.owner
+	        _td row.shepherd
+	      end
       end
     end
   end
diff --git a/views/main.js.rb b/views/main.js.rb
index 72d8105..fc9d577 100644
--- a/views/main.js.rb
+++ b/views/main.js.rb
@@ -1,4 +1,18 @@
+#
+# Main component, responsible for:
+#
+#  * Initial loading and polling of the agenda
+#
+#  * Routing based on path and query information in the URL
+#
+#  * Rendering a Header, a item view, and a Footer
+#
+#  * Resizing view to leave room for the Header and Footer
+#
+
 class Main < React
+
+  # initialize polling state
   def initialize
     @poll = {
       link: "../#{@@agenda[/(\d+_\d+_\d+)/,1].gsub('_','-')}.json",
@@ -7,6 +21,7 @@ def initialize
     }
   end
 
+  # route request based on path and query from the window location (URL)
   def route(path, query)
     if path == 'search'
       @item = {title: 'Search', view: Search, color: 'blank', query: query}
@@ -19,6 +34,7 @@ def route(path, query)
     end
   end
 
+  # common render for all pages: header, main, and footer
   def render
     _Header item: @item
 
@@ -29,6 +45,7 @@ def render
     _Footer item: @item
   end
 
+  # initial load of the agenda, and route first request
   def componentWillMount()
     Agenda.load(@@parsed)
     Agenda._date = @@agenda[/(\d+_\d+_\d+)/, 1].gsub('_', '-')
@@ -36,50 +53,64 @@ def componentWillMount()
     self.route(@@path, @@query)
   end
 
+  # navigation method that updates history (back button) information
   def navigate(path, query)
     self.route(path, query)
     history.pushState({path: path, query: query}, nil, path)
   end
 
+  # additional client side initialization
   def componentDidMount()
     # export navigate method
     Main.navigate = self.navigate
 
+    # store initial state in history, taking care not to overwrite
+    # history set by the Search component.
     if not history.state or not history.state.query
       history.replaceState({path: @@path}, nil, @@path)
     end
 
+    # listen for back button, and re-route/re-render when it occcurs
     window.addEventListener :popstate do |event|
       if event.state and defined? event.state.path
         self.route(event.state.path, event.state.query)
       end
     end
 
-    def (document.getElementsByTagName('body')[0]).onkeyup(event)
-      return if document.getElementById('search-text')
+    # keyboard navigation (unless on the search screen)
+    def (document.body).onkeyup(event)
+      return if ~'#search-text'
 
       if event.keyCode == 37
-        self.navigate document.querySelector("a[rel=prev]").getAttribute('href')
+        self.navigate ~"a[rel=prev]".getAttribute('href')
       elsif event.keyCode == 39
-        self.navigate document.querySelector("a[rel=next]").getAttribute('href')
+        self.navigate ~"a[rel=next]".getAttribute('href')
       end
     end
 
+    # whenever the window is resized, adjust margins of the main area to
+    # avoid overlapping the header and footer areas
     def window.onresize()
-      main = document.querySelector('main')
-      header = document.querySelector('header.navbar')
-      footer = document.querySelector('header.navbar')
-      main.style.marginTop = "#{header.clientHeight}px"
-      main.style.marginBottom = "#{footer.clientHeight}px"
+      main = ~'main'
+      main.style.marginTop = "#{~'header.navbar'.clientHeight}px"
+      main.style.marginBottom = "#{~'footer.navbar'.clientHeight}px"
     end
 
+    # do an initial resize
     window.onresize()
 
-    self.pollAgenda() unless @poll.etag
-    setInterval self.pollAgenda, @poll.interval
+    # if agenda is stale, fetch immediately; start polling agenda
+    self.fetchAgenda() unless @poll.etag
+    setInterval self.fetchAgenda, @poll.interval
   end
 
-  def pollAgenda()
+  # after each subsequent re-rendering, resize main window
+  def componentDidUpdate()
+    window.onresize()
+  end
+
+  # fetch agenda
+  def fetchAgenda()
     xhr = XMLHttpRequest.new()
     xhr.open('GET', @poll.link, true)
     xhr.setRequestHeader('If-None-Match', @poll.etag) if @poll.etag
@@ -94,7 +125,4 @@ def xhr.onreadystatechange()
     xhr.send()
   end
 
-  def componentDidUpdate()
-    window.onresize()
-  end
 end
diff --git a/views/modal-dialog.js.rb b/views/modal-dialog.js.rb
new file mode 100644
index 0000000..b9c3a61
--- /dev/null
+++ b/views/modal-dialog.js.rb
@@ -0,0 +1,63 @@
+class ModalDialog < React
+  def initialize
+    @header = []
+    @body = []
+    @footer = []
+  end
+
+  def componentWillMount()
+    self.componentWillReceiveProps(self.props)
+  end
+
+  def componentWillReceiveProps(props)
+    @header.clear()
+    @body.clear()
+    @footer.clear()
+
+    props.children.each do |child|
+      if child.type == 'h4'
+        if not child.props.className
+          child.props.className = 'modal-title'
+        elsif not child.props.className.split(' ').include? 'modal-title'
+          child.props.className += ' modal-title'
+        end
+
+        @header << child
+        ModalDialog.h4 = child
+
+      elsif child.type == 'button'
+        if not child.props.className
+          child.props.className = 'btn'
+        elsif not child.props.className.split(' ').include? 'btn'
+          child.props.className += ' btn'
+        end
+
+        @footer << child
+
+      else
+        @body << child
+      end
+    end
+  end
+
+  def render
+    _div.modal.fade id: @@id, class: @@className do
+      _div.modal_dialog do
+        _div.modal_content do
+          _div.modal_header class: @@color do
+            _button.close "\u00d7", type: 'button', data_dismiss: 'modal'
+            _[*@header]
+          end
+
+          _div.modal_body do
+            _[*@body]
+          end
+
+          _div.modal_footer class: @@color do
+            _[*@footer]
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/views/search.js.rb b/views/search.js.rb
index 27c33b6..8bebdc7 100644
--- a/views/search.js.rb
+++ b/views/search.js.rb
@@ -1,9 +1,18 @@
+#
+# Search component: 
+#  * prompt for search 
+#  * display matching paragraphs from agenda, highlighting search strings
+#  * keep query string in window location URL in synch
+#
+
 class Search < React
+  # initialize query text based on data passed to the component
   def initialize
     @text = @@data.query || ''
   end
 
   def render
+    # search input field
     _div.search do
       _label 'Search:', for: 'search_text'
       _input.search_text! autofocus: 'autofocus', value: @text, 
@@ -21,13 +30,11 @@ def render
         _section do
           _h4 {_Link text: item.title, href: item.href}
 
+          # highlight matching strings in paragraph
           item.text.split(/\n\s*\n/).each do |paragraph|
             if paragraph.downcase().include? text
-              paragraph = paragraph.gsub('&', '&amp;').gsub('>', '&gt;').
-                gsub('<', '&lt;')
-
               _pre.report dangerouslySetInnerHTML: {
-                __html: paragraph.gsub(/(#{text})/i,
+                __html: htmlEscape(paragraph).gsub(/(#{text})/i,
                  "<span class='hilite'>$1</span>")
               }
             end
@@ -35,20 +42,27 @@ def render
         end
       end
 
+      # if no sections were output, indicate 'no matches'
       _p {_em 'No matches'} unless matches
     else
+
+      # start producing query results when input string has three characters
       _p 'Please enter at least three characters'
+
     end
   end
 
+  # update text whenever input changes
   def input(event)
     @text = event.target.value
   end
 
+  # set history on initial rendering
   def componentDidMount()
     self.componentDidUpdate()
   end
 
+  # replace history state on subsequent renderings
   def componentDidUpdate()
     state = {path: 'search', query: @text}
 
diff --git a/views/utils.js.rb b/views/utils.js.rb
new file mode 100644
index 0000000..ac05a21
--- /dev/null
+++ b/views/utils.js.rb
@@ -0,0 +1,3 @@
+def htmlEscape(string)
+  return string.gsub('&', '&amp;').gsub('>', '&gt;').gsub('<', '&lt;')
+end

Mime
View raw message