You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@whimsical.apache.org by ru...@apache.org on 2017/09/21 15:45:45 UTC
[whimsy] branch agenda_on_vue updated: capture a snapshot of the
react client
This is an automated email from the ASF dual-hosted git repository.
rubys pushed a commit to branch agenda_on_vue
in repository https://gitbox.apache.org/repos/asf/whimsy.git
The following commit(s) were added to refs/heads/agenda_on_vue by this push:
new b3396b2 capture a snapshot of the react client
b3396b2 is described below
commit b3396b21dfb1200420d5439777cddaeb84a3efdc
Author: Sam Ruby <ru...@intertwingly.net>
AuthorDate: Thu Sep 21 11:45:22 2017 -0400
capture a snapshot of the react client
---
www/board/agenda/main.rb | 1 +
www/board/agenda/public/react/app.js | 9081 ++++++++++++++++++++++++
www/board/agenda/react.rb | 116 +
www/board/agenda/views/react/scaffold.html.erb | 27 +
4 files changed, 9225 insertions(+)
diff --git a/www/board/agenda/main.rb b/www/board/agenda/main.rb
index d294a29..f3705a7 100755
--- a/www/board/agenda/main.rb
+++ b/www/board/agenda/main.rb
@@ -38,6 +38,7 @@ end
FileUtils.mkdir_p AGENDA_WORK if not Dir.exist? AGENDA_WORK
require_relative './routes'
+require_relative './react'
require_relative './models/pending'
require_relative './models/agenda'
require_relative './models/minutes'
diff --git a/www/board/agenda/public/react/app.js b/www/board/agenda/public/react/app.js
new file mode 100644
index 0000000..781bd74
--- /dev/null
+++ b/www/board/agenda/public/react/app.js
@@ -0,0 +1,9081 @@
+//
+// Routing request based on path and query information in the URL
+//
+// Additionally provides defaults for color and title, and
+// determines what buttons are required.
+//
+// Returns item, buttons, and options
+function Router() {};
+
+// route request based on path and query from the window location (URL)
+Router.route = function(path, query) {
+ var options = {};
+ var buttons = [];
+ var item, shepherd;
+
+ if (!path || path == ".") {
+ item = Agenda
+ } else if (path == "search") {
+ item = {view: Search, query: query}
+ } else if (path == "comments") {
+ item = {view: Comments}
+ } else if (path == "backchannel") {
+ item = {
+ view: Backchannel,
+ title: "Agenda Backchannel",
+ online: Server.online
+ }
+ } else if (path == "queue") {
+ item = {view: Queue, title: "Queued approvals and comments"};
+ if (Server.role != "director") item.title = "Queued comments"
+ } else if (path == "flagged") {
+ item = {view: Flagged, title: "Flagged reports"}
+ } else if (path == "missing") {
+ item = {
+ view: Missing,
+ title: "Missing reports",
+ buttons: [{form: InitialReminder}, {button: FinalReminder}]
+ }
+ } else if (new RegExp("^flagged/[-\\w]+$").test(path)) {
+ item = Agenda.find(path.slice(8, path.length));
+ options = {traversal: "flagged"}
+ } else if (new RegExp("^queue/[-\\w]+$").test(path)) {
+ item = Agenda.find(path.slice(6, path.length));
+ options = {traversal: "queue"}
+ } else if (new RegExp("^shepherd/queue/[-\\w]+$").test(path)) {
+ item = Agenda.find(path.slice(15, path.length));
+ options = {traversal: "shepherd"}
+ } else if (new RegExp("^shepherd/\\w+$").test(path)) {
+ shepherd = path.slice(9, path.length);
+
+ item = {
+ view: Shepherd,
+ shepherd: shepherd,
+ next: null,
+ prev: null,
+ title: "Shepherded by " + shepherd
+ };
+
+ // determine next/previous links
+ Agenda.index.forEach(function(i) {
+ var href;
+
+ if (i.shepherd && i.comments) {
+ if (i.shepherd.indexOf(" ") != -1) return;
+ href = "shepherd/" + i.shepherd;
+
+ if (i.shepherd > shepherd) {
+ if (!item.next || item.next.href > href) {
+ item.next = {title: i.shepherd, href: href}
+ }
+ } else if (i.shepherd < shepherd) {
+ if (!item.prev || item.prev.href < href) {
+ item.prev = {title: i.shepherd, href: href}
+ }
+ }
+ }
+ })
+ } else if (path == "help") {
+ item = {view: Help}
+ } else if (path == "bootstrap.html") {
+ item = {view: BootStrapPage, title: " "}
+ } else if (path == "cache/") {
+ item = {view: CacheStatus}
+ } else if (new RegExp("^cache/").test(path)) {
+ item = {view: CachePage}
+ } else if (path == "fy22") {
+ item = {
+ view: FY22,
+ title: "FY22 Budget Worksheet",
+ color: "available",
+ prev: {title: "Discussion Items", href: "Discussion-Items"},
+ next: {title: "Action Items", href: "Action-Items"}
+ }
+ } else {
+ item = Agenda.find(path);
+
+ if (path == "Discussion-Items" && /^2017-02/.test(Agenda.date)) {
+ item.next = {title: "FY22 Budget Worksheet", href: "fy22"}
+ }
+ };
+
+ // bail unless an item was found
+ if (!item) return {};
+
+ // provide defaults for required properties
+ item.color = item.color || "blank";
+ item.title = item.title || item.view.displayName;
+
+ // determine what buttons are required, merging defaults, form provided
+ // overrides, and any overrides provided by the agenda item itself
+ buttons = item.buttons;
+ if (item.view.buttons) buttons = item.view.buttons().concat(buttons || []);
+
+ if (buttons) {
+ buttons = buttons.map(function(button) {
+ var props = {
+ text: "button",
+ attrs: {className: "btn"},
+ form: button.form
+ };
+
+ // form overrides
+ var form = button.form;
+
+ if (form && form.button) {
+ for (var name in form.button) {
+ if (name == "text") {
+ props.text = form.button.text
+ } else if (name == "class" || name == "classname") {
+ props.attrs.className += " " + form.button[name].replace(/_/g, "-")
+ } else {
+ props.attrs[name.replace(/_/g, "-")] = form.button[name]
+ }
+ }
+ } else {
+ // no form or form has no separate button: so this is just a button
+ delete props.text;
+ props.type = button.button || form;
+ props.attrs = {item: item, server: Server}
+ };
+
+ // item overrides
+ for (var name in button) {
+ if (name == "text") {
+ props.text = button.text
+ } else if (name == "class" || name == "classname") {
+ props.attrs.className += " " + button[name].replace(/_/g, "-")
+ } else if (name != "form") {
+ props.attrs[name.replace(/_/g, "-")] = button[name]
+ }
+ };
+
+ // clear modals
+ if (typeof document !== 'undefined') {
+ document.body.classList.remove("modal-open")
+ };
+
+ return props
+ })
+ };
+
+ return {item: item, buttons: buttons, options: options}
+};
+
+//
+// Respond to keyboard events
+//
+function Keyboard() {};
+
+Keyboard.initEventHandlers = function() {
+ // keyboard navigation (unless on the search screen)
+ document.body.onkeydown = function(event) {
+ if ($("#search-text")[0] || $(".modal-open")[0] || $(".modal.in")[0]) return;
+
+ if (!event.altKey && ["input", "textarea"].indexOf(document.activeElement.tagName.toLowerCase()) != -1) {
+ return
+ };
+
+ if (event.metaKey || event.ctrlKey) return;
+ var link, info;
+
+ if (event.keyCode == 37) {
+ link = $("a[rel=prev]")[0];
+
+ if (link) {
+ link.click();
+ return false
+ }
+ } else if (event.keyCode == 39) {
+ link = $("a[rel=next]")[0];
+
+ if (link) {
+ link.click();
+ return false
+ }
+ } else if (event.keyCode == 13) {
+ link = $(".default")[0];
+ if (link) Main.navigate(link.getAttribute("href"));
+ return false
+ } else if (event.keyCode == 67) {
+ link = $("#comments")[0];
+
+ if (link) {
+ jQuery("html, body").animate({scrollTop: link.offsetTop}, "slow")
+ } else {
+ Main.navigate("comments")
+ };
+
+ return false
+ } else if (event.keyCode == 73) {
+ info = document.getElementById("info");
+ if (info) info.click();
+ return false
+ } else if (event.keyCode == 77) {
+ Main.navigate("missing");
+ return false
+ } else if (event.keyCode == 78) {
+ $("#nav").click();
+ return false
+ } else if (event.keyCode == 65) {
+ Main.navigate(".");
+ return false
+ } else if (event.keyCode == 83) {
+ if (event.shiftKey) {
+ Server.role = "secretary";
+ Main.refresh()
+ } else {
+ link = $("#shepherd")[0];
+ if (link) Main.navigate(link.getAttribute("href"))
+ };
+
+ return false
+ } else if (event.keyCode == 88) {
+ if (Main.item.attach && Minutes.started && !Minutes.complete) {
+ Chat.changeTopic({
+ user: Server.userid,
+ link: Main.item.href,
+ text: "current topic: " + Main.item.title
+ });
+
+ return false
+ }
+ } else if (event.keyCode == 81) {
+ Main.navigate("queue");
+ return false
+ } else if (event.keyCode == 70) {
+ Main.navigate("flagged");
+ return false
+ } else if (event.keyCode == 66) {
+ Main.navigate("backchannel");
+ return false
+ } else if (event.shiftKey && event.keyCode == 191) {
+ Main.navigate("help");
+ return false
+ } else if (event.keyCode == 82) {
+ clock_counter++;
+ Main.refresh();
+
+ post("refresh", {agenda: Agenda.file}, function(response) {
+ clock_counter--;
+ Agenda.load(response.agenda, response.digest);
+ Main.refresh()
+ });
+
+ return false
+ } else if (event.keyCode == 61 || event.keyCode == 187) {
+ Main.navigate("cache/");
+ return false
+ }
+ }
+};
+
+// A convenient place to stash server data
+var Server = {};
+
+// controls display of clock in the header
+var clock_counter = 0;
+
+//
+// function to assist with production of HTML and regular expressions
+//
+// Escape HTML characters so that raw text can be safely inserted as HTML
+function htmlEscape(string) {
+ return string.replace(htmlEscape.chars, function(c) {
+ return htmlEscape.replacement[c]
+ })
+};
+
+htmlEscape.chars = /[&<>]/g;
+htmlEscape.replacement = {"&": "&", "<": "<", ">": ">"};
+
+// escape a string so that it can be used as a regular expression
+function escapeRegExp(string) {
+ // https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions
+ return string.replace(
+ new RegExp("([.*+?^=!:${}()|\\[\\]/\\\\])", "g"),
+ "\\$1"
+ )
+};
+
+// Replace http[s] links in text with anchor tags
+function hotlink(string) {
+ return string.replace(hotlink.regexp, function(match, pre, link) {
+ return pre + "<a href='" + link + "'>" + link + "</a>"
+ })
+};
+
+hotlink.regexp = new RegExp("(^|[\\s.:;?\\-\\]<\\(])(https?://[-\\w;/?:@&=+$.!~*'()%,#]+[\\w/])(?=$|[\\s.:;,?\\-\\[\\]&\\)])", "g");
+
+//
+// Requests to the server
+//
+// "AJAX" style post request to the server, with a callback
+function post(target, data, block) {
+ var xhr = new XMLHttpRequest();
+ xhr.open("POST", "../json/" + target, true);
+
+ xhr.setRequestHeader(
+ "Content-Type",
+ "application/json;charset=utf-8"
+ );
+
+ xhr.responseType = "text";
+
+ xhr.onreadystatechange = function() {
+ var message;
+
+ if (xhr.readyState == 4) {
+ data = null;
+
+ try {
+ if (xhr.status == 200) {
+ data = JSON.parse(xhr.responseText);
+ if (data.exception) alert("Exception\n" + data.exception)
+ } else if (xhr.status == 404) {
+ alert("Not Found: json/" + target)
+ } else if (xhr.status >= 400) {
+ if (!xhr.response) {
+ message = "Exception - " + xhr.statusText
+ } else if (xhr.response.exception) {
+ message = "Exception\n" + xhr.response.exception
+ } else {
+ message = "Exception\n" + JSON.parse(xhr.responseText).exception
+ };
+
+ console.log(message);
+ alert(message)
+ }
+ } catch (e) {
+ console.log(e)
+ };
+
+ block(data);
+ Main.refresh()
+ }
+ };
+
+ xhr.send(JSON.stringify(data))
+};
+
+// "AJAX" style get request to the server, with a callback
+//
+// Would love to use/build on 'fetch', but alas:
+//
+// https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API#Browser_compatibility
+function retrieve(target, type, block) {
+ var xhr = new XMLHttpRequest();
+
+ xhr.onreadystatechange = function() {
+ var data, message;
+
+ if (xhr.readyState == 1) {
+ clock_counter++;
+ setTimeout(function() {Main.refresh()}, 0)
+ } else if (xhr.readyState == 4) {
+ data = null;
+
+ try {
+ if (xhr.status == 200) {
+ if (type == "json") {
+ data = xhr.response || JSON.parse(xhr.responseText)
+ } else {
+ data = xhr.responseText
+ }
+ } else if (xhr.status == 404) {
+ alert("Not Found: " + type + "/" + target)
+ } else if (xhr.status >= 400) {
+ if (!xhr.response) {
+ message = "Exception - " + xhr.statusText
+ } else if (xhr.response.exception) {
+ message = "Exception\n" + xhr.response.exception
+ } else {
+ message = "Exception\n" + JSON.parse(xhr.responseText).exception
+ };
+
+ console.log(message);
+ alert(message)
+ }
+ } catch (e) {
+ console.log(e)
+ };
+
+ block(data);
+ clock_counter--;
+ Main.refresh()
+ }
+ };
+
+ if (/^https?:/.test(target)) {
+ xhr.open("GET", target, true);
+ if (type == "json") xhr.setRequestHeader("Accept", "application/json")
+ } else {
+ xhr.open("GET", "../" + type + "/" + target, true)
+ };
+
+ xhr.responseType = type;
+ xhr.send()
+};
+
+//
+// Reflow comments and lines
+//
+function Flow() {};
+
+// reflow comment
+Flow.comment = function(comment, initials, indent) {
+ if (typeof indent === 'undefined') indent = " ";
+ var lines = comment.split("\n");
+ var len = 71 - indent.length;
+
+ for (var i = 0; i < lines.length; i++) {
+ lines[i] = ((i == 0 ? initials + ": " : indent + " ")) + lines[i].replace(
+ new RegExp("(.{1," + len + "})( +|$\\n?)|(.{1," + len + "})", "g"),
+ "$1$3\n" + indent
+ ).trim()
+ };
+
+ return lines.join("\n")
+};
+
+// reflow text
+Flow.text = function(text, indent) {
+ if (typeof indent === 'undefined') indent = "";
+
+ // join consecutive lines (making exception for <markers> like <private>)
+ text = text.replace(/([^\s>])\n(\w)/g, "$1 $2");
+
+ // reflow each line
+ var lines = text.split("\n");
+ var len = 78 - indent.length;
+
+ for (var i = 0; i < lines.length; i++) {
+ indent = lines[i].match(/( *)(.?.?)(.*)/m);
+ var n;
+
+ if ((indent[1] == "" && indent[2] != "* ") || indent[3] == "") {
+ // not indented (or short) -> split
+ lines[i] = lines[i].replace(
+ new RegExp("(.{1," + len + "})( +|$\\n?)|(.{1," + len + "})", "g"),
+ "$1$3\n"
+ ).replace(/[\n\r]+$/, "")
+ } else {
+ // preserve indentation. indent[2] is the 'bullet' (if any) and is
+ // only to be placed on the first line.
+ n = 76 - indent[1].length;
+
+ lines[i] = indent[3].replace(
+ new RegExp("(.{1," + n + "})( +|$\\n?)|(.{1," + n + "})", "g"),
+ indent[1] + " $1$3\n"
+ ).replace(indent[1] + " ", indent[1] + indent[2]).replace(
+ /[\n\r]+$/,
+ ""
+ )
+ }
+ };
+
+ return lines.join("\n")
+};
+
+//
+// Split comments string into individual comments
+//
+function splitComments(string) {
+ var results = [];
+ if (!string) return results;
+ var comment = "";
+
+ string.split("\n").forEach(function(line) {
+ if (/^\S/.test(line)) {
+ if (comment.length != 0) results.push(comment);
+ comment = line
+ } else {
+ comment += "\n" + line
+ }
+ });
+
+ if (comment.length != 0) results.push(comment);
+ return results
+};
+
+//
+// Main component, responsible for:
+//
+// * Initial loading and polling of the agenda
+//
+// * Rendering a Header, a item view, and a Footer
+//
+// * Resizing view to leave room for the Header and Footer
+//
+var Main = React.createClass({
+ displayName: "Main",
+ statics: {refresh: function() {}},
+
+ getInitialState: function() {
+ return {}
+ },
+
+ // common layout for all pages: header, main, footer, and forms
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+ var view;
+
+ if (!self.state.item) {
+ $_.push(React.createElement("p", null, "Not found"))
+ } else {
+ $_.push(React.createElement(Header, {item: self.state.item}));
+ view = null;
+
+ $_.push(React.createElement("main", null, React.createElement(
+ self.state.item.view,
+
+ {item: self.state.item, ref: function(component) {
+ Main.view = component
+ }}
+ )));
+
+ $_.push(React.createElement(Footer, {
+ item: self.state.item,
+ buttons: self.state.buttons,
+ options: self.state.options
+ }));
+
+ // emit hidden forms associated with the buttons displayed on this page
+ if (self.state.buttons) {
+ self.state.buttons.forEach(function(button) {
+ if (button.form) {
+ $_.push(React.createElement(
+ button.form,
+ {item: self.state.item, server: Server, button: button}
+ ))
+ }
+ })
+ }
+ };
+
+ return $_
+ }())
+ },
+
+ // initial load of the agenda, and route first request
+ componentWillMount: function() {
+ // copy server info for later use
+ for (var prop in this.props.server) {
+ Server[prop] = this.props.server[prop]
+ };
+
+ Agenda.load(this.props.page.parsed, this.props.page.digest);
+ Minutes.load(this.props.page.minutes);
+ this.route(this.props.page.path, this.props.page.query);
+
+ // free memory
+ this.props.page.parsed = null
+ },
+
+ // encapsulate calls to the router
+ route: function(path, query) {
+ var route = Router.route(path, query);
+
+ this.setState({
+ item: route.item,
+ buttons: route.buttons,
+ options: route.options
+ });
+
+ if (!Main.item || Main.item.view != route.item.view) Main.view = null;
+ Main.item = route.item
+ },
+
+ // navigation method that updates history (back button) information
+ navigate: function(path, query) {
+ history.state.scrollY = window.scrollY;
+ history.replaceState(history.state, null, history.path);
+ Main.scrollTo = 0;
+ this.route(path, query);
+ history.pushState({path: path, query: query}, null, path);
+ window.onresize()
+ },
+
+ // refresh the current page
+ refresh: function() {
+ this.route(history.state.path, history.state.query)
+ },
+
+ // additional client side initialization
+ componentDidMount: function() {
+ var self = this;
+
+ // export navigate and refresh methods
+ Main.navigate = this.navigate;
+ Main.refresh = this.refresh;
+
+ // store initial state in history, taking care not to overwrite
+ // history set by the Search component.
+ var path, base;
+
+ if (!history.state || !history.state.query) {
+ path = this.props.page.path;
+
+ if (path == "bootstrap.html") {
+ path = document.location.href;
+ base = document.getElementsByTagName("base")[0].href;
+ if (path.substring(0, base.length) == base) path = path.slice(base.length)
+ };
+
+ history.replaceState({path: path}, null, path)
+ };
+
+ // listen for back button, and re-route/re-render when it occcurs
+ window.addEventListener("popstate", function(event) {
+ if (event.state && typeof event.state.path !== 'undefined') {
+ Main.scrollTo = event.state.scrollY || 0;
+ self.route(event.state.path, event.state.query)
+ }
+ });
+
+ // start watching keystrokes
+ Keyboard.initEventHandlers();
+
+ // whenever the window is resized, adjust margins of the main area to
+ // avoid overlapping the header and footer areas
+ window.onresize = function() {
+ var main = document.getElementsByTagName("main")[0];
+
+ if (window.innerHeight <= 400 && document.body.scrollHeight > window.innerHeight) {
+ document.querySelector("footer").style.position = "relative";
+ document.querySelector("header").style.position = "relative";
+ main.style.marginTop = 0;
+ main.style.marginBottom = 0
+ } else {
+ document.querySelector("footer").style.position = "fixed";
+ document.querySelector("header").style.position = "fixed";
+ main.style.marginTop = document.querySelector("header.navbar").clientHeight + "px";
+ main.style.marginBottom = document.querySelector("footer.navbar").clientHeight + "px"
+ };
+
+ if (Main.scrollTo == 0 || Main.scrollTo) {
+ if (Main.scrollTo == -1) {
+ jQuery("html, body").animate(
+ {scrollTop: document.documentElement.scrollHeight},
+ "fast"
+ )
+ } else {
+ window.scrollTo(0, Main.scrollTo);
+ Main.scrollTo = null
+ }
+ }
+ };
+
+ // do an initial resize
+ Main.scrollTo = 0;
+ window.onresize();
+
+ // if agenda is stale, fetch immediately; otherwise save etag
+ Agenda.fetch(this.props.page.etag, this.props.page.digest);
+
+ // start Service Worker
+ if (PageCache.enabled) PageCache.register();
+
+ // start backchannel
+ Events.monitor()
+ },
+
+ // after each subsequent re-rendering, resize main window
+ componentDidUpdate: function() {
+ window.onresize()
+ }
+});
+
+//
+// Header: title on the left, dropdowns on the right
+//
+// Also keeps the window/tab title in sync with the header title
+//
+// Finally: make info dropdown status 'sticky'
+var Header = React.createClass({
+ displayName: "Header",
+
+ getInitialState: function() {
+ return {infodropdown: null}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = [
+ "header",
+ {className: "navbar navbar-fixed-top " + (self.props.item.color || "")}
+ ];
+
+ $_.push(React.createElement(
+ "div",
+ {className: "navbar-brand"},
+ self.props.item.title
+ ));
+
+ if (/^7/.test(self.props.item.attach) && /^Establish /.test(self.props.item.title)) {
+ $_.push(React.createElement(
+ PodlingNameSearch,
+ {item: self.props.item}
+ ))
+ };
+
+ if (clock_counter > 0) {
+ $_.push(React.createElement("span", {id: "clock"}, "⌛"))
+ };
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["ul", {className: "nav nav-pills navbar-right"}];
+
+ // pending count
+ if (Pending.count > 0) {
+ $_.push(React.createElement(
+ "li",
+ {className: "label label-danger"},
+ React.createElement(Link, {text: Pending.count, href: "queue"})
+ ))
+ };
+
+ // 'info'/'online' dropdown
+ //
+ if (self.props.item.attach) {
+ $_.push(React.createElement(
+ "li",
+ {className: "report-info dropdown " + (self.state.infodropdown || "")},
+
+ React.createElement(
+ "a",
+ {className: "dropdown-toggle", id: "info", onClick: self.toggleInfo},
+ "info",
+ React.createElement("b", {className: "caret"})
+ ),
+
+ React.createElement(
+ Info,
+ {item: self.props.item, position: "dropdown-menu"}
+ )
+ ))
+ } else if (self.props.item.online) {
+ $_.push(React.createElement(
+ "li",
+ {className: "dropdown"},
+
+ React.createElement(
+ "a",
+ {className: "dropdown-toggle", id: "info", "data-toggle": "dropdown"},
+ "online",
+ React.createElement("b", {className: "caret"})
+ ),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["ul", {className: "online dropdown-menu"}];
+
+ self.props.item.online.forEach(function(id) {
+ $_.push(React.createElement(
+ "li",
+ null,
+ React.createElement("a", {href: "/roster/committer/" + id}, id)
+ ))
+ });
+
+ return $_
+ }())
+ ))
+ } else {
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["li", {className: "dropdown"}];
+
+ $_.push(React.createElement(
+ "a",
+ {className: "dropdown-toggle", id: "info", "data-toggle": "dropdown"},
+ "summary",
+ React.createElement("b", {className: "caret"})
+ ));
+
+ var summary = self.props.item.summary || Agenda.summary;
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = [
+ "table",
+ {className: "table-bordered online dropdown-menu"}
+ ];
+
+ summary.forEach(function(status) {
+ var text = status.text;
+ if (status.count == 1) text = text.replace(/s$/, "");
+
+ $_.push(React.createElement(
+ "tr",
+ {className: status.color},
+
+ React.createElement(
+ "td",
+ null,
+ React.createElement(Link, {text: status.count, href: status.href})
+ ),
+
+ React.createElement(
+ "td",
+ null,
+ React.createElement(Link, {text: text, href: status.href})
+ )
+ ))
+ });
+
+ return $_
+ }()));
+
+ return $_
+ }()))
+ };
+
+ // 'navigation' dropdown
+ //
+ $_.push(React.createElement(
+ "li",
+ {className: "dropdown"},
+
+ React.createElement(
+ "a",
+ {className: "dropdown-toggle", id: "nav", "data-toggle": "dropdown"},
+ "navigation",
+ React.createElement("b", {className: "caret"})
+ ),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["ul", {className: "dropdown-menu"}];
+
+ $_.push(React.createElement(
+ "li",
+ null,
+ React.createElement(Link, {id: "agenda", text: "Agenda", href: "."})
+ ));
+
+ Agenda.index.forEach(function(item) {
+ if (item.index) {
+ $_.push(React.createElement(
+ "li",
+ null,
+ React.createElement(Link, {text: item.index, href: item.href})
+ ))
+ }
+ });
+
+ $_.push(React.createElement("li", {className: "divider"}));
+
+ $_.push(React.createElement(
+ "li",
+ null,
+ React.createElement(Link, {text: "Search", href: "search"})
+ ));
+
+ $_.push(React.createElement(
+ "li",
+ null,
+ React.createElement(Link, {text: "Comments", href: "comments"})
+ ));
+
+ var shepherd = Agenda.shepherd;
+
+ if (shepherd) {
+ $_.push(React.createElement("li", null, React.createElement(
+ Link,
+ {id: "shepherd", text: "Shepherd", href: "shepherd/" + shepherd}
+ )))
+ };
+
+ $_.push(React.createElement("li", null, React.createElement(
+ Link,
+ {id: "queue", text: "Queue", href: "queue"}
+ )));
+
+ $_.push(React.createElement("li", {className: "divider"}));
+
+ $_.push(React.createElement("li", null, React.createElement(
+ Link,
+ {id: "backchannel", text: "Backchannel", href: "backchannel"}
+ )));
+
+ $_.push(React.createElement(
+ "li",
+ null,
+ React.createElement(Link, {id: "help", text: "Help", href: "help"})
+ ));
+
+ return $_
+ }())
+ ));
+
+ return $_
+ }()));
+
+ return $_
+ }())
+ },
+
+ // set history on initial rendering
+ componentDidMount: function() {
+ this.componentDidUpdate()
+ },
+
+ // update title to match the item title whenever page changes
+ componentDidUpdate: function() {
+ var title = document.getElementsByTagName("title")[0];
+
+ if (title.textContent != this.props.item.title) {
+ title.textContent = this.props.item.title
+ }
+ },
+
+ // toggle info dropdown
+ toggleInfo: function() {
+ return this.setState({infodropdown: (this.state.infodropdown ? null : "open")})
+ }
+});
+
+//
+// Layout footer consisting of a previous link, any number of buttons,
+// followed by a next link.
+//
+// Overrides previous and next links when traversal is queue, shepherd, or
+// Flagged. Injects the flagged items into the flow once the meeting starts
+// (last additional officer <-> first flagged &&
+// last flagged <-> first Special order)
+//
+var Footer = React.createClass({
+ displayName: "Footer",
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = [
+ "footer",
+ {className: "navbar navbar-fixed-bottom " + (self.props.item.color || "")}
+ ];
+
+ //
+ // Previous link
+ //
+ var link = self.props.item.prev;
+ var prefix = "";
+
+ if (self.props.options.traversal == "queue") {
+ prefix = "queue/";
+
+ while (link && !link.ready_for_review(Server.initials)) {
+ link = link.prev
+ };
+
+ link = link || {href: "../queue", title: "Queue"}
+ } else if (self.props.options.traversal == "shepherd") {
+ prefix = "shepherd/queue/";
+
+ while (link && link.shepherd != self.props.item.shepherd) {
+ link = link.prev
+ };
+
+ link = link || {
+ href: "../" + self.props.item.shepherd,
+ title: "Shepherd"
+ }
+ } else if (self.props.options.traversal == "flagged") {
+ prefix = "flagged/";
+
+ while (link && !link.flagged) {
+ link = link.prev
+ };
+
+ if (!link) {
+ if (Minutes.started) {
+ link = Agenda.index.find(function(item) {
+ return item.attach == "A"
+ }).prev;
+
+ prefix = ""
+ };
+
+ link = link || {href: "../flagged", title: "Flagged"}
+ }
+ } else if (Minutes.started && /\d/.test(self.props.item.attach) && link && /^[A-Z]/.test(link.attach)) {
+ Agenda.index.forEach(function(item) {
+ if (item.flagged) {
+ prefix = "flagged/";
+ link = item
+ }
+ })
+ };
+
+ if (link) {
+ $_.push(React.createElement(Link, {
+ className: "backlink navbar-brand " + (link.color || ""),
+ text: link.title,
+ rel: "prev",
+ href: prefix + link.href
+ }))
+ } else if (self.props.item.prev || self.props.item.next) {
+ // without this, Chrome will sometimes make the footer too tall
+ $_.push(React.createElement("a", {className: "navbar-brand"}))
+ };
+
+ //
+ // Buttons
+ //
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+
+ if (self.props.buttons) {
+ self.props.buttons.forEach(function(button) {
+ if (button.text) {
+ $_.push(React.createElement("button", button.attrs, button.text))
+ } else if (button.type) {
+ $_.push(React.createElement(button.type, button.attrs))
+ }
+ })
+ };
+
+ return $_
+ }()));
+
+ //
+ // Next link
+ //
+ link = self.props.item.next;
+
+ if (self.props.options.traversal == "queue") {
+ while (link && !link.ready_for_review(Server.initials)) {
+ link = link.next
+ };
+
+ link = link || {href: "queue", title: "Queue"}
+ } else if (self.props.options.traversal == "shepherd") {
+ while (link && link.shepherd != self.props.item.shepherd) {
+ link = link.next
+ };
+
+ link = link || {
+ href: "shepherd/" + self.props.item.shepherd,
+ title: "shepherd"
+ }
+ } else if (self.props.options.traversal == "flagged") {
+ prefix = "flagged/";
+
+ while (link && !link.flagged) {
+ if (Minutes.started && link.index) {
+ prefix = "";
+ break
+ } else {
+ link = link.next
+ }
+ };
+
+ link = link || {href: "flagged", title: "Flagged"}
+ } else if (Minutes.started && link && link.attach == "A") {
+ while (link && !link.flagged && /^[A-Z]/.test(link.attach)) {
+ link = link.next
+ };
+
+ if (link && /^[A-Z]/.test(link.attach)) prefix = "flagged/"
+ };
+
+ if (link) {
+ if (!/^[A-Z]/.test(link.attach)) prefix = "";
+
+ $_.push(React.createElement(Link, {
+ className: "nextlink navbar-brand " + (link.color || ""),
+ text: link.title,
+ rel: "next",
+ href: prefix + link.href
+ }))
+ } else if (self.props.item.prev || self.props.item.next) {
+ // without this, Chrome will sometimes make the footer too tall
+ $_.push(React.createElement(
+ "a",
+ {className: "nextarea navbar-brand"}
+ ))
+ };
+
+ return $_
+ }())
+ }
+});
+
+//
+// Secretary version of Adjournment section: shows todos
+//
+var Adjournment = React.createClass({
+ displayName: "Adjournment",
+
+ getInitialState: function() {
+ this.state = {};
+
+ Todos.set({
+ add: [],
+ remove: [],
+ establish: [],
+ feedback: [],
+ minutes: {},
+ loading: true,
+ fetched: false
+ });
+
+ return this.state
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement(
+ "section",
+ {className: "flexbox"},
+
+ React.createElement.apply(React, function() {
+ var $_ = ["section", null];
+
+ $_.push(React.createElement(
+ "pre",
+ {className: "report"},
+ self.props.item.text
+ ));
+
+ if (!Todos.loading || Todos.fetched) {
+ $_.push(React.createElement("h3", null, "Post Meeting actions"));
+
+ if (Todos.add.length == 0 && Todos.remove.length == 0 && Todos.establish.length == 0) {
+ if (Todos.loading) {
+ $_.push(React.createElement("em", null, "Loading..."))
+ } else {
+ $_.push(React.createElement("p", {className: "comment"}, "complete"))
+ }
+ }
+ };
+
+ if (Todos.add.length != 0) {
+ $_.push(React.createElement(TodoActions, {action: "add"}))
+ };
+
+ if (Todos.remove.length != 0) {
+ $_.push(React.createElement(TodoActions, {action: "remove"}))
+ };
+
+ if (Todos.establish.length != 0) {
+ $_.push(React.createElement(EstablishActions, {action: "remove"}))
+ };
+
+ if (Todos.feedback.length != 0) $_.push(React.createElement(FeedbackReminder));
+
+ // display a list of completed actions
+ var completed = Todos.minutes.todos;
+
+ if (completed && Object.keys(completed).length > 0 && ((completed.added && completed.added.length != 0) || (completed.removed && completed.removed.length != 0) || (completed.established && completed.established.length != 0) || (completed.feedback_sent && completed.feedback_sent.length != 0))) {
+ $_.push(React.createElement("h3", null, "Completed actions"));
+
+ if (completed.added && completed.added.length != 0) {
+ $_.push(React.createElement("p", null, "Added to PMC chairs"));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["ul", null];
+
+ completed.added.forEach(function(id) {
+ $_.push(React.createElement("li", null, React.createElement(
+ "a",
+ {href: "../../../roster/committer/" + id},
+ id
+ )))
+ });
+
+ return $_
+ }()))
+ };
+
+ if (completed.removed && completed.removed.length != 0) {
+ $_.push(React.createElement("p", null, "Removed from PMC chairs"));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["ul", null];
+
+ completed.removed.forEach(function(id) {
+ $_.push(React.createElement("li", null, React.createElement(
+ "a",
+ {href: "../../../roster/committer/" + id},
+ id
+ )))
+ });
+
+ return $_
+ }()))
+ };
+
+ if (completed.established && completed.established.length != 0) {
+ $_.push(React.createElement("p", null, "Established PMCs"));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["ul", null];
+
+ completed.established.forEach(function(pmc) {
+ $_.push(React.createElement("li", null, React.createElement(
+ "a",
+ {href: "../../../roster/committee/" + pmc},
+ pmc
+ )))
+ });
+
+ return $_
+ }()))
+ };
+
+ if (completed.feedback_sent && completed.feedback_sent.length != 0) {
+ $_.push(React.createElement("p", null, "Sent feedback"));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["ul", null];
+
+ completed.feedback_sent.forEach(function(pmc) {
+ $_.push(React.createElement("li", null, React.createElement(
+ Link,
+ {text: pmc, href: pmc.replace(/\s+/g, "-")}
+ )))
+ });
+
+ return $_
+ }()))
+ }
+ };
+
+ return $_
+ }()),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["section", null];
+ var minutes = Minutes.get(self.props.item.title);
+
+ if (minutes) {
+ $_.push(React.createElement("h3", null, "Minutes"));
+ $_.push(React.createElement("pre", {className: "comment"}, minutes))
+ };
+
+ return $_
+ }())
+ )
+ },
+
+ componentDidMount: function() {
+ this.componentDidUpdate()
+ },
+
+ // fetch secretary todos once the minutes are complete
+ componentDidUpdate: function() {
+ if (Minutes.complete && Todos.loading && !Todos.fetched) {
+ Todos.fetched = true;
+
+ retrieve("secretary-todos/" + Agenda.title, "json", function(todos) {
+ Todos.set(todos);
+ Todos.loading = false
+ })
+ }
+ }
+});
+
+//#######################################################################
+// Add, Remove chairs #
+//#######################################################################
+var TodoActions = React.createClass({
+ displayName: "TodoActions",
+
+ getInitialState: function() {
+ return {checked: {}, disabled: true, people: []}
+ },
+
+ // update on first update
+ // update on first update
+ componentDidMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // update check marks based on current Todo list
+ componentWillReceiveProps: function($$props) {
+ var self = this;
+ var $people = this.state.people;
+ $people = Todos[$$props.action];
+
+ // uncheck people who were removed
+ for (var id in this.state.checked) {
+ if (!$people.some(function(person) {
+ return person.id == id
+ })) this.state.checked[id] = false
+ };
+
+ // check people who were added
+ $people.forEach(function(person) {
+ if (self.state.checked[person.id] == undefined) {
+ if (!person.resolution || Minutes.get(person.resolution) != "tabled") {
+ self.state.checked[person.id] = true
+ }
+ }
+ });
+
+ this.refresh();
+ this.setState({people: $people})
+ },
+
+ refresh: function() {
+ // disable button if nobody is checked
+ var disabled = true;
+
+ for (var id in this.state.checked) {
+ if (this.state.checked[id]) disabled = false
+ };
+
+ this.setState({disabled: disabled});
+ this.forceUpdate()
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+
+ if (self.props.action == "add") {
+ $_.push(React.createElement(
+ "p",
+ null,
+ "Add to pmc-chairs and email welcome message:"
+ ))
+ } else {
+ $_.push(React.createElement("p", null, "Remove from pmc-chairs:"))
+ };
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["ul", {className: "checklist"}];
+
+ self.state.people.forEach(function(person) {
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["li", null];
+
+ $_.push(React.createElement("input", {
+ type: "checkbox",
+ checked: self.state.checked[person.id],
+
+ onChange: function() {
+ self.state.checked[person.id] = !self.state.checked[person.id];
+ self.refresh()
+ }
+ }));
+
+ $_.push(React.createElement(
+ "a",
+ {href: "/roster/committer/" + person.id},
+ person.id
+ ));
+
+ $_.push(" (" + person.name + ")");
+ var resolution;
+
+ if (self.props.action == "add" && person.resolution) {
+ resolution = Minutes.get(person.resolution);
+
+ if (resolution) {
+ $_.push(" - ");
+
+ $_.push(React.createElement(
+ Link,
+ {text: resolution, href: Todos.link(person.resolution)}
+ ))
+ }
+ };
+
+ return $_
+ }()))
+ });
+
+ return $_
+ }()));
+
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "checklist btn btn-default",
+ disabled: self.state.disabled,
+ onClick: self.submit
+ },
+
+ "Submit"
+ ));
+
+ return $_
+ }())
+ },
+
+ submit: function() {
+ var self = this;
+ this.setState({disabled: true});
+ var data = {};
+ data[this.props.action] = this.state.checked;
+
+ post("secretary-todos/" + Agenda.title, data, function(todos) {
+ self.setState({disabled: false});
+ Todos.set(todos)
+ })
+ }
+});
+
+//#######################################################################
+// Establish actions #
+//#######################################################################
+var EstablishActions = React.createClass({
+ displayName: "EstablishActions",
+
+ getInitialState: function() {
+ return {checked: {}, disabled: true, podlings: []}
+ },
+
+ componentDidMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // update check marks based on current Todo list
+ componentWillReceiveProps: function($$props) {
+ var self = this;
+ var $podlings = this.state.podlings;
+ $podlings = Todos.establish;
+
+ // uncheck podlings that were removed
+ for (var name in this.state.checked) {
+ if (!$podlings.some(function(podling) {
+ return podling.name == name
+ })) this.state.checked[name] = false
+ };
+
+ // check podlings that were added
+ $podlings.forEach(function(podling) {
+ if (self.state.checked[podling.name] == undefined) {
+ if (!podling.resolution || Minutes.get(podling.resolution) != "tabled") {
+ self.state.checked[podling.name] = true
+ }
+ }
+ });
+
+ this.refresh();
+ this.setState({podlings: $podlings})
+ },
+
+ refresh: function() {
+ // disable button if nobody is checked
+ var disabled = true;
+
+ for (var id in this.state.checked) {
+ if (this.state.checked[id]) disabled = false
+ };
+
+ this.setState({disabled: disabled});
+ this.forceUpdate()
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement(
+ "span",
+ null,
+
+ React.createElement("p", null, React.createElement(
+ "a",
+ {href: "https://infra.apache.org/officers/tlpreq"},
+ "Establish pmcs:"
+ )),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["ul", {className: "checklist"}];
+
+ self.state.podlings.forEach(function(podling) {
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["li", null];
+
+ $_.push(React.createElement("input", {
+ type: "checkbox",
+ checked: self.state.checked[podling.name],
+
+ onChange: function() {
+ self.state.checked[podling.name] = !self.state.checked[podling.name];
+ self.refresh()
+ }
+ }));
+
+ $_.push(React.createElement("span", null, podling.name));
+ var resolution = Minutes.get(podling.resolution);
+
+ if (resolution) {
+ $_.push(" - ");
+
+ $_.push(React.createElement(
+ Link,
+ {text: resolution, href: Todos.link(podling.resolution)}
+ ))
+ };
+
+ return $_
+ }()))
+ });
+
+ return $_
+ }()),
+
+ React.createElement(
+ "button",
+
+ {
+ className: "checklist btn btn-default",
+ disabled: this.state.disabled,
+ onClick: this.submit
+ },
+
+ "Submit"
+ )
+ )
+ },
+
+ submit: function() {
+ var self = this;
+ this.setState({disabled: true});
+ var data = {establish: this.state.checked};
+
+ post("secretary-todos/" + Agenda.title, data, function(todos) {
+ self.setState({disabled: false});
+ Todos.set(todos)
+ })
+ }
+});
+
+//#######################################################################
+// Reminder to draft feedback #
+//#######################################################################
+var FeedbackReminder = React.createClass({
+ displayName: "FeedbackReminder",
+
+ render: function() {
+ return React.createElement(
+ "span",
+ null,
+ React.createElement("p", null, "Draft feedback:"),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["ul", {className: "list-group row"}];
+
+ Todos.feedback.forEach(function(pmc) {
+ $_.push(React.createElement(
+ "li",
+ {className: "list-group-item col-xs-6 col-sm-4 col-md-3 col-lg-2"},
+
+ React.createElement(
+ Link,
+ {text: pmc, href: pmc.replace(/\s+/g, "-")}
+ )
+ ))
+ });
+
+ return $_
+ }()),
+
+ React.createElement(
+ "button",
+
+ {className: "checklist btn btn-default", onClick: function() {
+ window.location.href = "feedback"
+ }},
+
+ "Submit"
+ )
+ )
+ }
+});
+
+//#######################################################################
+// shared state #
+//#######################################################################
+function Todos() {};
+
+Todos.set = function(value) {
+ for (var attr in value) {
+ Todos[attr] = value[attr]
+ }
+};
+
+// find corresponding agenda item
+Todos.link = function(title) {
+ var link = null;
+
+ Agenda.index.forEach(function(item) {
+ if (item.title == title) link = item.href
+ });
+
+ return link
+};
+
+//
+// Blank canvas shown during bootstrapping
+//
+var BootStrapPage = React.createClass({
+ displayName: "BootStrapPage",
+
+ render: function() {
+ return React.createElement("p", null, "")
+ }
+});
+
+//
+// Overall Agenda page: simple table with one row for each item in the index
+//
+var Index = React.createClass({
+ displayName: "Index",
+
+ render: function() {
+ return React.createElement(
+ "span",
+ null,
+
+ React.createElement(
+ "header",
+ null,
+ React.createElement("h1", null, "ASF Board Agenda")
+ ),
+
+ React.createElement(
+ "table",
+ {className: "table-bordered"},
+
+ React.createElement(
+ "thead",
+ null,
+ React.createElement("th", null, "Attach"),
+ React.createElement("th", null, "Title"),
+ React.createElement("th", null, "Owner"),
+ React.createElement("th", null, "Shepherd")
+ ),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["tbody", null];
+
+ Agenda.index.forEach(function(row) {
+ $_.push(React.createElement(
+ "tr",
+ {className: row.color},
+ React.createElement("td", null, row.attach),
+
+ React.createElement(
+ "td",
+ null,
+ React.createElement(Link, {text: row.title, href: row.href})
+ ),
+
+ React.createElement("td", null, row.owner),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["td", null];
+
+ if (row.shepherd) {
+ $_.push(React.createElement(
+ Link,
+ {text: row.shepherd, href: "shepherd/" + row.shepherd.split(" ")[0]}
+ ))
+ };
+
+ return $_
+ }())
+ ))
+ });
+
+ return $_
+ }())
+ )
+ )
+ }
+});
+
+//
+// A two section representation of an agenda item (typically a PMC report),
+// where the two sections will show up as two columns on wide enough windows.
+//
+// The first section contains the item text, with a missing indicator if
+// the report isn't present. It also contains an inline copy of draft
+// minutes for agenda items in section 3.
+//
+// The second section contains posted comments, pending comments, and
+// action items associated with this agenda item.
+//
+// Filters may be used to highlight or hypertext link portions of the text.
+//
+var Report = React.createClass({
+ displayName: "Report",
+
+ getInitialState: function() {
+ return {}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement(
+ "section",
+ {className: "flexbox"},
+
+ React.createElement.apply(React, function() {
+ var $_ = ["section", null];
+
+ if (self.props.item.warnings) {
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["ul", {className: "missing"}];
+
+ self.props.item.warnings.forEach(function(warning) {
+ $_.push(React.createElement("li", null, warning))
+ });
+
+ return $_
+ }()))
+ };
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["pre", {className: "report"}];
+
+ if (self.props.item.text) {
+ $_.push(React.createElement(
+ Text,
+ {raw: self.props.item.text, filters: self.state.filters}
+ ))
+ } else if (self.props.item.missing) {
+ $_.push(React.createElement(
+ "p",
+ null,
+ React.createElement("em", null, "Missing")
+ ))
+ } else {
+ $_.push(React.createElement(
+ "p",
+ null,
+ React.createElement("em", null, "Empty")
+ ))
+ };
+
+ return $_
+ }()));
+
+ if ((self.props.item.missing || self.props.item.comments) && self.props.item.mail_list) {
+ $_.push(React.createElement(
+ "section",
+ {className: "reminder"},
+ React.createElement(Email, {item: self.props.item})
+ ))
+ };
+
+ if (self.props.item.minutes) {
+ $_.push(React.createElement(
+ "pre",
+ {className: "comment"},
+
+ React.createElement(
+ Text,
+ {raw: self.props.item.minutes, filters: [hotlink]}
+ )
+ ))
+ };
+
+ return $_
+ }()),
+
+ React.createElement(
+ "section",
+ null,
+ React.createElement(AdditionalInfo, {item: this.props.item}),
+
+ React.createElement(
+ "div",
+ {className: "report-info"},
+ React.createElement("h4", null, "Report Info"),
+ React.createElement(Info, {item: this.props.item})
+ )
+ )
+ )
+ },
+
+ // ensure componentWillReceiveProps is called on before first rendering
+ componentWillMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ componentWillReceiveProps: function($$props) {
+ var $filters = this.state.filters;
+
+ // determine what text filters to run
+ $filters = [
+ this.linebreak,
+ this.todo,
+ hotlink,
+ this.privates,
+ this.jira
+ ];
+
+ if ($$props.item.title == "Call to order") {
+ $filters = [this.localtime, hotlink]
+ };
+
+ if ($$props.item.people) $filters.push(this.names);
+
+ if ($$props.item.title == "President") {
+ $filters.push(this.president_attachments)
+ };
+
+ // special processing for Minutes from previous meetings
+ var date;
+
+ if (/^3[A-Z]$/.test($$props.item.attach)) {
+ $filters = [this.linkMinutes];
+ date = ($$props.item.text.match(/board_minutes_(\d+_\d+_\d+)\.txt/) || [])[1];
+
+ if (date && typeof $$props.item.minutes === 'undefined' && typeof XMLHttpRequest !== 'undefined' && Server.drafts.indexOf("board_minutes_" + date + ".txt") != -1) {
+ $$props.item.minutes = "";
+
+ retrieve("minutes/" + date, "text", function(minutes) {
+ $$props.item.minutes = minutes
+ })
+ }
+ };
+
+ this.setState({filters: $filters})
+ },
+
+ //
+ //## filters
+ //
+ // Highlight todos
+ todo: function(text) {
+ return text.replace(/TODO/g, "<span class=\"missing\">TODO</span>")
+ },
+
+ // Break long lines, treating HTML Entities (like &) as one character
+ linebreak: function(text) {
+ // find long, breakable lines
+ var regex = /(\&\w+;|.){80}.+/g;
+ var result = null;
+ var indicies = [];
+
+ while (result = regex.exec(text)) {
+ var line = result[0];
+ if (line.replace(/\&\w+;/g, ".").length < 80) break;
+ var lastspace = /^.*\s\S/.exec(line);
+
+ if (lastspace && lastspace[0].replace(/\&\w+;/g, ".").length - 1 > 40) {
+ indicies.unshift([line, result.index])
+ }
+ };
+
+ // reflow each line found
+ indicies.forEach(function(info) {
+ var line = info[0];
+ var index = info[1];
+ var prefix = /^\W*/.exec(line)[0];
+ var indent = new Array(prefix.length + 1).join(" ");
+
+ var replacement = "<span class=\"hilite\" title=\"reflowed\">" + prefix + Flow.text(
+ line.slice(prefix.length, line.length),
+ indent
+ ).replace(/\n/g, "\n" + indent) + "</span>";
+
+ text = text.slice(0, index) + replacement + text.slice(index + line.length)
+ });
+
+ return text
+ },
+
+ // Convert start time to local time on Call to order page
+ localtime: function(text) {
+ var self = this;
+
+ return text.replace(
+ /\n(\s+)(Other Time Zones:.*)/,
+
+ function(match, spaces, text) {
+ var localtime = new Date(self.props.item.timestamp).toLocaleString();
+ return ("\n" + spaces + "<span class='hilite'>") + ("Local Time: " + localtime + "</span>" + spaces + text)
+ }
+ )
+ },
+
+ // replace ids with committer links
+ names: function(text) {
+ var roster = "/roster/committer/";
+
+ for (var id in this.props.item.people) {
+ var person = this.props.item.people[id];
+
+ // email addresses in 'Establish' resolutions and (ids) everywhere
+ text = text.replace(
+ new RegExp("(\\(|<)(" + id + ")( at |@|\\))", "g"),
+
+ function(m, pre, id, post) {
+ if (person.icla) {
+ return (post == ")" && person.member ? pre + "<b><a href='" + roster + id + "'>" + id + "</a></b>" + post : pre + "<a href='" + roster + id + "'>" + id + "</a>" + post)
+ } else {
+ return (pre + "<a class='missing' href='" + roster + "?q=" + person.name + "'>") + (id + "</a>" + post)
+ }
+ }
+ );
+
+ // names
+ var pattern;
+
+ if (person.icla || this.props.item.title == "Roll Call") {
+ pattern = escapeRegExp(person.name).replace(/ +/g, "\\s+");
+
+ if (typeof person.member !== 'undefined') {
+ text = text.replace(new RegExp(pattern, "g"), function(match) {
+ return "<a href='" + roster + id + "'>" + match + "</a>"
+ })
+ } else {
+ text = text.replace(new RegExp(pattern, "g"), function(match) {
+ return "<a href='" + roster + "?q=" + person.name + "'>" + match + "</a>"
+ })
+ }
+ };
+
+ // highlight potentially misspelled names
+ var names, iclas, ok;
+
+ if (person.icla && person.icla != person.name) {
+ names = person.name.split(/\s+/);
+ iclas = person.icla.split(/\s+/);
+ ok = false;
+
+ ok = ok || names.every(function(part) {
+ return iclas.some(function(icla) {
+ return icla.indexOf(part) != -1
+ })
+ });
+
+ ok = ok || iclas.every(function(part) {
+ return names.some(function(name) {
+ return name.indexOf(part) != -1
+ })
+ });
+
+ if (/^Establish/.test(this.props.item.title) && !ok) {
+ text = text.replace(
+ new RegExp(escapeRegExp(id + "'>" + person.name), "g"),
+ ("?q=" + encodeURIComponent(person.name) + "'>") + ("<span class='commented'>" + person.name + "</span>")
+ )
+ } else {
+ text = text.replace(
+ new RegExp(escapeRegExp(person.name), "g"),
+ "<a href='" + roster + id + "'>" + person.name + "</a>"
+ )
+ }
+ };
+
+ // put members names in bold
+ if (person.member) {
+ pattern = escapeRegExp(person.name).replace(/ +/g, "\\s+");
+
+ text = text.replace(new RegExp(pattern, "g"), function(match) {
+ return "<b>" + match + "</b>"
+ })
+ }
+ };
+
+ // treat any unmatched names in Roll Call as misspelled
+ if (this.props.item.title == "Roll Call") {
+ text = text.replace(
+ /(\n\s{4})([A-Z].*)/g,
+
+ function(match, space, name) {
+ return space + "<a class='commented' href='" + roster + "?q=" + name + "'>" + name + "</a>"
+ }
+ )
+ };
+
+ // highlight any non-apache.org email addresses in establish resolutions
+ if (/^Establish/.test(this.props.item.title)) {
+ text = text.replace(
+ /(<|\()[-.\w]+@(([-\w]+\.)+\w+)(>|\))/g,
+
+ function(match) {
+ return (/@apache\.org/.test(match) ? match : "<span class=\"commented\" title=\"non @apache.org email address\">" + match + "</span>")
+ }
+ )
+ };
+
+ // highlight mis-spelling of previous and proposed chair names
+ if (this.props.item.title.substring(0, 6) == "Change" && /\(\w[-_.\w]+\)/.test(text)) {
+ text = text.replace(
+ /heretofore\s+appointed\s+(\w(\s|.)*?)\s+\(/,
+
+ function(text, name) {
+ return text.replace(name, "<span class='hilite'>" + name + "</span>")
+ }
+ );
+
+ text = text.replace(
+ /chosen\sto\s+recommend\s+(\w(\s|.)*?)\s+\(/,
+
+ function(text, name) {
+ return text.replace(name, "<span class='hilite'>" + name + "</span>")
+ }
+ )
+ };
+
+ return text
+ },
+
+ // link to board minutes
+ linkMinutes: function(text) {
+ text = text.replace(
+ /board_minutes_(\d+)_\d+_\d+\.txt/g,
+
+ function(match, year) {
+ var link;
+
+ if (Server.drafts.indexOf(match) != -1) {
+ link = "https://svn.apache.org/repos/private/foundation/board/" + match
+ } else {
+ link = "http://apache.org/foundation/records/minutes/" + year + "/" + match
+ };
+
+ return "<a href='" + link + "'>" + match + "</a>"
+ }
+ );
+
+ return text
+ },
+
+ // highlight private sections - these sections appear in the agenda but
+ // will be removed when the minutes are produced (see models/minutes.rb)
+ privates: function(text) {
+ // inline <private>...</private> sections (and preceding spaces and tabs)
+ // where the <private> and </private> are on the same line.
+ var private_inline = new RegExp("([ \\t]*<private>.*?<\\/private>)", "ig");
+
+ // block of lines (and preceding whitespace) where the first line starts
+ // with <private> and the last line ends </private>.
+ var private_lines = new RegExp("^([ \\t]*<private>(?:\\n|.)*?</private>)(\\s*)$", "mig");
+
+ // return the text with private sections marked with class private
+ return text.replace(
+ private_inline,
+ "<span class=\"private\">$1</span>"
+ ).replace(private_lines, "<div class=\"private\">$1</div>")
+ },
+
+ // expand president's attachments
+ president_attachments: function(text) {
+ var match = text.match(/Additionally, please see Attachments (\d) through (\d)/);
+ var agenda;
+
+ if (match) {
+ agenda = Agenda.index;
+
+ for (var i = 0; i < agenda.length; i++) {
+ if (!/^\d$/.test(agenda[i].attach)) continue;
+
+ if (agenda[i].attach >= match[1] && agenda[i].attach <= match[2]) {
+ text += ("\n " + agenda[i].attach + ". ") + ("<a " + (agenda[i].text.length == 0 ? "class=\"pres-missing\" " : "")) + ("href='" + agenda[i].href + "'>" + agenda[i].title + "</a>")
+ }
+ }
+ };
+
+ return text
+ },
+
+ // hotlink to JIRA issues
+ jira: function(text) {
+ var jira_issue = /(^|\s|\(|\[)([A-Z][A-Z0-9]+)-([1-9][0-9]*)(\.(\D|$)|[,;:\s)\]]|$)/g;
+
+ text = text.replace(jira_issue, function(m, pre, name, issue, post) {
+ if (JIRA.find(name)) {
+ return (pre + "<a target='_self' ") + ("href='https://issues.apache.org/jira/browse/" + name + "-" + issue + "'>") + (name + "-" + issue + "</a>" + post)
+ } else {
+ return pre + name + "-" + issue + post
+ }
+ });
+
+ return text
+ }
+});
+
+//
+// Action items. Link to PMC reports when possible, highlight missing
+// action item status updates.
+//
+var ActionItems = React.createClass({
+ displayName: "ActionItems",
+
+ getInitialState: function() {
+ return {disabled: false}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+ var first = true;
+ var updates = Object.keys(Pending.status);
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["section", {className: "flexbox"}];
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["pre", {className: "report"}];
+
+ self.props.item.actions.forEach(function(action) {
+ // skip actions that don't match the filter
+ var match;
+
+ if (self.props.filter) {
+ match = true;
+
+ for (var key in self.props.filter) {
+ match = match && (action[key] == self.props.filter[key])
+ };
+
+ if (!match) return
+ };
+
+ // space between items and add help info on top
+ if (first) {
+ if (!self.props.filter && !Minutes.complete) {
+ $_.push(React.createElement(
+ "p",
+ {className: "alert-info"},
+ "Click on Status to update"
+ ))
+ };
+
+ first = false
+ } else {
+ $_.push("\n")
+ };
+
+ // action owner and text
+ $_.push("* " + action.owner + ": " + action.text + "\n ");
+ var item, agenda;
+
+ if (action.pmc && !(self.props.filter && self.props.filter.title)) {
+ $_.push("[ ");
+
+ // if there is an associated PMC and that PMC is on this month's
+ // agenda, link to the current report, if reporting this month
+ item = Agenda.find(action.pmc);
+
+ if (item) {
+ $_.push(React.createElement(
+ Link,
+ {className: item.color, text: action.pmc, href: item.href}
+ ))
+ } else if (action.pmc) {
+ $_.push(React.createElement("span", {className: "blank"}, action.pmc))
+ };
+
+ // link to the original report
+ if (action.date) {
+ $_.push(" ");
+ agenda = "board_agenda_" + action.date.replace(/\-/g, "_") + ".txt";
+
+ if (Server.agendas.indexOf(agenda) != -1) {
+ $_.push(React.createElement(
+ "a",
+ {href: "../" + action.date + "/" + action.pmc.replace(/\W/g, "-")},
+ action.date
+ ))
+ } else {
+ $_.push(React.createElement(
+ "a",
+
+ {href: "/board/minutes/" + action.pmc.replace(/\W/g, "_") + ("#minutes_" + action.date.replace(
+ /\-/g,
+ "_"
+ ))},
+
+ action.date
+ ))
+ }
+ };
+
+ $_.push(" ]\n ")
+ } else if (action.date) {
+ $_.push("[ " + action.date + " ]\n ")
+ };
+
+ // launch edit dialog when there is a click on the status
+ var attrs = {onClick: self.updateStatus, className: "clickable"};
+ if (Minutes.complete) attrs = {};
+
+ // copy action properties to data attributes
+ for (var name in action) {
+ attrs["data-" + name] = action[name]
+ };
+
+ // include pending updates
+ var pending = Pending.find_status(action);
+ if (pending) attrs["data-status"] = pending.status;
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["span", attrs];
+
+ // highlight missing action item status updates
+ if (pending) {
+ $_.push(React.createElement("span", null, "Status: "));
+
+ pending.status.split("\n").forEach(function(line) {
+ match = line.match(/^( *)(.*)/);
+ $_.push(React.createElement("span", null, match[1]));
+
+ $_.push(React.createElement(
+ "em",
+ {className: "commented"},
+ match[2] + "\n"
+ ))
+ })
+ } else if (action.status == "") {
+ $_.push(React.createElement(
+ "span",
+ {className: "missing"},
+ "Status:"
+ ));
+
+ $_.push("\n")
+ } else {
+ $_.push(React.createElement(
+ Text,
+ {raw: "Status: " + action.status + "\n", filters: [hotlink]}
+ ))
+ };
+
+ return $_
+ }()))
+ });
+
+ if (first) {
+ $_.push(React.createElement(
+ "p",
+ null,
+ React.createElement("em", null, "Empty")
+ ))
+ };
+
+ return $_
+ }()));
+
+ if (!first) {
+ // Update action item (hidden form)
+ $_.push(React.createElement(
+ ModalDialog,
+ {id: "updateStatusForm", color: "commented"},
+ React.createElement("h4", null, "Update Action Item"),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["p", null];
+
+ $_.push(React.createElement(
+ "span",
+ null,
+ self.state.owner + ": " + self.state.text
+ ));
+
+ if (self.state.pmc) {
+ $_.push(" [ ");
+
+ if (self.state.pmc) {
+ $_.push(React.createElement("span", null, " " + self.state.pmc))
+ };
+
+ if (self.state.date) {
+ $_.push(React.createElement("span", null, " " + self.state.date))
+ };
+
+ $_.push(" ]")
+ };
+
+ return $_
+ }()),
+
+ React.createElement("textarea", {
+ ref: "statusText",
+ label: "Status:",
+ value: self.state.status,
+ rows: 5,
+
+ onChange: function(event) {
+ self.setState({status: event.target.value})
+ }
+ }),
+
+ React.createElement(
+ "button",
+
+ {
+ className: "btn-default",
+ "data-dismiss": "modal",
+ disabled: self.state.disabled
+ },
+
+ "Cancel"
+ ),
+
+ React.createElement(
+ "button",
+
+ {
+ className: "btn-primary",
+ onClick: self.save,
+ disabled: self.state.disabled || (self.state.baseline == self.state.status)
+ },
+
+ "Save"
+ )
+ ))
+ };
+
+ return $_
+ }()));
+
+ // Action Items Captured During the Meeting
+ var captured;
+
+ if (self.props.item.title == "Action Items") {
+ captured = [];
+
+ Minutes.actions.forEach(function(action) {
+ var match;
+
+ if (self.props.filter) {
+ match = true;
+
+ for (var key in self.props.filter) {
+ match = match && (action[key] == self.props.filter[key])
+ };
+
+ if (!match) return
+ };
+
+ captured.push(action)
+ });
+
+ if (captured.length != 0) {
+ $_.push(React.createElement(
+ "section",
+ null,
+
+ React.createElement(
+ "h3",
+ null,
+ "Action Items Captured During the Meeting"
+ ),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["pre", {className: "comment"}];
+
+ captured.forEach(function(action) {
+ // skip actions that don't match the filter
+ var match;
+
+ if (self.props.filter) {
+ match = true;
+
+ for (var key in self.props.filter) {
+ match = match && (action[key] == self.props.filter[key])
+ };
+
+ if (!match) return
+ };
+
+ $_.push("* " + action.owner + ": " + action.text.replace(
+ /\n/g,
+ "\n "
+ ) + "\n");
+
+ $_.push(" [ ");
+
+ if (action.item) {
+ $_.push(React.createElement(Link, {
+ className: action.item.color,
+ text: action.item.title,
+ href: action.item.href
+ }))
+ };
+
+ $_.push(" " + Agenda.title + " ]\n\n")
+ });
+
+ return $_
+ }())
+ ))
+ }
+ };
+
+ return $_
+ }())
+ },
+
+ // autofocus on action status in update action form
+ componentDidMount: function() {
+ var self = this;
+
+ jQuery("#updateStatusForm").on("shown.bs.modal", function() {
+ self.refs.statusText.focus()
+ })
+ },
+
+ // launch update status form when status text is clicked
+ updateStatus: function(event) {
+ var parent = event.target.parentNode;
+
+ // construct action from data attributes
+ var action = {};
+
+ for (var i = 0; i < parent.attributes.length; i++) {
+ var attr = parent.attributes[i];
+
+ if (attr.name.substring(0, 5) == "data-") {
+ action[attr.name.slice(5, attr.name.length)] = attr.value
+ }
+ };
+
+ // unindent action
+ action.status = action.status.replace(/\n {14}/g, "\n");
+
+ // set baseline to current value
+ action.baseline = action.status;
+
+ // show dialog
+ jQuery("#updateStatusForm").modal("show");
+
+ // update state
+ this.setState(action)
+ },
+
+ // when save button is pushed, post update and dismiss modal when complete
+ save: function(event) {
+ var self = this;
+
+ var data = {
+ agenda: Agenda.file,
+ owner: this.state.owner,
+ text: this.state.text,
+ pmc: this.state.pmc,
+ date: this.state.date,
+ status: this.state.status
+ };
+
+ this.setState({disabled: true});
+
+ post("status", data, function(pending) {
+ jQuery(self.refs.updateStatusForm).modal("hide");
+ self.setState({disabled: false});
+ Pending.load(pending)
+ })
+ }
+});
+
+//
+// Search component:
+// * prompt for search
+// * display matching paragraphs from agenda, highlighting search strings
+// * keep query string in window location URL in synch
+//
+var Search = React.createClass({
+ displayName: "Search",
+
+ // initialize query text based on data passed to the component
+ getInitialState: function() {
+ return {text: this.props.item.query || ""}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+
+ // search input field
+ $_.push(React.createElement(
+ "div",
+ {className: "search"},
+ React.createElement("label", {htmlFor: "search_text"}, "Search:"),
+
+ React.createElement("input", {
+ id: "search-text",
+ autoFocus: "autofocus",
+ value: self.state.text,
+ onInput: self.input,
+
+ onChange: function(event) {
+ self.setState({text: event.target.value})
+ }
+ })
+ ));
+
+ var matches, text;
+
+ if (self.state.text.length > 2) {
+ matches = false;
+ text = self.state.text.toLowerCase();
+
+ Agenda.index.forEach(function(item) {
+ if (!item.text || item.text.toLowerCase().indexOf(text) == -1) return;
+ matches = true;
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["section", null];
+
+ $_.push(React.createElement(
+ "h4",
+ null,
+ React.createElement(Link, {text: item.title, href: item.href})
+ ));
+
+ // highlight matching strings in paragraph
+ item.text.split(/\n\s*\n/).forEach(function(paragraph) {
+ if (paragraph.toLowerCase().indexOf(text) != -1) {
+ $_.push(React.createElement("pre", {
+ className: "report",
+
+ dangerouslySetInnerHTML: {__html: htmlEscape(paragraph).replace(
+ new RegExp("(" + text + ")", "gi"),
+ "<span class='hilite'>$1</span>"
+ )}
+ }))
+ }
+ });
+
+ return $_
+ }()))
+ });
+
+ // if no sections were output, indicate 'no matches'
+ if (!matches) {
+ $_.push(React.createElement(
+ "p",
+ null,
+ React.createElement("em", null, "No matches")
+ ))
+ }
+ } else {
+ // start producing query results when input string has three characters
+ $_.push(React.createElement(
+ "p",
+ null,
+ "Please enter at least three characters"
+ ))
+ };
+
+ return $_
+ }())
+ },
+
+ // update text whenever input changes
+ input: function(event) {
+ this.setState({text: event.target.value})
+ },
+
+ componentDidMount: function() {
+ this.componentDidUpdate()
+ },
+
+ // replace history state on subsequent renderings
+ componentDidUpdate: function() {
+ var state = {path: "search", query: this.state.text};
+
+ if (state.query) {
+ history.replaceState(
+ state,
+ null,
+ "search?q=" + encodeURIComponent(this.state.text)
+ )
+ } else {
+ history.replaceState(state, null, "search")
+ }
+ }
+});
+
+//
+// A page showing all comments present across all agenda items
+// Conditionally hide comments previously marked as seen.
+//
+var Comments = React.createClass({
+ displayName: "Comments",
+
+ statics: {buttons: function() {
+ var buttons = [];
+
+ if (MarkSeen.undo || Agenda.index.some(function(item) {
+ return item.unseen_comments.length != 0
+ })) buttons.push({button: MarkSeen});
+
+ if (Pending.seen && Object.keys(Pending.seen).length != 0) {
+ buttons.push({button: ShowSeen})
+ };
+
+ return buttons
+ }},
+
+ getInitialState: function() {
+ return {showseen: false}
+ },
+
+ toggleseen: function() {
+ this.setState({showseen: !this.state.showseen})
+ },
+
+ showseen: function() {
+ return this.state.showseen
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+ var found = false;
+
+ Agenda.index.forEach(function(item) {
+ if (item.comments.length == 0) return;
+ var visible = (self.state.showseen ? item.comments : item.unseen_comments);
+
+ if (visible.length != 0) {
+ found = true;
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["section", null];
+
+ $_.push(React.createElement(
+ Link,
+ {className: "h4 " + item.color, text: item.title, href: item.href}
+ ));
+
+ visible.forEach(function(comment) {
+ $_.push(React.createElement("pre", {className: "comment"}, comment))
+ });
+
+ return $_
+ }()))
+ }
+ });
+
+ if (!found) {
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["p", null];
+
+ if (Object.keys(Pending.seen).length == 0) {
+ $_.push(React.createElement("em", null, "No comments found"))
+ } else {
+ $_.push(React.createElement("em", null, "No new comments found"))
+ };
+
+ return $_
+ }()))
+ };
+
+ return $_
+ }())
+ }
+});
+
+var Help = React.createClass({
+ displayName: "Help",
+
+ render: function() {
+ var self = this;
+
+ return React.createElement(
+ "span",
+ null,
+ React.createElement("h3", null, "Keyboard shortcuts"),
+
+ React.createElement(
+ "dl",
+ {className: "dl-horizontal"},
+ React.createElement("dt", null, "left arrow"),
+ React.createElement("dd", null, "previous page"),
+ React.createElement("dt", null, "right arrow"),
+ React.createElement("dd", null, "next page"),
+ React.createElement("dt", null, "enter"),
+
+ React.createElement(
+ "dd",
+ null,
+ "On Shepherd and Queue pages, go to the first report listed"
+ ),
+
+ React.createElement("dt", null, "C"),
+ React.createElement("dd", null, "Scroll to comment section (if any)"),
+ React.createElement("dt", null, "I"),
+ React.createElement("dd", null, "Toggle Info dropdown"),
+ React.createElement("dt", null, "N"),
+ React.createElement("dd", null, "Toggle Navigation dropdown"),
+ React.createElement("dt", null, "A"),
+
+ React.createElement(
+ "dd",
+ null,
+ "Navigate to the overall agenda page"
+ ),
+
+ React.createElement("dt", null, "F"),
+ React.createElement("dd", null, "Show flagged items"),
+ React.createElement("dt", null, "M"),
+ React.createElement("dd", null, "Show missing items"),
+ React.createElement("dt", null, "Q"),
+ React.createElement("dd", null, "Show queued approvals/comments"),
+ React.createElement("dt", null, "S"),
+
+ React.createElement(
+ "dd",
+ null,
+ "Show shepherded items (and action items)"
+ ),
+
+ React.createElement("dt", null, "X"),
+
+ React.createElement(
+ "dd",
+ null,
+ "Set the topic (a.k.a. mark the spot)"
+ ),
+
+ React.createElement("dt", null, "?"),
+ React.createElement("dd", null, "Help (this page)")
+ ),
+
+ React.createElement("h3", null, "Color Legend"),
+
+ React.createElement(
+ "ul",
+ null,
+
+ React.createElement(
+ "li",
+ {className: "missing"},
+ "Report missing, rejected, or has formatting errors"
+ ),
+
+ React.createElement(
+ "li",
+ {className: "available"},
+ "Report present, not eligible for pre-reviews"
+ ),
+
+ React.createElement(
+ "li",
+ {className: "ready"},
+ "Report present, ready for (more) review(s)"
+ ),
+
+ React.createElement(
+ "li",
+ {className: "reviewed"},
+ "Report has sufficient pre-approvals"
+ ),
+
+ React.createElement(
+ "li",
+ {className: "commented"},
+ "Report has been flagged for discussion"
+ )
+ ),
+
+ React.createElement("h3", null, "Change Role"),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["form", {id: "role"}];
+
+ ["Secretary", "Director", "Guest"].forEach(function(role) {
+ $_.push(React.createElement(
+ "div",
+ null,
+
+ React.createElement("input", {
+ type: "radio",
+ name: "role",
+ value: role.toLowerCase(),
+ checked: role.toLowerCase() == Server.role,
+ onChange: self.setRole
+ }),
+
+ role
+ ))
+ });
+
+ return $_
+ }())
+ )
+ },
+
+ setRole: function(event) {
+ Server.role = event.target.value;
+ Main.refresh()
+ }
+});
+
+//
+// A page showing all queued approvals and comments, as well as items
+// that are ready for review.
+//
+var Shepherd = React.createClass({
+ displayName: "Shepherd",
+
+ getInitialState: function() {
+ return {disabled: false, followup: []}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+ var shepherd = self.props.item.shepherd.toLowerCase();
+ var actions = Agenda.find("Action-Items");
+
+ if (actions.actions.some(function(action) {
+ return action.owner == self.props.item.shepherd
+ })) {
+ $_.push(React.createElement("h2", null, "Action Items"));
+
+ $_.push(React.createElement(
+ ActionItems,
+ {item: actions, filter: {owner: self.props.item.shepherd}}
+ ))
+ };
+
+ $_.push(React.createElement("h2", null, "Committee Reports"));
+
+ // list agenda items associated with this shepherd
+ Agenda.index.forEach(function(item) {
+ var mine;
+
+ if (item.shepherd && item.shepherd.toLowerCase().substring(
+ 0,
+ shepherd.length
+ ) == shepherd) {
+ $_.push(React.createElement(Link, {
+ className: "h3 " + item.color,
+ text: item.title,
+ href: "shepherd/queue/" + item.href
+ }));
+
+ $_.push(React.createElement(
+ AdditionalInfo,
+ {item: item, prefix: true}
+ ));
+
+ // flag action
+ if (item.missing || item.comments.length != 0) {
+ if (/^[A-Z]+$/.test(item.attach)) {
+ mine = (shepherd == Server.firstname ? "btn-primary" : "btn-link");
+
+ $_.push(React.createElement(
+ "div",
+ {className: "shepherd"},
+
+ React.createElement(
+ "button",
+
+ {
+ className: "btn " + (mine || ""),
+ "data-attach": item.attach,
+ onClick: self.click,
+ disabled: self.state.disabled
+ },
+
+ (item.flagged ? "unflag" : "flag")
+ ),
+
+ React.createElement(Email, {item: item})
+ ))
+ }
+ }
+ }
+ });
+
+ // list feedback items that may need to be followed up
+ var followup = [];
+
+ for (var title in self.state.followup) {
+ if (self.state.followup[title].count != 1) continue;
+ if (self.state.followup[title].shepherd != self.props.item.shepherd) continue;
+
+ if (Agenda.index.some(function(item) {
+ return item.title == title
+ })) continue;
+
+ self.state.followup[title].title = title;
+ followup.push(self.state.followup[title])
+ };
+
+ if (followup.length != 0) {
+ $_.push(React.createElement(
+ "h2",
+ null,
+ "Feedback that may require followup"
+ ));
+
+ followup.forEach(function(followup) {
+ var link = followup.title.replace(/[^a-zA-Z0-9]+/g, "-");
+
+ $_.push(React.createElement(
+ "a",
+
+ {
+ className: "h3 ready",
+ href: "../" + self.state.prior_date + "/" + link
+ },
+
+ followup.title
+ ));
+
+ splitComments(followup.comments).forEach(function(comment) {
+ $_.push(React.createElement("pre", {className: "comment"}, comment))
+ })
+ })
+ };
+
+ return $_
+ }())
+ },
+
+ // Fetch followup items
+ componentDidMount: function() {
+ var self = this;
+
+ // if cached, reuse
+ if (Shepherd.followup) {
+ this.setState({followup: Shepherd.followup});
+ return
+ };
+
+ // determine date of previous meeting
+ var prior_agenda = Server.agendas[Server.agendas.length - 2];
+ if (!prior_agenda) return;
+
+ var $prior_date = (prior_agenda.match(/\d+_\d+_\d+/) || [])[0].replace(
+ /_/g,
+ "-"
+ );
+
+ retrieve(
+ "../" + $prior_date + "/followup.json",
+ "json",
+
+ function(followup) {
+ Shepherd.followup = followup;
+ self.setState({followup: followup})
+ }
+ );
+
+ this.setState({prior_date: $prior_date})
+ },
+
+ click: function(event) {
+ var self = this;
+
+ var data = {
+ agenda: Agenda.file,
+ initials: Server.initials,
+ attach: event.target.getAttribute("data-attach"),
+ request: event.target.textContent
+ };
+
+ this.setState({disabled: true});
+
+ post("approve", data, function(pending) {
+ self.setState({disabled: false});
+ Pending.load(pending)
+ })
+ }
+});
+
+//
+// A page showing all queued approvals and comments, as well as items
+// that are ready for review.
+//
+var Queue = React.createClass({
+ displayName: "Queue",
+
+ statics: {buttons: function() {
+ var buttons = [{button: Refresh}];
+ if (Pending.count > 0) buttons.push({form: Commit});
+ return buttons
+ }},
+
+ getInitialState: function() {
+ return {}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["div", {className: "col-xs-12"}];
+
+ if (Server.role == "director") {
+ // Approvals
+ $_.push(React.createElement("h4", null, "Approvals"));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["p", {className: "col-xs-12"}];
+
+ self.state.approvals.forEach(function(item, index) {
+ if (index > 0) $_.push(React.createElement("span", null, ", "));
+
+ $_.push(React.createElement(
+ Link,
+ {text: item.title, href: "queue/" + item.href}
+ ))
+ });
+
+ if (self.state.approvals.length == 0) {
+ $_.push(React.createElement("em", null, "None."))
+ };
+
+ return $_
+ }()));
+
+ // Unapproved
+ ["Unapprovals", "Flagged", "Unflagged"].forEach(function(section) {
+ var list = self.state[section.toLowerCase()];
+
+ if (list.length != 0) {
+ $_.push(React.createElement("h4", null, section));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["p", {className: "col-xs-12"}];
+
+ list.forEach(function(item, index) {
+ if (index > 0) $_.push(React.createElement("span", null, ", "));
+
+ $_.push(React.createElement(
+ Link,
+ {text: item.title, href: item.href}
+ ))
+ });
+
+ return $_
+ }()))
+ }
+ })
+ };
+
+ // Comments
+ $_.push(React.createElement("h4", null, "Comments"));
+
+ if (self.state.comments.length == 0) {
+ $_.push(React.createElement(
+ "p",
+ {className: "col-xs-12"},
+ React.createElement("em", null, "None.")
+ ))
+ } else {
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["dl", {className: "dl-horizontal"}];
+
+ self.state.comments.forEach(function(item) {
+ $_.push(React.createElement(
+ "dt",
+ null,
+ React.createElement(Link, {text: item.title, href: item.href})
+ ));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["dd", null];
+
+ item.pending.split("\n\n").forEach(function(paragraph) {
+ $_.push(React.createElement("p", null, paragraph))
+ });
+
+ return $_
+ }()))
+ });
+
+ return $_
+ }()))
+ };
+
+ // Action Item Status updates
+ if (Pending.status.length != 0) {
+ $_.push(React.createElement("h4", null, "Action Items"));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["ul", null];
+
+ Pending.status.forEach(function(item) {
+ var text = item.text;
+
+ if (item.pmc || item.date) {
+ text += " [";
+ if (item.pmc) text += " " + item.pmc;
+ if (item.date) text += " " + item.date;
+ text += " ]"
+ };
+
+ $_.push(React.createElement("li", null, text))
+ });
+
+ return $_
+ }()))
+ };
+
+ // Ready
+ if (Server.role == "director" && self.state.ready.length != 0) {
+ $_.push(React.createElement(
+ "div",
+ {className: "row col-xs-12"},
+ React.createElement("hr")
+ ));
+
+ $_.push(React.createElement("h4", null, "Ready for review"));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["p", {className: "col-xs-12"}];
+
+ self.state.ready.forEach(function(item, index) {
+ if (index > 0) $_.push(React.createElement("span", null, ", "));
+
+ $_.push(React.createElement(Link, {
+ className: (index == 0 ? "default" : null),
+ text: item.title,
+ href: "queue/" + item.href
+ }))
+ });
+
+ return $_
+ }()))
+ };
+
+ return $_
+ }())
+ },
+
+ componentWillMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // determine approvals, rejected, comments, and ready
+ componentWillReceiveProps: function($$props) {
+ var $approvals = [];
+ var $unapprovals = [];
+ var $flagged = [];
+ var $unflagged = [];
+ var $comments = [];
+ var $ready = [];
+
+ Agenda.index.forEach(function(item) {
+ if (Pending.comments[item.attach]) $comments.push(item);
+ var action = false;
+
+ if (Pending.approved.indexOf(item.attach) != -1) {
+ $approvals.push(item);
+ action = true
+ };
+
+ if (Pending.unapproved.indexOf(item.attach) != -1) {
+ $unapprovals.push(item);
+ action = true
+ };
+
+ if (Pending.flagged.indexOf(item.attach) != -1) {
+ $flagged.push(item);
+ action = true
+ };
+
+ if (Pending.unflagged.indexOf(item.attach) != -1) {
+ $unflagged.push(item);
+ action = true
+ };
+
+ if (!action && item.ready_for_review(Server.initials)) $ready.push(item)
+ });
+
+ this.setState({
+ unflagged: $unflagged,
+ unapprovals: $unapprovals,
+ ready: $ready,
+ flagged: $flagged,
+ comments: $comments,
+ approvals: $approvals
+ })
+ }
+});
+
+//
+// A page showing all flagged reports
+//
+var Flagged = React.createClass({
+ displayName: "Flagged",
+
+ render: function() {
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+ var first = true;
+
+ Agenda.index.forEach(function(item) {
+ if (item.flagged_by || Pending.flagged.indexOf(item.attach) != -1) {
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["h3", {className: item.color}];
+
+ $_.push(React.createElement(Link, {
+ className: (first ? "default" : null),
+ text: item.title,
+ href: "flagged/" + item.href
+ }));
+
+ first = false;
+
+ $_.push(React.createElement(
+ "span",
+ {className: "owner"},
+ " [" + item.owner + " / " + item.shepherd + "]"
+ ));
+
+ var flagged_by = Server.directors[item.flagged_by] || item.flagged_by;
+
+ $_.push(React.createElement(
+ "span",
+ {className: "owner"},
+ " flagged by: " + flagged_by
+ ));
+
+ return $_
+ }()));
+
+ $_.push(React.createElement(
+ AdditionalInfo,
+ {item: item, prefix: true}
+ ))
+ }
+ });
+
+ if (first) $_.push(React.createElement("em", {className: "comment"}, "None"));
+ return $_
+ }())
+ }
+});
+
+//
+// A page showing all flagged reports
+//
+var Missing = React.createClass({
+ displayName: "Missing",
+
+ getInitialState: function() {
+ return {checked: {}}
+ },
+
+ componentDidMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // update check marks based on current Index
+ componentWillReceiveProps: function($$props) {
+ var self = this;
+
+ Agenda.index.forEach(function(item) {
+ if (typeof self.state.checked[item.title] === 'undefined') {
+ self.state.checked[item.title] = true
+ }
+ })
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+ var first = true;
+
+ Agenda.index.forEach(function(item) {
+ if (item.missing) {
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["h3", {className: item.color}];
+
+ if (/^[A-Z]+/.test(item.attach)) {
+ $_.push(React.createElement("input", {
+ type: "checkbox",
+ name: "selected",
+ value: item.title,
+ checked: self.state.checked[item.title],
+
+ onChange: function() {
+ self.state.checked[item.title] = !self.state.checked[item.title];
+ self.forceUpdate()
+ }
+ }))
+ };
+
+ $_.push(React.createElement(Link, {
+ className: (first ? "default" : null),
+ text: item.title,
+ href: "flagged/" + item.href
+ }));
+
+ first = false;
+
+ $_.push(React.createElement(
+ "span",
+ {className: "owner"},
+ " [" + item.owner + " / " + item.shepherd + "]"
+ ));
+
+ var flagged_by;
+
+ if (item.flagged_by) {
+ flagged_by = Server.directors[item.flagged_by] || item.flagged_by;
+
+ $_.push(React.createElement(
+ "span",
+ {className: "owner"},
+ " flagged by: " + flagged_by
+ ))
+ };
+
+ return $_
+ }()));
+
+ $_.push(React.createElement(
+ AdditionalInfo,
+ {item: item, prefix: true}
+ ))
+ }
+ });
+
+ if (first) $_.push(React.createElement("em", {className: "comment"}, "None"));
+ return $_
+ }())
+ }
+});
+
+//
+// Overall Agenda page: simple table with one row for each item in the index
+//
+var Backchannel = React.createClass({
+ displayName: "Backchannel",
+ statics: {buttons: function() {return [{button: Message}]}},
+
+ // render a list of messages
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+
+ $_.push(React.createElement(
+ "header",
+ null,
+ React.createElement("h1", null, "Agenda Backchannel")
+ ));
+
+ // convert date into a localized string
+ var datefmt = function(timestamp) {
+ return new Date(timestamp).toLocaleDateString(
+ {},
+ {month: "short", day: "numeric", year: "numeric"}
+ )
+ };
+
+ var i;
+
+ if (Chat.log.length == 0) {
+ if (Chat.backlog_fetched) {
+ $_.push(React.createElement("em", null, "No messages found."))
+ } else {
+ $_.push(React.createElement("em", null, "Loading messages"))
+ }
+ } else {
+ i = 0;
+
+ // group messages by date
+ while (i < Chat.log.length) {
+ var date = datefmt(Chat.log[i].timestamp);
+
+ if (i != 0 || date != datefmt(new Date().valueOf())) {
+ $_.push(React.createElement("h5", {className: "chatlog"}, date))
+ };
+
+ // group of messages that share the same (local) date
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["dl", {className: "chatlog"}];
+
+ while (i < Chat.log.length) {
+ var message = Chat.log[i];
+ if (date != datefmt(message.timestamp)) break;
+
+ $_.push(React.createElement(
+ "dt",
+
+ {
+ className: message.type,
+ key: "t" + message.timestamp,
+ title: new Date(message.timestamp).toLocaleTimeString()
+ },
+
+ message.user
+ ));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = [
+ "dd",
+ {className: message.type, key: "d" + message.timestamp}
+ ];
+
+ if (message.link) {
+ $_.push(React.createElement(
+ Link,
+ {text: message.text, href: message.link}
+ ))
+ } else {
+ $_.push(React.createElement(
+ Text,
+ {raw: message.text, filters: [hotlink, self.mention]}
+ ))
+ };
+
+ return $_
+ }()));
+
+ i++
+ };
+
+ return $_
+ }()))
+ }
+ };
+
+ return $_
+ }())
+ },
+
+ // highlight mentions of my id
+ mention: function(text) {
+ return text.replace(
+ new RegExp("<.*?>|\\b(" + Server.userid + ")\\b", "g"),
+
+ function(match) {
+ return (match[0] == "<" ? match : "<span class=mention>" + match + "</span>")
+ }
+ )
+ },
+
+ // on initial display, fetch backlog
+ componentDidMount: function() {
+ Main.scrollTo = -1;
+ Chat.fetch_backlog()
+ },
+
+ // if we are at the bottom of the page, keep it that way
+ componentWillUpdate: function() {
+ if (window.pageYOffset + window.innerHeight >= document.documentElement.scrollHeight) {
+ Main.scrollTo = -1
+ } else {
+ Main.scrollTo = null
+ }
+ }
+});
+
+//
+// Secretary Roll Call update form
+var RollCall = React.createClass({
+ displayName: "RollCall",
+
+ getInitialState: function() {
+ this.state = {};
+ RollCall.lockFocus = false;
+ this.state.guest = "";
+ return this.state
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement(
+ "section",
+ {className: "flexbox"},
+
+ React.createElement(
+ "section",
+ {id: "rollcall"},
+ React.createElement("h3", null, "Directors"),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["ul", null];
+
+ self.state.people.forEach(function(person) {
+ if (person.role == "director") {
+ $_.push(React.createElement(Attendee, {person: person}))
+ }
+ });
+
+ return $_
+ }()),
+
+ React.createElement("h3", null, "Executive Officers"),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["ul", null];
+
+ self.state.people.forEach(function(person) {
+ if (person.role == "officer") {
+ $_.push(React.createElement(Attendee, {person: person}))
+ }
+ });
+
+ return $_
+ }()),
+
+ React.createElement("h3", null, "Guests"),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["ul", null];
+
+ self.state.people.forEach(function(person) {
+ if (person.role == "guest") {
+ $_.push(React.createElement(Attendee, {person: person}))
+ }
+ });
+
+ // walk-on guest support
+ $_.push(React.createElement(
+ "li",
+ null,
+
+ React.createElement("input", {
+ className: "walkon",
+ value: self.state.guest,
+ disabled: self.state.disabled,
+
+ onFocus: function() {
+ RollCall.lockFocus = true
+ },
+
+ onBlur: function() {
+ RollCall.lockFocus = false
+ },
+
+ onChange: function(event) {
+ self.setState({guest: event.target.value})
+ }
+ })
+ ));
+
+ var guest, found;
+
+ if (self.state.guest.length >= 3) {
+ guest = self.state.guest.toLowerCase().split(" ");
+ found = false;
+
+ Server.committers.forEach(function(person) {
+ if (guest.every(function(part) {
+ return person.id.indexOf(part) != -1 || person.name.toLowerCase().indexOf(part) != -1
+ }) && !self.state.people.some(function(registered) {
+ return registered.id == person.id
+ })) {
+ $_.push(React.createElement(Attendee, {person: person, walkon: true}));
+ found = true
+ }
+ });
+
+ // non committer
+ if (!found) {
+ $_.push(React.createElement(
+ Attendee,
+ {person: {name: self.state.guest}, walkon: true}
+ ))
+ }
+ };
+
+ return $_
+ }())
+ ),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["section", null];
+ var minutes = Minutes.get(self.props.item.title);
+
+ if (minutes) {
+ $_.push(React.createElement("h3", null, "Minutes"));
+ $_.push(React.createElement("pre", {className: "comment"}, minutes))
+ };
+
+ return $_
+ }())
+ )
+ },
+
+ componentWillMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // collect a sorted list of people
+ componentWillReceiveProps: function($$props) {
+ var people = [];
+
+ // start with those listed in the agenda
+ for (var id in $$props.item.people) {
+ var person = $$props.item.people[id];
+ person.id = id;
+ people.push(person)
+ };
+
+ // add remaining attendees
+ var attendees = Minutes.attendees;
+
+ if (attendees) {
+ for (var name in attendees) {
+ var person;
+
+ if (!people.some(function(person) {
+ return person.name == name
+ })) {
+ person = attendees[name];
+ person.name = name;
+ person.role = "guest";
+ people.push(person)
+ }
+ }
+ };
+
+ // sort list
+ this.setState({people: people.sort(function(person1, person2) {
+ return (person1.sortName > person2.sortName ? 1 : -1)
+ })})
+ },
+
+ // clear guest
+ clear_guest: function() {
+ this.setState({guest: ""})
+ },
+
+ // client side initialization on first rendering
+ componentDidMount: function() {
+ var self = this;
+
+ if (Server.committers) {
+ this.setState({disabled: false})
+ } else {
+ this.setState({disabled: true});
+
+ retrieve("committers", "json", function(committers) {
+ Server.committers = committers || [];
+ self.setState({disabled: false})
+ })
+ };
+
+ // export clear method
+ RollCall.clear_guest = this.clear_guest
+ },
+
+ // scroll walkon input field towards the center of the screen
+ componentDidUpdate: function() {
+ var walkon, offset;
+
+ if (RollCall.lockFocus && this.state.guest.length >= 3) {
+ walkon = document.getElementsByClassName("walkon")[0];
+ offset = walkon.offsetTop + walkon.offsetHeight / 2 - window.innerHeight / 2;
+ jQuery("html, body").animate({scrollTop: offset}, "slow")
+ }
+ }
+});
+
+//
+// An individual attendee (Director, Executive Officer, or Guest)
+//
+var Attendee = React.createClass({
+ displayName: "Attendee",
+
+ getInitialState: function() {
+ return {base: ""}
+ },
+
+ componentWillMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // whenever person changes, reflect current status
+ componentWillReceiveProps: function($$props) {
+ var status = Minutes.attendees[$$props.person.name];
+
+ if (status) {
+ this.setState({
+ checked: status.present,
+ notes: (status.notes ? status.notes.replace(" - ", "") : "")
+ })
+ } else {
+ this.setState({checked: "", notes: ""})
+ }
+ },
+
+ // render a checkbox, a hypertexted link of the attendee's name to the
+ // roster page for the committer, and notes in both editable and non-editable
+ // forms. CSS controls which version of the notes is actually displayed.
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["li", {onMouseOver: self.focus}];
+
+ $_.push(React.createElement(
+ "input",
+ {type: "checkbox", checked: self.state.checked, onChange: self.click}
+ ));
+
+ var roster = "/roster/committer/";
+
+ if (self.props.person.id) {
+ $_.push(React.createElement(
+ "a",
+
+ {
+ href: roster + self.props.person.id,
+ style: {fontWeight: (self.props.person.member ? "bold" : "normal")}
+ },
+
+ self.props.person.name
+ ))
+ } else {
+ $_.push(React.createElement(
+ "a",
+ {className: "hilite", href: roster + "?q=" + self.props.person.name},
+ self.props.person.name
+ ))
+ };
+
+ if (!self.props.walkon && !self.state.checked && self.props.person.role != "guest" && !self.props.person.attending) {
+ if (!self.state.notes) {
+ $_.push(React.createElement("span", null, " (expected to be absent)"))
+ }
+ };
+
+ if (!self.props.walkon) {
+ $_.push(React.createElement("label"));
+
+ $_.push(React.createElement("input", {
+ type: "text",
+ value: self.state.notes,
+ onBlur: self.blur,
+ disabled: self.state.disabled,
+
+ onChange: function(event) {
+ self.setState({notes: event.target.value})
+ }
+ }));
+
+ if (self.state.notes) {
+ $_.push(React.createElement("span", null, " - " + self.state.notes))
+ }
+ };
+
+ return $_
+ }())
+ },
+
+ // when moving cursor over a list item, focus on the input field
+ focus: function(event) {
+ if (!RollCall.lockFocus) {
+ event.target.parentNode.querySelector("input[type=text]").focus()
+ }
+ },
+
+ // initialize pending update status
+ componentDidMount: function() {
+ this.pending = false
+ },
+
+ // when checkbox is clicked, set pending update status
+ click: function(event) {
+ this.setState({checked: event.target.checked});
+ this.pending = true
+ },
+
+ // when leaving a list item, set pending update status if value changed
+ blur: function() {
+ if (this.state.base != this.state.notes) {
+ this.pending = true;
+ this.setState({base: this.state.notes})
+ }
+ },
+
+ // after display is updated, send any pending updates to the server
+ componentDidUpdate: function() {
+ var self = this;
+ if (!this.pending) return;
+
+ var data = {
+ agenda: Agenda.file,
+ action: "attendance",
+ name: this.props.person.name,
+ id: this.props.person.id,
+ present: this.state.checked,
+ notes: this.state.notes
+ };
+
+ this.setState({disabled: true});
+
+ post("minute", data, function(minutes) {
+ Minutes.load(minutes);
+ if (self.props.walkon) RollCall.clear_guest();
+ self.setState({disabled: false})
+ });
+
+ this.pending = false
+ }
+});
+
+//
+// Action items. Link to PMC reports when possible, highlight missing
+// action item status updates.
+//
+var SelectActions = React.createClass({
+ displayName: "SelectActions",
+ statics: {buttons: function() {return [{button: PostActions}]}},
+
+ getInitialState: function() {
+ this.state = {};
+ SelectActions.list = [];
+ this.state.names = [];
+ return this.state
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement(
+ "span",
+ null,
+ React.createElement("h3", null, "Post Action Items"),
+
+ React.createElement(
+ "p",
+ {className: "alert-info"},
+ "Action Items have yet to be posted. " + "Unselect the ones below that have been completed. " + "Click on the \"post actions\" button when done."
+ ),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["pre", {className: "report"}];
+
+ SelectActions.list.forEach(function(action) {
+ $_.push(React.createElement(
+ CandidateAction,
+ {action: action, names: self.state.names}
+ ))
+ });
+
+ return $_
+ }())
+ )
+ },
+
+ componentDidMount: function() {
+ var self = this;
+
+ retrieve("potential-actions", "json", function(response) {
+ if (response) {
+ SelectActions.list = response.actions;
+ self.setState({names: response.names})
+ }
+ })
+ }
+});
+
+var CandidateAction = React.createClass({
+ displayName: "CandidateAction",
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+
+ $_.push(React.createElement("input", {
+ type: "checkbox",
+ checked: !self.props.action.complete,
+
+ onChange: function() {
+ self.props.action.complete = !self.props.action.complete;
+ self.forceUpdate()
+ }
+ }));
+
+ $_.push(React.createElement("span", null, " "));
+ $_.push(React.createElement("span", null, self.props.action.owner));
+ $_.push(React.createElement("span", null, ": "));
+ $_.push(React.createElement("span", null, self.props.action.text));
+
+ $_.push(React.createElement(
+ "span",
+ null,
+ "\n [ " + self.props.action.pmc + " " + self.props.action.date + " ]\n "
+ ));
+
+ if (self.props.action.status) {
+ $_.push(React.createElement(Text, {
+ raw: "Status: " + self.props.action.status + "\n",
+ filters: [hotlink]
+ }))
+ };
+
+ $_.push(React.createElement("span", null, "\n"));
+ return $_
+ }())
+ }
+});
+
+//
+// A page showing status of caches and service workers
+//
+var CacheStatus = React.createClass({
+ displayName: "CacheStatus",
+
+ statics: {buttons: function() {
+ return [{button: ClearCache}, {button: UnregisterWorker}]
+ }},
+
+ getInitialState: function() {
+ return {cache: [], registrations: []}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+ $_.push(React.createElement("h2", null, "Status"));
+
+ if (typeof navigator !== 'undefined' && "serviceWorker" in navigator) {
+ $_.push(React.createElement(
+ "p",
+ null,
+ "Service workers ARE supported by this browser"
+ ))
+ } else {
+ $_.push(React.createElement(
+ "p",
+ null,
+ "Service workers are NOT supported by this browser"
+ ))
+ };
+
+ $_.push(React.createElement("h2", null, "Cache"));
+
+ if (self.state.cache.length == 0) {
+ $_.push(React.createElement("p", null, "empty"))
+ } else {
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["ul", null];
+
+ self.state.cache.forEach(function(item) {
+ var basename = item.split("/").pop();
+ if (basename == "") basename = "index.html";
+
+ if (basename == "bootstrap.html") {
+ basename = item.split("/")[item.split("/").length - 2] + ".html"
+ };
+
+ $_.push(React.createElement(
+ "li",
+ null,
+ React.createElement(Link, {text: item, href: "cache/" + basename})
+ ))
+ });
+
+ return $_
+ }()))
+ };
+
+ $_.push(React.createElement("h2", null, "Service Workers"));
+
+ if (self.state.registrations.length == 0) {
+ $_.push(React.createElement("p", null, "none found"))
+ } else {
+ $_.push(React.createElement(
+ "table",
+ {className: "table"},
+
+ React.createElement(
+ "thead",
+ null,
+ React.createElement("th", null, "Scope"),
+ React.createElement("th", null, "Status")
+ ),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["tbody", null];
+
+ self.state.registrations.forEach(function(registration) {
+ $_.push(React.createElement(
+ "tr",
+ null,
+ React.createElement("td", null, registration.scope),
+
+ React.createElement.apply(React, function() {
+ var $_ = ["td", null];
+
+ if (registration.installing) {
+ $_.push(React.createElement("span", null, "installing"))
+ } else if (registration.waiting) {
+ $_.push(React.createElement("span", null, "waiting"))
+ } else if (registration.active) {
+ $_.push(React.createElement("span", null, "active"))
+ } else {
+ $_.push(React.createElement("span", null, "unknown"))
+ };
+
+ return $_
+ }())
+ ))
+ });
+
+ return $_
+ }())
+ ))
+ };
+
+ return $_
+ }())
+ },
+
+ componentDidMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // update caches
+ componentWillReceiveProps: function($$props) {
+ var self = this;
+
+ if (typeof caches !== 'undefined') {
+ caches.open("board/agenda").then(function(cache) {
+ cache.matchAll().then(function(responses) {
+ cache = responses.map(function(response) {
+ return response.url
+ });
+
+ cache.sort();
+ self.setState({cache: cache})
+ })
+ });
+
+ navigator.serviceWorker.getRegistrations().then(function(registrations) {
+ self.setState({registrations: registrations})
+ })
+ }
+ }
+});
+
+//
+// A button that clear the cache
+//
+var ClearCache = React.createClass({
+ displayName: "ClearCache",
+
+ getInitialState: function() {
+ return {disabled: true}
+ },
+
+ render: function() {
+ return React.createElement(
+ "button",
+
+ {
+ className: "btn btn-primary",
+ onClick: this.click,
+ disabled: this.state.disabled
+ },
+
+ "Clear Cache"
+ )
+ },
+
+ componentDidMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // enable button if there is anything in the cache
+ componentWillReceiveProps: function($$props) {
+ var self = this;
+
+ if (typeof caches !== 'undefined') {
+ caches.open("board/agenda").then(function(cache) {
+ cache.matchAll().then(function(responses) {
+ self.setState({disabled: responses.length == 0})
+ })
+ })
+ }
+ },
+
+ click: function(event) {
+ if (typeof caches !== 'undefined') {
+ caches.delete("board/agenda").then(function(status) {
+ Main.refresh()
+ })
+ }
+ }
+});
+
+//
+// A button that removes the service worker. Sadly, it doesn't seem to have
+// any affect on the list of registrations that is dynamically returned.
+//
+var UnregisterWorker = React.createClass({
+ displayName: "UnregisterWorker",
+
+ render: function() {
+ return React.createElement(
+ "button",
+ {className: "btn btn-primary", onClick: this.click},
+ "Unregister ServiceWorker"
+ )
+ },
+
+ click: function(event) {
+ if (typeof caches !== 'undefined') {
+ navigator.serviceWorker.getRegistrations().then(function(registrations) {
+ var base = new URL("..", document.getElementsByTagName("base")[0].href).href;
+
+ registrations.forEach(function(registration) {
+ if (registration.scope == base) {
+ registration.unregister().then(function(status) {
+ Main.refresh()
+ })
+ }
+ })
+ })
+ }
+ }
+});
+
+//
+// Individual Cache page
+//
+var CachePage = React.createClass({
+ displayName: "CachePage",
+
+ getInitialState: function() {
+ return {response: {}, text: ""}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+ $_.push(React.createElement("h2", null, self.state.response.url));
+
+ $_.push(React.createElement(
+ "p",
+ null,
+ self.state.response.status + " " + self.state.response.statusText
+ ));
+
+ var keys, iterator, entry;
+
+ if (self.state.response.headers) {
+ // avoid buggy @response.headers.keys()
+ keys = [];
+ iterator = self.state.response.headers.entries();
+ entry = iterator.next();
+
+ while (!entry.done) {
+ if (entry.value[0] != "status") keys.push(entry.value[0]);
+ entry = iterator.next()
+ };
+
+ keys.sort();
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["ul", null];
+
+ keys.forEach(function(key) {
+ $_.push(React.createElement(
+ "li",
+ null,
+ key + ": " + self.state.response.headers.get(key)
+ ))
+ });
+
+ return $_
+ }()))
+ };
+
+ $_.push(React.createElement("pre", null, self.state.text));
+ return $_
+ }())
+ },
+
+ // update on first update
+ componentDidMount: function() {
+ var self = this;
+ var basename;
+
+ if (typeof caches !== 'undefined') {
+ basename = location.href.split("/").pop();
+ if (basename == "index.html") basename = "";
+ if (/^\d+-\d+-\d+\.html$/.test(basename)) basename = "bootstrap.html";
+
+ caches.open("board/agenda").then(function(cache) {
+ cache.matchAll().then(function(responses) {
+ responses.forEach(function(response) {
+ if (response.url.split("/").pop() == basename) {
+ self.setState({response: response});
+
+ response.text().then(function(text) {
+ self.setState({text: text})
+ })
+ }
+ })
+ })
+ })
+ }
+ }
+});
+
+//
+// FY22 budget worksheet
+//
+var FY22 = React.createClass({
+ displayName: "FY22",
+
+ getInitialState: function() {
+ this.state = {budget: (Minutes.started && Minutes.get("budget")) || {
+ donations: 110,
+ sponsorship: 1000,
+ infrastructure: 868,
+ publicity: 352,
+ brandManagement: 141,
+ conferences: 12,
+ travelAssistance: 79,
+ treasury: 51,
+ fundraising: 23,
+ generalAndAdministrative: 139
+ }};
+
+ if (Server.role == "secretary" || !Minutes.started) {
+ this.state.disabled = false
+ } else {
+ this.state.disabled = true
+ };
+
+ this.recalc();
+ return this.state
+ },
+
+ render: function() {
+ return React.createElement(
+ "span",
+ null,
+
+ React.createElement(
+ "style",
+ null,
+ "\n" + " .table thead tr th {text-align: right}\n" + " .table tbody tr td {text-align: left}\n" + " .table tbody tr td.num {text-align: right}\n" + " .table tbody tr td.indented {padding-left: 2em}\n" + " .table tbody tr td input {align: right; text-align: right}\n" + " .table tbody tr td a {color: blue; text-decoration:underline}\n" + " "
+ ),
+
+ React.createElement(
+ "p",
+ null,
+ "Instructions: change any input field and press the tab key to see " + "new results. Try to make FY22 Budget Net non-negative."
+ ),
+
+ React.createElement(
+ "table",
+ {className: "table table-sm table-striped"},
+
+ React.createElement("thead", null, React.createElement(
+ "tr",
+ null,
+ React.createElement("th"),
+ React.createElement("th", null, "FY17"),
+ React.createElement("th", null, "Min FY22"),
+ React.createElement("th", null, "FY22"),
+ React.createElement("th", null, "Max FY22"),
+ React.createElement("th", null, "FY22 Budget")
+ )),
+
+ React.createElement(
+ "tbody",
+ null,
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td", {colSpan: 6}, "Income")
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+
+ React.createElement(
+ "td",
+ {className: "indented"},
+
+ React.createElement(
+ "a",
+ {href: "https://s.apache.org/sxYI"},
+ "Total Public Donations"
+ )
+ ),
+
+ React.createElement("td", {className: "num"}, 89),
+ React.createElement("td", {className: "num"}, 90),
+ React.createElement("td", {className: "num"}, 110),
+ React.createElement("td", {className: "num"}, 135),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+
+ React.createElement("input", {
+ id: "donations",
+ onBlur: this.change,
+ disabled: this.state.disabled,
+ defaultValue: this.state.budget.donations.toLocaleString()
+ })
+ )
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+
+ React.createElement(
+ "td",
+ {className: "indented"},
+
+ React.createElement(
+ "a",
+ {href: "https://s.apache.org/sxYI"},
+ "Total Sponsorship"
+ )
+ ),
+
+ React.createElement("td", {className: "num"}, 968),
+ React.createElement("td", {className: "num"}, 900),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+ (1000).toLocaleString()
+ ),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+ (1100).toLocaleString()
+ ),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+
+ React.createElement("input", {
+ id: "sponsorship",
+ onBlur: this.change,
+ disabled: this.state.disabled,
+ defaultValue: this.state.budget.sponsorship.toLocaleString()
+ })
+ )
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td", {className: "indented"}, "Total Programs"),
+ React.createElement("td", {className: "num"}, 28),
+ React.createElement("td", {className: "num"}, 28),
+ React.createElement("td", {className: "num"}, 28),
+ React.createElement("td", {className: "num"}, 28),
+ React.createElement("td", {className: "num"}, 28)
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td", {className: "indented"}, "Interest Income"),
+ React.createElement("td", {className: "num"}, 4),
+ React.createElement("td", {className: "num"}, 4),
+ React.createElement("td", {className: "num"}, 4),
+ React.createElement("td", {className: "num"}, 4),
+ React.createElement("td", {className: "num"}, 4)
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td"),
+ React.createElement("td", {className: "num"}, "----"),
+ React.createElement("td", {className: "num"}, "----"),
+ React.createElement("td", {className: "num"}, "----"),
+ React.createElement("td", {className: "num"}, "----"),
+ React.createElement("td", {className: "num"}, "----")
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td", {className: "indented"}, "Total Income"),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+ (1089).toLocaleString()
+ ),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+ (1022).toLocaleString()
+ ),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+ (1142).toLocaleString()
+ ),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+ (1267).toLocaleString()
+ ),
+
+ React.createElement(
+ "td",
+ {className: "num", id: "income"},
+ this.state.budget.income.toLocaleString()
+ )
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td", {colSpan: 6})
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td", {colSpan: 6}, "Expense")
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+
+ React.createElement(
+ "td",
+ {className: "indented"},
+
+ React.createElement(
+ "a",
+ {href: "https://s.apache.org/Rlse"},
+ "Infrastructure"
+ )
+ ),
+
+ React.createElement("td", {className: "num"}, 723),
+ React.createElement("td", {className: "num"}, 868),
+ React.createElement("td", {className: "num"}, 868),
+ React.createElement("td", {className: "num"}, 868),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+
+ React.createElement("input", {
+ id: "infrastructure",
+ onBlur: this.change,
+ disabled: this.state.disabled,
+ defaultValue: this.state.budget.infrastructure.toLocaleString()
+ })
+ )
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+
+ React.createElement(
+ "td",
+ {className: "indented"},
+ "Program Expenses"
+ ),
+
+ React.createElement("td", {className: "num"}, 27),
+ React.createElement("td", {className: "num"}, 27),
+ React.createElement("td", {className: "num"}, 27),
+ React.createElement("td", {className: "num"}, 27),
+ React.createElement("td", {className: "num"}, 27)
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+
+ React.createElement(
+ "td",
+ {className: "indented"},
+
+ React.createElement(
+ "a",
+ {href: "https://s.apache.org/lv76"},
+ "Publicity"
+ )
+ ),
+
+ React.createElement("td", {className: "num"}, 141),
+ React.createElement("td", {className: "num"}, 273),
+ React.createElement("td", {className: "num"}, 352),
+ React.createElement("td", {className: "num"}, 540),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+
+ React.createElement("input", {
+ id: "publicity",
+ onBlur: this.change,
+ disabled: this.state.disabled,
+ defaultValue: this.state.budget.publicity.toLocaleString()
+ })
+ )
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+
+ React.createElement(
+ "td",
+ {className: "indented"},
+
+ React.createElement(
+ "a",
+ {href: "https://s.apache.org/gXdY"},
+ "Brand Management"
+ )
+ ),
+
+ React.createElement("td", {className: "num"}, 84),
+ React.createElement("td", {className: "num"}, 92),
+ React.createElement("td", {className: "num"}, 141),
+ React.createElement("td", {className: "num"}, 218),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+
+ React.createElement("input", {
+ id: "brandManagement",
+ onBlur: this.change,
+ disabled: this.state.disabled,
+ defaultValue: this.state.budget.brandManagement.toLocaleString()
+ })
+ )
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td", {className: "indented"}, "Conferences"),
+ React.createElement("td", {className: "num"}, 12),
+ React.createElement("td", {className: "num"}, 12),
+ React.createElement("td", {className: "num"}, 12),
+ React.createElement("td", {className: "num"}, 12),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+
+ React.createElement("input", {
+ id: "conferences",
+ onBlur: this.change,
+ disabled: this.state.disabled,
+ defaultValue: this.state.budget.conferences.toLocaleString()
+ })
+ )
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+
+ React.createElement(
+ "td",
+ {className: "indented"},
+
+ React.createElement(
+ "a",
+ {href: "https://s.apache.org/4LdI"},
+ "Travel Assistance"
+ )
+ ),
+
+ React.createElement("td", {className: "num"}, 62),
+ React.createElement("td", {className: "num"}, 0),
+ React.createElement("td", {className: "num"}, 79),
+ React.createElement("td", {className: "num"}, 150),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+
+ React.createElement("input", {
+ id: "travelAssistance",
+ onBlur: this.change,
+ disabled: this.state.disabled,
+ defaultValue: this.state.budget.travelAssistance.toLocaleString()
+ })
+ )
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+
+ React.createElement(
+ "td",
+ {className: "indented"},
+
+ React.createElement(
+ "a",
+ {href: "https://s.apache.org/EGiC"},
+ "Treasury"
+ )
+ ),
+
+ React.createElement("td", {className: "num"}, 48),
+ React.createElement("td", {className: "num"}, 49),
+ React.createElement("td", {className: "num"}, 51),
+ React.createElement("td", {className: "num"}, 61),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+
+ React.createElement("input", {
+ id: "treasury",
+ onBlur: this.change,
+ disabled: this.state.disabled,
+ defaultValue: this.state.budget.treasury.toLocaleString()
+ })
+ )
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+
+ React.createElement(
+ "td",
+ {className: "indented"},
+
+ React.createElement(
+ "a",
+ {href: "https://s.apache.org/sxYI"},
+ "Fundraising"
+ )
+ ),
+
+ React.createElement("td", {className: "num"}, 8),
+ React.createElement("td", {className: "num"}, 18),
+ React.createElement("td", {className: "num"}, 23),
+ React.createElement("td", {className: "num"}, 23),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+
+ React.createElement("input", {
+ id: "fundraising",
+ onBlur: this.change,
+ disabled: this.state.disabled,
+ defaultValue: this.state.budget.fundraising.toLocaleString()
+ })
+ )
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+
+ React.createElement(
+ "td",
+ {className: "indented"},
+
+ React.createElement(
+ "a",
+ {href: "https://s.apache.org/4LdI"},
+ "General & Administrative"
+ )
+ ),
+
+ React.createElement("td", {className: "num"}, 114),
+ React.createElement("td", {className: "num"}, 50),
+ React.createElement("td", {className: "num"}, 139),
+ React.createElement("td", {className: "num"}, 300),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+
+ React.createElement("input", {
+ id: "generalAndAdministrative",
+ onBlur: this.change,
+ disabled: this.state.disabled,
+ defaultValue: this.state.budget.generalAndAdministrative.toLocaleString()
+ })
+ )
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td"),
+ React.createElement("td", {className: "num"}, "----"),
+ React.createElement("td", {className: "num"}, "----"),
+ React.createElement("td", {className: "num"}, "----"),
+ React.createElement("td", {className: "num"}, "----"),
+ React.createElement("td", {className: "num"}, "----")
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td", {className: "indented"}, "Total Expense"),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+ (1219).toLocaleString()
+ ),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+ (1390).toLocaleString()
+ ),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+ (1693).toLocaleString()
+ ),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+ (2199).toLocaleString()
+ ),
+
+ React.createElement(
+ "td",
+ {className: "num", id: "expense"},
+ this.state.budget.expense.toLocaleString()
+ )
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td", {colSpan: 6})
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td", null, "Net"),
+ React.createElement("td", {className: "num"}, -130),
+ React.createElement("td", {className: "num"}, -369),
+ React.createElement("td", {className: "num"}, -552),
+ React.createElement("td", {className: "num"}, -993),
+
+ React.createElement(
+ "td",
+
+ {
+ className: "num " + (this.state.budget.net < 0 ? "danger" : "success"),
+ id: "net"
+ },
+
+ this.state.budget.net.toLocaleString()
+ )
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td", {colSpan: 6})
+ ),
+
+ React.createElement(
+ "tr",
+ null,
+ React.createElement("td", null, "Cash"),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+ (1656).toLocaleString()
+ ),
+
+ React.createElement("td", {className: "num"}, 290),
+ React.createElement("td", {className: "num"}, -259),
+
+ React.createElement(
+ "td",
+ {className: "num"},
+ (-1403).toLocaleString()
+ ),
+
+ React.createElement(
+ "td",
+ {className: "num", id: "cash"},
+ this.state.budget.cash.toLocaleString()
+ )
+ )
+ )
+ ),
+
+ React.createElement(
+ "p",
+ null,
+ "Units are in thousands of dollars US."
+ )
+ )
+ },
+
+ // evaluate computed fields
+ recalc: function() {
+ this.state.budget.income = this.state.budget.donations + this.state.budget.sponsorship + 28 + 4;
+ this.state.budget.expense = this.state.budget.infrastructure + 27 + this.state.budget.publicity + this.state.budget.brandManagement + this.state.budget.conferences + this.state.budget.travelAssistance + this.state.budget.treasury + this.state.budget.fundraising + this.state.budget.generalAndAdministrative;
+ this.state.budget.net = this.state.budget.income - this.state.budget.expense;
+ this.state.budget.cash = 1656 - 2 * 130 + 3 * this.state.budget.net
+ },
+
+ // update budget item when an input field changes
+ change: function(event) {
+ var self = this;
+
+ this.state.budget[event.target.id] = parseInt(event.target.value.replace(
+ /\D/g,
+ ""
+ )) || 0;
+
+ event.target.value = this.state.budget[event.target.id].toLocaleString();
+ this.recalc();
+
+ if (Server.role == "secretary" && Minutes.started) {
+ post(
+ "budget",
+ {agenda: Agenda.file, budget: this.state.budget},
+
+ function(budget) {
+ if (budget) self.setState({budget: budget})
+ }
+ )
+ };
+
+ this.forceUpdate()
+ },
+
+ // receive updated budget values
+ componentWillReceiveProps: function($$props) {
+ var budget = Minutes.get("budget");
+
+ if (budget && budget != this.state.budget && Minutes.started) {
+ for (var item in budget) {
+ var element = document.getElementById(item);
+
+ if (element.tagName == "INPUT") {
+ element.value = budget[item].toLocaleString()
+ } else {
+ element.textContent = budget[item].toLocaleString()
+ }
+ };
+
+ this.setState({budget: budget});
+ if (Server.role != "secretary") this.setState({disabled: true})
+ }
+ }
+});
+
+//
+// This component handles both add and edit comment actions. The save
+// button is disabled until the comment is changed. A delete button is
+// provided to clear the comment if it isn't already empty.
+//
+// When the save button is pushed, a POST request is sent to the server.
+// When a response is received, the pending status is updated and the
+// form is dismissed.
+//
+var AddComment = React.createClass({
+ displayName: "AddComment",
+
+ statics: {button: {
+ text: "add comment",
+ class: "btn_primary",
+ data_toggle: "modal",
+ data_target: "#comment-form"
+ }},
+
+ getInitialState: function() {
+ return {
+ base: this.props.item.pending,
+ comment: this.props.item.pending,
+ disabled: false,
+ checked: this.props.item.flagged
+ }
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = [ModalDialog, {id: "comment-form", color: "commented"}];
+
+ // header
+ if (self.state.base) {
+ $_.push(React.createElement("h4", null, "Edit comment"))
+ } else {
+ $_.push(React.createElement("h4", null, "Enter a comment"))
+ };
+
+ //input field: initials
+ $_.push(React.createElement("input", {
+ id: "comment-initials",
+ label: "Initials",
+ placeholder: "initials",
+ disabled: self.state.disabled,
+ defaultValue: self.props.server.pending.initials || self.props.server.initials
+ }));
+
+ //input field: comment text
+ $_.push(React.createElement("textarea", {
+ id: "comment-text",
+ value: self.state.comment,
+ label: "Comment",
+ placeholder: "comment",
+ rows: 5,
+ onChange: self.change,
+ disabled: self.state.disabled
+ }));
+
+ if (Server.role == "director" && /^[A-Z]+$/.test(self.props.item.attach)) {
+ $_.push(React.createElement("input", {
+ id: "flag",
+ type: "checkbox",
+ label: "item requires discussion or follow up",
+ onChange: self.flag,
+ checked: self.state.checked
+ }))
+ };
+
+ // footer buttons
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "btn-default",
+ "data-dismiss": "modal",
+ disabled: self.state.disabled
+ },
+
+ "Cancel"
+ ));
+
+ if (self.state.comment) {
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "btn-warning",
+ onClick: self.delete,
+ disabled: self.state.disabled
+ },
+
+ "Delete"
+ ))
+ };
+
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "btn-primary",
+ onClick: self.save,
+ disabled: self.state.disabled || self.state.comment == self.state.base
+ },
+
+ "Save"
+ ));
+
+ return $_
+ }())
+ },
+
+ // autofocus on comment text
+ componentDidMount: function() {
+ jQuery("#comment-form").on("shown.bs.modal", function() {
+ document.getElementById("comment-text").focus()
+ })
+ },
+
+ // update comment when textarea changes, triggering hiding/showing the
+ // Delete button and enabling/disabling the Save button.
+ change: function(event) {
+ this.setState({comment: event.target.value})
+ },
+
+ // when item changes, reset base and comment
+ componentWillReceiveProps: function(newprops) {
+ if (newprops.item.href != this.props.item.href) {
+ this.setState({
+ checked: newprops.item.flagged,
+ base: newprops.item.pending || "",
+ comment: newprops.item.pending || ""
+ })
+ }
+ },
+
+ // when delete button is pushed, clear the comment
+ delete: function(event) {
+ this.setState({comment: ""})
+ },
+
+ // when save button is pushed, post comment and dismiss modal when complete
+ save: function(event) {
+ var self = this;
+ Server.initials = document.getElementById("comment-initials").value;
+
+ var data = {
+ agenda: Agenda.file,
+ attach: this.props.item.attach,
+ initials: Server.initials,
+ comment: this.state.comment
+ };
+
+ this.setState({disabled: true});
+
+ post("comment", data, function(pending) {
+ jQuery("#comment-form").modal("hide");
+ document.body.classList.remove("modal-open");
+ self.setState({disabled: false});
+ Pending.load(pending)
+ })
+ },
+
+ flag: function(event) {
+ this.setState({checked: !this.state.checked});
+
+ var data = {
+ agenda: Agenda.file,
+ initials: Server.initials,
+ attach: this.props.item.attach,
+ request: (event.target.checked ? "flag" : "unflag")
+ };
+
+ post("approve", data, function(pending) {Pending.load(pending)})
+ }
+});
+
+var AddMinutes = React.createClass({
+ displayName: "AddMinutes",
+
+ statics: {button: {
+ text: "add minutes",
+ class: "btn_primary",
+ data_toggle: "modal",
+ data_target: "#minute-form"
+ }},
+
+ getInitialState: function() {
+ return {disabled: false}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = [
+ ModalDialog,
+ {className: "wide-form", id: "minute-form", color: "commented"}
+ ];
+
+ $_.push(React.createElement(
+ "h4",
+ {className: "commented"},
+ "Minutes"
+ ));
+
+ // either a large text area, or a slightly smaller text area
+ // followed by comments
+ if (self.props.item.comments.length == 0) {
+ $_.push(React.createElement("textarea", {
+ className: "form-control",
+ id: "minute-text",
+ rows: 17,
+ tabIndex: 1,
+ placeholder: "minutes",
+ value: self.state.draft,
+
+ onChange: function(event) {
+ self.setState({draft: event.target.value})
+ }
+ }))
+ } else {
+ $_.push(React.createElement("textarea", {
+ className: "form-control",
+ id: "minute-text",
+ rows: 12,
+ tabIndex: 1,
+ placeholder: "minutes",
+ value: self.state.draft,
+
+ onChange: function(event) {
+ self.setState({draft: event.target.value})
+ }
+ }));
+
+ $_.push(React.createElement("h3", null, "Comments"));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["div", {id: "minute-comments"}];
+
+ self.props.item.comments.forEach(function(comment) {
+ $_.push(React.createElement("pre", {className: "comment"}, comment))
+ });
+
+ return $_
+ }()))
+ };
+
+ // action items
+ $_.push(React.createElement(
+ "div",
+ {className: "row", style: {marginTop: "1em"}},
+
+ React.createElement(
+ "button",
+
+ {
+ className: "btn btn-sm btn-info col-md-offset-1 col-md-1",
+ onClick: self.addAI,
+ disabled: !self.state.ai_owner || !self.state.ai_text
+ },
+
+ "+ AI"
+ ),
+
+ React.createElement(
+ "label",
+ {className: "col-md-2"},
+
+ React.createElement.apply(React, function() {
+ var $_ = [
+ "select",
+
+ {value: self.state.ai_owner, onChange: function(event) {
+ self.setState({ai_owner: event.target.value})
+ }}
+ ];
+
+ Minutes.attendee_names.forEach(function(name) {
+ $_.push(React.createElement("option", null, name))
+ });
+
+ return $_
+ }())
+ ),
+
+ React.createElement("textarea", {
+ className: "col-md-7",
+ value: self.state.ai_text,
+ rows: 1,
+ cols: 40,
+ tabIndex: 2,
+
+ onChange: function(event) {
+ self.setState({ai_text: event.target.value})
+ }
+ })
+ ));
+
+ // variable number of buttons
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "btn-default",
+ type: "button",
+ "data-dismiss": "modal",
+
+ onClick: function() {
+ self.setState({draft: self.state.base})
+ }
+ },
+
+ "Cancel"
+ ));
+
+ if (self.state.base) {
+ $_.push(React.createElement(
+ "button",
+
+ {className: "btn-warning", type: "button", onClick: function() {
+ self.setState({draft: ""})
+ }},
+
+ "Delete"
+ ))
+ };
+
+ // special buttons for prior months draft minutes
+ if (/^3\w/.test(self.props.item.attach)) {
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "btn-warning",
+ type: "button",
+ onClick: self.save,
+ disabled: self.state.disabled
+ },
+
+ "Tabled"
+ ));
+
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "btn-success",
+ type: "button",
+ onClick: self.save,
+ disabled: self.state.disabled
+ },
+
+ "Approved"
+ ))
+ };
+
+ $_.push(React.createElement(
+ "button",
+ {className: self.reflow_color(), onClick: self.reflow},
+ "Reflow"
+ ));
+
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "btn-primary",
+ type: "button",
+ onClick: self.save,
+ disabled: self.state.disabled || self.state.base == self.state.draft
+ },
+
+ "Save"
+ ));
+
+ return $_
+ }())
+ },
+
+ // autofocus on minute text
+ componentDidMount: function() {
+ jQuery("#minute-form").on("shown.bs.modal", function() {
+ document.getElementById("minute-text").focus()
+ })
+ },
+
+ // when initially displayed, set various fields to match the item
+ componentWillMount: function() {
+ this.setup(this.props.item)
+ },
+
+ // when item changes, reset various fields to match
+ componentWillReceiveProps: function(newprops) {
+ if (newprops.item.href != this.props.item.href) this.setup(newprops.item)
+ },
+
+ // reset base, draft minutes, shepherd, default ai_text, and indent
+ setup: function(item) {
+ this.setState({base: draft = Minutes.get(item.title) || ""});
+
+ if (/^(8|9|1\d)\.$/.test(item.attach)) {
+ draft = draft || item.text
+ } else if (!item.text) {
+ this.setState({ai_text: "pursue a report for " + item.title})
+ };
+
+ this.setState({
+ draft: draft,
+ ai_owner: item.shepherd,
+ indent: (/^\w+$/.test(this.props.item.attach) ? 8 : 4)
+ })
+ },
+
+ // add an additional AI to the draft minutes for this item
+ addAI: function(event) {
+ var $draft = this.state.draft;
+ if ($draft) $draft += "\n";
+ $draft += "@" + this.state.ai_owner + ": " + this.state.ai_text;
+
+ this.setState({
+ ai_owner: this.props.item.shepherd,
+ ai_text: "",
+ draft: $draft
+ })
+ },
+
+ // determine if reflow button should be default or danger color
+ reflow_color: function() {
+ var width = 78 - this.state.indent;
+
+ if (!this.state.draft || this.state.draft.split("\n").every(function(line) {
+ return line.length <= width
+ })) {
+ return "btn-default"
+ } else {
+ return "btn-danger"
+ }
+ },
+
+ reflow: function() {
+ console.log("reflowing");
+
+ console.log(Flow.text(
+ this.state.draft || "",
+ new Array(this.state.indent + 1).join(" ")
+ ));
+
+ this.setState({draft: Flow.text(
+ this.state.draft || "",
+ new Array(this.state.indent + 1).join(" ")
+ )})
+ },
+
+ save: function(event) {
+ var self = this;
+ var text;
+
+ switch (event.target.textContent) {
+ case "Save":
+ text = this.state.draft;
+ break;
+
+ case "Tabled":
+ text = "tabled";
+ break;
+
+ case "Approved":
+ text = "approved"
+ };
+
+ var data = {
+ agenda: Agenda.file,
+ title: this.props.item.title,
+ text: text
+ };
+
+ this.setState({disabled: true});
+
+ post("minute", data, function(minutes) {
+ Minutes.load(minutes);
+ self.setup(self.props.item);
+ self.setState({disabled: false});
+ jQuery("#minute-form").modal("hide");
+ document.body.classList.remove("modal-open")
+ })
+ }
+});
+
+//
+// Approve/Unapprove a report
+//
+var Approve = React.createClass({
+ displayName: "Approve",
+
+ getInitialState: function() {
+ return {disabled: false, request: "approve"}
+ },
+
+ // render a single button
+ render: function() {
+ return React.createElement(
+ "button",
+
+ {
+ className: "btn btn-primary",
+ onClick: this.click,
+ disabled: this.state.disabled
+ },
+
+ this.state.request
+ )
+ },
+
+ componentWillMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // set request (and button text) depending on whether or not the
+ // not this items was previously approved
+ componentWillReceiveProps: function($$props) {
+ if (Pending.approved.indexOf($$props.item.attach) != -1) {
+ this.setState({request: "unapprove"})
+ } else if (Pending.unapproved.indexOf($$props.item.attach) != -1) {
+ this.setState({request: "approve"})
+ } else if ($$props.item.approved && $$props.item.approved.indexOf(Server.initials) != -1) {
+ this.setState({request: "unapprove"})
+ } else {
+ this.setState({request: "approve"})
+ }
+ },
+
+ // when button is clicked, send request
+ click: function(event) {
+ var self = this;
+
+ var data = {
+ agenda: Agenda.file,
+ initials: Server.initials,
+ attach: this.props.item.attach,
+ request: this.state.request
+ };
+
+ this.setState({disabled: true});
+
+ post("approve", data, function(pending) {
+ self.setState({disabled: false});
+ Pending.load(pending)
+ })
+ }
+});
+
+//
+// Indicate intention to attend / regrets for meeting
+//
+var Attend = React.createClass({
+ displayName: "Attend",
+
+ getInitialState: function() {
+ return {disabled: false}
+ },
+
+ render: function() {
+ return React.createElement(
+ "button",
+
+ {
+ className: "btn btn-primary",
+ onClick: this.click,
+ disabled: this.state.disabled
+ },
+
+ (this.state.attending ? "regrets" : "attend")
+ )
+ },
+
+ componentWillMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // match person by either userid or name
+ componentWillReceiveProps: function($$props) {
+ var person = $$props.item.people[Server.userid];
+
+ if (person) {
+ this.setState({attending: person.attending})
+ } else {
+ this.setState({attending: false});
+
+ for (var id in $$props.item.people) {
+ person = $$props.item.people[id];
+
+ if (person.name == Server.username) {
+ this.setState({attending: person.attending})
+ }
+ }
+ }
+ },
+
+ click: function(event) {
+ var self = this;
+
+ var data = {
+ agenda: Agenda.file,
+ action: (this.state.attending ? "regrets" : "attend"),
+ name: Server.username,
+ userid: Server.userid
+ };
+
+ this.setState({disabled: true});
+
+ post("attend", data, function(response) {
+ self.setState({disabled: false});
+ Agenda.load(response.agenda, response.digest)
+ })
+ }
+});
+
+//
+// Commit pending comments and approvals. Build a default commit message,
+// and allow it to be changed.
+//
+var Commit = React.createClass({
+ displayName: "Commit",
+
+ statics: {button: {
+ text: "commit",
+ class: "btn_primary",
+ data_toggle: "modal",
+ data_target: "#commit-form"
+ }},
+
+ getInitialState: function() {
+ return {disabled: false}
+ },
+
+ // commit form: allow the user to confirm or edit the commit message
+ render: function() {
+ var self = this;
+
+ return React.createElement(
+ ModalDialog,
+ {id: "commit-form", color: "blank"},
+ React.createElement("h4", null, "Commit message"),
+
+ React.createElement("textarea", {
+ id: "commit-text",
+ value: this.state.message,
+ rows: 5,
+ disabled: this.state.disabled,
+ label: "Commit message",
+
+ onChange: function(event) {
+ self.setState({message: event.target.value})
+ }
+ }),
+
+ React.createElement(
+ "button",
+ {className: "btn-default", "data-dismiss": "modal"},
+ "Close"
+ ),
+
+ React.createElement(
+ "button",
+
+ {
+ className: "btn-primary",
+ onClick: this.click,
+ disabled: this.state.disabled
+ },
+
+ "Submit"
+ )
+ )
+ },
+
+ componentWillMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // autofocus on comment text
+ componentDidMount: function() {
+ jQuery("#commit-form").on("shown.bs.modal", function() {
+ document.getElementById("commit-text").focus()
+ })
+ },
+
+ // update message on re-display
+ componentWillReceiveProps: function($$props) {
+ var pending = $$props.server.pending;
+ var messages = [];
+
+ // common format for message lines
+ var append = function(title, list) {
+ if (!list) return;
+ var titles;
+
+ if (list.length > 0 && list.length < 6) {
+ titles = [];
+
+ Agenda.index.forEach(function(item) {
+ if (list.indexOf(item.attach) != -1) titles.push(item.title)
+ });
+
+ messages.push(title + " " + titles.join(", "))
+ } else if (list.length > 1) {
+ messages.push(title + " " + list.length + " reports")
+ }
+ };
+
+ append("Approve", pending.approved);
+ append("Unapprove", pending.unapproved);
+ append("Flag", pending.flagged);
+ append("Unflag", pending.unflagged);
+
+ // list (or number) of comments made with this commit
+ var comments = Object.keys(pending.comments).length;
+ var titles;
+
+ if (comments > 0 && comments < 6) {
+ titles = [];
+
+ Agenda.index.forEach(function(item) {
+ if (pending.comments[item.attach]) titles.push(item.title)
+ });
+
+ messages.push("Comment on " + titles.join(", "))
+ } else if (comments > 1) {
+ messages.push("Comment on " + comments + " reports")
+ };
+
+ // identify (or number) action item(s) updated with this commit
+ var item, text;
+
+ if (pending.status) {
+ if (pending.status.length == 1) {
+ item = pending.status[0];
+ text = item.text;
+
+ if (item.pmc || item.date) {
+ text += " [";
+ if (item.pmc) text += " " + item.pmc;
+ if (item.date) text += " " + item.date;
+ text += " ]"
+ };
+
+ messages.push("Update AI: " + text)
+ } else if (pending.status.length > 1) {
+ messages.push("Update " + pending.status.length + " action items")
+ }
+ };
+
+ this.setState({message: messages.join("\n")})
+ },
+
+ // update message when textarea changes
+ change: function(event) {
+ this.setState({message: event.target.value})
+ },
+
+ // on click, disable the input fields and buttons and submit
+ click: function(event) {
+ var self = this;
+ this.setState({disabled: true});
+
+ post(
+ "commit",
+ {message: this.state.message, initials: Pending.initials},
+
+ function(response) {
+ Agenda.load(response.agenda, response.digest);
+ Pending.load(response.pending);
+ self.setState({disabled: false});
+
+ // delay jQuery updates to give React a chance to make updates first
+ setTimeout(
+ function() {
+ jQuery("#commit-form").modal("hide");
+ document.body.classList.remove("modal-open");
+ jQuery(".modal-backdrop").remove()
+ },
+
+ 300
+ )
+ }
+ )
+ }
+});
+
+var DraftMinutes = React.createClass({
+ displayName: "DraftMinutes",
+
+ statics: {button: {
+ text: "draft minutes",
+ class: "btn_danger",
+ data_toggle: "modal",
+ data_target: "#draft-minute-form"
+ }},
+
+ getInitialState: function() {
+ return {disabled: true}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement(
+ ModalDialog,
+ {className: "wide-form", id: "draft-minute-form", color: "commented"},
+
+ React.createElement(
+ "h4",
+ {className: "commented"},
+ "Commit Draft Minutes to SVN"
+ ),
+
+ React.createElement("textarea", {
+ className: "form-control",
+ id: "draft-minute-text",
+ rows: 17,
+ tabIndex: 1,
+ placeholder: "minutes",
+ value: this.state.draft,
+ disabled: this.state.disabled,
+
+ onChange: function(event) {
+ self.setState({draft: event.target.value})
+ }
+ }),
+
+ React.createElement(
+ "button",
+ {className: "btn-default", type: "button", "data-dismiss": "modal"},
+ "Cancel"
+ ),
+
+ React.createElement(
+ "button",
+
+ {
+ className: "btn-primary",
+ type: "button",
+ onClick: this.save,
+ disabled: this.state.disabled
+ },
+
+ "Save"
+ )
+ )
+ },
+
+ // autofocus on minute text; fetch draft
+ componentDidMount: function() {
+ var self = this;
+ this.setState({draft: ""});
+
+ jQuery("#draft-minute-form").on("shown.bs.modal", function() {
+ retrieve(
+ "draft/" + Agenda.title.replace(/\-/g, "_"),
+ "text",
+
+ function(draft) {
+ document.getElementById("draft-minute-text").focus();
+ self.setState({disabled: false, draft: draft});
+ jQuery("#draft-minute-text").animate({scrollTop: 0})
+ }
+ )
+ })
+ },
+
+ save: function(event) {
+ var self = this;
+
+ var data = {
+ agenda: Agenda.file,
+ message: "Draft minutes for " + Agenda.title,
+ text: this.state.draft
+ };
+
+ this.setState({disabled: true});
+
+ post("draft", data, function() {
+ self.setState({disabled: false});
+ jQuery("#draft-minute-form").modal("hide");
+ document.body.classList.remove("modal-open")
+ })
+ }
+});
+
+//
+// A button that mark all comments as 'seen', with an undo option
+//
+var MarkSeen = React.createClass({
+ displayName: "MarkSeen",
+
+ getInitialState: function() {
+ this.state = {disabled: false, label: "mark seen"};
+ MarkSeen.undo = null;
+ return this.state
+ },
+
+ render: function() {
+ return React.createElement(
+ "button",
+
+ {
+ className: "btn btn-primary",
+ onClick: this.click,
+ disabled: this.state.disabled
+ },
+
+ this.state.label
+ )
+ },
+
+ click: function(event) {
+ var self = this;
+ this.setState({disabled: true});
+ var seen;
+
+ if (MarkSeen.undo) {
+ seen = MarkSeen.undo
+ } else {
+ seen = {};
+
+ Agenda.index.forEach(function(item) {
+ if (item.comments && item.comments.length != 0) {
+ seen[item.attach] = item.comments
+ }
+ })
+ };
+
+ post(
+ "markseen",
+ {seen: seen, agenda: Agenda.file},
+
+ function(pending) {
+ self.setState({disabled: false});
+
+ if (MarkSeen.undo) {
+ MarkSeen.undo = null;
+ self.setState({label: "mark seen"})
+ } else {
+ MarkSeen.undo = Pending.seen;
+ self.setState({label: "undo mark"})
+ };
+
+ Pending.load(pending)
+ }
+ )
+ }
+});
+
+//
+// Message area for backchannel
+//
+var Message = React.createClass({
+ displayName: "Message",
+
+ getInitialState: function() {
+ return {disabled: false, message: ""}
+ },
+
+ // render an input area in the button area (a very w-i-d-e button)
+ render: function() {
+ var self = this;
+
+ return React.createElement(
+ "form",
+ {onSubmit: this.sendMessage},
+
+ React.createElement("input", {
+ id: "chatMessage",
+ value: this.state.message,
+
+ onChange: function(event) {
+ self.setState({message: event.target.value})
+ }
+ })
+ )
+ },
+
+ // autofocus on the chat message when the page is initially displayed
+ componentDidMount: function() {
+ document.getElementById("chatMessage").focus()
+ },
+
+ // send message to server
+ sendMessage: function(event) {
+ var self = this;
+ event.stopPropagation();
+ event.preventDefault();
+
+ if (this.state.message) {
+ post(
+ "message",
+ {agenda: Agenda.file, text: this.state.message},
+
+ function(message) {
+ Chat.add(message);
+ self.setState({message: ""})
+ }
+ )
+ };
+
+ return false
+ }
+});
+
+//
+// Post or edit a report or resolution
+//
+// For new resolutions, allow entry of title, but not commit message
+// For everything else, allow modification of commit message, but not title
+var Post = React.createClass({
+ displayName: "Post",
+
+ statics: {button: {
+ text: "post report",
+ class: "btn_primary",
+ data_toggle: "modal",
+ data_target: "#post-report-form"
+ }},
+
+ getInitialState: function() {
+ return {disabled: false, alerted: false, edited: false}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = [
+ ModalDialog,
+ {className: "wide-form", id: "post-report-form", color: "commented"}
+ ];
+
+ $_.push(React.createElement("h4", null, self.state.header));
+
+ //input field: title
+ if (self.props.button.text == "add resolution") {
+ $_.push(React.createElement("input", {
+ id: "post-report-title",
+ label: "title",
+ disabled: self.state.disabled,
+ placeholder: "title",
+ value: self.state.title,
+ onFocus: self.default_title,
+
+ onChange: function(event) {
+ self.setState({title: event.target.value})
+ }
+ }))
+ };
+
+ //input field: report text
+ $_.push(React.createElement("textarea", {
+ id: "post-report-text",
+ label: self.state.label,
+ value: self.state.report,
+ placeholder: self.state.label,
+ rows: 17,
+ disabled: self.state.disabled,
+ onChange: self.change_text
+ }));
+
+ // upload of spreadsheet from virtual
+ if (self.props.item.title == "Treasurer") {
+ $_.push(React.createElement("form", null, React.createElement(
+ "div",
+ {className: "form-group"},
+
+ React.createElement(
+ "label",
+ {htmlFor: "upload"},
+ "financial spreadsheet from virtual"
+ ),
+
+ React.createElement("input", {
+ id: "upload",
+ type: "file",
+ value: self.state.upload,
+
+ onChange: function(event) {
+ self.setState({upload: event.target.value})
+ }
+ }),
+
+ React.createElement(
+ "button",
+
+ {
+ className: "btn btn-primary",
+ onClick: self.upload,
+ disabled: self.state.disabled || !self.state.upload
+ },
+
+ "Upload"
+ )
+ )))
+ };
+
+ //input field: commit_message
+ if (self.props.button.text != "add resolution") {
+ $_.push(React.createElement("input", {
+ id: "post-report-message",
+ label: "commit message",
+ disabled: self.state.disabled,
+ value: self.state.message,
+
+ onChange: function(event) {
+ self.setState({message: event.target.value})
+ }
+ }))
+ };
+
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "btn-default",
+ "data-dismiss": "modal",
+ disabled: self.state.disabled
+ },
+
+ "Cancel"
+ ));
+
+ $_.push(React.createElement(
+ "button",
+ {className: self.reflow_color(), onClick: self.reflow},
+ "Reflow"
+ ));
+
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "btn-primary",
+ onClick: self.submit,
+ disabled: !self.ready()
+ },
+
+ "Submit"
+ ));
+
+ return $_
+ }())
+ },
+
+ componentWillMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // autofocus on report/resolution title/text
+ componentDidMount: function() {
+ var self = this;
+
+ jQuery("#post-report-form").on("shown.bs.modal", function() {
+ if (self.props.button.text == "add resolution") {
+ document.getElementById("post-report-title").focus()
+ } else {
+ document.getElementById("post-report-text").focus()
+ }
+ })
+ },
+
+ // match form title, input label, and commit message with button text
+ componentWillReceiveProps: function(newprops) {
+ var $edited = this.state.edited;
+
+ switch (newprops.button.text) {
+ case "post report":
+
+ this.setState({
+ header: "Post Report",
+ label: "report",
+ message: "Post " + newprops.item.title + " Report"
+ });
+
+ break;
+
+ case "edit report":
+
+ this.setState({
+ header: "Edit Report",
+ label: "report",
+ message: "Edit " + newprops.item.title + " Report"
+ });
+
+ break;
+
+ case "add resolution":
+
+ this.setState({
+ header: "Add Resolution",
+ label: "resolution",
+ title: ""
+ });
+
+ break;
+
+ case "edit resolution":
+
+ this.setState({
+ header: "Edit Resolution",
+ label: "resolution",
+ message: "Edit " + newprops.item.title + " Resolution"
+ })
+ };
+
+ var text, $digest, $alerted;
+
+ if (!$edited || newprops.item.attach != this.props.item.attach) {
+ text = newprops.item.text || "";
+
+ if (newprops.item.title == "President") {
+ text = text.replace(
+ /\s*Additionally, please see Attachments \d through \d\./,
+ ""
+ )
+ };
+
+ this.setState({report: text});
+ $digest = newprops.item.digest;
+ $alerted = false;
+ $edited = false
+ } else if (!$alerted && $edited && $digest != newprops.item.digest) {
+ alert("edit conflict");
+ $alerted = true
+ };
+
+ if (newprops.button.text == "add resolution" || /^[47]/.test(newprops.item.attach)) {
+ this.setState({indent: " "})
+ } else {
+ this.setState({indent: ""})
+ };
+
+ this.setState({edited: $edited, digest: $digest, alerted: $alerted})
+ },
+
+ // default title based on common resolution patterns
+ default_title: function(event) {
+ if (this.state.title) return;
+ var match = null;
+
+ if (match = this.state.report.match(/appointed\s+to\s+the\s+office\s+of\s+Vice\s+President,\s+Apache\s+(.*?),/)) {
+ this.setState({title: "Change the Apache " + match[1] + " Project Chair"})
+ } else if (match = this.state.report.match(/to\s+be\s+known\s+as\s+the\s+"Apache\s+(.*?)\s+Project",\s+be\s+and\s+hereby\s+is\s+established/)) {
+ this.setState({title: "Establish the Apache " + match[1] + " Project"})
+ } else if (match = this.state.report.match(/the\s+Apache\s+(.*?)\s+project\s+is\s+hereby\s+terminated/)) {
+ this.setState({title: "Terminate the Apache " + match[1] + " Project"})
+ }
+ },
+
+ // track changes to text value
+ change_text: function(event) {
+ this.setState({report: event.target.value, edited: true})
+ },
+
+ // determine if reflow button should be default or danger color
+ reflow_color: function() {
+ var width = 80 - this.state.indent.length;
+
+ if (this.state.report.split("\n").every(function(line) {
+ return line.length <= width
+ })) {
+ return "btn-default"
+ } else {
+ return "btn-danger"
+ }
+ },
+
+ // perform a reflow of report text
+ reflow: function() {
+ var report = this.state.report;
+
+ // remove indentation
+ var regex = /^( +)\S/gm;
+ var indents = [];
+
+ while (result = regex.exec(report)) {
+ indents.push(result[1].length)
+ };
+
+ if (indents.length != 0) {
+ report = report.replace(
+ new RegExp("^" + new Array(Math.min.apply(Math, indents) + 1).join(" "), "gm"),
+ ""
+ )
+ };
+
+ this.setState({report: Flow.text(report, this.state.indent)})
+ },
+
+ // determine if the form is ready to be submitted
+ ready: function() {
+ if (this.state.disabled) return false;
+
+ if (this.props.button.text == "add resolution") {
+ return this.state.report != "" && this.state.title != ""
+ } else {
+ return this.state.report != this.props.item.text && this.state.message != ""
+ }
+ },
+
+ // upload contents of spreadsheet in base64; append extracted table to report
+ upload: function(event) {
+ var self = this;
+ this.setState({disabled: true});
+ event.preventDefault();
+ var reader = new FileReader;
+
+ reader.onload = function(event) {
+ var result = event.target.result;
+
+ var base64 = btoa(String.fromCharCode.apply(
+ null,
+ new Uint8Array(result)
+ ));
+
+ post("financials", {spreadsheet: base64}, function(response) {
+ var report = self.state.report;
+ if (report && report.slice(-1) != "\n") report += "\n";
+ if (report) report += "\n";
+ report += response.table;
+ self.change_text({target: {value: report}});
+ self.setState({upload: null, disabled: false})
+ })
+ };
+
+ reader.readAsArrayBuffer(document.getElementById("upload").files[0])
+ },
+
+ // when save button is pushed, post comment and dismiss modal when complete
+ submit: function(event) {
+ var self = this;
+ this.setState({edited: false});
+ var data;
+
+ if (this.props.button.text == "add resolution") {
+ data = {
+ agenda: Agenda.file,
+ attach: "7?",
+ title: this.state.title,
+ report: this.state.report
+ }
+ } else {
+ data = {
+ agenda: Agenda.file,
+ attach: this.props.item.attach,
+ digest: this.state.digest,
+ message: this.state.message,
+ report: this.state.report
+ }
+ };
+
+ this.setState({disabled: true});
+
+ post("post", data, function(response) {
+ jQuery("#post-report-form").modal("hide");
+ document.body.classList.remove("modal-open");
+ self.setState({disabled: false});
+ Agenda.load(response.agenda, response.digest)
+ })
+ }
+});
+
+//
+// Indicate intention to attend / regrets for meeting
+//
+var PostActions = React.createClass({
+ displayName: "PostActions",
+
+ getInitialState: function() {
+ return {disabled: false}
+ },
+
+ render: function() {
+ return React.createElement(
+ "button",
+
+ {
+ className: "btn btn-primary",
+ onClick: this.click,
+ disabled: this.state.disabled || SelectActions.list.length == 0
+ },
+
+ "post actions"
+ )
+ },
+
+ click: function(event) {
+ var self = this;
+
+ var data = {
+ agenda: Agenda.file,
+ message: "Post Action Items",
+ actions: SelectActions.list
+ };
+
+ this.setState({disabled: true});
+
+ post("post-actions", data, function(response) {
+ self.setState({disabled: false});
+ Agenda.load(response.agenda, response.digest)
+ })
+ }
+});
+
+var PublishMinutes = React.createClass({
+ displayName: "PublishMinutes",
+
+ statics: {button: {
+ text: "publish minutes",
+ class: "btn_danger",
+ data_toggle: "modal",
+ data_target: "#publish-minutes-form"
+ }},
+
+ getInitialState: function() {
+ return {disabled: false, previous_title: null}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement(
+ ModalDialog,
+
+ {
+ className: "wide-form",
+ id: "publish-minutes-form",
+ color: "commented"
+ },
+
+ React.createElement(
+ "h4",
+ {className: "commented"},
+ "Publish Minutes onto the ASF web site"
+ ),
+
+ React.createElement("textarea", {
+ className: "form-control",
+ id: "summary-text",
+ rows: 10,
+ tabIndex: 1,
+ value: this.state.summary,
+ disabled: this.state.disabled,
+ label: "Minutes summary",
+
+ onChange: function(event) {
+ self.setState({summary: event.target.value})
+ }
+ }),
+
+ React.createElement("input", {
+ id: "message",
+ label: "Commit message",
+ value: this.state.message,
+ disabled: this.state.disabled,
+
+ onChange: function(event) {
+ self.setState({message: event.target.value})
+ }
+ }),
+
+ React.createElement(
+ "button",
+ {className: "btn-default", type: "button", "data-dismiss": "modal"},
+ "Cancel"
+ ),
+
+ React.createElement(
+ "button",
+
+ {
+ className: "btn-primary",
+ type: "button",
+ onClick: this.publish,
+ disabled: this.state.disabled
+ },
+
+ "Submit"
+ )
+ )
+ },
+
+ componentWillMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // when page title changes, update form values
+ componentWillReceiveProps: function($$props) {
+ var self = this;
+ var date, url;
+
+ if ($$props.item.title != this.state.previous_title) {
+ if (!$$props.item.attach) {
+ // Index page for a path month's agenda
+ this.summary(Agenda.index, Agenda.title.replace(/\-/g, "_"))
+ } else if (typeof XMLHttpRequest !== 'undefined') {
+ // Minutes from previous meetings section of the agenda
+ date = ($$props.item.text.match(/board_minutes_(\d+_\d+_\d+)\.txt/) || [])[1];
+
+ url = document.baseURI.replace(
+ new RegExp("[-\\d]+/$"),
+ date.replace(/_/g, "-")
+ ) + ".json";
+
+ retrieve(url, "json", function(agenda) {self.summary(agenda, date)})
+ };
+
+ this.setState({previous_title: $$props.item.title})
+ }
+ },
+
+ // autofocus on minute text
+ componentDidMount: function() {
+ jQuery("#publish-minutes-form").on("shown.bs.modal", function() {
+ document.getElementById("summary-text").focus()
+ })
+ },
+
+ // compute default summary for web site and commit message
+ summary: function(agenda, date) {
+ var summary = ("- [" + this.formatDate(date) + "]") + ("(../records/minutes/" + date.slice(
+ 0,
+ 4
+ ) + "/board_minutes_" + date + ".txt)\n");
+
+ agenda.forEach(function(item) {
+ if (/^7\w$/.test(item.attach)) {
+ if (item.minutes && item.minutes.toLowerCase().indexOf("tabled") != -1) {
+ summary += " * " + item.title.trim() + " (tabled)\n"
+ } else {
+ summary += " * " + item.title.trim() + "\n"
+ }
+ }
+ });
+
+ this.setState({
+ date: date,
+ summary: summary,
+ message: "Publish " + this.formatDate(date) + " minutes"
+ })
+ },
+
+ // convert date to displayable form
+ formatDate: function(date) {
+ var months = [
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December"
+ ];
+
+ date = new Date(date.replace(/_/g, "/"));
+ return date.getDate() + " " + months[date.getMonth()] + " " + (date.getYear() + 1900)
+ },
+
+ publish: function(event) {
+ var self = this;
+
+ var data = {
+ date: this.state.date,
+ summary: this.state.summary,
+ message: this.state.message
+ };
+
+ this.setState({disabled: true});
+
+ post("publish", data, function(drafts) {
+ self.setState({disabled: false});
+ Server.drafts = drafts;
+ jQuery("#publish-minutes-form").modal("hide");
+ document.body.classList.remove("modal-open");
+ window.open("https://cms.apache.org/www/publish", "_blank").focus()
+ })
+ }
+});
+
+//
+// Send initial and final reminders. Note that this is a form (with an
+// associated button) as well as a second button.
+//
+var InitialReminder = React.createClass({
+ displayName: "InitialReminder",
+
+ statics: {button: {
+ text: "send initial reminders",
+ class: "btn_primary",
+ data_toggle: "modal",
+ data_target: "#reminder-form"
+ }},
+
+ getInitialState: function() {
+ return {disabled: true, subject: "", message: ""}
+ },
+
+ // fetch email template
+ loadText: function(event) {
+ var self = this;
+ var reminder;
+
+ if (event.target.textContent == "send initial reminders") {
+ reminder = "reminder1"
+ } else {
+ reminder = "reminder2"
+ };
+
+ retrieve(reminder, "json", function(response) {
+ self.setState({
+ subject: response.subject,
+ message: response.body,
+ disabled: false
+ })
+ })
+ },
+
+ // wire up event handlers
+ componentDidMount: function() {
+ var self = this;
+
+ Array.prototype.slice.call(document.querySelectorAll(".btn-primary")).forEach(function(button) {
+ if (button.getAttribute("data-target") == "#reminder-form") {
+ button.onclick = self.loadText
+ }
+ })
+ },
+
+ // commit form: allow the user to confirm or edit the commit message
+ render: function() {
+ var self = this;
+
+ return React.createElement(
+ ModalDialog,
+ {className: "wide-form", id: "reminder-form", color: "blank"},
+ React.createElement("h4", null, "Email message"),
+
+ React.createElement("input", {
+ id: "email-subject",
+ value: this.state.subject,
+ disabled: this.state.disabled,
+ label: "subject",
+ placeholder: "loading...",
+
+ onChange: function(event) {
+ self.setState({subject: event.target.value})
+ }
+ }),
+
+ React.createElement("textarea", {
+ id: "email-text",
+ value: this.state.message,
+ rows: 12,
+ disabled: this.state.disabled,
+ label: "body",
+ placeholder: "loading...",
+
+ onChange: function(event) {
+ self.setState({message: event.target.value})
+ }
+ }),
+
+ React.createElement(
+ "button",
+ {className: "btn-default", "data-dismiss": "modal"},
+ "Close"
+ ),
+
+ React.createElement(
+ "button",
+
+ {
+ className: "btn-info",
+ onClick: this.click,
+ disabled: this.state.disabled
+ },
+
+ "Dry Run"
+ ),
+
+ React.createElement(
+ "button",
+
+ {
+ className: "btn-primary",
+ onClick: this.click,
+ disabled: this.state.disabled
+ },
+
+ "Submit"
+ )
+ )
+ },
+
+ // on click, disable the input fields and buttons and submit
+ click: function(event) {
+ var self = this;
+ this.setState({disabled: true});
+ var dryrun = event.target.textContent == "Dry Run";
+
+ // data to be sent to the server
+ var data = {
+ dryrun: dryrun,
+ agenda: Agenda.file,
+ subject: this.state.subject,
+ message: this.state.message,
+ pmcs: []
+ };
+
+ // collect up a list of PMCs that are checked
+ Array.prototype.slice.call(document.querySelectorAll("input[type=checkbox]")).forEach(function(input) {
+ if (input.checked) data.pmcs.push(input.value)
+ });
+
+ post("send-reminders", data, function(response) {
+ if (!response) {
+ alert("Server error - check console log")
+ } else if (dryrun) {
+ console.log(response);
+ alert("Dry run - check console log")
+ } else if (response.count == data.pmcs.length) {
+ alert("Reminders have been sent to: " + data.pmcs.join(", ") + ".")
+ } else if (response.count && response.unsent) {
+ alert("Error: no emails were sent to " + response.unsent.join(", "))
+ } else {
+ alert("No reminders were sent")
+ };
+
+ self.setState({disabled: false});
+ jQuery("#reminder-form").modal("hide");
+ document.body.classList.remove("modal-open")
+ })
+ }
+});
+
+//
+// A button for final reminders
+//
+var FinalReminder = React.createClass({
+ displayName: "FinalReminder",
+
+ render: function() {
+ return React.createElement(
+ "button",
+
+ {
+ className: "btn btn-primary",
+ "data-toggle": "modal",
+ "data-target": "#reminder-form"
+ },
+
+ "send final reminders"
+ )
+ }
+});
+
+//
+// A button that will do a 'svn update' of the agenda on the server
+//
+var Refresh = React.createClass({
+ displayName: "Refresh",
+
+ getInitialState: function() {
+ return {disabled: false}
+ },
+
+ render: function() {
+ return React.createElement(
+ "button",
+
+ {
+ className: "btn btn-primary",
+ onClick: this.click,
+ disabled: this.state.disabled
+ },
+
+ "refresh"
+ )
+ },
+
+ click: function(event) {
+ var self = this;
+ this.setState({disabled: true});
+
+ post("refresh", {agenda: Agenda.file}, function(response) {
+ self.setState({disabled: false});
+ Agenda.load(response.agenda, response.digest)
+ })
+ }
+});
+
+//
+// Show/hide seen items
+//
+var ShowSeen = React.createClass({
+ displayName: "ShowSeen",
+
+ getInitialState: function() {
+ return {label: "show seen"}
+ },
+
+ render: function() {
+ return React.createElement(
+ "button",
+ {className: "btn btn-primary", onClick: this.click},
+ this.state.label
+ )
+ },
+
+ componentWillReceiveProps: function($$props) {
+ if (Main.view && !Main.view.showseen()) {
+ this.setState({label: "hide seen"})
+ } else {
+ this.setState({label: "show seen"})
+ }
+ },
+
+ click: function(event) {
+ Main.view.toggleseen();
+ this.componentWillReceiveProps(this.props)
+ }
+});
+
+//
+// Timestamp start/stop of meeting
+//
+var Timestamp = React.createClass({
+ displayName: "Timestamp",
+
+ getInitialState: function() {
+ return {disabled: false}
+ },
+
+ render: function() {
+ return React.createElement(
+ "button",
+
+ {
+ className: "btn btn-primary",
+ onClick: this.click,
+ disabled: this.state.disabled
+ },
+
+ "timestamp"
+ )
+ },
+
+ click: function(event) {
+ var self = this;
+
+ var data = {
+ agenda: Agenda.file,
+ title: this.props.item.title,
+ action: "timestamp"
+ };
+
+ this.setState({disabled: true});
+
+ post("minute", data, function(minutes) {
+ self.setState({disabled: false});
+ Minutes.load(minutes)
+ })
+ }
+});
+
+var Vote = React.createClass({
+ displayName: "Vote",
+
+ statics: {button: {
+ text: "vote",
+ class: "btn_primary",
+ data_toggle: "modal",
+ data_target: "#vote-form"
+ }},
+
+ getInitialState: function() {
+ return {disabled: false}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = [
+ ModalDialog,
+ {className: "wide-form", id: "vote-form", color: "commented"}
+ ];
+
+ $_.push(React.createElement("h4", {className: "commented"}, "Vote"));
+
+ $_.push(React.createElement(
+ "p",
+ null,
+
+ React.createElement(
+ "span",
+ null,
+ self.state.votetype + " vote on the matter of "
+ ),
+
+ React.createElement(
+ "em",
+ null,
+ self.props.item.fulltitle.replace(/^Resolution to/, "")
+ )
+ ));
+
+ $_.push(React.createElement("pre", null, self.state.directors));
+
+ $_.push(React.createElement("textarea", {
+ id: "vote-text",
+ rows: 4,
+ placeholder: "minutes",
+ value: self.state.draft,
+
+ onChange: function(event) {
+ self.setState({draft: event.target.value})
+ }
+ }));
+
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "btn-default",
+ type: "button",
+ "data-dismiss": "modal",
+
+ onClick: function() {
+ self.setState({draft: self.state.base})
+ }
+ },
+
+ "Cancel"
+ ));
+
+ if (self.state.base) {
+ $_.push(React.createElement(
+ "button",
+ {className: "btn-warning", type: "button", onClick: self.save},
+ "Delete"
+ ))
+ };
+
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "btn-primary",
+ type: "button",
+ onClick: self.save,
+ disabled: self.state.draft == self.state.base
+ },
+
+ "Save"
+ ));
+
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "btn-warning",
+ type: "button",
+ onClick: self.save,
+ disabled: self.state.draft != ""
+ },
+
+ "Tabled"
+ ));
+
+ $_.push(React.createElement(
+ "button",
+
+ {
+ className: "btn-success",
+ type: "button",
+ onClick: self.save,
+ disabled: self.state.draft != ""
+ },
+
+ "Unanimous"
+ ));
+
+ return $_
+ }())
+ },
+
+ componentWillMount: function() {
+ this.setup(this.props.item)
+ },
+
+ componentWillReceiveProps: function(newprops) {
+ if (newprops.item.href != this.props.item.href) this.setup(newprops.item)
+ },
+
+ // reset base, draft minutes, directors present, and vote type
+ setup: function(item) {
+ var $directors = Minutes.directors_present;
+
+ // alternate forward/reverse roll calls based on month and attachment
+ var month = new Date(Date.parse(Agenda.date)).getMonth();
+ var attach = item.attach.charCodeAt(1);
+
+ if ((month + attach) % 2 == 0) {
+ this.setState({votetype: "Roll call"})
+ } else {
+ this.setState({votetype: "Reverse roll call"});
+ $directors = $directors.split("\n").reverse().join("\n")
+ };
+
+ this.setState({
+ base: Minutes.get(item.title) || "",
+ draft: Minutes.get(item.title) || "",
+ directors: $directors
+ })
+ },
+
+ // post vote results
+ save: function(event) {
+ var self = this;
+ var text;
+
+ switch (event.target.textContent) {
+ case "Save":
+ text = this.state.draft;
+ break;
+
+ case "Delete":
+ text = "";
+ break;
+
+ case "Tabled":
+ text = "tabled";
+ break;
+
+ case "Unanimous":
+ text = "unanimous"
+ };
+
+ var data = {
+ agenda: Agenda.file,
+ title: this.props.item.title,
+ text: text
+ };
+
+ this.setState({disabled: true});
+
+ post("minute", data, function(minutes) {
+ Minutes.load(minutes);
+ self.setup(self.props.item);
+ self.setState({disabled: false});
+ jQuery("#vote-form").modal("hide");
+ document.body.classList.remove("modal-open")
+ })
+ }
+});
+
+//
+// Send email
+//
+var Email = React.createClass({
+ displayName: "Email",
+
+ render: function() {
+ return React.createElement(
+ "button",
+
+ {
+ className: "btn " + (this.mailto_class() || ""),
+ onClick: this.launch_email_client
+ },
+
+ "send email"
+ )
+ },
+
+ // render 'send email' as a primary button if the viewer is the shepherd for
+ // the report, otherwise render the text as a simple link.
+ mailto_class: function() {
+ if (Server.firstname && this.props.item.shepherd && Server.firstname.substring(
+ 0,
+ this.props.item.shepherd.toLowerCase().length
+ ) == this.props.item.shepherd.toLowerCase()) {
+ return "btn-primary"
+ } else {
+ return "btn-link"
+ }
+ },
+
+ // launch email client, pre-filling the destination, subject, and body
+ launch_email_client: function() {
+ var destination = ("mailto:" + this.props.item.chair_email) + ("?cc=private@" + this.props.item.mail_list + ".apache.org,board@apache.org");
+ var subject, body;
+
+ if (this.props.item.missing) {
+ subject = "Missing " + this.props.item.title + " Board Report";
+ body = ("Dear " + this.props.item.owner + ",\n\nThe board report for ") + (this.props.item.title + " has not yet been submitted for this ") + "month's board meeting. If you're unable to get " + "it in by twenty-four hours before meeting time, " + "please plan to report next month.\n\nThanks,\n\n " + (Server.username + "\n\n") + "(on behalf of the ASF Board)"
+ } else {
+ subject = this.props.item.title + " Board Report";
+ body = this.props.item.comments
+ };
+
+ window.location = destination + ("&subject=" + encodeURIComponent(subject)) + ("&body=" + encodeURIComponent(body))
+ }
+});
+
+//
+// Display information associated with an agenda item:
+// - special notes
+// - minutes
+// - posted reports
+// - action items
+// - posted comments
+// - pending comments
+// - historical comments
+//
+// Note: if AdditionalInfo is included multiple times in a page, set
+// prefix to true (or a string) to ensure rendered id attributes
+// are unique.
+//
+var AdditionalInfo = React.createClass({
+ displayName: "AdditionalInfo",
+
+ getInitialState: function() {
+ return {}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", null];
+
+ // special notes
+ if (self.props.item.notes) {
+ $_.push(React.createElement(
+ "p",
+ {className: "notes"},
+ self.props.item.notes
+ ))
+ };
+
+ // minutes
+ var minutes = Minutes.get(self.props.item.title);
+
+ if (minutes) {
+ $_.push(React.createElement(
+ "h4",
+ {id: self.state.prefix + "minutes"},
+ "Minutes"
+ ));
+
+ $_.push(React.createElement("pre", {className: "comment"}, minutes))
+ };
+
+ // posted reports
+ var posted;
+
+ if (self.props.item.missing) {
+ posted = Posted.get(self.props.item.title);
+
+ if (posted.length != 0) {
+ $_.push(React.createElement(
+ "h4",
+ {id: self.state.prefix + "posted"},
+ "Posted reports"
+ ));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["ul", null];
+
+ posted.forEach(function(post) {
+ $_.push(React.createElement(
+ "li",
+ null,
+ React.createElement("a", {href: post.link}, post.subject)
+ ))
+ });
+
+ return $_
+ }()))
+ }
+ };
+
+ // action items
+ if (self.props.item.title != "Action Items" && self.props.item.actions.length != 0) {
+ $_.push(React.createElement(
+ "h4",
+ {id: self.state.prefix + "actions"},
+
+ React.createElement(
+ Link,
+ {text: "Action Items", href: "Action-Items"}
+ )
+ ));
+
+ $_.push(React.createElement(
+ ActionItems,
+ {item: self.props.item, filter: {pmc: self.props.item.title}}
+ ))
+ };
+
+ if (self.props.item.special_orders.length != 0) {
+ $_.push(React.createElement(
+ "h4",
+ {id: self.state.prefix + "orders"},
+ "Special Orders"
+ ));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["ul", null];
+
+ self.props.item.special_orders.forEach(function(resolution) {
+ $_.push(React.createElement("li", null, React.createElement(
+ Link,
+ {text: resolution.title, href: resolution.href}
+ )))
+ });
+
+ return $_
+ }()))
+ };
+
+ // posted comments
+ var history = HistoricalComments.find(self.props.item.title);
+
+ if (self.props.item.comments.length != 0 || (history && !self.state.prefix)) {
+ $_.push(React.createElement(
+ "h4",
+ {id: self.state.prefix + "comments"},
+ "Comments"
+ ));
+
+ self.props.item.comments.forEach(function(comment) {
+ $_.push(React.createElement(
+ "pre",
+ {className: "comment"},
+ React.createElement(Text, {raw: comment, filters: [hotlink]})
+ ))
+ });
+
+ // pending comments
+ if (self.props.item.pending) {
+ $_.push(React.createElement(
+ "h5",
+ {id: self.state.prefix + "pending"},
+ "Pending Comment"
+ ));
+
+ $_.push(React.createElement(
+ "pre",
+ {className: "comment"},
+ Flow.comment(self.props.item.pending, Pending.initials)
+ ))
+ };
+
+ // historical comments
+ if (history && !self.state.prefix) {
+ for (var date in history) {
+ if (Agenda.file == ("board_agenda_" + date + ".txt")) continue;
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["h5", {className: "history"}];
+ $_.push(React.createElement("span", null, "• "));
+
+ $_.push(React.createElement(
+ "a",
+ {href: HistoricalComments.link(date, self.props.item.title)},
+ date.replace(/_/g, "-")
+ ));
+
+ $_.push(React.createElement("span", null, ": "));
+
+ // link to mail archive for feedback thread
+ var dfr, dto;
+
+ if (date > "2016_04") {
+ // compute date range: from date of that meeting to now
+ dfr = date.replace(/_/g, "-");
+ dto = new Date(Date.now()).toISOString().slice(0, 10);
+
+ $_.push(React.createElement(
+ "a",
+ {href: "https://lists.apache.org/list.html?board@apache.org&" + ("d=dfr=" + dfr + "|dto=" + dto + "&header_subject=") + ("'Board%20feedback%20on%20" + dfr + "%20" + self.props.item.title + "%20report'")},
+ "(thread)"
+ ))
+ };
+
+ return $_
+ }()));
+
+ splitComments(history[date]).forEach(function(comment) {
+ $_.push(React.createElement(
+ "pre",
+ {className: "comment"},
+ React.createElement(Text, {raw: comment, filters: [hotlink]})
+ ))
+ })
+ }
+ }
+ } else if (self.props.item.pending) {
+ $_.push(React.createElement(
+ "h4",
+ {id: self.state.prefix + "pending"},
+ "Pending Comment"
+ ));
+
+ $_.push(React.createElement(
+ "pre",
+ {className: "comment"},
+ Flow.comment(self.props.item.pending, Pending.initials)
+ ))
+ };
+
+ return $_
+ }())
+ },
+
+ componentWillMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ // determine prefix (if any)
+ componentWillReceiveProps: function($$props) {
+ if ($$props.prefix == true) {
+ this.setState({prefix: $$props.item.title.toLowerCase() + "-"})
+ } else if ($$props.prefix) {
+ this.setState({prefix: $$props.prefix})
+ } else {
+ this.setState({prefix: ""})
+ }
+ }
+});
+
+//
+// Replacement for 'a' element which handles clicks events that can be
+// processed locally by calling Main.navigate.
+//
+var Link = React.createClass({
+ displayName: "Link",
+
+ getInitialState: function() {
+ return {attrs: {}}
+ },
+
+ componentWillMount: function() {
+ this.componentWillReceiveProps(this.props);
+ this.state.attrs.onClick = this.click
+ },
+
+ componentWillReceiveProps: function(props) {
+ this.setState({text: props.text});
+
+ for (var attr in props) {
+ if (!props[attr]) continue;
+ if (attr != "text") this.state.attrs[attr] = props[attr]
+ };
+
+ if (props.href) {
+ this.setState({element: "a"});
+
+ this.state.attrs.href = props.href.replace(
+ new RegExp("(^|/)\\w+/\\.\\.(/|$)", "g"),
+ "$1"
+ )
+ } else {
+ this.setState({element: "span"})
+ }
+ },
+
+ render: function() {
+ return React.createElement(
+ this.state.element,
+ this.state.attrs,
+ this.state.text
+ )
+ },
+
+ click: function(event) {
+ if (event.ctrlKey || event.shiftKey || event.metaKey) return;
+ var href = event.target.getAttribute("href");
+
+ if (new RegExp("^(\\.|cache/.*|(flagged/|(shepherd/)?(queue/)?)[-\\w]+)$").test(href)) {
+ event.stopPropagation();
+ event.preventDefault();
+ Main.navigate(href);
+ return false
+ }
+ }
+});
+
+//
+// Bootstrap modal dialogs are great, but they require a lot of boilerplate.
+// This component provides the boiler plate so that other form components
+// don't have to. The elements provided by the calling component are
+// distributed to header, body, and footer sections.
+//
+var ModalDialog = React.createClass({
+ displayName: "ModalDialog",
+
+ getInitialState: function() {
+ return {header: [], body: [], footer: []}
+ },
+
+ componentWillMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ componentWillReceiveProps: function($$props) {
+ var self = this;
+ this.state.header.length = 0;
+ this.state.body.length = 0;
+ this.state.footer.length = 0;
+
+ $$props.children.forEach(function(child) {
+ var label, props;
+
+ if (child.type == "h4") {
+ // place h4 elements into the header, adding a modal-title class
+ child = self.addClass(child, "modal-title");
+ self.state.header.push(child);
+ ModalDialog.h4 = child
+ } else if (child.type == "button") {
+ // place button elements into the footer, adding a btn class
+ child = self.addClass(child, "btn");
+ self.state.footer.push(child)
+ } else if (child.type == "input" || child.type == "textarea") {
+ // wrap input and textarea elements in a form-control,
+ // add label if present
+ child = self.addClass(child, "form-control");
+ label = null;
+
+ if (child.props.label && child.props.id) {
+ props = {htmlFor: child.props.id};
+
+ if (child.props.type == "checkbox") {
+ props.className = "checkbox";
+ label = React.createElement("label", props, child, child.props.label);
+ delete child.props.label;
+ child = null
+ } else {
+ label = React.createElement("label", props, child.props.label);
+ child = React.cloneElement(child, {label: null})
+ }
+ };
+
+ self.state.body.push(React.createElement(
+ "div",
+ {className: "form-group"},
+ label,
+ child
+ ))
+ } else {
+ // place all other elements into the body
+ self.state.body.push(child)
+ }
+ })
+ },
+
+ render: function() {
+ return React.createElement(
+ "div",
+
+ {
+ className: "modal fade " + (this.props.className || ""),
+ id: this.props.id
+ },
+
+ React.createElement(
+ "div",
+ {className: "modal-dialog"},
+
+ React.createElement(
+ "div",
+ {className: "modal-content"},
+
+ React.createElement.apply(React, [
+ "div",
+ {className: "modal-header " + (this.props.color || "")},
+
+ React.createElement(
+ "button",
+ {className: "close", type: "button", "data-dismiss": "modal"},
+ "×"
+ )
+ ].concat(this.state.header)),
+
+ React.createElement.apply(
+ React,
+ ["div", {className: "modal-body"}].concat(this.state.body)
+ ),
+
+ React.createElement.apply(
+ React,
+ ["div", {className: "modal-footer " + (this.props.color || "")}].concat(this.state.footer)
+ )
+ )
+ )
+ )
+ },
+
+ // helper method: add a class to an element, returning new element
+ addClass: function(element, name) {
+ if (!element.props.className) {
+ element = React.cloneElement(element, {className: name})
+ } else if (element.props.className.split(" ").indexOf(name) == -1) {
+ element = React.cloneElement(
+ element,
+ {className: element.props.className + (" " + name)}
+ )
+ };
+
+ return element
+ }
+});
+
+//
+// Escape text for inclusion in HTML; optionally apply filters
+//
+var Text = React.createClass({
+ displayName: "Text",
+
+ getInitialState: function() {
+ return {}
+ },
+
+ componentWillMount: function() {
+ this.componentWillReceiveProps(this.props)
+ },
+
+ componentWillReceiveProps: function($$props) {
+ var self = this;
+ var $text = htmlEscape($$props.raw || "");
+
+ if ($$props.filters) {
+ $$props.filters.forEach(function(filter) {
+ self.setState({text: $text = filter($text)})
+ })
+ };
+
+ this.setState({text: $text})
+ },
+
+ render: function() {
+ return React.createElement(
+ "span",
+ {dangerouslySetInnerHTML: {__html: this.state.text}}
+ )
+ }
+});
+
+var Info = React.createClass({
+ displayName: "Info",
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = [
+ "dl",
+ {className: "dl-horizontal " + (self.props.position || "")}
+ ];
+
+ $_.push(React.createElement("dt", null, "Attach"));
+ $_.push(React.createElement("dd", null, self.props.item.attach));
+
+ if (self.props.item.owner) {
+ $_.push(React.createElement("dt", null, "Author"));
+ $_.push(React.createElement("dd", null, self.props.item.owner))
+ };
+
+ if (self.props.item.shepherd) {
+ $_.push(React.createElement("dt", null, "Shepherd"));
+
+ $_.push(React.createElement.apply(React, function() {
+ var $_ = ["dd", null];
+
+ if (self.props.item.shepherd) {
+ $_.push(React.createElement(Link, {
+ text: self.props.item.shepherd,
+ href: "shepherd/" + self.props.item.shepherd.split(" ")[0]
+ }))
+ };
+
+ return $_
+ }()))
+ };
+
+ if (self.props.item.flagged_by && self.props.item.flagged_by.length != 0) {
+ $_.push(React.createElement("dt", null, "Flagged By"));
+
+ $_.push(React.createElement(
+ "dd",
+ null,
+ self.props.item.flagged_by.join(", ")
+ ))
+ };
+
+ if (self.props.item.approved && self.props.item.approved.length != 0) {
+ $_.push(React.createElement("dt", null, "Approved By"));
+
+ $_.push(React.createElement(
+ "dd",
+ null,
+ self.props.item.approved.join(", ")
+ ))
+ };
+
+ if (self.props.item.roster || self.props.item.prior_reports || self.props.item.stats) {
+ $_.push(React.createElement("dt", null, "Links"));
+
+ if (self.props.item.roster) {
+ $_.push(React.createElement(
+ "dd",
+ null,
+ React.createElement("a", {href: self.props.item.roster}, "Roster")
+ ))
+ };
+
+ if (self.props.item.prior_reports) {
+ $_.push(React.createElement("dd", null, React.createElement(
+ "a",
+ {href: self.props.item.prior_reports},
+ "Prior Reports"
+ )))
+ };
+
+ if (self.props.item.stats) {
+ $_.push(React.createElement(
+ "dd",
+ null,
+ React.createElement("a", {href: self.props.item.stats}, "Statistics")
+ ))
+ }
+ };
+
+ return $_
+ }())
+ }
+});
+
+//
+// Determine status of podling name
+//
+var PodlingNameSearch = React.createClass({
+ displayName: "PodlingNameSearch",
+
+ getInitialState: function() {
+ return {}
+ },
+
+ render: function() {
+ var self = this;
+
+ return React.createElement.apply(React, function() {
+ var $_ = ["span", {className: "pns", title: "podling name search"}];
+
+ if (Server.podlingnamesearch) {
+ if (!self.state.results) {
+ $_.push(React.createElement(
+ "abbr",
+ {title: "No PODLINGNAMESEARCH found"},
+ "✘"
+ ))
+ } else if (self.state.results.resolution == "Fixed") {
+ $_.push(React.createElement(
+ "a",
+ {href: "https://issues.apache.org/jira/browse/" + self.state.results.issue},
+ "✔"
+ ))
+ } else {
+ $_.push(React.createElement(
+ "a",
+ {href: "https://issues.apache.org/jira/browse/" + self.state.results.issue},
+ "﹖"
+ ))
+ }
+ };
+
+ return $_
+ }())
+ },
+
+ // initial mount: fetch podlingnamesearch data unless already downloaded
+ componentDidMount: function() {
+ var self = this;
+
+ if (Server.podlingnamesearch) {
+ this.check(this.props)
+ } else {
+ retrieve("podlingnamesearch", "json", function(results) {
+ Server.podlingnamesearch = results;
+ self.check(self.props)
+ })
+ }
+ },
+
+ // when properties (in particular: title) changes, lookup name again
+ componentWillReceiveProps: function(newprops) {
+ this.check(newprops)
+ },
+
+ // lookup name in the establish resolution against the podlingnamesearches
+ check: function(props) {
+ this.setState({results: null});
+ var name = (props.item.title.match(/Establish (.*)/) || [])[1];
+
+ // if full title contains a name in parenthesis, check for that name too
+ var altname = (props.item.fulltitle.match(/\((.*?)\)/) || [])[1];
+
+ if (name && Server.podlingnamesearch) {
+ for (var podling in Server.podlingnamesearch) {
+ if (name == podling) {
+ this.setState({results: Server.podlingnamesearch[name]})
+ } else if (altname == podling) {
+ this.setState({results: Server.podlingnamesearch[altname]})
+ }
+ }
+ }
+ }
+});
+
+//
+// Motivation: browsers limit the number of open web socket connections to any
+// one host to somewhere between 6 and 250, making it impractical to have one
+// Web Socket per tab.
+//
+// The solution below uses localStorage to communicate between tabs, with
+// the majority of logic involved with the "election" of a master. This
+// enables a single open connection to service all tabs open by a browser.
+//
+// Alternatives include:
+//
+// * Replacing localStorage with Service Workers. This would be much cleaner,
+// unfortunately Service Workers aren't widely deployed yet. Sadly, the
+// state isn't much better for Shared Web Workers.
+//
+//##
+//
+// Class variables:
+// * prefix: application prefix for localStorage variables (which are
+// shared across the domain).
+// * timestamp: unique identifier for each window/tab
+// * master: identifier of the current master
+// * ondeck: identifier of the next in line to assume the role of master
+//
+function Events() {};
+Events._subscriptions = {};
+Events._socket = null;
+
+Events.subscribe = function(event, block) {
+ Events._subscriptions[event] = Events._subscriptions[event] || [];
+ Events._subscriptions[event].push(block)
+};
+
+Events.monitor = function() {
+ var self = this;
+ Events._prefix = JSONStorage.prefix;
+
+ // pick something unique to identify this tab/window
+ Events._timestamp = new Date().getTime() + Math.random();
+ this.log("Events id: " + Events._timestamp);
+
+ // determine the current master (if any)
+ Events._master = localStorage.getItem(Events._prefix + "-master");
+ this.log("Events.master: " + Events._master);
+
+ // register as a potential candidate for master
+ localStorage.setItem(
+ Events._prefix + "-ondeck",
+ Events._ondeck = Events._timestamp
+ );
+
+ // relinquish roles on exit
+ window.addEventListener("unload", function(event) {
+ if (Events._master == Events._timestamp) {
+ localStorage.removeItem(Events._prefix + "-master")
+ };
+
+ if (Events._ondeck == Events._timestamp) {
+ localStorage.removeItem(Events._prefix + "-ondeck")
+ }
+ });
+
+ // watch for changes
+ window.addEventListener("storage", function(event) {
+ // update tracking variables
+ if (event.key == (Events._prefix + "-master")) {
+ Events._master = event.newValue;
+ self.log("Events.master: " + Events._master);
+ self.negotiate()
+ } else if (event.key == (Events._prefix + "-ondeck")) {
+ Events._ondeck = event.newValue;
+ self.log("Events.ondeck: " + Events._ondeck);
+ self.negotiate()
+ } else if (event.key == (Events._prefix + "-event")) {
+ self.dispatch(event.newValue)
+ }
+ });
+
+ // dead man's switch: remove master when timestamp isn't updated
+ if (Events._master && Events._timestamp - localStorage.getItem(Events._prefix + "-timestamp") > 30000) {
+ this.log("Events: Removing previous master");
+ Events._master = localStorage.removeItem(Events._prefix + "-master")
+ };
+
+ // negotiate for the role of master
+ this.negotiate()
+};
+
+// negotiate changes in masters
+Events.negotiate = function() {
+ var self = this;
+ var options, request;
+
+ if (Events._master == null && Events._ondeck == Events._timestamp) {
+ this.log("Events: Assuming the role of master");
+
+ localStorage.setItem(
+ Events._prefix + "-timestamp",
+ new Date().getTime()
+ );
+
+ localStorage.setItem(
+ Events._prefix + "-master",
+ Events._master = Events._timestamp
+ );
+
+ Events._ondeck = localStorage.removeItem(Events._prefix + "-ondeck");
+
+ if (Server.session) {
+ this.master()
+ } else {
+ options = {credentials: "include"};
+ request = new Request("../session.json", options);
+
+ fetch(request).then(function(response) {
+ response.json().then(function(json) {
+ Server.session = json.session;
+ self.master()
+ })
+ })
+ }
+ } else if (Events._ondeck == null && Events._master != Events._timestamp && !localStorage.getItem(Events._prefix + "-ondeck")) {
+ localStorage.setItem(
+ Events._prefix + "-ondeck",
+ Events._ondeck = Events._timestamp
+ )
+ }
+};
+
+// master logic
+Events.master = function() {
+ var self = this;
+ this.connectToServer();
+
+ // proof of life; maintain connection to the server
+ setInterval(
+ function() {
+ localStorage.setItem(
+ Events._prefix + "-timestamp",
+ new Date().getTime()
+ );
+
+ self.connectToServer()
+ },
+
+ 25000
+ );
+
+ // close connection on exit
+ window.addEventListener("unload", function(event) {
+ if (Events._socket) Events._socket.close()
+ })
+};
+
+// establish a connection to the server
+Events.connectToServer = function() {
+ var self = this;
+
+ try {
+ if (Events._socket) return;
+ var socket_url = window.location.protocol.replace("http", "ws") + "//" + window.location.hostname + ":34234/";
+ Events._socket = new WebSocket(Server.websocket);
+
+ Events._socket.onopen = function(event) {
+ Events._socket.send("session: " + Server.session + "\n\n");
+ self.log("WebSocket connection established")
+ };
+
+ Events._socket.onmessage = function(event) {
+ localStorage.setItem(Events._prefix + "-event", event.data);
+ self.dispatch(event.data)
+ };
+
+ Events._socket.onerror = function(event) {
+ if (Events._socket) self.log("WebSocket connection terminated");
+ Events._socket = null
+ };
+
+ Events._socket.onclose = function(event) {
+ if (Events._socket) self.log("WebSocket connection terminated");
+ Events._socket = null
+ }
+ } catch (e) {
+ this.log(e)
+ }
+};
+
+// dispatch logic (common to all tabs)
+Events.dispatch = function(data) {
+ var self = this;
+ var message = JSON.parse(data);
+ this.log(message);
+ var options, request;
+
+ if (message.type == "unauthorized") {
+ options = {credentials: "include"};
+ request = new Request("../session.json", options);
+
+ fetch(request).then(function(response) {
+ response.json().then(function(json) {
+ self.log(json);
+ Server.session = json.session
+ })
+ })
+ } else if (Events._subscriptions[message.type]) {
+ Events._subscriptions[message.type].forEach(function(sub) {
+ sub(message)
+ })
+ };
+
+ Main.refresh()
+};
+
+// log messages (unless running tests)
+Events.log = function(message) {
+ if (!navigator.userAgent || navigator.userAgent.indexOf("PhantomJS") != -1) {
+ return
+ };
+
+ console.log(message)
+};
+
+Object.defineProperty(
+ Events,
+ "prefix",
+
+ {enumerable: true, configurable: true, get: function() {
+ if (Events._prefix) return Events._prefix;
+
+ // determine localStorage variable prefix based on url up to the date
+ var base = document.getElementsByTagName("base")[0].href;
+ var origin = location.origin;
+
+ if (!origin) {
+ origin = window.location.protocol + "//" + window.location.hostname + ((window.location.port ? ":" + window.location.port : ""))
+ };
+
+ Events._prefix = base.slice(origin.length, base.length).replace(
+ new RegExp("/\\d{4}-\\d\\d-\\d\\d/.*"),
+ ""
+ ).replace(/^\W+|\W+$/g, "").replace(/\W+/g, "_") || location.port
+ }}
+);
+
+//
+// A cache of agenda related pages, useful for:
+//
+// 1) quick loading of possibly stale data, which will be updated with
+// current information as it becomes available.
+//
+// 2) offline access to the agenda tool
+//
+function PageCache() {};
+
+// is page cache available?
+Object.defineProperty(
+ PageCache,
+ "enabled",
+
+ {enumerable: true, configurable: true, get: function() {
+ if (location.protocol != "https:" && location.hostname != "localhost") {
+ return false
+ };
+
+ // disable service workers for the production server(s) for now. See:
+ // https://lists.w3.org/Archives/Public/public-webapps/2016JulSep/0016.html
+ if (/^whimsy.*\.apache\.org$/.test(location.hostname)) {
+ if (location.hostname.indexOf("-test") == -1) return false
+ };
+
+ return typeof ServiceWorker !== 'undefined' && typeof navigator !== 'undefined'
+ }}
+);
+
+// registration and related startup actions
+PageCache.register = function() {
+ // preload page cache once page finishes loading
+ window.addEventListener("load", function(event) {
+ PageCache.preload()
+ });
+
+ // register service worker
+ var scope = new URL("..", document.getElementsByTagName("base")[0].href);
+ navigator.serviceWorker.register(scope + "sw.js", scope)
+};
+
+// aggressively attempt to preload pages directly used by the agenda pages
+// into the appropriate cache.
+PageCache.preload = function() {
+ if (!PageCache.enabled) return;
+ var request = new Request("bootstrap.html", {credentials: "include"});
+
+ fetch(request).then(function(response) {
+ // add/update bootstrap.html in the cache
+ caches.open("board/agenda").then(function(cache) {
+ cache.put(request, response.clone())
+ })
+ })
+};
+
+//
+// This is the client model for an entire Agenda. Class methods refer to
+// the agenda as a whole. Instance methods refer to an individual agenda
+// item.
+//
+// initialize an entry by copying each JSON property to a class instance
+// variable.
+function Agenda(entry) {
+ for (var name in entry) {
+ this["_" + name] = entry[name]
+ }
+};
+
+Agenda._index = [];
+Agenda._etag = null;
+Agenda._digest = null;
+
+// (re)-load an agenda, creating instances for each item, and linking
+// each instance to their next and previous items.
+Agenda.load = function(list, digest) {
+ if (!list) return;
+ Agenda._digest = digest;
+ Agenda._index.length = 0;
+ var prev = null;
+
+ list.forEach(function(item) {
+ item = new Agenda(item);
+ item.prev = prev;
+ if (prev) prev.next = item;
+ prev = item;
+ Agenda._index.push(item)
+ });
+
+ // remove president attachments from the normal flow
+ Agenda._index.forEach(function(pres) {
+ match = pres.title == "President" && pres.text && pres.text.match(/Additionally, please see Attachments (\d) through (\d)/);
+ if (!match) return;
+ var first;
+ var last;
+ first = last = null;
+
+ Agenda._index.forEach(function(item) {
+ if (item.attach == match[1]) first = item;
+ if (first && !last) item._shepherd = item._shepherd || pres.shepherd;
+ if (item.attach == match[2]) last = item
+ });
+
+ if (first && last) {
+ first.prev.next = last.next;
+ last.next.prev = first.prev;
+ last.next.index = first.index;
+ first.index = null;
+ last.next = pres;
+ first.prev = pres
+ }
+ });
+
+ Agenda._date = (new Date(Agenda._index[0].timestamp).toISOString().match(/(.*?)T/) || [])[1];
+ Main.refresh();
+ return Agenda._index
+};
+
+// fetch agenda if etag is not supplied
+Agenda.fetch = function(etag, digest) {
+ var loaded, options, request, xhr;
+
+ if (etag) {
+ Agenda._etag = etag
+ } else if (digest != Agenda._digest || !Agenda._etag) {
+ if (PageCache.enabled) {
+ loaded = false;
+
+ // if bootstrapping and cache is available, load it
+ if (!digest) {
+ caches.open("board/agenda").then(function(cache) {
+ cache.match("../" + Agenda._date + ".json").then(function(response) {
+ if (response) {
+ response.json().then(function(json) {
+ if (!loaded) Agenda.load(json);
+ Main.refresh()
+ })
+ }
+ })
+ })
+ };
+
+ // set fetch options: credentials and etag
+ options = {credentials: "include"};
+ if (Agenda._etag) options["headers"] = {"If-None-Match": Agenda._etag};
+ request = new Request("../" + Agenda._date + ".json", options);
+
+ // perform fetch
+ fetch(request).then(function(response) {
+ if (response) {
+ loaded = true;
+
+ // load response into the agenda
+ response.clone().json().then(function(json) {
+ Agenda._etag = response.headers.get("etag");
+ Agenda.load(json);
+ Main.refresh()
+ });
+
+ // save response in the cache
+ caches.open("board/agenda").then(function(cache) {
+ cache.put(request, response)
+ })
+ }
+ })
+ } else {
+ // AJAX fallback
+ xhr = new XMLHttpRequest();
+ xhr.open("GET", "../" + Agenda._date + ".json", true);
+ if (Agenda._etag) xhr.setRequestHeader("If-None-Match", Agenda._etag);
+ xhr.responseType = "text";
+
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState == 4 && xhr.status == 200 && xhr.responseText != "") {
+ Agenda._etag = xhr.getResponseHeader("ETag");
+ Agenda.load(JSON.parse(xhr.responseText));
+ Main.refresh()
+ }
+ };
+
+ xhr.send()
+ }
+ };
+
+ Agenda._digest = digest
+};
+
+// return the entire agenda
+Object.defineProperty(
+ Agenda,
+ "index",
+
+ {enumerable: true, configurable: true, get: function() {
+ return Agenda._index
+ }}
+);
+
+// find an agenda item by path name
+Agenda.find = function(path) {
+ var result = null;
+
+ Agenda._index.forEach(function(item) {
+ if (item.href == path) result = item
+ });
+
+ return result
+};
+
+Agenda.prototype = {
+ // provide read-only access to a number of properties
+ get attach() {
+ return this._attach
+ },
+
+ get title() {
+ return this._title
+ },
+
+ get owner() {
+ return this._owner
+ },
+
+ get shepherd() {
+ return this._shepherd
+ },
+
+ get index() {
+ return this._index
+ },
+
+ get timestamp() {
+ return this._timestamp
+ },
+
+ get digest() {
+ return this._digest
+ },
+
+ get approved() {
+ return this._approved
+ },
+
+ get roster() {
+ return this._roster
+ },
+
+ get prior_reports() {
+ return this._prior_reports
+ },
+
+ get stats() {
+ return this._stats
+ },
+
+ get people() {
+ return this._people
+ },
+
+ get notes() {
+ return this._notes
+ },
+
+ get chair_email() {
+ return this._chair_email
+ },
+
+ get mail_list() {
+ return this._mail_list
+ },
+
+ get warnings() {
+ return this._warnings
+ },
+
+ get flagged_by() {
+ return this._flagged_by
+ },
+
+ get fulltitle() {
+ return this._fulltitle || this._title
+ },
+
+ // override missing if minutes aren't present
+ get missing() {
+ if (this._missing) {
+ return true
+ } else if (/^3\w$/.test(this._attach)) {
+ if (Server.drafts.indexOf((this._text.match(/board_minutes_\w+.txt/) || [])[0]) != -1) {
+ return false
+ } else {
+ return true
+ }
+ } else {
+ return false
+ }
+ },
+
+ // compute href by taking the title and replacing all non alphanumeric
+ // characters with dashes
+ get href() {
+ return this._title.replace(/[^a-zA-Z0-9]+/g, "-")
+ },
+
+ // return the text or report for the agenda item
+ get text() {
+ return this._text || this._report
+ },
+
+ // return comments as an array of individual comments
+ get comments() {
+ return splitComments(this._comments)
+ },
+
+ // item's comments excluding comments that have been seen before
+ get unseen_comments() {
+ var visible = [];
+ var seen = Pending.seen[this._attach] || [];
+
+ this.comments.forEach(function(comment) {
+ if (seen.indexOf(comment) == -1) visible.push(comment)
+ });
+
+ return visible
+ },
+
+ // retrieve the pending comment (if any) associated with this agenda item
+ get pending() {
+ return Pending.comments[this._attach]
+ },
+
+ // retrieve the action items associated with this agenda item
+ get actions() {
+ var self = this;
+ var item, list;
+
+ if (this._title == "Action Items") {
+ return this._actions
+ } else {
+ item = Agenda.find("Action-Items");
+ list = [];
+
+ if (item) {
+ item.actions.forEach(function(action) {
+ if (action.pmc == self._title) list.push(action)
+ })
+ };
+
+ return list
+ }
+ },
+
+ get special_orders() {
+ var self = this;
+ var items = [];
+
+ if (/^[A-Z]+$/.test(this._attach)) {
+ Agenda.index.forEach(function(item) {
+ if (/^7/.test(item.attach) && item.roster == self._roster) items.push(item)
+ })
+ };
+
+ return items
+ },
+
+ ready_for_review: function(initials) {
+ return typeof this._approved !== 'undefined' && !this.missing && this._approved.indexOf(initials) == -1 && !(this._flagged_by && this._flagged_by.indexOf(initials) != -1)
+ }
+};
+
+Object.defineProperties(Agenda, {
+ // the default view to use for the agenda as a whole
+ view: {enumerable: true, configurable: true, get: function() {
+ return Index
+ }},
+
+ // buttons to show on the index page
+ buttons: {enumerable: true, configurable: true, get: function() {
+ var list = [{button: Refresh}];
+ if (!Minutes.complete) list.push({form: Post, text: "add resolution"});
+
+ if (Server.role == "secretary") {
+ if (Server.drafts.indexOf(Agenda.file.replace("agenda", "minutes")) != -1) {
+ list.push({form: PublishMinutes})
+ } else if (Minutes.ready_to_post_draft) {
+ list.push({form: DraftMinutes})
+ }
+ };
+
+ return list
+ }},
+
+ // the default banner color to use for the agenda as a whole
+ color: {enumerable: true, configurable: true, get: function() {
+ return "blank"
+ }},
+
+ // the default title for the agenda as a whole
+ date: {enumerable: true, configurable: true, get: function() {
+ return Agenda._date
+ }},
+
+ title: {enumerable: true, configurable: true, get: function() {
+ return Agenda._date
+ }},
+
+ // the file associated with this agenda
+ file: {enumerable: true, configurable: true, get: function() {
+ return "board_agenda_" + Agenda._date.replace(/\-/g, "_") + ".txt"
+ }},
+
+ // get the digest of the file associated with this agenda
+ digest: {enumerable: true, configurable: true, get: function() {
+ return Agenda._digest
+ }},
+
+ // previous link for the agenda index page
+ prev: {enumerable: true, configurable: true, get: function() {
+ var result = {title: "Help", href: "help"};
+
+ Server.agendas.forEach(function(agenda) {
+ var date = (agenda.match(/(\d+_\d+_\d+)/) || [])[1].replace(
+ /_/g,
+ "-"
+ );
+
+ if (date < Agenda._date && (result.title == "Help" || date > result.title)) {
+ result = {title: date, href: "../" + date + "/"}
+ }
+ });
+
+ return result
+ }},
+
+ // next link for the agenda index page
+ next: {enumerable: true, configurable: true, get: function() {
+ var result = {title: "Help", href: "help"};
+
+ Server.agendas.forEach(function(agenda) {
+ var date = (agenda.match(/(\d+_\d+_\d+)/) || [])[1].replace(
+ /_/g,
+ "-"
+ );
+
+ if (date > Agenda._date && (result.title == "Help" || date < result.title)) {
+ result = {title: date, href: "../" + date + "/"}
+ }
+ });
+
+ return result
+ }},
+
+ // find the shortest match for shepherd name (example: Rich)
+ shepherd: {enumerable: true, configurable: true, get: function() {
+ var shepherd = null;
+ var firstname = Server.firstname.toLowerCase();
+
+ Agenda.index.forEach(function(item) {
+ if (item.shepherd && firstname.substring(
+ 0,
+ item.shepherd.toLowerCase().length
+ ) == item.shepherd.toLowerCase() && (!shepherd || item.shepherd.length < shepherd.lenth)) {
+ shepherd = item.shepherd
+ }
+ });
+
+ return shepherd
+ }},
+
+ // summary
+ summary: {enumerable: true, configurable: true, get: function() {
+ var results = [];
+
+ // committee reports
+ var count = 0;
+ var link = null;
+
+ Agenda.index.forEach(function(item) {
+ if (/^[A-Z]+$/.test(item.attach)) {
+ count++;
+ link = link || item.href
+ }
+ });
+
+ results.push({
+ color: "available",
+ count: count,
+ href: link,
+ text: "committee reports"
+ });
+
+ // special orders
+ count = 0;
+ link = null;
+
+ Agenda.index.forEach(function(item) {
+ if (/^7[A-Z]+$/.test(item.attach)) {
+ count++;
+ link = link || item.href
+ }
+ });
+
+ results.push({
+ color: "available",
+ count: count,
+ href: link,
+ text: "special orders"
+ });
+
+ // awaiting preapprovals
+ count = 0;
+
+ Agenda.index.forEach(function(item) {
+ if (item.color == "ready") count++
+ });
+
+ results.push({
+ color: "ready",
+ count: count,
+ href: "queue",
+ text: "awaiting preapprovals"
+ });
+
+ // flagged reports
+ count = 0;
+
+ Agenda.index.forEach(function(item) {
+ if (item.flagged_by) count++
+ });
+
+ results.push({
+ color: "commented",
+ count: count,
+ href: "flagged",
+ text: "flagged reports"
+ });
+
+ // missing reports
+ count = 0;
+
+ Agenda.index.forEach(function(item) {
+ if (item.missing) count++
+ });
+
+ results.push({
+ color: "missing",
+ count: count,
+ href: "missing",
+ text: "missing reports"
+ });
+
+ return results
+ }}
+});
+
+Object.defineProperties(Agenda.prototype, {
+ //
+ // Methods on individual agenda items
+ //
+ // default view for an individual agenda item
+ view: {enumerable: true, configurable: true, get: function() {
+ if (this._title == "Action Items") {
+ return (this._text || Minutes.started ? ActionItems : SelectActions)
+ } else if (this._title == "Roll Call" && Server.role == "secretary") {
+ return RollCall
+ } else if (this._title == "Adjournment" && Server.role == "secretary") {
+ return Adjournment
+ } else {
+ return Report
+ }
+ }},
+
+ // buttons and forms to show with this report
+ buttons: {enumerable: true, configurable: true, get: function() {
+ var list = [];
+
+ if (this._comments !== undefined && !Minutes.complete) {
+ // some reports don't have comments
+ if (this.pending) {
+ list.push({form: AddComment, text: "edit comment"})
+ } else {
+ list.push({form: AddComment, text: "add comment"})
+ }
+ };
+
+ if (this._title == "Roll Call") list.push({button: Attend});
+
+ if (/^(\d|7?[A-Z]+|4[A-Z])$/.test(this._attach) && !Minutes.complete) {
+ if (this.missing) {
+ list.push({form: Post, text: "post report"})
+ } else if (/^7\w/.test(this._attach)) {
+ list.push({form: Post, text: "edit resolution"})
+ } else {
+ list.push({form: Post, text: "edit report"})
+ }
+ };
+
+ if (Server.role == "director") {
+ if (!this.missing && this._comments !== undefined && !Minutes.complete) {
+ list.push({button: Approve})
+ }
+ } else if (Server.role == "secretary") {
+ if (/^7\w/.test(this._attach)) {
+ list.push({form: Vote})
+ } else if (Minutes.get(this._title)) {
+ list.push({form: AddMinutes, text: "edit minutes"})
+ } else if (["Call to order", "Adjournment"].indexOf(this._title) != -1) {
+ list.push({button: Timestamp})
+ } else {
+ list.push({form: AddMinutes, text: "add minutes"})
+ };
+
+ if (/^3\w/.test(this._attach)) {
+ if (Minutes.get(this._title) == "approved" && Server.drafts.indexOf((this._text.match(/board_minutes_\w+\.txt/) || [])[0]) != -1) {
+ list.push({form: PublishMinutes})
+ }
+ } else if (this._title == "Adjournment") {
+ if (Minutes.ready_to_post_draft) list.push({form: DraftMinutes})
+ }
+ };
+
+ return list
+ }},
+
+ // determine if this item is flagged, accounting for pending actions
+ flagged: {enumerable: true, configurable: true, get: function() {
+ if (Pending.flagged.indexOf(this._attach) != -1) return true;
+ if (!this._flagged_by) return false;
+
+ if (this._flagged_by.length == 1 && this._flagged_by[0] == Server.initials && Pending.unflagged.indexOf(this._attach) != -1) {
+ return false
+ };
+
+ return this._flagged_by.length != 0
+ }},
+
+ // banner color for this agenda item
+ color: {enumerable: true, configurable: true, get: function() {
+ if (!this._title) {
+ return "blank"
+ } else if (this._warnings) {
+ return "missing"
+ } else if (this.missing) {
+ return "missing"
+ } else if (this._approved) {
+ if (this.flagged) {
+ return "commented"
+ } else if (this._approved.length < 5) {
+ return "ready"
+ } else {
+ return "reviewed"
+ }
+ } else if (this._text || this._report) {
+ return "available"
+ } else if (this._text === undefined) {
+ return "missing"
+ } else {
+ return "reviewed"
+ }
+ }}
+});
+
+Events.subscribe("agenda", function(message) {
+ if (message.file == Agenda.file) Agenda.fetch(null, message.digest)
+});
+
+Events.subscribe("server", function(message) {
+ if (message.drafts) Server.drafts = message.drafts;
+ if (message.agendas) Server.agendas = message.agendas
+});
+
+//
+// This is the client model for draft Minutes.
+//
+function Minutes() {};
+Minutes._list = {};
+
+// (re)-load minutes
+Minutes.load = function(list) {
+ Minutes._list = {};
+
+ if (list) {
+ for (var title in list) {
+ Minutes._list[title] = list[title]
+ }
+ };
+
+ Minutes._list.attendance = Minutes._list.attendance || {}
+};
+
+// list of actions created during the meeting
+Object.defineProperty(
+ Minutes,
+ "actions",
+
+ {enumerable: true, configurable: true, get: function() {
+ var actions = [];
+
+ for (var title in Minutes._list) {
+ var minutes = Minutes._list[title] + "\n\n";
+ var pattern = /^(?:@|AI\s+)(\w+):?\s+([\s\S]*?)(\n\n|$)/g;
+ var match = pattern.exec(minutes);
+
+ while (match) {
+ actions.push({
+ owner: match[1],
+ text: match[2],
+ item: Agenda.find(title.replace(/\W/g, "-"))
+ });
+
+ match = pattern.exec(minutes)
+ }
+ };
+
+ return actions
+ }}
+);
+
+// fetch minutes for a given agenda item, by title
+Minutes.get = function(title) {
+ return Minutes._list[title]
+};
+
+Object.defineProperties(Minutes, {
+ attendees: {enumerable: true, configurable: true, get: function() {
+ return Minutes._list.attendance
+ }},
+
+ // return a list of actual or expected attendee names
+ attendee_names: {
+ enumerable: true,
+ configurable: true,
+
+ get: function() {
+ var names = [];
+ var attendance = Object.keys(Minutes._list.attendance);
+ var rollcall, pattern;
+
+ if (attendance.length == 0) {
+ rollcall = Minutes.get("Roll Call") || Agenda.find("Roll-Call").text;
+ pattern = /\n ( [a-z]*[A-Z][a-zA-Z]*\.?)+/g;
+
+ while (match = pattern.exec(rollcall)) {
+ var name = match[0].replace(/^\s+/, "").split(" ")[0];
+ if (names.indexOf(name) == -1) names.push(name)
+ }
+ } else {
+ attendance.forEach(function(name) {
+ if (!Minutes._list.attendance[name].present) return;
+ name = name.split(" ")[0];
+ if (names.indexOf(name) == -1) names.push(name)
+ })
+ };
+
+ return names.sort()
+ }
+ },
+
+ // return a list of directors present
+ directors_present: {
+ enumerable: true,
+ configurable: true,
+
+ get: function() {
+ var rollcall = Minutes.get("Roll Call") || Agenda.find("Roll-Call").text;
+
+ return (rollcall.match(/Directors.*Present:\n\n((.*\n)*?)\n/) || [])[1].replace(
+ /\n$/,
+ ""
+ )
+ }
+ },
+
+ // determine if the meeting has started
+ started: {enumerable: true, configurable: true, get: function() {
+ return Minutes._list.started
+ }},
+
+ // determine if the meeting is over
+ complete: {enumerable: true, configurable: true, get: function() {
+ return Minutes._list.complete
+ }},
+
+ // determine if the draft is ready
+ ready_to_post_draft: {
+ enumerable: true,
+ configurable: true,
+
+ get: function() {
+ return this.complete && Server.drafts.indexOf(Agenda.file.replace(
+ "_agenda_",
+ "_minutes_"
+ )) == -1
+ }
+ }
+});
+
+Events.subscribe("minutes", function(message) {
+ if (message.agenda == Agenda.file) Minutes.load(message.value)
+});
+
+function Chat() {};
+Chat._log = [];
+Chat._topic = {};
+Chat.fetch_requested = false;
+Chat.backlog_fetched = false;
+
+// as it says: fetch backlog of chat messages from the server
+Chat.fetch_backlog = function() {
+ if (Chat.fetch_requested) return;
+
+ retrieve(
+ "chat/" + (Agenda.file.match(/\d[\d_]+/) || [])[0],
+ "json",
+
+ function(messages) {
+ messages.forEach(function(message) {
+ Chat.add(message)
+ });
+
+ Chat.backlog_fetched = true
+ }
+ );
+
+ Chat.fetch_requested = true;
+ this.countdown();
+ setInterval(this.countdown, 30000)
+};
+
+// set topic to meeting status
+Chat.countdown = function() {
+ var status = Chat.status;
+ if (status) Chat.setTopic({subtype: "status", user: "whimsy", text: status})
+};
+
+// replace topic locally
+Chat.setTopic = function(entry) {
+ if (Chat._topic.text == entry.text) return;
+
+ Chat._log = Chat._log.filter(function(item) {
+ return item.type != "topic"
+ });
+
+ entry.type = "topic";
+ Chat._topic = entry;
+ Chat.add(entry);
+ if (entry.subtype == "status") Main.refresh()
+};
+
+// change topic globally
+Chat.changeTopic = function(entry) {
+ if (Chat._topic.text == entry.text) return;
+ entry.type = "topic";
+ entry.agenda = Agenda.file;
+ post("message", entry, function(message) {Chat.setTopic(entry)})
+};
+
+// return the chat log
+Object.defineProperty(
+ Chat,
+ "log",
+
+ {enumerable: true, configurable: true, get: function() {
+ return Chat._log
+ }}
+);
+
+// add an entry to the chat log
+Chat.add = function(entry) {
+ entry.timestamp = entry.timestamp || new Date().getTime();
+
+ if (Chat._log.length == 0 || Chat._log[Chat._log.length - 1].timestamp < entry.timestamp) {
+ Chat._log.push(entry)
+ } else {
+ for (var i = 0; i < Chat._log.length; i++) {
+ if (entry.timestamp <= Chat._log[i].timestamp) {
+ if (entry.timestamp != Chat._log[i].timestamp || entry.text != Chat._log[i].text) {
+ Chat._log.splice(i, 0, entry)
+ };
+
+ break
+ }
+ }
+ }
+};
+
+// meeting status for countdown
+Object.defineProperty(
+ Chat,
+ "status",
+
+ {enumerable: true, configurable: true, get: function() {
+ var diff = Agenda.find("Call-to-order").timestamp - new Date().getTime();
+
+ if (Minutes.complete) {
+ return "meeting has completed"
+ } else if (Minutes.started) {
+ return (Chat._topic.subtype == "status" ? Chat._topic.text : "meeting has started")
+ } else if (diff > 86400000 * 3 / 2) {
+ return "meeting will start in about " + Math.floor(diff / 86400000 + 0.5) + " days"
+ } else if (diff > 3600000 * 3 / 2) {
+ return "meeting will start in about " + Math.floor(diff / 3600000 + 0.5) + " hours"
+ } else if (diff > 300000) {
+ return "meeting will start in about " + Math.floor(diff / 300000 + 0.5) * 5 + " minutes"
+ } else if (diff > 90000) {
+ return "meeting will start in about " + Math.floor(diff / 60000 + 0.5) + " minutes"
+ } else {
+ return "meeting will start shortly"
+ }
+ }}
+);
+
+// subscriptions
+Events.subscribe("chat", function(message) {
+ if (message.agenda == Agenda.file) {
+ delete message[agenda];
+ Chat.add(message)
+ }
+});
+
+Events.subscribe("info", function(message) {
+ if (message.agenda == Agenda.file) {
+ delete message[agenda];
+ Chat.add(message)
+ }
+});
+
+Events.subscribe("topic", function(message) {
+ if (message.agenda == Agenda.file) Chat.setTopic(message)
+});
+
+Events.subscribe("arrive", function(message) {
+ Server.online = message.present;
+
+ Chat.add({
+ type: "info",
+ user: message.user,
+ timestamp: message.timestamp,
+ text: "joined the chat"
+ })
+});
+
+Events.subscribe("depart", function(message) {
+ Server.online = message.present;
+
+ Chat.add({
+ type: "info",
+ user: message.user,
+ timestamp: message.timestamp,
+ text: "left the chat"
+ })
+});
+
+//
+// Fetch, retain, and query the list of JIRA projects
+//
+function JIRA() {};
+JIRA._list = null;
+
+JIRA.find = function(name) {
+ if (JIRA._list) {
+ return JIRA._list.indexOf(name) != -1
+ } else {
+ JIRA._list = [];
+ JSONStorage.fetch("jira", function(list) {JIRA._list = list})
+ }
+};
+
+//
+// Provide a thin (and quite possibly unnecessary) interface to the
+// Server.pending data structure.
+//
+function Pending() {};
+
+Pending.load = function(value) {
+ if (value) Server.pending = value;
+ Main.refresh();
+ return value
+};
+
+Object.defineProperties(Pending, {
+ count: {enumerable: true, configurable: true, get: function() {
+ return Object.keys(this.comments).length + Object.keys(this.approved).length + Object.keys(this.unapproved).length + Object.keys(this.flagged).length + Object.keys(this.unflagged).length + Object.keys(this.status).length
+ }},
+
+ comments: {enumerable: true, configurable: true, get: function() {
+ return (Server.pending ? Server.pending.comments : [])
+ }},
+
+ approved: {enumerable: true, configurable: true, get: function() {
+ return Server.pending.approved
+ }},
+
+ unapproved: {enumerable: true, configurable: true, get: function() {
+ return Server.pending.unapproved
+ }},
+
+ flagged: {enumerable: true, configurable: true, get: function() {
+ return Server.pending.flagged
+ }},
+
+ unflagged: {enumerable: true, configurable: true, get: function() {
+ return Server.pending.unflagged
+ }},
+
+ seen: {enumerable: true, configurable: true, get: function() {
+ return Server.pending.seen
+ }},
+
+ initials: {enumerable: true, configurable: true, get: function() {
+ return Server.pending.initials || Server.initials
+ }},
+
+ status: {enumerable: true, configurable: true, get: function() {
+ return Server.pending.status || []
+ }}
+});
+
+// find a pending status update that matches a given action item
+Pending.find_status = function(action) {
+ var match = null;
+
+ Pending.status.forEach(function(status) {
+ var found = true;
+
+ for (var name in action) {
+ if (name != "status" && action[name] != status[name]) found = false
+ };
+
+ if (found) match = status
+ });
+
+ return match
+};
+
+Events.subscribe("pending", function(message) {
+ Pending.load(message.value)
+});
+
+// Posted PMC reports - see https://whimsy.apache.org/board/posted-reports
+function Posted() {};
+Posted._list = [];
+Posted._fetched = false;
+
+Posted.get = function(title) {
+ var results = [];
+
+ // fetch list of reports on first reference
+ if (!Posted._fetched) {
+ Posted._list = [];
+
+ JSONStorage.fetch("posted-reports", function(list) {
+ Posted._list = list
+ });
+
+ Posted._fetched = true
+ };
+
+ // return list of matching reports
+ Posted._list.forEach(function(entry) {
+ if (entry.title == title) results.push(entry)
+ });
+
+ return results
+};
+
+//
+// Fetch, retain, and query the list of historical comments
+//
+function HistoricalComments() {};
+HistoricalComments._comments = null;
+
+// find historical comments based on report title
+HistoricalComments.find = function(title) {
+ if (HistoricalComments._comments) {
+ return HistoricalComments._comments[title]
+ } else {
+ HistoricalComments._comments = {};
+
+ JSONStorage.fetch("historical-comments", function(comments) {
+ HistoricalComments._comments = comments || {}
+ })
+ }
+};
+
+// find link for historical comments based on date and report title
+HistoricalComments.link = function(date, title) {
+ if (Server.agendas.indexOf("board_agenda_" + date + ".txt") != -1) {
+ return "../" + date.replace(/_/g, "-") + "/" + title
+ } else {
+ return "../../minutes/" + title + ".html#minutes_" + date
+ }
+};
+
+//
+// Originally defined to simplify access to sessionStorage for JSON objects.
+//
+// Now expanded to include caching using fetch and the cache defined in
+// the Service Workers specification (but without the user of SWs).
+//
+function JSONStorage() {};
+
+// determine sessionStorage variable prefix based on url up to the date
+Object.defineProperty(
+ JSONStorage,
+ "prefix",
+
+ {enumerable: true, configurable: true, get: function() {
+ if (JSONStorage._prefix) return JSONStorage._prefix;
+ var base = document.getElementsByTagName("base")[0].href;
+ var origin = location.origin;
+
+ if (!origin) {
+ origin = window.location.protocol + "//" + window.location.hostname + ((window.location.port ? ":" + window.location.port : ""))
+ };
+
+ JSONStorage._prefix = base.slice(origin.length, base.length).replace(
+ new RegExp("/\\d{4}-\\d\\d-\\d\\d/.*"),
+ ""
+ ).replace(/^\W+|\W+$/g, "").replace(/\W+/g, "_") || location.port
+ }}
+);
+
+// store an item, converting it to JSON
+JSONStorage.put = function(name, value) {
+ name = JSONStorage.prefix + "-" + name;
+
+ try {
+ sessionStorage.setItem(name, JSON.stringify(value))
+ } catch (e) {
+
+ };
+ return value
+};
+
+// retrieve an item, converting it back to an object
+JSONStorage.get = function(name) {
+ if (typeof sessionStorage !== 'undefined') {
+ name = JSONStorage.prefix + "-" + name;
+ return JSON.parse(sessionStorage.getItem(name) || "null")
+ }
+};
+
+// retrieve an cached object. Note: block may be dispatched twice,
+// once with slightly stale data and once with current data
+//
+// Note: caches only work currently on Firefox and Chrome. All
+// other browsers fall back to XMLHttpRequest (AJAX).
+JSONStorage.fetch = function(name, block) {
+ if (typeof fetch !== 'undefined' && typeof caches !== 'undefined' && (location.protocol == "https:" || location.hostname == "localhost")) {
+ caches.open("board/agenda").then(function(cache) {
+ var fetched = null;
+ clock_counter++;
+
+ // construct request
+ var request = new Request("../json/" + name, {
+ method: "get",
+ credentials: "include",
+ headers: {Accept: "application/json"}
+ });
+
+ // dispatch request
+ fetch(request).then(function(response) {
+ cache.put(request, response.clone());
+
+ response.json().then(function(json) {
+ if (!fetched || JSON.stringify(fetched) != JSON.stringify(json)) {
+ if (!fetched) clock_counter--;
+ fetched = json;
+ if (json) block(json);
+ Main.refresh()
+ }
+ })
+ });
+
+ // check cache
+ cache.match("../json/" + name).then(function(response) {
+ if (response && !fetched) {
+ response.json().then(function(json) {
+ clock_counter--;
+ fetched = json;
+ if (json) block(json);
+ Main.refresh()
+ })
+ }
+ })
+ })
+ } else if (typeof XMLHttpRequest !== 'undefined') {
+ // retrieve from the network only
+ retrieve(name, "json", block)
+ }
+}
\ No newline at end of file
diff --git a/www/board/agenda/react.rb b/www/board/agenda/react.rb
new file mode 100644
index 0000000..e0969da
--- /dev/null
+++ b/www/board/agenda/react.rb
@@ -0,0 +1,116 @@
+# redirect root (minus trailing slash) to latest agenda
+get "/react" do
+ agenda = dir('board_agenda_*.txt').sort.last
+ redirect "#{request.path}/#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}/"
+end
+
+# redirect root to latest agenda
+get '/react/' do
+ agenda = dir('board_agenda_*.txt').sort.last
+ redirect "#{request.path}#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}/"
+end
+
+# redirect missing to missing page for the latest agenda
+get '/react/missing' do
+ agenda = dir('board_agenda_*.txt').sort.last
+ response.headers['Location'] =
+ "/react/#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}/missing"
+ status 302
+end
+
+# all agenda pages
+get %r{/react/(\d\d\d\d-\d\d-\d\d)/(.*)} do |date, path|
+ agenda = "board_agenda_#{date.gsub('-','_')}.txt"
+ pass unless Agenda.parse agenda, :quick
+
+ @base = "#{env['SCRIPT_NAME']}/react/#{date}/"
+
+ if env['REMOTE_USER']
+ userid = env['REMOTE_USER']
+ elsif ENV['RACK_ENV'] == 'test'
+ userid = env['HTTP_REMOTE_USER'] || 'test'
+ elsif env.respond_to? :user
+ userid = env.user
+ else
+ require 'etc'
+ userid = Etc.getlogin
+ end
+
+ if userid == 'test' and ENV['RACK_ENV'] == 'test'
+ username = 'Joe Tester'
+ else
+ username = ASF::Person.new(userid).public_name
+ username ||= Etc.getpwnam(userid)[4].split(',')[0].force_encoding('utf-8')
+ end
+
+ pending = Pending.get(userid)
+ initials = pending['initials'] || username.gsub(/[^A-Z]/, '').downcase
+
+ if userid == 'test' or ASF::Service['board'].members.map(&:id).include? userid
+ role = :director
+ elsif ASF::Service['asf-secretary'].members.map(&:id).include? userid
+ role = :secretary
+ else
+ role = :guest
+ end
+
+ # determine who is present
+ @present = []
+ @present_mtime = nil
+ file = File.join(AGENDA_WORK, 'sessions', 'present.yml')
+ if File.exist?(file) and File.mtime(file) != @present_mtime
+ @present_mtime = File.mtime(file)
+ @present = YAML.load_file(file)
+ end
+
+ @server = {
+ userid: userid,
+ agendas: dir('board_agenda_*.txt').sort,
+ drafts: dir('board_minutes_*.txt').sort,
+ pending: pending,
+ username: username,
+ firstname: username.split(' ').first.downcase,
+ initials: initials,
+ online: @present,
+ session: Session.user(userid),
+ role: role,
+ directors: Hash[ASF::Service['board'].members.map {|person|
+ initials = person.public_name.gsub(/[^A-Z]/, '').downcase
+ [initials, person.public_name.split(' ').first]
+ }],
+ websocket: (env['rack.url_scheme'].sub('http', 'ws')) + '://' +
+ env['SERVER_NAME'] + env['SCRIPT_NAME'] + '/websocket/'
+ }
+
+ @page = {
+ path: path,
+ query: params['q'],
+ agenda: agenda,
+ parsed: Agenda[agenda][:parsed],
+ digest: Agenda[agenda][:digest],
+ etag: Agenda.uptodate(agenda) ? Agenda[agenda][:etag] : nil
+ }
+
+ minutes = AGENDA_WORK + '/' +
+ agenda.sub('agenda', 'minutes').sub('.txt', '.yml')
+ @page[:minutes] = YAML.load(File.read(minutes)) if File.exist? minutes
+
+ @cssmtime = File.mtime('public/stylesheets/app.css').to_i
+ @appmtime = File.mtime('public/react/app.js').to_i
+
+ erb :"react/scaffold.html"
+end
+
+# append slash to agenda page if not present
+get %r{/react/(\d\d\d\d-\d\d-\d\d)} do |date|
+ redirect to("/react/#{date}/")
+end
+
+# internally redirect the rest to the main routes
+get %r{/react(\/.*)} do |path|
+ call env.merge!("PATH_INFO" => path)
+end
+
+post %r{/react(\/.*)} do |path|
+ call env.merge!("PATH_INFO" => path)
+end
diff --git a/www/board/agenda/views/react/scaffold.html.erb b/www/board/agenda/views/react/scaffold.html.erb
new file mode 100644
index 0000000..1315f94
--- /dev/null
+++ b/www/board/agenda/views/react/scaffold.html.erb
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta charset="utf-8"/>
+ <title>ASF Board Agenda</title>
+ <base href="<%= @base %>"/>
+ <link rel="stylesheet" type="text/css" href="/assets/bootstrap-min.css"/>
+ <link rel="stylesheet" type="text/css" href="/assets/bootstrap-theme.min.css"/>
+ <link rel="stylesheet" href="../../stylesheets/app.css?<%= @cssmtime %>"/>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+ </head>
+
+ <body>
+ <div id="main"></div>
+
+ <script src="/assets/react-min.js"></script>
+ <script src="/assets/react-dom.min.js"></script>
+ <script src="/assets/jquery-min.js"></script>
+ <script src="/assets/bootstrap-min.js"></script>
+ <script src="../app.js?<%= @appmtime %>" lang="text/javascript"></script>
+ <script>//<![CDATA[
+ ReactDOM.render(React.createElement(Main,
+ <%= JSON.generate server: @server, page: @page %>),
+ document.getElementById("main"))
+ //]]></script>
+ </body>
+</html>
--
To stop receiving notification emails like this one, please contact
['"commits@whimsical.apache.org" <co...@whimsical.apache.org>'].