diff --git a/COPYRIGHT b/COPYRIGHT index 7f7b4a9..03a70ed 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -13,3 +13,8 @@ Tablesort.js std_reporting/static/tablesort.js https://github.com/tristen/tablesort MIT License + +Font Awesome (comment icon) +std_document_store/static/comment.svg + http://fontawesome.io/ + http://fontawesome.io/license/ diff --git a/example_usage/js/egu_forms.js b/example_usage/js/egu_forms.js index ff49058..6902fb8 100644 --- a/example_usage/js/egu_forms.js +++ b/example_usage/js/egu_forms.js @@ -14,6 +14,43 @@ var form0 = P.form({ label:"Description", required:true }, + { + type:"text", + path:"something", + label:"Another note", + }, + { + "type": "repeating-section", + "path": "researchLocation", + "label": "If your research fieldwork takes place outside of the UK, please state the location.", + "allowAdd": true, + "allowDelete": true, + "elements": [ + { "type": "text", "path": "region", "label": "Region" }, + { "type": "choice", "path": "country", "label": "Country", + "choices": [ + ["GB", "United Kingdom"], + ["AD", "Andorra"], + ["AX", "Ă…land Islands"], + ["AF", "Afghanistan"], + ["AL", "Albania"], + ["DZ", "Algeria"], + ["AS", "American Samoa"], + ["AO", "Angola"], + ["AI", "Anguilla"], + ["AQ", "Antarctica"], + ["AG", "Antigua and Barbuda"], + ["AR", "Argentina"] + ] + } + ] + }, + { + "type": "date", + "path": "startDate", + "label": "Project start date", + "required": true + }, { type:"boolean", path:"secondForm", @@ -55,5 +92,7 @@ P.ExampleUsageWorkflow.use("std:document_store", { return true; }, view: [{}], - edit: [{roles:["user"], selector:{state:"wait_submit"}}] + edit: [{roles:["user"], selector:{state:"wait_submit"}}], + addComment: [{}], + viewComments: [{}] }); diff --git a/std_document_store/js/docstore_comments.js b/std_document_store/js/docstore_comments.js new file mode 100644 index 0000000..02c91d6 --- /dev/null +++ b/std_document_store/js/docstore_comments.js @@ -0,0 +1,76 @@ + +// checkPermissions called with key & action, where action is 'addComment' or 'viewComments' +P.implementService("std:document_store:comments:respond", function(E, docstore, key, checkPermissions) { + E.response.kind = 'json'; + + // Check permission + if(!checkPermissions(key, (E.request.method === "POST") ? 'addComment' : 'viewComments')) { + E.response.body = JSON.stringify({result:"error",message:"Permission denied"}); + E.response.statusCode = HTTP.UNAUTHORIZED; + return; + } + + var instance = docstore.instance(key); + var response = {result:"success"}; + + if(E.request.method === "POST") { + // Add a comment for this user + var version = parseInt(E.request.parameters.version,10), + formId = E.request.parameters.form, + elementUName = E.request.parameters.uname, + comment = E.request.parameters.comment; + if(!(version && formId && elementUName && comment && (formId.length < 200) && (elementUName.length < 200) && (comment.length < 131072))) { + response.result = "error"; + response.method = "Bad parameters"; + } else { + var row = docstore.commentsTable.create({ + keyId: instance.keyId, + version: version, + userId: O.currentUser.id, + datetime: new Date(), + formId: formId, + elementUName: elementUName, + comment: comment + }); + row.save(); + response.comment = rowForClient(row); + response.commentUserName = O.currentUser.name; + } + + } else { + // Return all comments for this document + var users = {}, forms = {}; + var allComments = docstore.commentsTable.select(). + where("keyId","=",instance.keyId). + order("datetime", true); // latest comments first + var onlyCommentsForForm = E.request.parameters.onlyform; + if(onlyCommentsForForm) { allComments.where("formId","=",onlyCommentsForForm); } + _.each(allComments, function(row) { + var form = forms[row.formId]; + if(!form) { form = forms[row.formId] = {}; } + var comments = form[row.elementUName]; + if(!comments) { comments = form[row.elementUName] = []; } + comments.push(rowForClient(row)); + var uid = row.userId; + if(!users[uid]) { + users[uid] = O.user(uid).name; + } + }); + response.users = users; + response.forms = forms; + } + + E.response.body = JSON.stringify(response); +}); + +// -------------------------------------------------------------------------- + +var rowForClient = function(row) { + return { + id: row.id, + uid: row.userId, + version: row.version, + datetime: (new XDate(row.datetime)).toString("dd MMM yyyy HH:mm"), + comment: row.comment + }; +}; diff --git a/std_document_store/js/docstore_instance.js b/std_document_store/js/docstore_instance.js index 02056fb..c7e97de 100644 --- a/std_document_store/js/docstore_instance.js +++ b/std_document_store/js/docstore_instance.js @@ -72,6 +72,14 @@ DocumentInstance.prototype.__defineGetter__("committedDocumentIsComplete", funct } }); +DocumentInstance.prototype.__defineGetter__("committedVersionNumber", function() { + var latest = this.store.versionsTable.select(). + where("keyId","=",this.keyId). + order("version", true). + limit(1); + return latest.length ? latest[0].version : undefined; +}); + DocumentInstance.prototype._notifyDelegate = function(fn) { var delegate = this.store.delegate; if(delegate[fn]) { @@ -185,7 +193,7 @@ DocumentInstance.prototype._displayForms = function(document) { }; // Render as document -DocumentInstance.prototype._renderDocument = function(document, deferred, idPrefix) { +DocumentInstance.prototype._renderDocument = function(document, deferred, idPrefix, requiresUNames) { var html = []; var delegate = this.store.delegate; var key = this.key; @@ -194,6 +202,7 @@ DocumentInstance.prototype._renderDocument = function(document, deferred, idPref idPrefix = idPrefix || ''; _.each(forms, function(form) { var instance = form.instance(document); + if(requiresUNames) { instance.setIncludeUniqueElementNamesInHTML(true); } if(delegate.prepareFormInstance) { delegate.prepareFormInstance(key, form, instance, "document"); } @@ -254,6 +263,7 @@ DocumentInstance.prototype.handleEditDocument = function(E, actions) { var instance = this, delegate = this.store.delegate, cdocument = this.currentDocument, + requiresUNames = !!actions.viewComments, forms, pages, isSinglePage, activePage; @@ -266,6 +276,7 @@ DocumentInstance.prototype.handleEditDocument = function(E, actions) { for(var i = 0; i < forms.length; ++i) { var form = forms[i], formInstance = form.instance(cdocument); + if(requiresUNames) { formInstance.setIncludeUniqueElementNamesInHTML(true); } if(!delegate.shouldEditForm || delegate.shouldEditForm(instance.key, form, cdocument) || actions._showAllForms) { if(delegate.prepareFormInstance) { delegate.prepareFormInstance(instance.key, form, formInstance, "form"); @@ -381,6 +392,9 @@ DocumentInstance.prototype.handleEditDocument = function(E, actions) { } actions.render(this, E, P.template("edit").deferredRender({ isSinglePage: isSinglePage, + viewComments: actions.viewComments, + commentsUrl: actions.commentsUrl, + versionForComments: actions.viewComments ? this.committedVersionNumber : undefined, saveButtonStyle: saveButtonStyle, navigation: navigation, showFormTitles: actions.showFormTitlesWhenEditing, diff --git a/std_document_store/js/docstore_store.js b/std_document_store/js/docstore_store.js index 608bf3d..bcf6805 100644 --- a/std_document_store/js/docstore_store.js +++ b/std_document_store/js/docstore_store.js @@ -36,6 +36,21 @@ var DocumentStore = P.DocumentStore = function(P, delegate) { // Keep references to the databases this.currentTable = P.db[currentDbName]; this.versionsTable = P.db[versionsDbName]; + // Create comments table, if in use by this document store + this.enablePerElementComments = delegate.enablePerElementComments; + if(this.enablePerElementComments) { + var commentsDbName = "dsComment"+dbNameFragment; + P.db.table(commentsDbName, { + keyId: { type:delegate.keyIdType || "int", indexed:true, indexedWith:"datetime" }, + version: { type:"bigint" }, + userId: { type:"int" }, // don't use 'user' to avoid unnecessary creation of user objects + datetime: { type:"datetime"}, + formId: { type:"text" }, + elementUName: { type:"text" }, + comment: { type:"text" } + }); + this.commentsTable = P.db[commentsDbName]; + } }; // ---------------------------------------------------------------------------- diff --git a/std_document_store/js/docstore_viewer.js b/std_document_store/js/docstore_viewer.js index 44d5949..23234e0 100644 --- a/std_document_store/js/docstore_viewer.js +++ b/std_document_store/js/docstore_viewer.js @@ -9,6 +9,9 @@ // version - version number to display (overrides version) // showVersions - true to allow user to select a version to view // showCurrent - allow the user to see the current version +// viewComments - show comments in this viewer +// addComment - user is allowed to add comments +// commentsUrl - path of comment server (required if viewComments or addComment is true) // hideFormNavigation - hide the interform links from the sidebar // uncommittedChangesWarningText - specify (or disable) the uncommitted changes warning text // style - specify the style of the viewer @@ -25,7 +28,8 @@ var DocumentViewer = P.DocumentViewer = function(instance, E, options) { if("version" in this.options) { this.version = this.options.version; } else if(this.options.showVersions && ("version" in E.request.parameters)) { - this.version = parseInt(E.request.parameters.version, 10); + var vstr = E.request.parameters.version; + this.version = (vstr === '') ? undefined : parseInt(vstr,10); } // Requested change? @@ -104,6 +108,31 @@ var DocumentViewer = P.DocumentViewer = function(instance, E, options) { this.showChangesFromDocument = JSON.parse(requestedPrevious[0].json); } + // Commenting? (but only if we're not showing changes) + if(!(this.showChangesFrom) && (this.options.viewComments || this.options.addComment)) { + this.requiresComments = true; + if(!this.options.commentsUrl) { + throw new Error("viewComments or addComment used in docstore viewer, but commentsUrl not specified"); + } + this.versionForComments = this.version; + if(!this.versionForComments) { + this.versionForComments = instance.committedVersionNumber; + } + if(!this.versionForComments) { + this.requiresComments = false; // disable if there isn't a committed version yet, so won't have comments anyway + } + // Don't want to clutter up display of final versions, so comments can be turned off + if(this.requiresComments && this.options.hideCommentsByDefault && (E.request.parameters.comments !== "1")) { + this.requiresComments = false; // just turn it all off + var numberOfComments = store.commentsTable.select(). + where("keyId","=",instance.keyId). + count(); + if(numberOfComments > 0) { + this.couldShowNumberOfComments = numberOfComments; + } + } + } + // Get any additional UI to display var delegate = this.instance.store.delegate; if(delegate.getAdditionalUIForViewer) { @@ -141,7 +170,7 @@ DocumentViewer.prototype.__defineGetter__("_viewerBody", function() { }); DocumentViewer.prototype.__defineGetter__("_viewerDocumentDeferred", function() { - return this.instance._renderDocument(this.document, true); + return this.instance._renderDocument(this.document, true, undefined, this.requiresComments /* so needs unames */); }); DocumentViewer.prototype.__defineGetter__("_viewerSelectedForm", function() { diff --git a/std_document_store/js/docstore_workflow.js b/std_document_store/js/docstore_workflow.js index 3d340fc..82e81d2 100644 --- a/std_document_store/js/docstore_workflow.js +++ b/std_document_store/js/docstore_workflow.js @@ -31,12 +31,16 @@ P.use("std:workflow"); // property allows the history to be viewable by everyone // view: [{roles:[],selector:{}}, ...] - when the document can be viewed // (omit roles key to mean everyone) +// viewDraft: [{roles:[],selector:{}}, ...] - when drafts of the document can be viewed // edit: [{roles:[],selector:{},transitionsFiltered:[]},optional:true, ...] - when the document // can be edited, the (optional) transitionsFiltered property specifies // which transitions should only be avaialble if the form has been // edited & completed, the optional property overrides the default that, // when a user is allowed to edit a document, there must be a committed // version before they can transition +// addComment: [{roles:[],selector:{}}, ...] - OPTIONAL, when a user can comment on the forms +// viewComments: [{roles:[],selector:{}}, ...] - OPTIONAL, when a user can view the comments +// hideCommentsWhen: selector - OPTIONAL, defaults to {closed:true} // ---------- // actionableUserMustReview: (selector) - a selector which specifies when the // current actionable user should be shown the completed document and @@ -45,6 +49,8 @@ P.use("std:workflow"); // ---------------------------------------------------------------------------- +var DEFAULT_HIDE_COMMENTS_WHEN = {closed:true}; + var Delegate = function() { }; Delegate.prototype = { __formSubmissionDoesNotCompleteProcess: true, @@ -134,6 +140,12 @@ P.workflow.registerWorkflowFeature("std:document_store", function(workflow, spec } var delegate = _.extend(new Delegate(), spec); + + // The 'addComment' permission implies that per element comments are needed + if(spec.addComment) { + delegate.enablePerElementComments = true; + } + var docstore = plugin.defineDocumentStore(delegate); if(!("documentStore" in workflow)) { workflow.documentStore = {}; @@ -278,7 +290,13 @@ P.workflow.registerWorkflowFeature("std:document_store", function(workflow, spec O.stop("Not permitted."); } var instance = docstore.instance(M); - instance.handleEditDocument(E, editor); + var configuredEditor = editor; + if(delegate.enablePerElementComments) { + configuredEditor = Object.create(editor); + configuredEditor.viewComments = can(M, O.currentUser, spec, 'viewComments'); + configuredEditor.commentsUrl = spec.path+"/comments/"+M.workUnit.id; + } + instance.handleEditDocument(E, configuredEditor); }); // ------------------------------------------------------------------------ @@ -324,7 +342,9 @@ P.workflow.registerWorkflowFeature("std:document_store", function(workflow, spec E.setResponsiblePlugin(P); // take over as source of templates, etc var instance = docstore.instance(M); var ui = instance.makeViewerUI(E, { - showCurrent: true + showCurrent: true, + viewComments: delegate.enablePerElementComments && can(M, O.currentUser, spec, 'viewComments'), + commentsUrl: delegate.enablePerElementComments ? spec.path+"/comments/"+M.workUnit.id : undefined }); // std:ui:choose var text = M.getTextMaybe("docstore-review-prompt:"+spec.name) || @@ -369,6 +389,8 @@ P.workflow.registerWorkflowFeature("std:document_store", function(workflow, spec var ui = instance.makeViewerUI(E, { showVersions: spec.history ? can(M, O.currentUser, spec, 'history') : true, showCurrent: true, + viewComments: delegate.enablePerElementComments && can(M, O.currentUser, spec, 'viewComments'), + commentsUrl: delegate.enablePerElementComments ? spec.path+"/comments/"+M.workUnit.id : undefined, uncommittedChangesWarningText: M.getTextMaybe("docstore-draft-warning-text:"+ spec.name) || "This is a draft version" }); @@ -398,6 +420,10 @@ P.workflow.registerWorkflowFeature("std:document_store", function(workflow, spec var ui = instance.makeViewerUI(E, { showVersions: spec.history ? can(M, O.currentUser, spec, 'history') : true, showCurrent: canEdit, + addComment: delegate.enablePerElementComments && can(M, O.currentUser, spec, 'addComment'), + viewComments: delegate.enablePerElementComments && can(M, O.currentUser, spec, 'viewComments'), + commentsUrl: delegate.enablePerElementComments ? spec.path+"/comments/"+M.workUnit.id : undefined, + hideCommentsByDefault: delegate.enablePerElementComments ? M.selected(spec.hideCommentsByDefault||DEFAULT_HIDE_COMMENTS_WHEN) : true, uncommittedChangesWarningText: M.getTextMaybe("docstore-uncommitted-changes-warning-text:"+ spec.name) }); @@ -417,6 +443,23 @@ P.workflow.registerWorkflowFeature("std:document_store", function(workflow, spec // ---------------------------------------------------------------------- + if(delegate.enablePerElementComments) { + + var checkPermissions = function(M, action) { + return can(M, O.currentUser, spec, action); + }; + + plugin.respond("GET,POST", spec.path+'/comments', [ + {pathElement:0, as:"workUnit", workType:workflow.fullName, allUsers:true} + ], function(E, workUnit) { + var M = workflow.instance(workUnit); + O.service("std:document_store:comments:respond", E, docstore, M, checkPermissions); + }); + + } + + // ---------------------------------------------------------------------- + plugin.respond("GET,POST", spec.path+'/admin', [ {pathElement:0, as:"workUnit", workType:workflow.fullName, allUsers:true} ], function(E, workUnit) { diff --git a/std_document_store/js/std_document_store.js b/std_document_store/js/std_document_store.js index d284d9d..a7eeaae 100644 --- a/std_document_store/js/std_document_store.js +++ b/std_document_store/js/std_document_store.js @@ -15,6 +15,7 @@ // Delegate has properties: // name - of store (short string) - REQUIRED // keyIdType - type of keyId, if not "int" +// enablePerElementComments - true if users can comment on individual elements in form // __formSubmissionDoesNotCompleteProcess -- (internal) if true, UI adjusted with expectation submitting form is not last step // TODO: public API for __formSubmissionDoesNotCompleteProcess equivalent // Delegate has methods: diff --git a/std_document_store/plugin.json b/std_document_store/plugin.json index 5949a8e..2ecd3c7 100644 --- a/std_document_store/plugin.json +++ b/std_document_store/plugin.json @@ -11,6 +11,7 @@ "js/docstore_instance.js", "js/docstore_viewer.js", "js/docstore_store.js", + "js/docstore_comments.js", "js/docstore_workflow.js" ] } diff --git a/std_document_store/static/comment.svg b/std_document_store/static/comment.svg new file mode 100644 index 0000000..71b6d8c --- /dev/null +++ b/std_document_store/static/comment.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/std_document_store/static/comments.css b/std_document_store/static/comments.css new file mode 100644 index 0000000..68de434 --- /dev/null +++ b/std_document_store/static/comments.css @@ -0,0 +1,103 @@ + +/* fix position of things which need comments */ +[data-uname] { + position: relative; +} + +#z__docstore_show_only_comments { + display: inline-block; + background-color: #eee; + border: 1px solid #ddd; + border-radius: 4px; + padding: 4px 12px 4px 6px; +} + +.z__docstore_add_comment { + position: absolute; + top: 0; + right: -26px; + z-index: 99999; +} + +.z__docstore_add_comment_button { + background: url(comment.svg) right no-repeat; + color: transparent; + display: block; + width: 24px; + height: 24px; +} + +.z__docstore_add_comment_button:hover { + color: #000; + width: 132px; + text-decoration: none; + background-color: #eee; + border-radius: 4px; + font-weight: bold; + padding: 4px 0 0 12px; +} + +.z__docstore_comment_enter_ui, +.z__docstore_comment_container { + position: relative; + z-index: 99999; /* TODO: fix this hack properly (this obsurces 'delete' in repeating sections, as otherwise they'd overlap) */ + background: #eee; + border: 1px solid #ddd; + border-radius: 3px; + margin: 4px 0 4px 24px; +} +.z__docstore_comment_container { + padding: 1px 4px 4px 4px; +} + +.z__docstore_comment_header { + padding: 0px 4px; + font-size: 10px; + border-bottom: 1px solid #ccc; +} + +.z__docstore_comment_username { + font-weight: bold; +} + +.z__docstore_comment_datetime { + float:right; +} + +.z__docstore_comment_container p { + padding: 0px 4px; + margin: 2px 0; +} + +.z__docstore_comment_previous_version { + border: 2px solid #800; +} + +.z__docstore_comment_later_version { + border: 2px solid #080; +} + +.z__docstore_comment_different_version_msg { + text-align: center; + font-style: italic; +} + +.z__docstore_comment_enter_ui div { + text-align: right; + padding: 0 2% 4px 0; +} + +.z__docstore_comment_enter_ui a, +.z__docstore_comment_enter_ui a:hover { + color: #666; + margin-right: 20px; +} + +.z__docstore_comment_enter_ui span { + text-align: center; + display: block; +} +.z__docstore_comment_enter_ui textarea { + width: 95%; /* needs to be % because width of box is variable (repeating section borders) */ + margin: 6px 0; +} diff --git a/std_document_store/static/comments.js b/std_document_store/static/comments.js new file mode 100644 index 0000000..ab639b8 --- /dev/null +++ b/std_document_store/static/comments.js @@ -0,0 +1,187 @@ + +(function($) { + + $(document).ready(function() { + + // Configuration + var configDiv = $('#z__docstore_comments_configuration')[0], + displayedVersion = configDiv.getAttribute('data-displayedversion')*1, + viewingComments = !!configDiv.getAttribute('data-view'), + onlyViewingCommentsForForm = configDiv.getAttribute('data-onlyform'), + canAddComment = !!configDiv.getAttribute('data-add'), + commentServerUrl = configDiv.getAttribute('data-url'), + isViewer = !!configDiv.getAttribute('data-isviewer'); + + // ------------------------------------------------------------------ + + var userNameLookup = {}; + + var displayComment = function(formId, uname, comment, insertAtTop) { + var element = $('#'+formId+' div[data-uname="'+uname+'"]'); + var div = $('
'); + var header = $(''); + div.append(header); + header.append($('', { + "class": "z__docstore_comment_datetime", + text: comment.datetime + })); + header.append($('', { + "class": "z__docstore_comment_username", + text: (userNameLookup[comment.uid]||'') + })); + _.each(comment.comment.split(/[\r\n]+/), function(p) { + p = $.trim(p); + if(p) { div.append($("", {text:p})); } + }); + var versionMsg; + if(comment.version < displayedVersion) { + div.addClass("z__docstore_comment_previous_version"); + versionMsg = 'This comment refers to a previous version of this form.'; + } else if(comment.version > displayedVersion) { + div.addClass("z__docstore_comment_later_version"); + versionMsg = 'This comment refers to a later version of this form.'; + } + if(versionMsg) { + header.append($('', { + "class": "z__docstore_comment_different_version_msg", + text: versionMsg + })); + } + if(insertAtTop) { + var existingComments = $('.z__docstore_comment_container', element); + if(existingComments.length) { + existingComments.first().before(div); + return; + } + } + element.append(div); + }; + + // ------------------------------------------------------------------ + + // Viewing comments? + if(viewingComments) { + var data = {}; + if(onlyViewingCommentsForForm) { + data.onlyform = onlyViewingCommentsForForm; + } + $.ajax(commentServerUrl, { + data: data, + dataType: "json", + success: function(data) { + if(data.result !== "success") { + window.alert("Failed to load comments"); + return; + } + userNameLookup = data.users || {}; + _.each(data.forms, function(elements, formId) { + _.each(elements, function(comments, uname) { + _.each(comments, function(comment) { + displayComment(formId, uname, comment); + }); + }); + }); + updateShowCommentsOnlyUI(); + } + }); + } + + var updateShowCommentsOnlyUI = function() { + if(!viewingComments || !isViewer) { return; } + var commentCount = $('.z__docstore_comment_container').length; + if(commentCount) { + if($('#z__docstore_show_only_comments').length === 0) { + var showOnlyUI = $('