diff --git a/package.json b/package.json index d044586b466..8aae2403061 100644 --- a/package.json +++ b/package.json @@ -100,14 +100,16 @@ "electron-squirrel-startup": "^0.1.4", "font-awesome": "https://github.com/FortAwesome/Font-Awesome/archive/v4.4.0.tar.gz", "get-object-path": "azer/get-object-path#74eb42de0cfd02c14ffdd18552f295aba723d394", - "hadron-action": "^0.0.2", + "hadron-action": "^0.0.4", "hadron-auto-update-manager": "^0.0.12", "hadron-compile-cache": "^0.1.0", - "hadron-component-registry": "^0.2.0", + "hadron-component-registry": "^0.3.0", + "hadron-document": "^0.12.0", "hadron-ipc": "^0.0.7", "hadron-module-cache": "^0.0.3", "hadron-package-manager": "0.1.0", "hadron-reflux-store": "^0.0.2", + "hadron-type-checker": "^0.3.0", "highlight.js": "^8.9.1", "jquery": "^2.1.4", "kerberos": "mongodb-js/kerberos#bc619b1b9213eb4cdae786cf3fb916fc7be66758", @@ -120,8 +122,8 @@ "marky-mark": "^1.2.1", "moment": "^2.10.6", "mongodb-collection-model": "^0.2.3", - "mongodb-connection-model": "^4.2.0", - "mongodb-data-service": "^0.2.1", + "mongodb-connection-model": "^4.3.0", + "mongodb-data-service": "^0.3.0", "mongodb-database-model": "^0.1.2", "mongodb-explain-plan-model": "^0.2.0", "mongodb-extended-json": "^1.6.0", @@ -137,6 +139,7 @@ "qs": "^5.2.0", "raf": "^3.1.0", "react": "^0.14.8", + "react-bootstrap": "0.29.5", "react-dom": "^0.14.8", "semver": "^5.1.0", "storage-mixin": "^0.6.2", diff --git a/src/app/documents/document-list.js b/src/app/documents/document-list.js index 00e4ecc652c..a4cb8ba0351 100644 --- a/src/app/documents/document-list.js +++ b/src/app/documents/document-list.js @@ -25,7 +25,8 @@ var DocumentListView = View.extend({ Action.filterChanged(app.queryOptions.query.serialize()); }, render: function() { - ReactDOM.render(React.createElement(this.documentList), this.el.parentNode); + var container = this.el.parentNode.parentNode.parentNode; + ReactDOM.render(React.createElement(this.documentList), container); return this; }, reset: function() { diff --git a/src/app/documents/index.less b/src/app/documents/index.less index b96bf540a33..c7aa5229df5 100644 --- a/src/app/documents/index.less +++ b/src/app/documents/index.less @@ -1,5 +1,5 @@ .truncate-text-mixin() { - overflow-x: hidden; + // overflow-x: hidden; // commenting out to make the dropdowns work text-overflow: ellipsis; white-space: nowrap; max-width: 100%; @@ -31,6 +31,7 @@ ol.document-list { } li.document-list-item { + position: relative; font-family: @font-family-monospace; font-size: 11px; padding-bottom: 10px; @@ -97,6 +98,15 @@ ol.document-list { } } + .document-property.code > .document-property-value { + &::before { + content: "Code('"; + } + &::after { + content: "')"; + } + } + li.document-property.array, li.document-property.object { display: block; margin-left: -16px; @@ -114,8 +124,8 @@ ol.document-list { display: inline-block; width: 12px; height: 1em; - margin-left: 2px; - margin-right: 10px; + margin-left: -8px !important; // remove important hack later + margin-right: 4px; .caret-right; } @@ -130,18 +140,21 @@ ol.document-list { } ol.document-property-body { - // margin-left: 21px; - padding-left: 36px; - // border-left: 1px dotted @gray6; + padding-left: 0; display: none; + + .document-property-key, + .editable-key { + margin-left: 16px; + } } &.expanded { > .document-property-header { > .caret { .caret-down; vertical-align: bottom; - margin-left: 4px; - margin-right: 8px; + margin-left: -10px !important; // remove important hack later + margin-right: 2px; } } > ol.document-property-body { diff --git a/src/app/explain-plan/index.less b/src/app/explain-plan/index.less index 28483502905..164985881af 100644 --- a/src/app/explain-plan/index.less +++ b/src/app/explain-plan/index.less @@ -1,5 +1,27 @@ .explain-container { + // hacks for json view on explain tab + ol.document-list li.document-list-item ol.document-property-body li.document-property.array ol.document-property-body .document-property-key, + ol.document-list li.document-list-item ol.document-property-body li.document-property.object ol.document-property-body .document-property-key { + margin-left: 0; + } + + ol.document-list li.document-list-item ol.document-property-body li.document-property.array.expanded > ol.document-property-body, + ol.document-list li.document-list-item ol.document-property-body li.document-property.object.expanded > ol.document-property-body { + margin-left: 16px; + } + + ol.document-list li.document-list-item ol.document-property-body li.document-property.array.expanded, + ol.document-list li.document-list-item ol.document-property-body li.document-property.object.expanded { + margin-left: 0; + } + + ol.document-list li.document-list-item ol.document-property-body li.document-property.array, + ol.document-list li.document-list-item ol.document-property-body li.document-property.object { + margin-left: 0; + } + // end hacks + .summary-container { background: @pw; padding: 12px; @@ -59,7 +81,6 @@ float: left; margin-right: 24px; } - } i.link { diff --git a/src/app/index.less b/src/app/index.less index eff4a442be6..467bb27d09d 100644 --- a/src/app/index.less +++ b/src/app/index.less @@ -22,3 +22,5 @@ @import "../auto-update/index.less"; @import "../help/index.less"; @import "metrics/index.less"; + +@import "../internal-packages/crud/styles/crud.less"; diff --git a/src/app/models/preferences.js b/src/app/models/preferences.js index 69234b853cb..63b6be38544 100644 --- a/src/app/models/preferences.js +++ b/src/app/models/preferences.js @@ -144,6 +144,14 @@ var Preferences = Model.extend(storageMixin, { required: true, default: false }, + /** + * Allow single document CRUD. + */ + singleDocumentCrud: { + type: 'boolean', + required: true, + default: false + }, /** * Switches to enable/disable various authentication / ssl types * diff --git a/src/app/styles/caret.less b/src/app/styles/caret.less index f7a6d94939a..ba2a2982c3d 100644 --- a/src/app/styles/caret.less +++ b/src/app/styles/caret.less @@ -2,7 +2,6 @@ display: inline-block; width: 0; height: 0; - margin-left: 2px; vertical-align: middle; } .caret-down { diff --git a/src/app/styles/palette.less b/src/app/styles/palette.less index ec3ffb376c1..8f96636c041 100644 --- a/src/app/styles/palette.less +++ b/src/app/styles/palette.less @@ -44,6 +44,7 @@ @pw: #fff; @cautionOrange: @alertOrange; @errorBackground: #fdd0d1; +@greenBg: #e9f2e3; /* Text Colors */ /* default text color is @gray1 */ @@ -76,7 +77,10 @@ /* Alert Colors */ @alertOrange: #fbb129; +@alertOrangeBg: #fff4dc; +@alertOrangeBorder: #a06c29; @alertRed: #ef4c4c; +@alertRedBg: #feeded; @alertBlue: #43b1e5; /* Semantic Colors */ diff --git a/src/internal-packages/crud/lib/actions.js b/src/internal-packages/crud/lib/actions.js new file mode 100644 index 00000000000..4ddd3f84356 --- /dev/null +++ b/src/internal-packages/crud/lib/actions.js @@ -0,0 +1,11 @@ +'use strict'; + +const Reflux = require('reflux'); + +const Actions = Reflux.createActions([ + 'documentRemoved', + 'openInsertDocumentDialog', + 'insertDocument' +]); + +module.exports = Actions; diff --git a/src/internal-packages/crud/lib/component/binary-value.jsx b/src/internal-packages/crud/lib/component/binary-value.jsx new file mode 100644 index 00000000000..2513b27b835 --- /dev/null +++ b/src/internal-packages/crud/lib/component/binary-value.jsx @@ -0,0 +1,71 @@ +'use strict'; + +const React = require('react'); +const truncate = require('hadron-component-registry').truncate; + +/** + * Base 64 constant. + */ +const BASE_64 = 'base64'; + +/** + * The new UUID type. + */ +const UUID = 4; + +/** + * The old UUID type. + */ +const UUID_OLD = 3; + +/** + * The document value class. + */ +const VALUE_CLASS = 'document-property-value'; + +/** + * Binary value component. + */ +class BinaryValue extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.value = props.element.currentValue; + } + + /** + * Render a single binary value. + * + * @returns {React.Component} The element component. + */ + render() { + return ( +
+ {this.renderValue()} +
+ ); + } + + /** + * Render the value. + * + * @returns {Component} The component. + */ + renderValue() { + var type = this.value.sub_type; + var buffer = this.value.buffer; + if (type === UUID || type === UUID_OLD) { + return `Binary('${truncate(buffer.toString())}')`; + } + return `Binary('${truncate(buffer.toString(BASE_64))}')`; + } +} + +BinaryValue.displayName = 'BinaryValue'; + +module.exports = BinaryValue; diff --git a/src/internal-packages/crud/lib/component/cancel-edit-button.jsx b/src/internal-packages/crud/lib/component/cancel-edit-button.jsx new file mode 100644 index 00000000000..4b4d4fd6181 --- /dev/null +++ b/src/internal-packages/crud/lib/component/cancel-edit-button.jsx @@ -0,0 +1,38 @@ +'use strict'; + +const React = require('react'); + +/** + * Component for the cancel button. + */ +class CancelEditButton extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + } + + /** + * Render the button. + * + * @returns {Component} The button component. + */ + render() { + return ( + + ); + } +} + +CancelEditButton.displayName = 'CancelEditButton'; + +module.exports = CancelEditButton; diff --git a/src/internal-packages/crud/lib/component/cancel-insert-button.jsx b/src/internal-packages/crud/lib/component/cancel-insert-button.jsx new file mode 100644 index 00000000000..f971e79eb0b --- /dev/null +++ b/src/internal-packages/crud/lib/component/cancel-insert-button.jsx @@ -0,0 +1,38 @@ +'use strict'; + +const React = require('react'); + +/** + * Component for the cancel button. + */ +class CancelInsertButton extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + } + + /** + * Render the button. + * + * @returns {Component} The button component. + */ + render() { + return ( + + ); + } +} + +CancelInsertButton.displayName = 'CancelInsertButton'; + +module.exports = CancelInsertButton; diff --git a/src/internal-packages/crud/lib/component/clone-document-button.jsx b/src/internal-packages/crud/lib/component/clone-document-button.jsx new file mode 100644 index 00000000000..1d90ba60353 --- /dev/null +++ b/src/internal-packages/crud/lib/component/clone-document-button.jsx @@ -0,0 +1,35 @@ +'use strict'; + +const React = require('react'); + +/** + * Component for the clone document button. + */ +class CloneDocumentButton extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + } + + /** + * Render the button. + * + * @returns {Component} The button component. + */ + render() { + return ( + + ); + } +} + +CloneDocumentButton.displayName = 'CloneDocumentButton'; + +module.exports = CloneDocumentButton; diff --git a/src/internal-packages/crud/lib/component/code-value.jsx b/src/internal-packages/crud/lib/component/code-value.jsx new file mode 100644 index 00000000000..48c7c1f0538 --- /dev/null +++ b/src/internal-packages/crud/lib/component/code-value.jsx @@ -0,0 +1,42 @@ +'use strict'; + +const React = require('react'); +const truncate = require('hadron-component-registry').truncate; + +/** + * The document value class. + */ +const VALUE_CLASS = 'document-property-value'; + +/** + * Code value component. + */ +class CodeValue extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.value = props.element.currentValue; + } + + /** + * Render a single max key value. + * + * @returns {React.Component} The element component. + */ + render() { + return ( +
+ {truncate(this.value.code)} +
+ ); + } +} + +CodeValue.displayName = 'CodeValue'; + +module.exports = CodeValue; diff --git a/src/internal-packages/crud/lib/component/delete-document-button.jsx b/src/internal-packages/crud/lib/component/delete-document-button.jsx new file mode 100644 index 00000000000..f91f549473c --- /dev/null +++ b/src/internal-packages/crud/lib/component/delete-document-button.jsx @@ -0,0 +1,35 @@ +'use strict'; + +const React = require('react'); + +/** + * Component for the edit document button. + */ +class DeleteDocumentButton extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + } + + /** + * Render the button. + * + * @returns {Component} The button component. + */ + render() { + return ( + + ); + } +} + +DeleteDocumentButton.displayName = 'DeleteDocumentButton'; + +module.exports = DeleteDocumentButton; diff --git a/src/internal-packages/crud/lib/component/document-actions.jsx b/src/internal-packages/crud/lib/component/document-actions.jsx new file mode 100644 index 00000000000..7b073d80666 --- /dev/null +++ b/src/internal-packages/crud/lib/component/document-actions.jsx @@ -0,0 +1,51 @@ +'use strict'; + +const React = require('react'); +const app = require('ampersand-app'); +const EditDocumentButton = require('./edit-document-button'); +const DeleteDocumentButton = require('./delete-document-button'); +const CloneDocumentButton = require('./clone-document-button'); + +/** + * The feature flag. + */ +const FEATURE = 'singleDocumentCrud'; + +/** + * Component for actions on the document. + */ +class DocumentActions extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + } + + /** + * Render the actions. + * + * @returns {Component} The actions component. + */ + render() { + if (app.isFeatureEnabled(FEATURE)) { + return ( +
+ + + +
+ ); + } + return ( +
+ ); + } +} + +DocumentActions.displayName = 'DocumentActions'; + +module.exports = DocumentActions; diff --git a/src/internal-packages/crud/lib/component/document-footer.jsx b/src/internal-packages/crud/lib/component/document-footer.jsx new file mode 100644 index 00000000000..8a3cec0b642 --- /dev/null +++ b/src/internal-packages/crud/lib/component/document-footer.jsx @@ -0,0 +1,191 @@ +'use strict'; + +const _ = require('lodash'); +const React = require('react'); +const Element = require('hadron-document').Element; +const CancelEditButton = require('./cancel-edit-button'); +const UpdateButton = require('./update-button'); + +/** + * The progress mode. + */ +const PROGRESS = 'Progress'; + +/** + * The success mode. + */ +const SUCCESS = 'Success'; + +/** + * The error mode. + */ +const ERROR = 'Error'; + +/** + * The editing mode. + */ +const EDITING = 'Editing'; + +/** + * The viewing mode. + */ +const VIEWING = 'Viewing'; + +/** + * Map of modes to styles. + */ +const MODES = { + 'Progress': 'in-progress', + 'Success': 'success', + 'Error': 'error', + 'Editing': 'modified', + 'Viewing': 'viewing' +} + +/** + * The empty message. + */ +const EMPTY = ''; + +/** + * The modified message. + */ +const MODIFIED = 'Document Modified.'; + +/** + * The updating message. + */ +const UPDATING = 'Updating Document.'; + +/** + * The updated message. + */ +const UPDATED = 'Document Updated.'; + +/** + * Component for a the edit document footer. + */ +class DocumentFooter extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.doc = props.doc; + this.updateStore = props.updateStore; + this.actions = props.actions; + + this.doc.on(Element.Events.Added, this.handleModification.bind(this)); + this.doc.on(Element.Events.Edited, this.handleModification.bind(this)); + this.doc.on(Element.Events.Removed, this.handleModification.bind(this)); + this.doc.on(Element.Events.Reverted, this.handleModification.bind(this)); + + this.state = { mode: VIEWING, message: EMPTY }; + } + + /** + * Subscribe to the update store on mount. + */ + componentDidMount() { + this.unsubscribeUpdate = this.updateStore.listen(this.handleStoreUpdate.bind(this)); + } + + /** + * Unsubscribe from the udpate store on unmount. + */ + componentWillUnmount() { + this.unsubscribeUpdate(); + } + + /** + * Handle the user clicking the cancel button. + */ + handleCancel() { + this.doc.cancel(); + this.setState({ mode: VIEWING }); + } + + /** + * Handle an error with the document update. + * + * @param {Error} error - The error. + */ + handleError(error) { + this.setState({ mode: ERROR, message: error.message }); + } + + /** + * Handle modification to the document. + */ + handleModification() { + this.setState({ + mode: this.doc.isModified() ? EDITING : VIEWING, + message: MODIFIED + }); + } + + /** + * Handle the user clicking the update button. + */ + handleUpdate() { + var object = this.props.doc.generateObject(); + this.setState({ mode: PROGRESS, message: UPDATING }); + this.actions.update(object); + } + + /** + * Handle a successful document update. + */ + handleSuccess() { + this.setState({ mode: SUCCESS, message: UPDATED }); + } + + /** + * Handles a trigger from the store. + * + * @param {Boolean} success - If the update succeeded. + * @param {Error, Document} object - The error or document. + */ + handleStoreUpdate(success, object) { + if (success) { + this.handleSuccess(); + } else { + this.handleError(object); + } + } + + /** + * Render the footer. + * + * @returns {Component} The footer component. + */ + render() { + return ( +
+
+ {this.state.message} +
+
+ + +
+
+ ); + } + + /** + * Get the style of the footer based on the current mode. + * + * @returns {String} The style. + */ + style() { + return `document-footer ${MODES[this.state.mode]}`; + } +} + +DocumentFooter.displayName = 'DocumentFooter'; + +module.exports = DocumentFooter; diff --git a/src/internal-packages/crud/lib/component/document-list.jsx b/src/internal-packages/crud/lib/component/document-list.jsx index 573aa47061b..426520e13aa 100644 --- a/src/internal-packages/crud/lib/component/document-list.jsx +++ b/src/internal-packages/crud/lib/component/document-list.jsx @@ -4,9 +4,16 @@ const _ = require('lodash'); const React = require('react'); const ReactDOM = require('react-dom'); const app = require('ampersand-app'); -const ElementFactory = require('hadron-component-registry').ElementFactory; const Action = require('hadron-action'); -const DocumentListStore = require('../store/document-list-store'); +const ObjectID = require('bson').ObjectID; +const Document = require('./document'); +const ResetDocumentListStore = require('../store/reset-document-list-store'); +const LoadMoreDocumentsStore = require('../store/load-more-documents-store'); +const RemoveDocumentStore = require('../store/remove-document-store'); +const InsertDocumentStore = require('../store/insert-document-store'); +const InsertDocumentDialog = require('./insert-document-dialog'); +const SamplingMessage = require('./sampling-message'); +const Actions = require('../actions'); /** * The full document list container class. @@ -18,45 +25,45 @@ const LIST_CLASS = 'document-list'; */ const SCROLL_EVENT = 'scroll'; +/** + * Base empty doc for insert dialog. + */ +const EMPTY_DOC = { '': '' }; + /** * Component for the entire document list. */ class DocumentList extends React.Component { + /** + * Attach the scroll event to the parent container. + */ + attachScrollEvent() { + this._node.parentNode.addEventListener( + SCROLL_EVENT, + this.handleScroll.bind(this) + ); + } + /** * Fetch the state when the component mounts. */ componentDidMount() { - this._attachScrollEvent(); - this.unsubscribe = DocumentListStore.listen((documents, reset, count) => { - if (reset) { - // If resetting, then we need to go back to page one with - // the documents as the filter changed. The loaded count and - // total count are reset here as well. - this.setState({ - docs: this._documentListItems(documents), - currentPage: 1, - count: count, - loadedCount: documents.length - }); - } else { - // If not resetting we append the documents to the existing - // list and increment the page. The loaded count is incremented - // by the number of new documents. - this.setState({ - docs: this.state.docs.concat(this._documentListItems(documents)), - currentPage: (this.state.currentPage + 1), - loadedCount: (this.state.loadedCount + documents.length) - }); - } - }); + this.attachScrollEvent(); + this.unsubscribeReset = ResetDocumentListStore.listen(this.handleReset.bind(this)); + this.unsubscribeLoadMore = LoadMoreDocumentsStore.listen(this.handleLoadMore.bind(this)); + this.unsubscribeRemove = RemoveDocumentStore.listen(this.handleRemove.bind(this)); + this.unsibscribeInsert = InsertDocumentStore.listen(this.handleInsert.bind(this)); } /** * Unsibscribe from the document list store when unmounting. */ componentWillUnmount() { - this.unsubscribe(); + this.unsubscribeReset(); + this.unsubscribeLoadMore(); + this.unsubscribeRemove(); + this.unsubscribeInsert(); } /** @@ -66,43 +73,60 @@ class DocumentList extends React.Component { */ constructor(props) { super(props); - this.state = { docs: [], currentPage: 0 }; + this.state = { docs: [], nextSkip: 0 }; } /** - * Render the document list. + * Handle the loading of more documents. * - * @returns {React.Component} The document list. + * @param {Array} documents - The next batch of documents. */ - render() { - return ( -
    - {this.state.docs} -
- ); + handleLoadMore(documents) { + // If not resetting we append the documents to the existing + // list and increment the page. The loaded count is incremented + // by the number of new documents. + this.setState({ + docs: this.state.docs.concat(this.renderDocuments(documents)), + nextSkip: (this.state.nextSkip + documents.length), + loadedCount: (this.state.loadedCount + documents.length) + }); } /** - * Attach the scroll event to the parent container. + * Handle the reset of the document list. + * + * @param {Array} documents - The documents. + * @param {Integer} count - The count. */ - _attachScrollEvent() { - this.documentListNode = ReactDOM.findDOMNode(this); - this.documentListNode.parentNode.addEventListener( - SCROLL_EVENT, - this._handleScroll.bind(this) - ); + handleReset(documents, count) { + // If resetting, then we need to go back to page one with + // the documents as the filter changed. The loaded count and + // total count are reset here as well. + this.setState({ + docs: this.renderDocuments(documents), + nextSkip: documents.length, + count: count, + loadedCount: documents.length + }); } /** - * Get the document list item components. - * - * @param {Array} docs - The raw documents. + * Handles removal of a document from the document list. * - * @return {Array} The document list item components. + * @param {Object} id - The id of the removed document. */ - _documentListItems(docs) { - return _.map(docs, (doc) => { - return React.createElement(DocumentListItem, { doc: doc, key: doc._id }); + handleRemove(id) { + var index = _.findIndex(this.state.docs, (component) => { + if (id instanceof ObjectID) { + return id.equals(component.props.doc._id); + } + return component.props.doc._id === id; + }); + this.state.docs.splice(index, 1); + this.setState({ + docs: this.state.docs, + loadedCount: (this.state.loadedCount - 1), + nextSkip: (this.state.nextSkip - 1) }); } @@ -111,31 +135,91 @@ class DocumentList extends React.Component { * * @param {Event} evt - The scroll event. */ - _handleScroll(evt) { + handleScroll(evt) { var container = evt.srcElement; - if (container.scrollTop > (this.documentListNode.offsetHeight - this._scrollDelta())) { + if (container.scrollTop > (this._node.offsetHeight - this._scrollDelta())) { // If we are scrolling downwards, and have hit the distance to initiate a scroll // from the end of the list, we will fire the event to load more documents. - this._nextBatch(); + this.loadMore(); + } + } + + /** + * Handle opening of the insert dialog. + */ + handleOpenInsert() { + Actions.openInsertDocumentDialog(EMPTY_DOC); + } + + /** + * Handle insert of a new document. + * + * @param {Boolean} success - If the insert was successful. + * @param {Object} object - The new document or error. + */ + handleInsert(success, object) { + if (success) { + this.setState({ count: this.state.count + 1 }); + this.loadMore(); } - // Bonus: if we have passed a certain number of docs that are out of view: - // this._unloadPreviousBatch(); - // Bonus: if we are scrolling back up and are running out of previous docs: - // this._previousBatch(); - // Bonus: if we are scrolling up and docs below are out of view: - // this._unloadNextBatch(); } /** * Get the next batch of documents. Will only fire if there are more documents * in the collection to load. */ - _nextBatch() { + loadMore() { if (this.state.loadedCount < this.state.count) { - Action.fetchNextDocuments(this.state.currentPage); + Action.fetchNextDocuments(this.state.nextSkip); } } + /** + * Render the document list. + * + * @returns {React.Component} The document list. + */ + render() { + return ( +
+ +
+
+
    this._node = c}> + {this.state.docs} + +
+
+
+
+ ); + } + + /** + * Get the document list item components. + * + * @param {Array} docs - The raw documents. + * + * @return {Array} The document list item components. + */ + renderDocuments(docs) { + return _.map(docs, (doc) => { + return (); + }); + } + + /** + * Determine if the component should update. + * + * @param {Object} nextProps - The next properties. + * @param {Object} nextState - The next state. + */ + shouldComponentUpdate(nextProps, nextState) { + return (nextState.docs.length !== this.state.docs.length) || + (nextState.nextSkip !== this.state.nextSkip) || + (nextState.loadedCount !== this.state.loadedCount); + } + /** * Get the distance in pixels from the end of the document list to the point when * scrolling where we want to load more documents. @@ -144,50 +228,13 @@ class DocumentList extends React.Component { */ _scrollDelta() { if (!this.scrollDelta) { - this.scrollDelta = this.documentListNode.offsetHeight; + this.scrollDelta = this._node.offsetHeight; } return this.scrollDelta; } } -/** - * The class for the document itself. - */ -const DOCUMENT_CLASS = 'document-property-body'; - -/** - * The class for the list item wrapper. - */ -const LIST_ITEM_CLASS = 'document-list-item'; - -/** - * Component for a single document in a list of documents. - */ -class DocumentListItem extends React.Component { - - /** - * Render a single document list item. - */ - render() { - return ( -
  • -
      - {ElementFactory.elements(this.props.doc)} -
    -
  • - ); - } -} - -/** - * Set the display names for all components. - */ DocumentList.displayName = 'DocumentList'; -DocumentListItem.displayName = 'DocumentListItem'; - -/** - * Set the child components. - */ -DocumentList.DocumentListItem = DocumentListItem; +DocumentList.Document = Document; module.exports = DocumentList; diff --git a/src/internal-packages/crud/lib/component/document.jsx b/src/internal-packages/crud/lib/component/document.jsx new file mode 100644 index 00000000000..4b4a4f3a332 --- /dev/null +++ b/src/internal-packages/crud/lib/component/document.jsx @@ -0,0 +1,327 @@ +'use strict'; + +const _ = require('lodash'); +const app = require('ampersand-app'); +const React = require('react'); +const Reflux = require('reflux'); +const ElementFactory = require('hadron-component-registry').ElementFactory; +const NamespaceStore = require('hadron-reflux-store').NamespaceStore; +const HadronDocument = require('hadron-document'); +const Element = require('hadron-document').Element; +const Actions = require('../actions'); +const EditableElement = require('./editable-element'); +const DocumentActions = require('./document-actions'); +const DocumentFooter = require('./document-footer'); +const Hotspot = require('./hotspot'); + +/** + * The class for the document itself. + */ +const DOCUMENT_CLASS = 'document-property-body'; + +/** + * The class for the list item wrapper. + */ +const LIST_ITEM_CLASS = 'document-list-item'; + +/** + * Component for a single document in a list of documents. + */ +class Document extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.doc = props.doc; + this.state = { doc: this.doc, editing: false }; + + // Actions need to be scoped to the single document component and not + // global singletons. + this.actions = Reflux.createActions([ 'update', 'remove' ]); + + // The update store needs to be scoped to a document and not a global + // singleton. + this.updateStore = this.createUpdateStore(this.actions); + this.removeStore = this.createRemoveStore(this.actions); + } + + /** + * Create the scoped update store. + * + * @param {Action} actions - The component reflux actions. + * + * @returns {Store} The scoped store. + */ + createUpdateStore(actions) { + return Reflux.createStore({ + + /** + * Initialize the store. + */ + init: function() { + this.ns = NamespaceStore.ns; + this.listenTo(actions.update, this.update); + }, + + /** + * Update the document in the database. + * + * @param {Object} object - The replacement document. + */ + update: function(object) { + app.dataService.findOneAndReplace( + this.ns, + { _id: object._id }, + object, + { returnOriginal: false }, + this.handleResult + ); + }, + + /** + * Handle the result from the driver. + * + * @param {Error} error - The error. + * @param {Object} doc - The document. + * + * @returns {Object} The trigger event. + */ + handleResult: function(error, doc) { + return (error) ? this.trigger(false, error) : this.trigger(true, doc); + } + }); + } + + /** + * Create the scoped remove store. + * + * @param {Action} actions - The component reflux actions. + * + * @returns {Store} The scoped store. + */ + createRemoveStore(actions) { + return Reflux.createStore({ + + /** + * Initialize the store. + */ + init: function() { + this.ns = NamespaceStore.ns; + this.listenTo(actions.remove, this.remove); + }, + + /** + * Remove the document from the collection. + * + * @param {Object} object - The object to delete. + */ + remove: function(object) { + app.dataService.deleteOne(this.ns, { _id: object._id }, {}, this.handleResult); + }, + + /** + * Handle the result from the driver. + * + * @param {Error} error - The error. + * @param {Object} doc - The document. + * + * @returns {Object} The trigger event. + */ + handleResult: function(error, result) { + return (error) ? this.trigger(false, error) : this.trigger(true, result); + } + }); + } + + /** + * Subscribe to the update store on mount. + */ + componentDidMount() { + this.unsubscribeUpdate = this.updateStore.listen(this.handleStoreUpdate.bind(this)); + this.unsubscribeRemove = this.removeStore.listen(this.handleStoreRemove.bind(this)); + } + + /** + * Unsubscribe from the udpate store on unmount. + */ + componentWillUnmount() { + this.unsubscribeUpdate(); + this.unsubscribeRemove(); + } + + /** + * Handles a trigger from the store. + * + * @param {Boolean} success - If the update succeeded. + * @param {Error, Document} object - The error or document. + */ + handleStoreUpdate(success, object) { + if (this.state.editing) { + if (success) { + this.handleUpdateSuccess(object); + } + } + } + + /** + * Handles a trigger from the store. + * + * @param {Boolean} success - If the update succeeded. + * @param {Error, Document} object - The error or document. + */ + handleStoreRemove(success) { + if (success) { + this.handleRemoveSuccess(); + } + } + + /** + * Handle a sucessful update. + * + * @param {Object} doc - The updated document. + */ + handleUpdateSuccess(doc) { + this.doc = doc; + this.setState({ doc: doc, editing: false }); + } + + /** + * Handle a sucessful update. + * + * @param {Object} doc - The updated document. + */ + handleRemoveSuccess() { + Actions.documentRemoved(this.doc._id); + } + + /** + * Handle the editing of the document. + */ + handleEdit() { + var doc = new HadronDocument(this.doc); + doc.on(Element.Events.Added, this.handleModify.bind(this)); + doc.on(Element.Events.Removed, this.handleModify.bind(this)); + doc.on(HadronDocument.Events.Cancel, this.handleCancel.bind(this)); + + this.setState({ doc: doc, editing: true }); + } + + /** + * Handles canceling edits to the document. + */ + handleCancel() { + this.setState({ doc: this.doc, editing: false }); + } + + /** + * Handle cloning of the document. + */ + handleClone() { + Actions.openInsertDocumentDialog(this.doc); + } + + /** + * Handles document deletion. + */ + handleDelete() { + this.actions.remove(this.doc); + } + + /** + * Handles modification to the document. + */ + handleModify() { + this.setState({}); + } + + /** + * Get the elements for the document. If we are editing, we get editable elements, + * otherwise the readonly elements are returned. + * + * @returns {Array} The elements. + */ + elements() { + if (this.state.editing) { + return this.editableElements(this.state.doc); + } + return ElementFactory.elements(this.state.doc); + } + + /** + * Get the editable elements. + * + * @returns {Array} The editable elements. + */ + editableElements() { + var elements = _.map(this.state.doc.elements, (element) => { + return ( + + ); + }); + var lastElement = elements[elements.length - 1].props.element; + elements.push(); + return elements; + } + + /** + * Render a single document list item. + */ + render() { + return ( +
  • +
      +
      + {this.elements()} +
      + {this.renderActions()} +
    + {this.renderFooter()} +
  • + ); + } + + /** + * Render the actions component. + * + * @returns {Component} The actions component. + */ + renderActions() { + if (!this.state.editing) { + return ( + + ); + } + } + + /** + * Render the footer component. + * + * @returns {Component} The footer component. + */ + renderFooter() { + if (this.state.editing) { + return ( + + ); + } + } + + style() { + var style = LIST_ITEM_CLASS; + if (this.state.editing) { + style = style.concat(' editing'); + } + return style; + } +} + +Document.displayName = 'Document'; + +module.exports = Document; diff --git a/src/internal-packages/crud/lib/component/edit-document-button.jsx b/src/internal-packages/crud/lib/component/edit-document-button.jsx new file mode 100644 index 00000000000..c356b7e59b4 --- /dev/null +++ b/src/internal-packages/crud/lib/component/edit-document-button.jsx @@ -0,0 +1,35 @@ +'use strict'; + +const React = require('react'); + +/** + * Component for the edit document button. + */ +class EditDocumentButton extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + } + + /** + * Render the button. + * + * @returns {Component} The button component. + */ + render() { + return ( + + ); + } +} + +EditDocumentButton.displayName = 'EditDocumentButton'; + +module.exports = EditDocumentButton; diff --git a/src/internal-packages/crud/lib/component/editable-element.jsx b/src/internal-packages/crud/lib/component/editable-element.jsx new file mode 100644 index 00000000000..2e133f2824b --- /dev/null +++ b/src/internal-packages/crud/lib/component/editable-element.jsx @@ -0,0 +1,259 @@ +'use strict'; + +const React = require('react'); +const Element = require('hadron-document').Element; +const EditableKey = require('./editable-key'); +const EditableValue = require('./editable-value'); +const RevertAction = require('./revert-action'); +const RemoveAction = require('./remove-action'); +const NoAction = require('./no-action'); +const Types = require('./types'); + +/** + * The added constant. + */ +const ADDED = 'added'; + +/** + * The edited constant. + */ +const EDITED = 'edited'; + +/** + * The removed constant. + */ +const REMOVED = 'removed'; + +/** + * The editing class constant. + */ +const EDITING = 'editing'; + +/** + * The caret for expanding elements. + */ +const CARET = 'caret'; + +/** + * The class for the document itself. + */ +const DOCUMENT_CLASS = 'document-property-body'; + +/** + * The header class for expandable elements. + */ +const HEADER_CLASS = 'document-property-header expandable'; + +/** + * The property class. + */ +const PROPERTY_CLASS = 'document-property'; + +/** + * The expandable label class. + */ +const LABEL_CLASS = 'document-property-type-label'; + +/** + * The expanded class name. + */ +const EXPANDED = 'expanded'; + +/** + * The non-expandable class. + */ +const NON_EXPANDABLE = 'non-expandable'; + +/** + * Mappings for non editable value components. + */ +const VALUE_MAPPINGS = { + 'Binary': './binary-value', + 'MinKey': './min-key-value', + 'MaxKey': './max-key-value', + 'Code': './code-value', + 'Timestamp': './timestamp-value', + 'ObjectID': './objectid-value' +}; + +/** + * General editable element component. + */ +class EditableElement extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.element = props.element; + this.state = { expanded: false }; + this.element.on(Element.Events.Added, this.handleAdd.bind(this)); + this.element.on(Element.Events.Edited, this.handleEdit.bind(this)); + this.element.on(Element.Events.Removed, this.handleRemove.bind(this)); + this.element.on(Element.Events.Reverted, this.handleRevert.bind(this)); + } + + /** + * Render a single editable element. + * + * @returns {React.Component} The element component. + */ + render() { + return this.element.elements ? this.renderExpandable() : this.renderNonExpandable(); + } + + /** + * Render a non-expandable element. + * + * @returns {Component} The component. + */ + renderNonExpandable() { + return ( +
  • +
    + {this.renderAction()} + + : + {this.renderValue()} + +
  • + ); + } + + /** + * Render the value for the component. + * + * @returns {Component} The value component. + */ + renderValue() { + if (this.element.isValueEditable()) { + return (); + } + var props = { element: this.element }; + return React.createElement(this.valueComponent(this.element.currentType), props); + } + + /** + * Render an expandable element. + * + * @returns {Component} The component. + */ + renderExpandable() { + return ( +
  • +
    +
    + {this.renderAction()} +
    + + : +
    + {this.element.currentType} +
    +
    +
      + {this.elementComponents()} +
    +
  • + ); + } + + /** + * Get the components for the elements. + * + * @returns {Array} The components. + */ + elementComponents() { + return _.map(this.element.elements, (element) => { + return (); + }); + } + + /** + * Get the revert or remove action. + * + * @returns {Component} The component. + */ + renderAction() { + if (this.element.isEdited() || this.element.isRemoved()) { + return (); + } else if (this.element.key === '_id') { + return (); + } + return (); + } + + /** + * Handle the addition of an element. + */ + handleAdd() { + this.setState({ expanded: true }); + } + + /** + * Here to re-render the component when a key or value is edited. + */ + handleEdit() { + this.setState({}); + } + + /** + * Handle removal of an element. + */ + handleRemove() { + this.setState({}); + } + + /** + * Here to re-render the component when an edit is reverted. + */ + handleRevert() { + this.setState({}); + } + + /** + * Toggles the expandable aspect of the element. + */ + toggleExpandable() { + this.setState({ expanded: !this.state.expanded }); + } + + /** + * Get the style for the element component. + * + * @returns {String} The element style. + */ + style() { + var style = `${PROPERTY_CLASS} ${this.element.currentType.toLowerCase()}`; + if (this.element.isAdded()) { + style = style.concat(` ${ADDED}`); + } else if (this.element.isEdited()) { + style = style.concat(` ${EDITED}`); + } else if (this.element.isRemoved()) { + style = style.concat(` ${REMOVED}`); + } + if (!this.element.elements) { + style = style.concat(` ${NON_EXPANDABLE}`); + } + if (this.state.expanded) { + style = style.concat(` ${EXPANDED}`); + } + return style; + } + + /** + * Get the value component for the type. + * + * @returns {Component} The value component. + */ + valueComponent(type) { + return require(VALUE_MAPPINGS[type]); + } +} + +EditableElement.displayName = 'EditableElement'; + +module.exports = EditableElement; diff --git a/src/internal-packages/crud/lib/component/editable-key.jsx b/src/internal-packages/crud/lib/component/editable-key.jsx new file mode 100644 index 00000000000..c08af915e99 --- /dev/null +++ b/src/internal-packages/crud/lib/component/editable-key.jsx @@ -0,0 +1,154 @@ +'use strict'; + +const React = require('react'); + +/** + * The editing class constant. + */ +const EDITING = 'editing'; + +/** + * The duplicate key value. + */ +const DUPLICATE = 'duplicate'; + +/** + * The document key class. + */ +const KEY_CLASS = 'editable-key'; + +/** + * General editable key component. + */ +class EditableKey extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.element = props.element; + this.state = { duplcate: false, editing: false }; + } + + /** + * Focus on this field on mount, so the tab can do it's job and move + * to the value field. + */ + componentDidMount() { + if (this.element.isAdded()) { + if (!this.isEditable() && this._node) { + this._node.focus(); + } + } + } + + /** + * Render a single editable key. + * + * @returns {React.Component} The element component. + */ + render() { + return ( + this._node = c} + size={this.element.currentKey.length} + onBlur={this.handleBlur.bind(this)} + onFocus={this.handleFocus.bind(this)} + onChange={this.handleChange.bind(this)} + onKeyDown={this.handleKeyDown.bind(this)} + value={this.element.currentKey} + title={this.renderTitle()} /> + ); + } + + /** + * Render the title. + * + * @returns {String} The title. + */ + renderTitle() { + if (this.state.duplicate) { + return `Duplicate key: '${this.element.currentKey}'` + } + return this.element.currentKey; + } + /** + * When hitting a key on the last element some special things may happen. + * + * @param {Event} evt - The event. + */ + handleKeyDown(evt) { + if (evt.keyCode === 27) { + this._node.blur(); + } + } + + /** + * Handles changes to the element key. + * + * @param {Event} evt - The event. + */ + handleChange(evt) { + var value = evt.target.value; + if (this.isEditable()) { + if (this.element.isDuplicateKey(value)) { + this.setState({ duplicate: true }); + } else if (this.state.duplicate) { + this.setState({ duplicate: false }); + } + this.element.rename(value); + } + } + + /** + * Handle focus on the key. + */ + handleFocus() { + if (this.isEditable()) { + this.setState({ editing: true }); + } + } + + /** + * Handle blur from the key. + */ + handleBlur() { + if (this.isEditable()) { + this.setState({ editing: false }); + } + } + + /** + * Is this component editable? + * + * @returns {Boolean} If the component is editable. + */ + isEditable() { + return this.element.isKeyEditable() && this.element.parentElement.currentType !== 'Array'; + } + + /** + * Get the style for the key of the element. + * + * @returns {String} The key style. + */ + style() { + var style = KEY_CLASS; + if (this.state.editing) { + style = style.concat(` ${EDITING}`); + } + if (this.state.duplicate) { + style = style.concat(` ${DUPLICATE}`); + } + return style; + } +} + +EditableKey.displayName = 'EditableKey'; + +module.exports = EditableKey; diff --git a/src/internal-packages/crud/lib/component/editable-value.jsx b/src/internal-packages/crud/lib/component/editable-value.jsx new file mode 100644 index 00000000000..5857e763d53 --- /dev/null +++ b/src/internal-packages/crud/lib/component/editable-value.jsx @@ -0,0 +1,107 @@ +'use strict'; + +const _ = require('lodash'); +const React = require('react'); +const ElementFactory = require('hadron-component-registry').ElementFactory; +const TypeChecker = require('hadron-type-checker'); + +/** + * The editing class constant. + */ +const EDITING = 'editing'; + +/** + * The document value class. + */ +const VALUE_CLASS = 'editable-value'; + +/** + * General editable value component. + */ +class EditableValue extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.element = props.element; + this.state = { editing: false }; + } + + /** + * Render a single editable value. + * + * @returns {React.Component} The element component. + */ + render() { + return ( + this._node = c} + type='text' + className={this.style()} + onBlur={this.handleBlur.bind(this)} + onFocus={this.handleFocus.bind(this)} + onChange={this.handleChange.bind(this)} + onKeyDown={this.handleKeyDown.bind(this)} + value={this.element.currentValue} /> + ); + } + + /** + * When hitting a key on the last element some special things may happen. + * + * @param {Event} evt - The event. + */ + handleKeyDown(evt) { + if (evt.keyCode === 9 && !evt.shiftKey) { + this.element.next(); + } else if (evt.keyCode === 27) { + this._node.blur(); + } + } + + /** + * Handles changes to the element value. + * + * @param {Event} evt - The event. + */ + handleChange(evt) { + var value = evt.target.value; + var currentType = this.element.currentType; + if (_.includes(TypeChecker.castableTypes(value), currentType)) { + this.element.edit(TypeChecker.cast(value, currentType)); + } else { + this.element.edit(value); + } + } + + /** + * Handle focus on the value. + */ + handleFocus() { + this.setState({ editing: true }); + } + + /** + * Handle blur from the value. + */ + handleBlur() { + this.setState({ editing: false }); + } + + /** + * Get the style for the value of the element. + * + * @returns {String} The value style. + */ + style() { + return this.state.editing ? `${VALUE_CLASS} ${EDITING}` : VALUE_CLASS; + } +} + +EditableValue.displayName = 'EditableValue'; + +module.exports = EditableValue; diff --git a/src/internal-packages/crud/lib/component/hotspot.jsx b/src/internal-packages/crud/lib/component/hotspot.jsx new file mode 100644 index 00000000000..ecdc70ec4d5 --- /dev/null +++ b/src/internal-packages/crud/lib/component/hotspot.jsx @@ -0,0 +1,46 @@ +'use strict'; + +const React = require('react'); + +/** + * Component for add element hotspot. + */ +class Hotspot extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.doc = props.doc; + this.element = props.element; + } + + /** + * When clicking on a hotspot we append to the parent. + */ + handleClick() { + if (this.element && this.element.parentElement) { + this.element.next(); + } else { + this.doc.add('', ''); + } + } + + /** + * Render the hotspot. + * + * @returns {Component} The hotspot component. + */ + render() { + return ( +
    + ); + } +} + +Hotspot.displayName = 'Hotspot'; + +module.exports = Hotspot; diff --git a/src/internal-packages/crud/lib/component/insert-button.jsx b/src/internal-packages/crud/lib/component/insert-button.jsx new file mode 100644 index 00000000000..86efe53ddef --- /dev/null +++ b/src/internal-packages/crud/lib/component/insert-button.jsx @@ -0,0 +1,38 @@ +'use strict'; + +const React = require('react'); + +/** + * Component for the insert button. + */ +class InsertButton extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + } + + /** + * Render the button. + * + * @returns {Component} The button component. + */ + render() { + return ( + + ); + } +} + +InsertButton.displayName = 'InsertButton'; + +module.exports = InsertButton; diff --git a/src/internal-packages/crud/lib/component/insert-document-dialog.jsx b/src/internal-packages/crud/lib/component/insert-document-dialog.jsx new file mode 100644 index 00000000000..39bf9f65240 --- /dev/null +++ b/src/internal-packages/crud/lib/component/insert-document-dialog.jsx @@ -0,0 +1,69 @@ +'use strict'; + +const React = require('react'); +const Modal = require('react-bootstrap').Modal; +const OpenInsertDocumentDialogStore = require('../store/open-insert-document-dialog-store'); +const InsertDocument = require('./insert-document'); +const CancelInsertButton = require('./cancel-insert-button'); +const InsertButton = require('./insert-button'); +const Actions = require('../actions'); + +/** + * Component for the insert document dialog. + */ +class InsertDocumentDialog extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.state = { open: false }; + } + + componentWillMount() { + this.unsubscribeOpen = OpenInsertDocumentDialogStore.listen(this.handleStoreOpen.bind(this)); + } + + componentWillUnmount() { + this.unsubscribeOpen(); + } + + handleStoreOpen(doc) { + this.setState({ doc: doc, open: true }); + } + + handleCancel() { + this.setState({ open: false }); + } + + handleInsert() { + this.setState({ open: false }); + Actions.insertDocument(this.state.doc.generateObject()); + } + + render() { + return ( + + + Insert Document + + + + + + + + + + + + ); + } +} + +InsertDocumentDialog.displayName = 'InsertDocumentDialog'; + +module.exports = InsertDocumentDialog; diff --git a/src/internal-packages/crud/lib/component/insert-document.jsx b/src/internal-packages/crud/lib/component/insert-document.jsx new file mode 100644 index 00000000000..b2b1ad5265e --- /dev/null +++ b/src/internal-packages/crud/lib/component/insert-document.jsx @@ -0,0 +1,85 @@ +'use strict'; + +const _ = require('lodash'); +const React = require('react'); +const Element = require('hadron-document').Element; +const EditableElement = require('./editable-element'); +const Hotspot = require('./hotspot'); + +/** + * The class for the document itself. + */ +const DOCUMENT_CLASS = 'document-property-body'; + +/** + * The full document list container class. + */ +const LIST_CLASS = 'document-list'; + +/** + * The class for the list item wrapper. + */ +const LIST_ITEM_CLASS = 'document-list-item'; + +/** + * Component for a single document in a list of documents. + */ +class InsertDocument extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.doc = props.doc; + this.doc.on(Element.Events.Added, this.handleModify.bind(this)); + this.doc.on(Element.Events.Removed, this.handleModify.bind(this)); + } + + /** + * Handle modifications to the document. + */ + handleModify() { + this.setState({}); + } + + /** + * Render a single document list item. + */ + render() { + return ( +
      +
    1. +
        +
        + {this.renderElements(this.doc)} +
        +
      +
    2. +
    + ); + } + + /** + * Get the editable elements. + * + * @returns {Array} The editable elements. + */ + renderElements() { + var elements = _.map(this.doc.elements, (element) => { + return ( + + ); + }); + var lastComponent = elements[elements.length - 1]; + var lastElement = lastComponent ? lastComponent.props.element : null; + elements.push(); + return elements; + } +} + +InsertDocument.displayName = 'InsertDocument'; + +module.exports = InsertDocument; diff --git a/src/internal-packages/crud/lib/component/max-key-value.jsx b/src/internal-packages/crud/lib/component/max-key-value.jsx new file mode 100644 index 00000000000..b0468c82a42 --- /dev/null +++ b/src/internal-packages/crud/lib/component/max-key-value.jsx @@ -0,0 +1,41 @@ +'use strict'; + +const React = require('react'); + +/** + * The document value class. + */ +const VALUE_CLASS = 'document-property-value'; + +/** + * MaxKey value component. + */ +class MaxKeyValue extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.value = props.element.currentValue; + } + + /** + * Render a single max key value. + * + * @returns {React.Component} The element component. + */ + render() { + return ( +
    + MaxKey +
    + ); + } +} + +MaxKeyValue.displayName = 'MaxKeyValue'; + +module.exports = MaxKeyValue; diff --git a/src/internal-packages/crud/lib/component/min-key-value.jsx b/src/internal-packages/crud/lib/component/min-key-value.jsx new file mode 100644 index 00000000000..9751e98281f --- /dev/null +++ b/src/internal-packages/crud/lib/component/min-key-value.jsx @@ -0,0 +1,41 @@ +'use strict'; + +const React = require('react'); + +/** + * The document value class. + */ +const VALUE_CLASS = 'document-property-value'; + +/** + * MinKey value component. + */ +class MinKeyValue extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.value = props.element.currentValue; + } + + /** + * Render a single min key value. + * + * @returns {React.Component} The element component. + */ + render() { + return ( +
    + MinKey +
    + ); + } +} + +MinKeyValue.displayName = 'MinKeyValue'; + +module.exports = MinKeyValue; diff --git a/src/internal-packages/crud/lib/component/no-action.jsx b/src/internal-packages/crud/lib/component/no-action.jsx new file mode 100644 index 00000000000..b18dccf0bc7 --- /dev/null +++ b/src/internal-packages/crud/lib/component/no-action.jsx @@ -0,0 +1,39 @@ +'use strict'; + +const React = require('react'); + +/** + * The actions class. + */ +const ACTIONS = 'actions'; + +/** + * General element action component. + */ +class NoAction extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.element = props.element; + } + + /** + * Render a single editable key. + * + * @returns {React.Component} The element component. + */ + render() { + return ( +
    + ); + } +} + +NoAction.displayName = 'NoAction'; + +module.exports = NoAction; diff --git a/src/internal-packages/crud/lib/component/objectid-value.jsx b/src/internal-packages/crud/lib/component/objectid-value.jsx new file mode 100644 index 00000000000..90d7a77cbc6 --- /dev/null +++ b/src/internal-packages/crud/lib/component/objectid-value.jsx @@ -0,0 +1,41 @@ +'use strict'; + +const React = require('react'); + +/** + * The document value class. + */ +const VALUE_CLASS = 'document-property-value'; + +/** + * ObjectID value component. + */ +class ObjectIDValue extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.value = props.element.currentValue; + } + + /** + * Render a single object id value. + * + * @returns {React.Component} The element component. + */ + render() { + return ( +
    + {String(this.value)} +
    + ); + } +} + +ObjectIDValue.displayName = 'ObjectIDValue'; + +module.exports = ObjectIDValue; diff --git a/src/internal-packages/crud/lib/component/open-insert-dialog-button.jsx b/src/internal-packages/crud/lib/component/open-insert-dialog-button.jsx new file mode 100644 index 00000000000..c13261c1543 --- /dev/null +++ b/src/internal-packages/crud/lib/component/open-insert-dialog-button.jsx @@ -0,0 +1,38 @@ +'use strict'; + +const React = require('react'); + +/** + * Component for the open insert dialog button. + */ +class OpenInsertDialogButton extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + } + + /** + * Render the button. + * + * @returns {Component} The button component. + */ + render() { + return ( + + ); + } +} + +OpenInsertDialogButton.displayName = 'OpenInsertDialogButton'; + +module.exports = OpenInsertDialogButton; diff --git a/src/internal-packages/crud/lib/component/remove-action.jsx b/src/internal-packages/crud/lib/component/remove-action.jsx new file mode 100644 index 00000000000..f3d62b09f6a --- /dev/null +++ b/src/internal-packages/crud/lib/component/remove-action.jsx @@ -0,0 +1,48 @@ +'use strict'; + +const React = require('react'); + +/** + * The actions class. + */ +const ACTIONS = 'actions'; + +/** + * General element action component. + */ +class RemoveAction extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.element = props.element; + } + + /** + * Render a single editable key. + * + * @returns {React.Component} The element component. + */ + render() { + return ( +
    + +
    + ); + } + + /** + * Remove the change. + */ + handleClick() { + this.element.remove(); + } +} + +RemoveAction.displayName = 'RemoveAction'; + +module.exports = RemoveAction; diff --git a/src/internal-packages/crud/lib/component/revert-action.jsx b/src/internal-packages/crud/lib/component/revert-action.jsx new file mode 100644 index 00000000000..9313e9dc109 --- /dev/null +++ b/src/internal-packages/crud/lib/component/revert-action.jsx @@ -0,0 +1,48 @@ +'use strict'; + +const React = require('react'); + +/** + * The actions class. + */ +const ACTIONS = 'actions'; + +/** + * General element action component. + */ +class RevertAction extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.element = props.element; + } + + /** + * Render a single editable key. + * + * @returns {React.Component} The element component. + */ + render() { + return ( +
    + +
    + ); + } + + /** + * Revert the change. + */ + handleClick() { + this.element.revert(); + } +} + +RevertAction.displayName = 'RevertAction'; + +module.exports = RevertAction; diff --git a/src/internal-packages/crud/lib/component/sampling-message.jsx b/src/internal-packages/crud/lib/component/sampling-message.jsx new file mode 100644 index 00000000000..04cbd669622 --- /dev/null +++ b/src/internal-packages/crud/lib/component/sampling-message.jsx @@ -0,0 +1,104 @@ +'use strict'; + +const React = require('react'); +const app = require('ampersand-app'); +const ResetDocumentListStore = require('../store/reset-document-list-store'); +const RemoveDocumentStore = require('../store/remove-document-store'); +const InsertDocumentStore = require('../store/insert-document-store'); +const OpenInsertDialogButton = require('./open-insert-dialog-button'); + +/** + * The feature flag. + */ +const FEATURE = 'singleDocumentCrud'; + +/** + * Component for the sampling message. + */ +class SamplingMessage extends React.Component { + + /** + * Fetch the state when the component mounts. + */ + componentDidMount() { + this.unsubscribeReset = ResetDocumentListStore.listen(this.handleReset.bind(this)); + this.unsubscribeRemove = RemoveDocumentStore.listen(this.handleRemove.bind(this)); + this.unsibscribeInsert = InsertDocumentStore.listen(this.handleInsert.bind(this)); + } + + /** + * Unsibscribe from the document list store when unmounting. + */ + componentWillUnmount() { + this.unsubscribeReset(); + this.unsubscribeRemove(); + this.unsubscribeInsert(); + } + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.state = { count: 0 }; + } + + /** + * Handle the reset of the document list. + * + * @param {Array} documents - The documents. + * @param {Integer} count - The count. + */ + handleReset(documents, count) { + this.setState({ count: count }); + } + + /** + * Handles removal of a document from the document list. + */ + handleRemove() { + this.setState({ count: this.state.count - 1 }); + } + + /** + * Handle insert of a new document. + * + * @param {Boolean} success - If the insert was successful. + * @param {Object} object - The new document or error. + */ + handleInsert(success, object) { + if (success) { + this.setState({ count: this.state.count + 1 }); + } + } + + /** + * Render the sampling message. + * + * @returns {React.Component} The document list. + */ + render() { + return ( +
    + Query returned {this.state.count} documents. + + {this.renderInsertButton()} +
    + ); + } + + /** + * Render the insert button. + */ + renderInsertButton() { + if (app.isFeatureEnabled(FEATURE)) { + return (); + } + } +} + +SamplingMessage.displayName = 'SamplingMessage'; + +module.exports = SamplingMessage; diff --git a/src/internal-packages/crud/lib/component/timestamp-value.jsx b/src/internal-packages/crud/lib/component/timestamp-value.jsx new file mode 100644 index 00000000000..54a953246d0 --- /dev/null +++ b/src/internal-packages/crud/lib/component/timestamp-value.jsx @@ -0,0 +1,41 @@ +'use strict'; + +const React = require('react'); + +/** + * The document value class. + */ +const VALUE_CLASS = 'document-property-value'; + +/** + * Timestamp value component. + */ +class TimestampValue extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.value = props.element.currentValue; + } + + /** + * Render a single timestamp value. + * + * @returns {React.Component} The element component. + */ + render() { + return ( +
    + {String(this.value)} +
    + ); + } +} + +TimestampValue.displayName = 'TimestampValue'; + +module.exports = TimestampValue; diff --git a/src/internal-packages/crud/lib/component/types.jsx b/src/internal-packages/crud/lib/component/types.jsx new file mode 100644 index 00000000000..88de6fbf48e --- /dev/null +++ b/src/internal-packages/crud/lib/component/types.jsx @@ -0,0 +1,114 @@ +'use strict'; + +const React = require('react'); +const Element = require('hadron-document').Element; +const TypeChecker = require('hadron-type-checker'); + +/** + * General types component. + */ +class Types extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.element = props.element; + } + + /** + * Handles a change in the type. + * + * @param {Event} evt - The event. + */ + handleTypeChange(evt) { + this.element.edit(TypeChecker.cast(this.castableValue(), evt.target.innerText)); + } + + /** + * Render a type list. + * + * @returns {React.Component} The element component. + */ + render() { + return this.element.isValueEditable() ? this.renderDropdown() : this.renderLabel(); + } + + /** + * Render the type list dropdown. + * + * @returns {Component} The react component. + */ + renderDropdown() { + return ( +
    + +
      + {this.renderTypes()} +
    +
    + ); + } + + /** + * Render the type list label. + * + * @returns {Component} The react component. + */ + renderLabel() { + return ( +
    + {this.element.currentType} +
    + ); + } + + /** + * Render the types + * + * @returns {Component} The react component. + */ + renderTypes() { + return _.map(TypeChecker.castableTypes(this.castableValue()), (type) => { + return ( +
  • + {type} +
  • + ); + }); + } + + /** + * Get the castable value for this value. + * + * @returns {Object} The cast value. + */ + castableValue() { + if (this.element.elements) { + if (this.element.currentType === 'Object') { + return {}; + } + return _.map(this.element.elements, (element) => { + return element.currentValue; + }); + } + return this.element.currentValue; + } +} + +Types.displayName = 'Types'; + +module.exports = Types; diff --git a/src/internal-packages/crud/lib/component/update-button.jsx b/src/internal-packages/crud/lib/component/update-button.jsx new file mode 100644 index 00000000000..874f5a56733 --- /dev/null +++ b/src/internal-packages/crud/lib/component/update-button.jsx @@ -0,0 +1,38 @@ +'use strict'; + +const React = require('react'); + +/** + * Component for the update button. + */ +class UpdateButton extends React.Component { + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + } + + /** + * Render the button. + * + * @returns {Component} The button component. + */ + render() { + return ( + + ); + } +} + +UpdateButton.displayName = 'UpdateButton'; + +module.exports = UpdateButton; diff --git a/src/internal-packages/crud/lib/store/document-list-store.js b/src/internal-packages/crud/lib/store/document-list-store.js deleted file mode 100644 index ef325eb930b..00000000000 --- a/src/internal-packages/crud/lib/store/document-list-store.js +++ /dev/null @@ -1,55 +0,0 @@ -'use strict'; - -const Reflux = require('reflux'); -const app = require('ampersand-app'); -const NamespaceStore = require('hadron-reflux-store').NamespaceStore; -const Action = require('hadron-action'); - -/** - * The reflux store for the list of documents. - */ -const DocumentListStore = Reflux.createStore({ - - /** - * Initialize the document list store. - */ - init: function() { - this.listenTo(Action.filterChanged, this._resetDocuments); - this.listenTo(Action.fetchNextDocuments, this._fetchNextDocuments); - }, - - /** - * This function is called when the collection filter changes. - * - * @param {Object} filter - The query filter. - */ - _resetDocuments: function(filter) { - var ns = NamespaceStore.ns; - if (ns) { - app.dataService.count(ns, filter, {}, (err, count) => { - var options = { limit: 20, sort: [[ '_id', 1 ]] }; - app.dataService.find(ns, filter, options, (error, documents) => { - this.trigger(documents, true, count); - }); - }); - } - }, - - /** - * Fetch the next page of documents. - * - * @param {Integer} currentPage - The current page in the view. - */ - _fetchNextDocuments: function(currentPage) { - var ns = NamespaceStore.ns; - if (ns) { - var filter = app.queryOptions.query.serialize(); - var options = { skip: (currentPage * 20), limit: 20, sort: [[ '_id', 1 ]] }; - app.dataService.find(ns, filter, options, (error, documents) => { - this.trigger(documents, false); - }); - } - } -}); - -module.exports = DocumentListStore; diff --git a/src/internal-packages/crud/lib/store/insert-document-store.jsx b/src/internal-packages/crud/lib/store/insert-document-store.jsx new file mode 100644 index 00000000000..770f52afa22 --- /dev/null +++ b/src/internal-packages/crud/lib/store/insert-document-store.jsx @@ -0,0 +1,42 @@ +'use strict'; + +const Reflux = require('reflux'); +const app = require('ampersand-app'); +const NamespaceStore = require('hadron-reflux-store').NamespaceStore; +const Actions = require('../actions'); + +/** + * The reflux store for inserting documents. + */ +const InsertDocumentStore = Reflux.createStore({ + + /** + * Initialize the insert document list store. + */ + init: function() { + this.listenTo(Actions.insertDocument, this.insertDocument); + }, + + /** + * Insert the document. + * + * @param {Document} doc - The document to insert. + */ + insertDocument: function(doc) { + app.dataService.insertOne(NamespaceStore.ns, doc, {}, this.handleResult.bind(this)); + }, + + /** + * Handle the result from the driver. + * + * @param {Error} error - The error. + * @param {Object} doc - The document. + * + * @returns {Object} The trigger event. + */ + handleResult: function(error, doc) { + return (error) ? this.trigger(false, error) : this.trigger(true, doc); + } +}); + +module.exports = InsertDocumentStore; diff --git a/src/internal-packages/crud/lib/store/load-more-documents-store.js b/src/internal-packages/crud/lib/store/load-more-documents-store.js new file mode 100644 index 00000000000..22c955b50c1 --- /dev/null +++ b/src/internal-packages/crud/lib/store/load-more-documents-store.js @@ -0,0 +1,34 @@ +'use strict'; + +const Reflux = require('reflux'); +const app = require('ampersand-app'); +const NamespaceStore = require('hadron-reflux-store').NamespaceStore; +const Action = require('hadron-action'); + +/** + * The reflux store for loading more documents. + */ +const LoadMoreDocumentsStore = Reflux.createStore({ + + /** + * Initialize the reset document list store. + */ + init: function() { + this.listenTo(Action.fetchNextDocuments, this.loadMoreDocuments); + }, + + /** + * Fetch the next page of documents. + * + * @param {Integer} skip - The number of documents to skip. + */ + loadMoreDocuments: function(skip) { + var filter = app.queryOptions.query.serialize(); + var options = { skip: skip, limit: 20, sort: [[ '_id', 1 ]] }; + app.dataService.find(NamespaceStore.ns, filter, options, (error, documents) => { + this.trigger(documents); + }); + } +}); + +module.exports = LoadMoreDocumentsStore; diff --git a/src/internal-packages/crud/lib/store/open-insert-document-dialog-store.js b/src/internal-packages/crud/lib/store/open-insert-document-dialog-store.js new file mode 100644 index 00000000000..67fc1392043 --- /dev/null +++ b/src/internal-packages/crud/lib/store/open-insert-document-dialog-store.js @@ -0,0 +1,35 @@ +'use strict'; + +const Reflux = require('reflux'); +const Actions = require('../actions'); +const HadronDocument = require('hadron-document'); + +/** + * The reflux store for opening the insert document dialog. + */ +const OpenInsertDocumentDialogStore = Reflux.createStore({ + + /** + * Initialize the reset document list store. + */ + init: function() { + this.listenTo(Actions.openInsertDocumentDialog, this.openInsertDocumentDialog); + }, + + /** + * Open the insert document dialog. + * + * @param {Object} doc - The document to open the dialog with. + */ + openInsertDocumentDialog: function(doc) { + var hadronDoc = new HadronDocument(doc, true); + // We need to remove the _id or we will get an duplicate key error on + // insert, and we currently do not allow editing of the _id field. + if (hadronDoc.elements[0].currentKey === '_id') { + hadronDoc.elements.shift(); + } + this.trigger(hadronDoc); + } +}); + +module.exports = OpenInsertDocumentDialogStore; diff --git a/src/internal-packages/crud/lib/store/remove-document-store.js b/src/internal-packages/crud/lib/store/remove-document-store.js new file mode 100644 index 00000000000..c930a6c7daf --- /dev/null +++ b/src/internal-packages/crud/lib/store/remove-document-store.js @@ -0,0 +1,28 @@ +'use strict'; + +const Reflux = require('reflux'); +const Actions = require('../actions'); + +/** + * The reflux store for removing a document from the list. + */ +const RemoveDocumentStore = Reflux.createStore({ + + /** + * Initialize the reset document list store. + */ + init: function() { + this.listenTo(Actions.documentRemoved, this.remove); + }, + + /** + * This function is called when when a document is deleted. + * + * @param {Object} id - The document id. + */ + remove: function(id) { + this.trigger(id); + }, +}); + +module.exports = RemoveDocumentStore; diff --git a/src/internal-packages/crud/lib/store/reset-document-list-store.js b/src/internal-packages/crud/lib/store/reset-document-list-store.js new file mode 100644 index 00000000000..c7c764f3483 --- /dev/null +++ b/src/internal-packages/crud/lib/store/reset-document-list-store.js @@ -0,0 +1,35 @@ +'use strict'; + +const Reflux = require('reflux'); +const app = require('ampersand-app'); +const NamespaceStore = require('hadron-reflux-store').NamespaceStore; +const Action = require('hadron-action'); + +/** + * The reflux store for resetting the document list. + */ +const ResetDocumentListStore = Reflux.createStore({ + + /** + * Initialize the reset document list store. + */ + init: function() { + this.listenTo(Action.filterChanged, this.reset); + }, + + /** + * This function is called when the collection filter changes. + * + * @param {Object} filter - The query filter. + */ + reset: function(filter) { + app.dataService.count(NamespaceStore.ns, filter, {}, (err, count) => { + var options = { limit: 20, sort: [[ '_id', 1 ]] }; + app.dataService.find(NamespaceStore.ns, filter, options, (error, documents) => { + this.trigger(documents, count); + }); + }); + }, +}); + +module.exports = ResetDocumentListStore; diff --git a/src/internal-packages/crud/styles/crud.less b/src/internal-packages/crud/styles/crud.less new file mode 100644 index 00000000000..fec01559378 --- /dev/null +++ b/src/internal-packages/crud/styles/crud.less @@ -0,0 +1,402 @@ +li.document-list-item.editing { + box-shadow: 2px 5px 8px gainsboro; +} + +.column.main { + .sampling-message { + } +} + +.document-footer { + font-family: "Akzidenz", "Helvetica Neue", Helvetica, Arial, sans-serif; + height: 28px; + vertical-align: middle; + top: 13px; + position: relative; + + .edit-message { + font-size: 14px; + font-style: italic; + padding-left: 10px; + padding-top: 4px; + color: @pw; + overflow: hidden; + height: 28px; + } + + .document-footer-actions { + position: absolute; + right: 10px; + top: 2px; + padding-top: 1px; + + .cancel { + font-weight: bold; + } + + .update { + font-weight: bold; + border-radius: 3px; + } + } + + &.modified { + background-color: @alertOrange; + + .document-footer-actions { + + .cancel { + color: @alertOrangeBorder; + } + + .update { + color: @alertOrangeBorder; + border-color: @alertOrangeBorder; + + &:hover { + background-color: rgba(255,255,255,0.7); + } + } + } + } + &.in-progress.in-progress { + background-color: @chart1; + } + &.success { + background-color: @green2; + } + &.error { + background-color: red; + + .document-footer-actions { + + .cancel { + color: @alertRedBg; + } + } + } + &.viewing { + background-color: #F8F8F8; + + .document-footer-actions { + + .cancel { + color: #B8B8B8; + } + } + } +} + +ol.document-property-body:hover { + .document-actions { + button { + visibility: visible; + } + } +} + +.document-elements { + width: 75%; + counter-reset: line; + position: relative; + + .document-property.array { + margin-left: 0px !important; + } + + .document-property.object { + margin-left: 0px !important; + } + + .line-number { + display: inline-block; + color: @gray4; + width: 18px; + height: 17px; + text-align: center; + position: absolute; + left: -18px; + + &::before { + counter-increment: line; + content: counter(line); + } + } + + .types { + display: inline-block; + float: right; + width: 100px; + color: #999999; + height: 17px; + + .type-label { + padding: 1px 12px; + } + + .btn { + background-color: transparent; + color: @gray4; + border: none; + text-transform: none; + font-size: 11px; + border-radius: 8px; + padding: 1px 12px; + } + + .caret { + visibility: hidden; + // margin-right: 0px; + // margin-left: 4px; + } + + .dropdown-menu { + li { + span { + display: block; + padding: 3px 20px; + clear: both; + font-weight: normal; + font-size: 11px; + color: #333333; + white-space: nowrap; + } + span:hover { + color: #313030; + text-decoration: none; + background: #e6e6e6; + } + } + } + } + + li.document-property.non-expandable:hover { + background-color: @gray8; + + input { + background-color: @gray8; + + &:focus { + background-color: @pw; + } + } + + .actions { + visibility: visible; + } + + .types { + .btn { + background-color: @gray6; + color: @gray0; + } + .caret { + visibility: visible; + } + } + } + + li.document-property { + // position: relative; + + .actions { + display: inline-block; + visibility: hidden; + width: 18px; + margin-right: 8px; + text-align: center; + cursor: pointer; + color: #999999; + } + + &.edited { + background-color: @alertOrangeBg; + + input { + background-color: @alertOrangeBg; + + &:focus { + background-color: @pw; + } + } + + .line-number { + background-color: @alertOrange; + color: @pw; + } + } + + &.added { + background-color: @greenBg; + + input { + background-color: @greenBg; + + &:focus { + background-color: @pw; + } + } + + .line-number { + background-color: @green2; + color: @pw; + } + } + + &.removed { + background-color: @alertRedBg; + + input { + background-color: @alertRedBg; + + &:focus { + background-color: @pw; + } + } + + .line-number { + background-color: @alertRed; + color: @pw; + } + } + } + + div.document-property-header.expandable:hover { + background-color: #ebebed !important; + + input { + background-color: #ebebed !important; + } + + .line-number { + background-color: #ebebed !important; + } + + .actions { + visibility: visible; + } + + .types { + .btn { + border-color: #8c8c8c; + } + .caret { + visibility: visible; + } + } + } + + .hotspot { + height: 10px; + cursor: text; + } +} + +.document-actions { + position: absolute; + top: 0; + right: 10px; + + button { + visibility: hidden; + } +} + +input.editable-key { + position: relative; + font-weight: bold; + border: none; + padding-left: 1px; + z-index: 1; + + &.editing { + border: 1px solid #999999; + box-shadow: 0px 2px 4px 0px rgba(0,0,0,0.2); + z-index: 10; + margin-top: -1px; + margin-bottom: -1px; + + &.duplicate { + border: 1px solid red; + } + } + + &.duplicate { + color: red; + } +} + +input.editable-value { + position: relative; + border: none; + padding-left: 1px; + z-index: 1; + + &.editing { + border: 1px solid #999999; + box-shadow: 0px 2px 4px 0px rgba(0,0,0,0.2); + z-index: 10; + margin-top: -1px; + margin-bottom: -1px; + } +} + +.document-property.string > .editable-value { + color: steelblue; +} + +.document-property.number > .editable-value { + color: green; +} + +.document-property.boolean > .editable-value { + color: purple; +} + +.document-property.date > .editable-value { + color: firebrick; +} + +.modal-body { + .document-elements { + width: 100%; + + li.document-property { + + .actions { + display: inline-block; + visibility: hidden; + width: 18px; + margin-right: 8px; + text-align: center; + cursor: pointer; + color: #999999; + } + + &.added { + background-color: #FFFFFF; + + input { + background-color: #FFFFFF; + + &:focus { + background-color: @pw; + } + } + + .line-number { + background-color: #FFFFFF; + color: #999999; + } + } + } + } +} + +.modal-footer { + .btn.insert { + border-color: green; + background-color: @green2; + color: #FFFFFF; + } +}