From c07828d6632150ee6ea9fc873dbd054a3d43f2b7 Mon Sep 17 00:00:00 2001 From: Aaron Boodman Date: Fri, 22 Aug 2014 16:16:39 -0700 Subject: [PATCH] Rewrite permanode detail in React. This is more general than the old one... it allows generic editing of all attributes. Change-Id: I308e39034cf206f9cd8e99cb52863a09c2755705 --- server/camlistored/ui/index.css | 7 + server/camlistored/ui/index.html | 1 + server/camlistored/ui/index.js | 4 +- server/camlistored/ui/permanode_detail.css | 75 ++++++ server/camlistored/ui/permanode_detail.js | 279 ++++++++++++++++++++- 5 files changed, 353 insertions(+), 13 deletions(-) create mode 100644 server/camlistored/ui/permanode_detail.css diff --git a/server/camlistored/ui/index.css b/server/camlistored/ui/index.css index c9e189a25..3f3280b45 100644 --- a/server/camlistored/ui/index.css +++ b/server/camlistored/ui/index.css @@ -43,3 +43,10 @@ body { .cam-content-wrap { position: relative; } + +.cam-unselectable { + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; + -o-user-select: none; +} diff --git a/server/camlistored/ui/index.html b/server/camlistored/ui/index.html index 9f09d5d3d..fda4443f3 100644 --- a/server/camlistored/ui/index.html +++ b/server/camlistored/ui/index.html @@ -44,6 +44,7 @@ limitations under the License. + diff --git a/server/camlistored/ui/index.js b/server/camlistored/ui/index.js index 3d04eaa27..420c60d42 100644 --- a/server/camlistored/ui/index.js +++ b/server/camlistored/ui/index.js @@ -159,7 +159,7 @@ cam.IndexPage = React.createClass({ return [ this.getSearchAspect_, cam.ImageDetail.getAspect, - cam.PermanodeDetail.getAspect.bind(null, this.baseURL_, childFrameClickHandler), + cam.PermanodeDetail.getAspect.bind(null, this.props.serverConnection, this.props.timer), cam.DirectoryDetail.getAspect.bind(null, this.baseURL_, childFrameClickHandler), cam.BlobDetail.getAspect.bind(null, this.getDetailURL_, this.props.serverConnection), ].map(function(f) { @@ -183,7 +183,7 @@ cam.IndexPage = React.createClass({ // If we don't render a contents view, then permanodes that are meant to actually be sets, but are currently empty won't have a contents view to drag items on to. And when you delete the last item from a set, the contents view will disappear. // // I'm not sure what the right long term solution is, but not showing a contents view in this case seems less crappy for now. - if (this.childSearchSession_ && !this.childSearchSession_.getCurrentResults().length) { + if (this.childSearchSession_ && !this.childSearchSession_.getCurrentResults().blobs.length) { return null; } } diff --git a/server/camlistored/ui/permanode_detail.css b/server/camlistored/ui/permanode_detail.css new file mode 100644 index 000000000..e95cb776a --- /dev/null +++ b/server/camlistored/ui/permanode_detail.css @@ -0,0 +1,75 @@ +/* +Copyright 2014 The Camlistore Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.cam-permanode-detail { + font-family: 'Open Sans', sans-serif; + margin: 1.5em 2em; +} + +.cam-permanode-detail h1 { + font-size: 1.5em; +} + +.cam-permanode-detail table { + border-collapse: collapse; + border-spacing: 0; + width: 100%; +} + +.cam-permanode-detail th { + border-bottom: 1px solid black; + cursor: pointer; + padding: 0.6em 1em 0.4em; + text-align: left; +} + +.cam-permanode-detail td { + border: 1px solid #aaa; + padding: 0.6em 1em 0.4em; + text-align: left; +} + +.cam-permanode-detail tr>*:nth-child(1) { + width: 50%; +} + +.cam-permanode-detail tr>*:nth-child(2) { + width: 50%; +} + +.cam-permanode-detail tr>*:nth-child(3) { + color: #444; + text-align: center; + width: 0; +} + +.cam-permanode-detail-delete-attribute { + cursor: pointer; +} + +.cam-permanode-detail td input[type=text] { + border: none; + font: inherit; + width: 100%; +} + +.cam-permanode-detail-status { + background: #eee; + bottom: 1em; + left: 1em; + padding: 1em; + position: fixed; +} \ No newline at end of file diff --git a/server/camlistored/ui/permanode_detail.js b/server/camlistored/ui/permanode_detail.js index f4943c7fa..c9829af1d 100644 --- a/server/camlistored/ui/permanode_detail.js +++ b/server/camlistored/ui/permanode_detail.js @@ -16,9 +16,271 @@ limitations under the License. goog.provide('cam.PermanodeDetail'); -goog.require('cam.CacheBusterIframe'); +goog.require('goog.array'); +goog.require('goog.labs.Promise'); +goog.require('goog.object'); -cam.PermanodeDetail.getAspect = function(baseURL, onChildFrameClick, blobref, targetSearchSession) { +goog.require('cam.ServerConnection'); + +cam.PermanodeDetail = React.createClass({ + displayName: 'PermanodeDetail', + + propTypes: { + meta: React.PropTypes.object.isRequired, + serverConnection: React.PropTypes.instanceOf(cam.ServerConnection).isRequired, + timer: React.PropTypes.shape({ + setTimeout: React.PropTypes.func.isRequired, + }).isRequired, + }, + + getInitialState: function() { + return { + newRow: {}, + rows: this.getInitialRows_(), + sortBy: 'name', + sortAsc: true, + status: '', + }; + }, + + render: function() { + return React.DOM.div({className: 'cam-permanode-detail'}, + React.DOM.h1(null, 'Current attributes'), + this.getAttributesTable_(), + this.getStatus_() + ); + }, + + getStatus_: function() { + if (this.state.status) { + return React.DOM.div( + {className: 'cam-permanode-detail-status'}, + this.state.status + ); + } else { + return null; + } + }, + + getInitialRows_: function() { + var rows = []; + for (var name in this.props.meta.permanode.attr) { + var values = this.props.meta.permanode.attr[name]; + for (var i = 0; i < values.length; i++) { + rows.push({ + 'name': name, + 'value': values[i], + }); + } + } + return rows; + }, + + getAttributesTable_: function() { + var headerText = function(name, column) { + var children = [name]; + if (this.state.sortBy == column) { + children.push(' '); + children.push( + React.DOM.i({ + className: React.addons.classSet({ + 'fa': true, + 'fa-caret-up': this.state.sortAsc, + 'fa-caret-down': !this.state.sortAsc, + }), + }) + ); + } + return React.DOM.span(null, children); + }.bind(this); + + var header = function(content, onclick) { + return React.DOM.th( + { + className: 'cam-unselectable', + onClick: onclick, + }, + content + ); + }; + + return React.DOM.table(null, + React.DOM.tbody(null, + React.DOM.tr( + {key: 'header'}, + header(headerText('Name', 'name'), this.handleSort_.bind(null, 'name')), + header(headerText('Value', 'value'), this.handleSort_.bind(null, 'value')), + header('') + ), + cam.PermanodeDetail.AttributeRow({ + className: 'cam-permanode-detail-new-row', + key: 'new', + onBlur: this.handleBlur_, + onChange: this.handleChange_, + row: this.state.newRow, + }), + this.state.rows.map(function(r, i) { + return cam.PermanodeDetail.AttributeRow({ + key: i, + onBlur: this.handleBlur_, + onChange: this.handleChange_, + onDelete: this.handleDelete_.bind(null, r), + row: r, + }); + }, this) + ) + ); + }, + + handleChange_: function(row, column, e) { + row[column] = e.target.value; + this.forceUpdate(); + }, + + handleDelete_: function(row) { + this.setState({ + rows: this.state.rows.filter(function(r) { return r != row; }), + }, function() { + this.commitChanges_(); + }.bind(this)); + }, + + handleBlur_: function(row) { + if (row == this.state.newRow) { + if (row.name && row.value) { + this.state.rows.splice(0, 0, row); + this.state.newRow = {}; + this.forceUpdate(); + this.commitChanges_(); + } + } else { + this.commitChanges_(); + } + }, + + handleSort_: function(sortBy) { + var sortAsc = true; + if (this.state.sortBy == sortBy) { + sortAsc = !this.state.sortAsc; + } + this.setState({ + rows: this.getSortedRows_(sortBy, sortAsc), + sortAsc: sortAsc, + sortBy: sortBy, + }); + }, + + getSortedRows_: function(sortBy, sortAsc) { + var numericSort = function(a, b) { + return parseFloat(a) - parseFloat(b); + } + var stringSort = function(a, b) { + return a.localeCompare(b); + } + + var rows = goog.array.clone(this.state.rows); + var sort = rows.some(function(r) { + return isNaN(parseFloat(r[sortBy])); + }) ? stringSort : numericSort; + + rows.sort(function(a, b) { + if (!sortAsc) { + var tmp = a; + a = b; + b = tmp; + } + return sort(a[sortBy], b[sortBy]); + }); + + return rows; + }, + + getChanges_: function() { + var key = function(r) { + return r.name + ':' + r.value; + }; + var before = goog.array.toObject(this.getInitialRows_(), key); + var after = goog.array.toObject(this.state.rows, key); + + var adds = goog.object.filter(after, function(v, k) { return !(k in before); }); + var deletes = goog.object.filter(before, function(v, k) { return !(k in after); }); + + return { + adds: goog.object.getValues(adds), + deletes: goog.object.getValues(deletes), + }; + }, + + commitChanges_: function() { + this.setState({ + status: 'Saving...', + }); + var changes = this.getChanges_(); + var promises = changes.adds.map(function(add) { + return new goog.labs.Promise(this.props.serverConnection.newAddAttributeClaim.bind(this.props.serverConnection, this.props.meta.blobRef, add.name, add.value)); + }, this).concat(changes.deletes.map(function(del) { + return new goog.labs.Promise(this.props.serverConnection.newDelAttributeClaim.bind(this.props.serverConnection, this.props.meta.blobRef, del.name, del.value)); + }, this)); + goog.labs.Promise.all(promises).then(function() { + this.props.timer.setTimeout(function() { + this.setState({ + status: '', + }); + }.bind(this), 500); + }.bind(this)); + } +}); + +cam.PermanodeDetail.AttributeRow = React.createClass({ + displayName: 'AttributeRow', + + propTypes: { + className: React.PropTypes.string, + onBlur: React.PropTypes.func, + onDelete: React.PropTypes.func, + onChange: React.PropTypes.func.isRequired, + row: React.PropTypes.object, + }, + + render: function() { + var deleteButton = function(onDelete) { + if (onDelete) { + return React.DOM.i({ + className: 'fa fa-times-circle-o cam-permanode-detail-delete-attribute', + onClick: onDelete, + }); + } else { + return null; + } + }; + + return React.DOM.tr( + { + className: this.props.className, + onBlur: this.props.onBlur && this.props.onBlur.bind(null, this.props.row), + }, + React.DOM.td(null, + React.DOM.input({ + onChange: this.props.onChange.bind(null, this.props.row, 'name'), + placeholder: this.props.row.name ? '': 'New attribute name', + type: 'text', + value: this.props.row.name || '', + }) + ), + React.DOM.td(null, + React.DOM.input({ + onChange: this.props.onChange.bind(null, this.props.row, 'value'), + placeholder: this.props.row.value ? '' : 'New attribute value', + type: 'text', + value: this.props.row.value || '', + }) + ), + React.DOM.td(null, deleteButton(this.props.onDelete)) + ); + }, +}); + +cam.PermanodeDetail.getAspect = function(serverConnection, timer, blobref, targetSearchSession) { if (!targetSearchSession) { return null; } @@ -32,15 +294,10 @@ cam.PermanodeDetail.getAspect = function(baseURL, onChildFrameClick, blobref, ta fragment: 'permanode', title: 'Permanode', createContent: function(size) { - var url = baseURL.clone(); - url.setParameterValue('p', blobref); - return cam.CacheBusterIframe({ - baseURL: baseURL, - height: size.height, - onChildFrameClick: onChildFrameClick, - key: 'permanode', - src: url, - width: size.width, + return cam.PermanodeDetail({ + meta: pm, + serverConnection: serverConnection, + timer: timer, }); }, };