├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src ├── app.jsx ├── components │ ├── cells.css │ ├── cells.jsx │ ├── firebase-table.jsx │ └── row.jsx ├── entry.js ├── index.html └── utils.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Chris Villa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # firestation 2 | A simple, configurable, realtime admin interface for Firebase, built on React. 3 | 4 | See a live demo [here](https://s3-eu-west-1.amazonaws.com/firestation/demo/index.html) (and view the source [here](http://github.com/chrisvxd/firestation-demo)). 5 | 6 | ![Screenshot](https://s3-eu-west-1.amazonaws.com/firestation/Screenshot%202016-08-25%2017.43.59.png) 7 | 8 | 9 | ## Global Dependencies 10 | 11 | - npm 12 | - webpack 13 | 14 | 15 | ## Installation 16 | 17 | Clone this repository. 18 | 19 | Setup packages: 20 | 21 | npm i 22 | 23 | 24 | 25 | ## Example 26 | 27 | You need to add a `firestation.config.js` file in the root of the project. This is where you configure your dashboard. Here's an example: 28 | 29 | import {ImageCell, TextCell, SelectCell} from 'components/cells.jsx'; 30 | 31 | export default { 32 | auth: myAuthMethod, 33 | title: 'Pet Owners Database' 34 | refs: [ 35 | { 36 | ref: myFirebaseRef.child('pets'), 37 | title: 'My Favorite Pets', 38 | orderBy: 'size.height', 39 | orderByDirection: 'desc', 40 | children: [ 41 | { 42 | key: 'picture', 43 | title: 'Profile Pic', 44 | cell: ImageCell, 45 | cellProps: { 46 | width: '120', 47 | height: '120' 48 | }, 49 | canFilter: false 50 | }, 51 | { 52 | key: '_key', 53 | cell: TextCell 54 | }, 55 | { 56 | key: 'name', 57 | cell: TextCell 58 | }, { 59 | key: 'size.height', 60 | title: 'Height', 61 | cell: TextCell 62 | }, 63 | { 64 | key: 'size.weight', 65 | title: 'Weight', 66 | cell: TextCell 67 | }, 68 | { 69 | key: 'species', 70 | cell: SelectCell, 71 | cellProps: { 72 | options: [ 73 | { 74 | value: 'cat', 75 | title: 'Cat' 76 | }, 77 | { 78 | value: 'dog', 79 | title: 'Dog' 80 | } 81 | ] 82 | } 83 | } 84 | ] 85 | }, 86 | { 87 | ref: myFirebaseRef.child('owners').orderByChild('lazy').equalTo(true) // Full ref chaining support 88 | title: 'Lazy Owners', 89 | resolve: function (key, val, callback) { // Custom firebase resolve method 90 | val.calculatedLaziness = Math.random(); 91 | callback(val); 92 | }, 93 | children: [ 94 | { 95 | key: 'surname', 96 | cell: TextCell, 97 | canWrite: true 98 | }, 99 | { 100 | key: 'lazy', 101 | cell: TextCell 102 | }, 103 | { 104 | key: 'calculatedLaziness', 105 | cell: TextCell 106 | } 107 | ] 108 | } 109 | ] 110 | } 111 | 112 | That's it. The `ref` array contains configuration for each firebase ref you want to render in the dashboard, and each of the objects in `children` represent a column that will be rendered for a key on _that ref_, and defines how to render them. 113 | 114 | 115 | ## Configuration API 116 | `firestation.config.js` contains the configuration for your firestation. It's important to do your exports using `ES6`, as shown in the [example above](#example). You'll need to export an object containing your configuration. 117 | 118 | Firestation works by rendering a table for each `ref` you're interested in, with each column representing a `key` for the objects in that `ref`. 119 | 120 | 121 | ### Top-level 122 | The top level defines how to connect to your firebase: 123 | 124 | - `auth` (function) - an authentication method to authenticate with your firebase server. It's up to you to implement that, but is should take a `callback`. 125 | - `title` (string) - the title of your database. This will render in the top left. 126 | - `refs` (array) - an array of `ref` configuration that will describe how to render your firebase. [See Refs](#refs) for more. 127 | 128 | Example: 129 | 130 | var myFirebaseRef = ...; 131 | 132 | // Custom authentication method. Up to you to implement. 133 | var myAuthMethod = function (callback) { 134 | ... 135 | callback(); 136 | }; 137 | 138 | export default { 139 | auth: myAuthMethod, 140 | refs: [...] 141 | } 142 | 143 | 144 | ### Refs 145 | Each item in the top-level `refs` renders to a table. It defines a [firebase ref](https://www.firebase.com/docs/web/guide/understanding-data.html#section-creating-references) for your database, and configuration for how it should be rendered: 146 | 147 | - `ref` (Firebase) - a full firebase reference for the child you want to create a table for. Supports full chaining, such as `orderByChild`. 148 | - `children` (array) - an array of configurations that define how each key should be rendered. See [Cell API](#cell-api) for cell configuration. 149 | - `resolve` (function, optional) - a custom method for running custom resolves before rendering the item ([see Resolves](#resolves)) 150 | - `title` (string, optional) - human-readable title for this ref configuration 151 | - `orderBy` (string, optional) - the default `key` to order your 152 | - `orderByDirection` (string, optional) - the direction (`asc` or `desc`) for the ordering of your `orderBy` value 153 | - `rangeStart` (integer, optional) - the initial index of the first item in rendered range. Defaults to `1` 154 | - `rangeEnd` (integer, optional) - the initial index of the last of the rendered range. Defaults to `10` 155 | 156 | Example: 157 | 158 | { 159 | ref: myFirebaseRef.child('pets'), 160 | title: 'Pets', 161 | orderBy: 'age', 162 | orderByDirection: 'asc', 163 | resolve: function (value, callback) { 164 | value.myExtraField = 'Wohoo!'; 165 | callback(value); 166 | }, 167 | rangeStart: 1, 168 | rangeEnd: 25, 169 | children: [ 170 | ... 171 | ] 172 | } 173 | 174 | 175 | ## Cell API 176 | 177 | Cells render your firebase values depending on their type and how you want to display them. For example, there's an `ImageCell` which loads an image from a URL, or just a lowly `TextCell`. Many of them have both read and write states, making them extremely powerful for managing your data. They are all built on [`React`](http://facebook.github.io/react/), which makes [defining custom cells](#custom-cells) extremely easily. 178 | 179 | Each configuration takes the following properties: 180 | 181 | - `key` (string) - The firebase key for this column 182 | - `cell` (object) - The cell to render the value with (see below) 183 | - `cellProps` (object) - Cell-specific attributes which are passed to the cell 184 | - `title` (string) - A human-readable name for this key 185 | - `canFilter` (boolean, optional) - Whether this column can be filtered using column searching 186 | - `canWrite` (boolean, optional) - Whether the cells can be used in write-mode (currently only partial support) 187 | - `width` (string or integer, optional) - Width of the cell column ([passed directly to ``](http://www.w3schools.com/tags/att_col_width.asp)) 188 | 189 | `key` can also take some special values, indiciated by a `_` prefix: 190 | 191 | - `_key` - The key (id) for this object 192 | 193 | Firestation provides various cells for common use cases: 194 | 195 | - [`TextCell`](#text-cell) 196 | - [`LongTextCell`](#long-text-cell) 197 | - [`NumberCell`](#number-cell) 198 | - [`BooleanCell`](#boolean-cell) 199 | - [`ImageCell`](#image-cell) 200 | - [`SelectCell`](#select-cell) 201 | - [`TimeSinceCell`](#time-since-cell) 202 | - [`DateCell`](#date-cell) 203 | - [`CurrencyCell`](#currency-cell) 204 | - [`ButtonCell`](#button-cell) 205 | - [`DropdownCell`](#dropdown-cell) 206 | 207 | We're adding to this (see [Future Cells](#future-cells)), but [you can write custom react cells](#custom-cells) if you need anything fancy. 208 | 209 | 210 | ### TextCell 211 | Renders the value as simple text. Does not require any `cellProps`. 212 | 213 | This cell _does_ support the `canWrite` method. 214 | 215 | 216 | ### LongTextCell 217 | Renders the value as text in a mulitline textarea. Does not require any `cellProps`. 218 | 219 | This cell _does_ support the `canWrite` method. 220 | 221 | 222 | ### Number 223 | Renders the value as number. Does not require any `cellProps`. 224 | 225 | This cell _does_ support the `canWrite` method. 226 | 227 | 228 | ### BooleanCell 229 | Renders a boolean value as a checkbox. Takes the following `cellProps`: 230 | 231 | - `label` (string, optional) - inline label for the checkbox 232 | 233 | Example: 234 | 235 | { 236 | key: 'likesChicken', 237 | cell: BooleanCell, 238 | cellProps: { 239 | label: 'Chicken Lover?' 240 | } 241 | } 242 | 243 | This cell _does_ support the `canWrite` method. 244 | 245 | 246 | ### ImageCell 247 | Renders the value as an image. Takes the following `cellProps`: 248 | 249 | - `width` (string) - width of the image 250 | - `height` (string) - height of the image 251 | 252 | Example: 253 | 254 | { 255 | key: 'species', 256 | cell: SelectCell, 257 | cellProps: { 258 | width: '50', 259 | height: '50' 260 | } 261 | } 262 | 263 | This cell __does not__ support the `canWrite` method. 264 | 265 | 266 | ### SelectCell 267 | Renders value as an option in a list of options, with human-readable counterparts. Takes the following `cellProps`: 268 | 269 | - `options` (array) - contains objects with the `key` and `title` for each possible value for this key 270 | 271 | Example: 272 | 273 | { 274 | key: 'species', 275 | cell: SelectCell, 276 | cellProps: { 277 | options: [ 278 | { 279 | value: 'cat', 280 | title: 'Cat' 281 | }, 282 | { 283 | value: 'dog', 284 | title: 'Dog' 285 | } 286 | ] 287 | } 288 | } 289 | 290 | This cell _does_ support the `canWrite` method. 291 | 292 | 293 | ### DateCell 294 | Renders a date in seconds or ISO format to a human-readable date. Write mode uses [a datepicker](https://github.com/YouCanBookMe/react-datetime) to make date selection easy. Formats for your locale and handles daylight changes. 295 | 296 | - `dateFormat` (string) - date format. Defaults to UK format, i.e. `DD/MM/YY`. Follows the [moment.js format](http://momentjs.com/docs/#/displaying/format/) 297 | - `timeFormat` (string) - time format. Also follows the [moment.js format](http://momentjs.com/docs/#/displaying/format/) 298 | - `saveFormat` (string) - the format to save to firebase. Either `numeric` (in ms) or `iso`. Defaults to `iso`. 299 | 300 | Example: 301 | 302 | { 303 | key: 'startAt', 304 | cell: Start, 305 | cellProps: { 306 | dateFormat: 'MM/DD/YY', 307 | saveFormat: 'numeric' 308 | }, 309 | canWrite: true 310 | } 311 | 312 | This cell _does_ support the `canWrite` method. 313 | 314 | 315 | ### TimeSinceCell 316 | Renders a date in seconds or ISO format to a time since string, for example "2 days ago". 317 | 318 | This cell __does not__ support the `canWrite` method. 319 | 320 | 321 | ### CurrencyCell 322 | Renders a number as a currency. Takes the following `cellProps`: 323 | 324 | - `symbol` (string) - object containing the `key` and `title` of each option 325 | 326 | Support for adding trailing zeros will be added. 327 | 328 | Example: 329 | 330 | { 331 | key: 'price', 332 | cell: CurrencyCell, 333 | cellProps: { 334 | symbol: '£' 335 | } 336 | } 337 | 338 | This cell __does not__ support the `canWrite` method. 339 | 340 | 341 | ### ButtonCell 342 | Renders a button. Takes the following `cellProps`: 343 | 344 | - `title` (string) - title of the button 345 | - `type` (string) - type of the button. Options are `primary`, `success`, `warning` and `danger`. Defaults to `primary` 346 | - `disabled` (string) - whether the cell is disabled. Defaults to `false` 347 | - `action` (function) - method to call when the button is clicked. You can modify `title`, `type` and `disabled` in the callback. 348 | 349 | Simple example: 350 | 351 | { 352 | key: '', 353 | cell: ButtonCell, 354 | cellProps: { 355 | title: 'Click me!', 356 | action: function (rowKey, rowValue, callback) { 357 | console.log('Woohoo! Performed action on', rowKey); 358 | 359 | callback({ 360 | disabled: true 361 | }); 362 | } 363 | } 364 | } 365 | 366 | 367 | ### DropdownCell 368 | Renders a dropdown that can trigger actions. Takes the following `cellProps`: 369 | 370 | - `title` (string) - title of the button 371 | - `disabled` (string) - whether the cell is disabled. Defaults to `false` 372 | - `items` (function) - an array of items to render, made up of `actions`, `dividers` and `headers`. See example below for usage. 373 | 374 | Simple example: 375 | 376 | { 377 | key: '', 378 | cell: DropdownCell, 379 | cellProps: { 380 | title: 'Drop it like it's hot', 381 | items: [ 382 | { 383 | label: 'Action', 384 | action: function (rowKey, rowValue) { 385 | console.log('Bam!', rowKey, rowValue); 386 | } 387 | }, 388 | { label: 'Another action' }, 389 | { label: 'Something else here' }, 390 | { type: 'divider' }, 391 | { type: 'header', label: 'Dropdown header' }, 392 | { label: 'Separated link' } 393 | ] 394 | } 395 | } 396 | 397 | 398 | ### Future Cells 399 | 400 | Cells planned but not yet implemented: 401 | 402 | No cells are currently planned at this time. 403 | 404 | 405 | ### Custom Cells 406 | All cells are [built on react](http://facebook.github.io/react/). They are totally pluggable, so it is possible to write custom cells. It is **not** necessary to have an extensive knowledge of React to implement them. Here is a basic, read-only cell that renders an image: 407 | 408 | import React from 'react'; 409 | 410 | var FancyImageCell = React.createClass({ 411 | render: function () { 412 | return ( 413 | 417 | 418 | ); 419 | } 420 | }); 421 | 422 | This is all basic React. The properties (`this.props`) that the cell will from firestation receive are: 423 | 424 | - `value` - the value for the key this cell will render 425 | - `clean` (bool) - whether the value of this cell is in sync with firebase. Useful for handling write state. 426 | - `rowKey` - the value for the entire row 427 | - `rowValue` - the key for entire row 428 | - `extras` (object) - the [`cellProps`](#cell-api) for that configuration. These could be anything you need. 429 | - `canWrite` (bool) - the [`canWrite`](#cell-api) for that configuration, determining if the cell should be writable 430 | - `childKey` (string) - the `key` that this cell is rendering (e.g. `age`) 431 | - `valueChanged` (function) - a method to inform firestation that the value has changed, enabling the Save Button which will write the changes to firebase. 432 | 433 | Here's a more advanced example, implementing a read-and-writable cell that renders a text input: 434 | 435 | import React from 'react'; 436 | 437 | var FancyTextCell = React.createClass({ 438 | getInitialState: function () { 439 | return { 440 | value: this.props.value 441 | } 442 | }, 443 | handleChange: function (event) { 444 | this.setState({ 445 | value: event.target.value 446 | }); 447 | this.props.valueChanged(this.props.childKey, event.target.value); 448 | }, 449 | render: function () { 450 | // We're in sync with firebase, so ensure values of props and state match. 451 | if (this.props.clean) { 452 | this.state.value = this.props.value; 453 | } 454 | 455 | if (this.props.canWrite) { 456 | // Read and write 457 | return ( 458 | 459 | ) 460 | } else { 461 | // Read only 462 | return ( 463 | 464 | ) 465 | } 466 | } 467 | }); 468 | 469 | You can do whatever you want with the functionality of these cells, easily tailoring firestation to your specific needs. 470 | 471 | 472 | ## Resolves 473 | Resolve methods allow you to modify the `value` for each Firebase Snapshot _before_ it gets rendered in the table. This greatly extends the capability of firestation, enabling you to do things calculate values or even run extra firebase calls to flatten related objects! 474 | 475 | Calculation Example - adding a `yearBorn` value when we only know the age: 476 | 477 | function (key, value, callback) { 478 | var currentYear = new Date().getFullYear(); 479 | value.yearBorn = currentYear - value.age; 480 | callback(value); 481 | } 482 | 483 | Flattening (denormalizing) related objects Example - `owner` onto `pet` using the `owner` key: 484 | 485 | function (key, petValue, callback) { 486 | myFirebaseRef.child('owners').child(petValue.ownerKey).once('value', function (ownerSnapshot) { 487 | petValue.owner = ownerSnapshot.value(); 488 | callback(petValue); 489 | }); 490 | } 491 | 492 | Neat, huh? 493 | 494 | A small caveat with this method for flattening resources is that firestation will not monitor for or render changes in the flattened object, only the main resource. You could use `.on('child_changed')` or similar method to address this, but this is not officially supported or tested and firestation may behave unexpectedly. 495 | 496 | __Please note, since these values are calculated and do not actually exist on the firebase resource, they are intrinsically read-only. It is impossible to write to them.__ 497 | 498 | 499 | ## Favicons 500 | You can add favicons to your project. `index.html` will render favicons rendered by [RealFaviconGenerator](http://realfavicongenerator.net), placed in the `favicons` directory of your project (probably in `dist`). 501 | 502 | 503 | ## Running locally 504 | 505 | Just use 506 | 507 | npm start 508 | 509 | Now go to http://127.0.0.1:8080. 510 | 511 | 512 | ## Building for distribution 513 | 514 | Build that shiz: 515 | 516 | webpack 517 | 518 | Deploy `dist` wherever. 519 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firestation", 3 | "version": "0.1.0", 4 | "description": "A simple, configurable admin interface for firebase", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "rsync -a src/index.html dist/ && webpack-dev-server --progress --colors --content-base dist", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/chrisvxd/firestation.git" 13 | }, 14 | "author": "Chris Villa ", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/chrisvxd/firestation/issues" 18 | }, 19 | "homepage": "https://github.com/chrisvxd/firestation#readme", 20 | "dependencies": { 21 | "elemental": "^0.5.11", 22 | "firebase": "^2.4.0", 23 | "lodash": "^4.3.0", 24 | "moment": "^2.11.2", 25 | "react": "^0.14.7", 26 | "react-addons-css-transition-group": "^0.14.7", 27 | "react-datetime": "^2.5.0", 28 | "react-dom": "^0.14.7", 29 | "string_score": "^0.1.22" 30 | }, 31 | "devDependencies": { 32 | "babel-core": "^6.4.5", 33 | "babel-loader": "^6.2.2", 34 | "babel-preset-es2015": "^6.3.13", 35 | "babel-preset-react": "^6.3.13", 36 | "css-loader": "^0.23.1", 37 | "less": "^2.6.0", 38 | "less-loader": "^2.2.2", 39 | "style-loader": "^0.13.0", 40 | "webpack": "^1.12.13", 41 | "webpack-dev-server": "^1.14.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import configuration from '../firestation.config.js'; 5 | 6 | import 'elemental/less/elemental.less'; 7 | 8 | var elemental = require('elemental'); 9 | 10 | var GridRow = elemental.Row; 11 | var GridCol = elemental.Col; 12 | var Card = elemental.Card; 13 | var Form = elemental.Form; 14 | var FormField = elemental.FormField; 15 | var FormInput = elemental.FormInput; 16 | var Spinner = elemental.Spinner; 17 | var Glyph = elemental.Glyph; 18 | 19 | import FirebaseTable from 'components/firebase-table.jsx'; 20 | 21 | import {getNestedValue, setNestedValue} from './utils.js'; 22 | 23 | var defaultResolve = function (key, val, callback) { 24 | callback(val); 25 | }; 26 | 27 | export default React.createClass({ 28 | componentWillMount: function () { 29 | this.items = {}; 30 | this.layouts = {}; 31 | this.refSelected(0); 32 | }, 33 | getInitialState: function () { 34 | document.title = configuration.title + ' | ' + configuration.refs[0].title; 35 | return { 36 | currentRefIndex: 0, 37 | currentItems: [], 38 | currentHandlers: {}, 39 | rangeStart: configuration.refs[0].rangeStart || 1, 40 | rangeEnd: configuration.refs[0].rangeEnd || 10, 41 | filteredSize: 0, 42 | loaded: false 43 | }; 44 | }, 45 | refSelected: function (refIndex) { 46 | document.title = configuration.title + ' | ' + configuration.refs[refIndex].title; 47 | this.setState({ 48 | currentRefIndex: refIndex, 49 | currentItems: [], 50 | rangeStart: configuration.refs[refIndex].rangeStart || 1, 51 | rangeEnd: configuration.refs[refIndex].rangeEnd || 10 52 | }); 53 | 54 | if (this.items[refIndex] === undefined) { 55 | this.setState({loaded: false}); 56 | this.layouts[refIndex] = { 57 | filters: {}, 58 | orderBy: configuration.refs[refIndex].orderBy, 59 | orderByDirection: configuration.refs[refIndex].orderByDirection, 60 | }; 61 | this.makeQuery(refIndex); 62 | } else { 63 | this.layouts[refIndex].childAdded = false; 64 | this.layouts[refIndex].childChanged = false; 65 | this.layouts[refIndex].childRemoved = false; 66 | this.prepareAndSetState(refIndex); 67 | } 68 | }, 69 | makeQuery: function (refIndex) { 70 | this.items[refIndex] = []; 71 | 72 | const config = configuration.refs[refIndex]; 73 | 74 | const ref = (config.getRef || function () {return config.ref})(); 75 | const batchRef = config.batchRef || ref; 76 | 77 | var _this = this; 78 | 79 | var monitorRef = function () { 80 | ref.on('child_added', function (snapshot) { 81 | if (this.state.currentRefIndex !== refIndex && this.layouts[refIndex].childAdded !== true) { 82 | this.layouts[refIndex].childAdded = true; 83 | this.setState({}); 84 | } 85 | this.processSnapshot(refIndex, snapshot)}.bind(this) 86 | ), 87 | batchRef.on('child_changed', function (snapshot) { 88 | if (this.state.currentRefIndex !== refIndex && this.layouts[refIndex].childChanged !== true) { 89 | this.layouts[refIndex].childChanged = true; 90 | this.setState({}); 91 | } 92 | this.processSnapshot(refIndex, snapshot)}.bind(this) 93 | ), 94 | batchRef.on('child_removed', function (snapshot) { 95 | if (this.state.currentRefIndex !== refIndex && this.layouts[refIndex].childRemoved !== true) { 96 | this.layouts[refIndex].childRemoved = true; 97 | this.setState({}); 98 | } 99 | this.removeSnapshot(refIndex, snapshot)}.bind(this) 100 | ) 101 | }; 102 | 103 | // Run value once, then watch for children added 104 | batchRef.once('value', function (snapshot) { 105 | var i = 0; 106 | 107 | console.log('Queried firebase!'); 108 | 109 | var resolve = configuration.refs[refIndex].resolve || defaultResolve; 110 | 111 | snapshot.forEach(function (snapshotChild) { 112 | resolve(snapshotChild.key(), snapshotChild.val(), function (val) { 113 | var key = snapshotChild.key(); 114 | 115 | val._key = key; 116 | 117 | this.items[refIndex].push({ 118 | val: val, 119 | key: key 120 | }); 121 | 122 | i += 1; 123 | 124 | if (i === snapshot.numChildren()) { 125 | this.prepareAndSetState(refIndex); 126 | monitorRef.bind(this)(); 127 | }; 128 | }.bind(this)); 129 | }.bind(this)); 130 | }.bind(this)); 131 | 132 | }, 133 | processSnapshot: function (refIndex, snapshot) { 134 | var resolve = configuration.refs[refIndex].resolve || defaultResolve; 135 | 136 | resolve(snapshot.key(), snapshot.val(), function (val) { 137 | var key = snapshot.key(); 138 | 139 | var existingIndex = _.findIndex(this.items[refIndex], {'key': key}); 140 | 141 | val._key = key; 142 | 143 | var newItem = { 144 | val: val, 145 | key: key 146 | }; 147 | 148 | if (existingIndex > -1) { 149 | if (JSON.stringify(this.items[refIndex][existingIndex]) !== JSON.stringify(newItem)) { 150 | this.items[refIndex][existingIndex] = newItem 151 | this.setStateIfRefActive(refIndex); 152 | } 153 | } else { 154 | this.items[refIndex].push(newItem); 155 | this.setStateIfRefActive(refIndex); 156 | }; 157 | }.bind(this)); 158 | }, 159 | removeSnapshot: function (refIndex, snapshot) { 160 | var key = snapshot.key(); 161 | var existingIndex = _.findIndex(this.items[refIndex], {'key': key}); 162 | 163 | this.items[refIndex].splice(existingIndex, 1); 164 | 165 | this.setStateIfRefActive(refIndex); 166 | }, 167 | setStateIfRefActive: function (refIndex) { 168 | if (refIndex === this.state.currentRefIndex) { 169 | this.prepareAndSetState(refIndex) 170 | } 171 | }, 172 | prepareAndSetState: function (refIndex) { 173 | this.sortItems(refIndex); // in place 174 | this.setState({ 175 | currentItems: this.filterItems(refIndex), 176 | filteredSize: this.items[refIndex].length, 177 | loaded: true 178 | }); 179 | }, 180 | // Filters this.items and returns filtered 181 | filterItems: function (refIndex) { 182 | var items = this.items[refIndex]; 183 | 184 | for (var k in this.layouts[refIndex].filters) { 185 | items = _.filter(items, this.layouts[refIndex].filters[k]); 186 | } 187 | 188 | return items; 189 | }, 190 | // Sorts in place 191 | sortItems: function (refIndex) { 192 | if (this.layouts[refIndex].orderBy) { 193 | this.items[refIndex] = _.orderBy(this.items[refIndex], function (item) { 194 | return getNestedValue(item.val, this.layouts[refIndex].orderBy); 195 | }.bind(this), this.layouts[refIndex].orderByDirection); 196 | }; 197 | }, 198 | setCurrentOrderBy: function (orderBy, direction) { 199 | this.layouts[this.state.currentRefIndex].orderBy = orderBy 200 | this.layouts[this.state.currentRefIndex].orderByDirection = direction 201 | this.prepareAndSetState(this.state.currentRefIndex); 202 | }, 203 | setFiltersByKey: function (filtersByKey) { 204 | this.layouts[this.state.currentRefIndex].filtersByKey = filtersByKey 205 | }, 206 | setOpenFilterFields: function (openFilterFields) { 207 | this.layouts[this.state.currentRefIndex].openFilterFields = openFilterFields 208 | }, 209 | registerFilterAndRun: function (key, value) { 210 | if (value) { 211 | this.layouts[this.state.currentRefIndex].filters[key] = function(val){ 212 | return function (o) { 213 | var queriedValue = getNestedValue(o.val, key); 214 | 215 | if (queriedValue.toString().toLowerCase().indexOf(val.toLowerCase()) > -1) { 216 | return true 217 | } else if (queriedValue.toString().score(val) > 0.4) { 218 | return true 219 | } else { 220 | return false 221 | } 222 | } 223 | }(value); 224 | 225 | } else { 226 | delete this.layouts[this.state.currentRefIndex].filters[key]; 227 | }; 228 | 229 | var items = this.filterItems(this.state.currentRefIndex); 230 | 231 | this.layouts[this.state.currentRefIndex].items = items; 232 | 233 | this.setState({ 234 | currentItems: items, 235 | filteredSize: items.length 236 | }); 237 | }, 238 | updateBackgroundItems: function (items) { 239 | 240 | }, 241 | handleRangeStartChange: function (event) { 242 | var rangeDiff = this.state.rangeEnd - this.state.rangeStart; 243 | 244 | var value = Number(event.target.value); 245 | 246 | if (value < 1) { 247 | value = 1; 248 | }; 249 | 250 | this.setState({ 251 | rangeStart: value, 252 | rangeEnd: value + Number(rangeDiff) 253 | }); 254 | }, 255 | handleRangeEndChange: function (event) { 256 | var value = Number(event.target.value); 257 | if (value < 1) { 258 | value = 1; 259 | } 260 | 261 | var rangeStart = this.state.rangeStart; 262 | 263 | if (this.state.rangeStart > this.state.rangeEnd) { 264 | rangeStart = this.state.rangeEnd 265 | } 266 | 267 | this.setState({ 268 | rangeStart: rangeStart, 269 | rangeEnd: value 270 | }); 271 | }, 272 | render: function() { 273 | var refOptions = []; 274 | 275 | for (var i = 0; i < configuration.refs.length; i++) { 276 | if (this.state.currentRefIndex === i) { 277 | refOptions.push( 278 |
{configuration.refs[i].title}
279 | ); 280 | } else { 281 | 282 | var indicators = []; 283 | var liveIndicator = null; 284 | const layout = this.layouts[i] || {} 285 | 286 | if (layout.childAdded) { 287 | indicators.push(
) 288 | } 289 | 290 | if (layout.childChanged) { 291 | indicators.push(
) 292 | } 293 | 294 | if (layout.childRemoved) { 295 | indicators.push(
) 296 | } 297 | 298 | if (this.items[i] !== undefined) { 299 | liveIndicator =
300 | } 301 | 302 | refOptions.push( 303 |
304 |
{indicators}
305 | {configuration.refs[i].title} 306 |
{liveIndicator}
307 |
308 | ); 309 | } 310 | }; 311 | 312 | var table; 313 | 314 | if (!this.state.loaded) { 315 | table = (
316 | 317 |
) 318 | } else { 319 | table = 334 | } 335 | 336 | return ( 337 |
338 |
339 | {configuration.title} 340 | 341 |
342 | {refOptions} 343 |
344 | 345 |
346 | 347 | 348 | 349 | 350 | to 351 | 352 | 353 | 354 | 355 | of {this.state.filteredSize || '...'} 356 |
357 |
358 | 359 | {table} 360 |
361 | ) 362 | } 363 | }); 364 | -------------------------------------------------------------------------------- /src/components/cells.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * https://github.com/arqex/react-datetime 3 | */ 4 | 5 | .rdt { 6 | position: relative; 7 | } 8 | .rdtPicker { 9 | display: none; 10 | position: absolute; 11 | width: 250px; 12 | padding: 4px; 13 | margin-top: 1px; 14 | z-index: 99999 !important; 15 | background: #fff; 16 | box-shadow: 0 1px 3px rgba(0,0,0,.1); 17 | border: 1px solid #f9f9f9; 18 | } 19 | .rdtOpen .rdtPicker { 20 | display: block; 21 | } 22 | .rdtStatic .rdtPicker { 23 | box-shadow: none; 24 | position: static; 25 | } 26 | 27 | .rdtPicker .rdtTimeToggle { 28 | text-align: center; 29 | } 30 | 31 | .rdtPicker table { 32 | width: 100%; 33 | margin: 0; 34 | } 35 | .rdtPicker td, 36 | .rdtPicker th { 37 | text-align: center; 38 | height: 28px; 39 | } 40 | .rdtPicker td { 41 | cursor: pointer; 42 | } 43 | .rdtPicker td.rdtToday:hover, 44 | .rdtPicker td.rdtHour:hover, 45 | .rdtPicker td.rdtMinute:hover, 46 | .rdtPicker td.rdtSecond:hover, 47 | .rdtPicker .rdtTimeToggle:hover { 48 | background: #eeeeee; 49 | cursor: pointer; 50 | } 51 | .rdtPicker td.rdtOld, 52 | .rdtPicker td.rdtNew { 53 | color: #999999; 54 | } 55 | .rdtPicker td.rdtToday { 56 | position: relative; 57 | } 58 | .rdtPicker td.rdtToday:before { 59 | content: ''; 60 | display: inline-block; 61 | border-left: 7px solid transparent; 62 | border-bottom: 7px solid #428bca; 63 | border-top-color: rgba(0, 0, 0, 0.2); 64 | position: absolute; 65 | bottom: 4px; 66 | right: 4px; 67 | } 68 | .rdtPicker td.rdtActive, 69 | .rdtPicker td.rdtActive:hover { 70 | background-color: #428bca; 71 | color: #fff; 72 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 73 | } 74 | .rdtPicker td.rdtActive.rdtToday:before { 75 | border-bottom-color: #fff; 76 | } 77 | .rdtPicker td.rdtDisabled, 78 | .rdtPicker td.rdtDisabled:hover { 79 | background: none; 80 | color: #999999; 81 | cursor: not-allowed; 82 | } 83 | 84 | .rdtPicker td span.rdtOld { 85 | color: #999999; 86 | } 87 | .rdtPicker td span.rdtDisabled, 88 | .rdtPicker td span.rdtDisabled:hover { 89 | background: none; 90 | color: #999999; 91 | cursor: not-allowed; 92 | } 93 | .rdtPicker th { 94 | border-bottom: 1px solid #f9f9f9; 95 | } 96 | .rdtPicker .dow { 97 | width: 14.2857%; 98 | border-bottom: none; 99 | } 100 | .rdtPicker th.rdtSwitch { 101 | width: 100px; 102 | } 103 | .rdtPicker th.rdtNext, 104 | .rdtPicker th.rdtPrev { 105 | font-size: 21px; 106 | vertical-align: top; 107 | } 108 | 109 | .rdtPrev span, 110 | .rdtNext span { 111 | display: block; 112 | -webkit-touch-callout: none; /* iOS Safari */ 113 | -webkit-user-select: none; /* Chrome/Safari/Opera */ 114 | -khtml-user-select: none; /* Konqueror */ 115 | -moz-user-select: none; /* Firefox */ 116 | -ms-user-select: none; /* Internet Explorer/Edge */ 117 | user-select: none; 118 | } 119 | 120 | .rdtPicker th.rdtDisabled, 121 | .rdtPicker th.rdtDisabled:hover { 122 | background: none; 123 | color: #999999; 124 | cursor: not-allowed; 125 | } 126 | .rdtPicker thead tr:first-child th { 127 | cursor: pointer; 128 | } 129 | .rdtPicker thead tr:first-child th:hover { 130 | background: #eeeeee; 131 | } 132 | 133 | .rdtPicker tfoot { 134 | border-top: 1px solid #f9f9f9; 135 | } 136 | 137 | .rdtPicker button { 138 | border: none; 139 | background: none; 140 | cursor: pointer; 141 | } 142 | .rdtPicker button:hover { 143 | background-color: #eee; 144 | } 145 | 146 | .rdtPicker thead button { 147 | width: 100%; 148 | height: 100%; 149 | } 150 | 151 | td.rdtMonth, 152 | td.rdtYear { 153 | height: 50px; 154 | width: 25%; 155 | cursor: pointer; 156 | } 157 | td.rdtMonth:hover, 158 | td.rdtYear:hover { 159 | background: #eee; 160 | } 161 | 162 | .rdtCounters { 163 | display: inline-block; 164 | } 165 | 166 | .rdtCounters > div { 167 | float: left; 168 | } 169 | 170 | .rdtCounter { 171 | height: 100px; 172 | } 173 | 174 | .rdtCounter { 175 | width: 40px; 176 | } 177 | 178 | .rdtCounterSeparator { 179 | line-height: 100px; 180 | } 181 | 182 | .rdtCounter .rdtBtn { 183 | height: 40%; 184 | line-height: 40px; 185 | cursor: pointer; 186 | display: block; 187 | 188 | -webkit-touch-callout: none; /* iOS Safari */ 189 | -webkit-user-select: none; /* Chrome/Safari/Opera */ 190 | -khtml-user-select: none; /* Konqueror */ 191 | -moz-user-select: none; /* Firefox */ 192 | -ms-user-select: none; /* Internet Explorer/Edge */ 193 | user-select: none; 194 | } 195 | .rdtCounter .rdtBtn:hover { 196 | background: #eee; 197 | } 198 | .rdtCounter .rdtCount { 199 | height: 20%; 200 | font-size: 1.2em; 201 | } 202 | 203 | .rdtMilli { 204 | vertical-align: middle; 205 | padding-left: 8px; 206 | width: 48px; 207 | } 208 | 209 | .rdtMilli input { 210 | width: 100%; 211 | font-size: 1.2em; 212 | margin-top: 37px; 213 | } 214 | -------------------------------------------------------------------------------- /src/components/cells.jsx: -------------------------------------------------------------------------------- 1 | require('./cells.css'); 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | var moment = require('moment'); 6 | var elemental = require('elemental'); 7 | var Button = elemental.Button; 8 | var Form = elemental.Form; 9 | var FormInput = elemental.FormInput; 10 | var Glyph = elemental.Glyph; 11 | var Checkbox = elemental.Checkbox; 12 | var Datetime = require('react-datetime'); 13 | var Dropdown = elemental.Dropdown; 14 | 15 | export var TextCell = React.createClass({ 16 | getInitialState: function () { 17 | return { 18 | value: this.props.value, 19 | editing: false 20 | } 21 | }, 22 | handleChange: function (event) { 23 | this.setState({ 24 | value: event.target.value 25 | }); 26 | this.props.valueChanged(this.props.childKey, event.target.value); 27 | }, 28 | handleEditToggle: function (event) { 29 | event.preventDefault(); 30 | this.setState({ 31 | editing: !this.state.editing 32 | }); 33 | }, 34 | render: function () { 35 | var editGlyph = null; 36 | 37 | if (this.props.canWrite) { 38 | editGlyph = ( 39 | 40 | ) 41 | }; 42 | 43 | if (this.props.clean) { 44 | this.state.value = this.props.value; 45 | } 46 | 47 | if (this.props.canWrite && this.state.editing === true) { 48 | // Read and write 49 | return ( 50 | 51 |
52 | 53 | {editGlyph} 54 |
55 |
56 | ) 57 | } else { 58 | // Read only 59 | return ( 60 | 61 | {this.state.value} 62 | {editGlyph} 63 | 64 | ) 65 | } 66 | } 67 | }); 68 | 69 | export var LongTextCell = React.createClass({ 70 | getInitialState: function () { 71 | return { 72 | value: this.props.value, 73 | editing: false 74 | } 75 | }, 76 | handleChange: function (event) { 77 | this.setState({ 78 | value: event.target.value 79 | }); 80 | this.props.valueChanged(this.props.childKey, event.target.value); 81 | }, 82 | handleEditToggle: function (event) { 83 | event.preventDefault(); 84 | this.setState({ 85 | editing: !this.state.editing 86 | }); 87 | }, 88 | render: function () { 89 | var editGlyph = null; 90 | 91 | if (this.props.canWrite) { 92 | editGlyph = ( 93 | 94 | ) 95 | }; 96 | 97 | if (this.props.clean) { 98 | this.state.value = this.props.value; 99 | } 100 | 101 | if (this.props.canWrite && this.state.editing === true) { 102 | // Read and write 103 | return ( 104 | 105 |
106 | 107 | {editGlyph} 108 |
109 |
110 | ) 111 | } else { 112 | // Read only 113 | return ( 114 | 115 | 116 | {editGlyph} 117 | 118 | ) 119 | } 120 | } 121 | }); 122 | 123 | export var NumberCell = React.createClass({ 124 | getInitialState: function () { 125 | return { 126 | value: this.props.value, 127 | editing: false 128 | } 129 | }, 130 | handleChange: function (event) { 131 | this.setState({ 132 | value: event.target.value 133 | }); 134 | this.props.valueChanged(this.props.childKey, Number(event.target.value)); 135 | }, 136 | handleEditToggle: function (event) { 137 | event.preventDefault(); 138 | this.setState({ 139 | editing: !this.state.editing 140 | }); 141 | }, 142 | render: function () { 143 | var editGlyph = null; 144 | 145 | if (this.props.canWrite) { 146 | editGlyph = ( 147 | 148 | ) 149 | }; 150 | 151 | if (this.props.clean) { 152 | this.state.value = this.props.value; 153 | } 154 | 155 | if (this.props.canWrite && this.state.editing === true) { 156 | // Read and write 157 | return ( 158 | 159 |
160 | 161 | {editGlyph} 162 |
163 |
164 | ) 165 | } else { 166 | // Read only 167 | return ( 168 | 169 | {this.state.value} 170 | {editGlyph} 171 | 172 | ) 173 | } 174 | } 175 | }); 176 | 177 | export var BooleanCell = React.createClass({ 178 | getInitialState: function () { 179 | return { 180 | value: this.props.value 181 | } 182 | }, 183 | handleChange: function (value) { 184 | this.setState({ 185 | value: value 186 | }); 187 | this.props.valueChanged(this.props.childKey, value); 188 | }, 189 | handleChecked: function (event) { 190 | this.handleChange(true); 191 | }, 192 | handleUnchecked: function (event) { 193 | this.handleChange(false); 194 | }, 195 | render: function () { 196 | var optionConfig = ((this.props.extras || {}).options || {}); 197 | 198 | if (this.props.clean) { 199 | this.state.value = this.props.value; 200 | } 201 | 202 | if (this.state.value === true) { 203 | return ( 204 | 205 | 206 | 207 | ) 208 | } else { 209 | return ( 210 | 211 | 212 | 213 | ) 214 | } 215 | 216 | } 217 | }); 218 | 219 | export var ImageCell = React.createClass({ 220 | render: function () { 221 | return ( 222 | 226 | 227 | ); 228 | } 229 | }); 230 | 231 | export var SelectCell = React.createClass({ 232 | getInitialState: function () { 233 | return {} 234 | }, 235 | handleChange: function (event) { 236 | this.setState({ 237 | value: event.target.value 238 | }); 239 | this.props.valueChanged(this.props.childKey, event.target.value); 240 | }, 241 | render: function () { 242 | var optionConfig = this.props.extras.options; 243 | var options = []; 244 | 245 | if (this.props.clean) { 246 | this.state.value = this.props.value; 247 | } 248 | 249 | for (var i = 0; i < optionConfig.length; i++) { 250 | var option = optionConfig[i]; 251 | options.push( 252 | 253 | ); 254 | }; 255 | 256 | return ( 257 | 260 | ); 261 | } 262 | }); 263 | 264 | export var DateCell = React.createClass({ 265 | // The following three methods are lifted from Datetime picker module https://github.com/YouCanBookMe/react-datetime/ 266 | localMoment: function( date, format ){ 267 | const extras = this.props.extras || {}; 268 | 269 | var strictParsing = extras.strictParsing; 270 | 271 | if (strictParsing === undefined) { 272 | strictParsing = false 273 | } 274 | 275 | var m = moment( date, format, strictParsing ); 276 | if ( extras.locale ) 277 | m.locale( extras.locale ); 278 | return m; 279 | }, 280 | getUpdateOn: function(formats){ 281 | if ( formats.date.match(/[lLD]/) ){ 282 | return 'days'; 283 | } 284 | else if ( formats.date.indexOf('M') !== -1 ){ 285 | return 'months'; 286 | } 287 | else if ( formats.date.indexOf('Y') !== -1 ){ 288 | return 'years'; 289 | } 290 | 291 | return 'days'; 292 | }, 293 | getFormats: function(){ 294 | const extras = this.props.extras || {}; 295 | 296 | var formats = { 297 | date: extras.dateFormat || '', 298 | time: extras.timeFormat || '' 299 | }, 300 | locale = this.localMoment( this.props.value ).localeData() 301 | ; 302 | 303 | if ( formats.date === true ){ 304 | formats.date = locale.longDateFormat('L'); 305 | } else if ( this.getUpdateOn(formats) !== 'days' ){ 306 | formats.time = ''; 307 | } 308 | 309 | if ( formats.time === true ){ 310 | formats.time = locale.longDateFormat('LT'); 311 | } 312 | 313 | formats.datetime = formats.date && formats.time ? 314 | formats.date + ' ' + formats.time : 315 | formats.date || formats.time 316 | ; 317 | 318 | return formats; 319 | }, 320 | getMoment: function () { 321 | if (this.state.value !== undefined && this.state.value !== "") { 322 | return moment(this.state.value).format(this.getFormats().datetime || "DD/MM/YY, h:mm:ss a"); 323 | } else { 324 | return "" 325 | } 326 | }, 327 | getInitialState: function() { 328 | return { 329 | editing: false, 330 | value: this.props.value 331 | } 332 | }, 333 | handleEditToggle: function (event) { 334 | event.preventDefault(); 335 | this.setState({ 336 | editing: !this.state.editing 337 | }); 338 | }, 339 | handleChange: function (moment) { 340 | var value; 341 | var type; 342 | 343 | if (moment === "") { 344 | this.props.valueChanged(this.props.childKey, null); 345 | this.setState({value: ""}); 346 | return 347 | } 348 | 349 | // Ensure we stick with the format, whether ISO or milliseconds 350 | if (this.props.extras.saveFormat !== undefined) { 351 | type = this.props.extras.saveFormat; 352 | } else { 353 | if (typeof this.props.value === "number") { 354 | type = "numeric" 355 | } else { 356 | type = "iso" 357 | } 358 | } 359 | 360 | if (type === "numeric") { 361 | value = moment.toDate().getTime(); 362 | } else { 363 | value = moment.toDate().toISOString(); 364 | } 365 | 366 | this.props.valueChanged(this.props.childKey, value); 367 | 368 | this.setState({value: value}); 369 | }, 370 | render: function () { 371 | var editGlyph; 372 | 373 | if (this.props.canWrite) { 374 | editGlyph = ( 375 | 376 | ) 377 | }; 378 | 379 | if (!this.state.editing) { 380 | return
381 | {this.getMoment()} {editGlyph} 382 |
383 | } else { 384 | return
389 | {editGlyph} 390 |
391 | } 392 | } 393 | }); 394 | 395 | export var TimeSinceCell = React.createClass({ 396 | componentWillMount: function () { 397 | this.startMomentTicker(); 398 | }, 399 | getInitialState: function () { 400 | return { 401 | moment: moment(this.props.value).fromNow() 402 | } 403 | }, 404 | startMomentTicker: function () { 405 | this.ticker = setInterval(function() { 406 | this.setState({ 407 | moment: moment(this.props.value).fromNow() 408 | }); 409 | }.bind(this), 1000); 410 | }, 411 | componentWillUnmount: function () { 412 | clearInterval(this.ticker); 413 | }, 414 | render: function () { 415 | return ( 416 |
{this.state.moment}
417 | ); 418 | } 419 | }); 420 | 421 | export var CurrencyCell = React.createClass({ 422 | render: function () { 423 | return ( 424 |
{this.props.extras.symbol}{this.props.value}
425 | ); 426 | } 427 | }); 428 | 429 | export var ButtonCell = React.createClass({ 430 | getInitialState: function () { 431 | return { 432 | disabled: this.props.extras.disabled || false, 433 | title: this.props.extras.title, 434 | type: this.props.extras.type || 'primary' 435 | } 436 | }, 437 | action: function () { 438 | var $this = this; 439 | this.props.extras.action(this.props.rowKey, this.props.rowValue, function (newProps) { 440 | $this.setState(newProps || {}); 441 | }); 442 | }, 443 | render: function () { 444 | return 445 | } 446 | }); 447 | 448 | export var DropdownCell = React.createClass({ 449 | getInitialState: function () { 450 | return { 451 | title: this.props.extras.title || '', 452 | items: this.props.extras.items || [] 453 | } 454 | }, 455 | onSelect: function (index) { 456 | var action = this.state.items[index].action; 457 | 458 | if (action !== undefined) { 459 | var $this = this; 460 | this.state.items[index].action(this.props.rowKey, this.props.rowValue); 461 | } 462 | }, 463 | render: function () { 464 | // Map item values to index so we can use inline methods when defining 465 | for (var i = 0; i < this.state.items.length; i++) { 466 | this.state.items[i].value = i 467 | } 468 | 469 | return ( 470 | 475 | ); 476 | } 477 | }); 478 | -------------------------------------------------------------------------------- /src/components/firebase-table.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import _ from 'lodash'; 5 | require("string_score"); 6 | 7 | var elemental = require('elemental'); 8 | var Table = elemental.Table; 9 | var Form = elemental.Form; 10 | var FormField = elemental.FormField; 11 | var FormInput = elemental.FormInput; 12 | var Glyph = elemental.Glyph; 13 | var GridRow = elemental.Row; 14 | var GridCol = elemental.Col; 15 | var Card = elemental.Card; 16 | var Spinner = elemental.Spinner; 17 | 18 | import configuration from '../../firestation.config.js'; 19 | import Row from './row.jsx'; 20 | import {getNestedValue, setNestedValue} from '../utils.js'; 21 | 22 | export default React.createClass({ 23 | getInitialState: function () { 24 | return { 25 | items: [], 26 | orderBy: this.props.orderBy, 27 | orderByDirection: this.props.orderByDirection, 28 | openFilterFields: this.props.openFilterFields || [], 29 | filtersByKey: this.props.filtersByKey || {}, 30 | loaded: true 31 | }; 32 | }, 33 | componentWillMount: function() { 34 | const config = configuration.refs[this.props.refIndex]; 35 | 36 | this.currentOrderBy = (this.props.orderBy || configuration.refs[this.props.refIndex].orderBy); 37 | this.currentOrderByDirection = (this.props.orderByDirection || configuration.refs[this.props.refIndex].orderByDirection); 38 | }, 39 | handleFilterChange: function (key, title, event) { 40 | const value = event.target.value.replace(title + ': ', '').replace(title + ':', ''); 41 | 42 | if (this.filterDebounced) { 43 | this.filterDebounced.cancel(); 44 | }; 45 | 46 | var filtersByKey = this.state.filtersByKey; 47 | if (value != '') { 48 | filtersByKey[key] = title + ': ' + value; 49 | } else { 50 | delete filtersByKey[key]; 51 | }; 52 | 53 | this.setState({ 54 | filtersByKey: filtersByKey 55 | }); 56 | 57 | this.filterDebounced = _.debounce(this.props.registerFilterAndRun, 250, {maxWait: 1000}); 58 | 59 | this.filterDebounced(key, value); 60 | 61 | this.props.setFiltersByKey(filtersByKey); 62 | }, 63 | handleSortClick: function (key) { 64 | if (key === this.currentOrderBy) { 65 | if (this.currentOrderByDirection === 'asc') { 66 | this.currentOrderByDirection = 'desc' 67 | } else { 68 | this.currentOrderByDirection = 'asc'; 69 | } 70 | } else { 71 | this.currentOrderBy = key; 72 | this.currentOrderByDirection = 'asc'; 73 | }; 74 | 75 | this.setState({ 76 | orderBy: this.currentOrderBy, 77 | orderByDirection: this.currentOrderByDirection 78 | }); 79 | 80 | this.props.setCurrentOrderBy(this.currentOrderBy, this.currentOrderByDirection) 81 | }, 82 | handleSearchClick: function (key, event) { 83 | var openFilterFields = this.state.openFilterFields; 84 | if (openFilterFields.indexOf(key) > -1) { 85 | delete openFilterFields[openFilterFields.indexOf(key)]; 86 | } else { 87 | openFilterFields.push(key); 88 | } 89 | 90 | this.setState({ 91 | openFilterFields: openFilterFields 92 | }); 93 | 94 | this.props.setOpenFilterFields(openFilterFields); 95 | }, 96 | handleSearchSubmit: function (key, event) { 97 | event.preventDefault(); 98 | 99 | var openFilterFields = this.state.openFilterFields; 100 | delete openFilterFields[openFilterFields.indexOf(key)]; 101 | 102 | this.setState({ 103 | openFilterFields: openFilterFields 104 | }); 105 | }, 106 | // Should probably be another component 107 | renderHeaders: function () { 108 | var refConfiguration = configuration.refs[this.props.refIndex].children; 109 | 110 | // Create table headers 111 | var headers = []; 112 | for (var i = 0; i < refConfiguration.length; i++) { 113 | var config = refConfiguration[i]; 114 | var title = config.title || config.key; 115 | var key = config.key; 116 | 117 | var headerArrowClass = ''; 118 | 119 | if (this.currentOrderBy === key) { 120 | if (this.currentOrderByDirection === 'asc') { 121 | headerArrowClass = 'Arrow isAsc' 122 | } else { 123 | headerArrowClass = 'Arrow isDesc' 124 | } 125 | }; 126 | 127 | var headerLabel = null; 128 | var headerLabelClass = ""; 129 | var headerSortArrow = null; 130 | var filterInput = null; 131 | var searchGlyph = null; 132 | 133 | if (config.canFilter !== false) { 134 | if (this.state.filtersByKey[key] !== undefined) { 135 | searchGlyph = ( 136 | 137 | ) 138 | headerLabelClass = "HeaderLabel isPrimary" 139 | } else { 140 | searchGlyph = ( 141 | 142 | ) 143 | } 144 | } 145 | 146 | if (this.state.openFilterFields.indexOf(key) > -1) { 147 | filterInput =
148 | 157 |
158 | } else { 159 | headerLabel = {this.state.filtersByKey[key] || title} 160 | headerSortArrow = 161 | }; 162 | 163 | headers.push( 164 | 165 |
166 | {searchGlyph} 167 | {filterInput} 168 | {headerLabel} 169 | {headerSortArrow} 170 |
171 | 172 | ); 173 | }; 174 | 175 | return headers 176 | }, 177 | render: function () { 178 | 179 | var headers = this.renderHeaders(); 180 | 181 | // Dynamically create rows based on ref configuration 182 | var rows = []; 183 | 184 | var rangeStart = this.props.rangeStart; 185 | var rangeEnd = this.props.rangeEnd; 186 | var rangeDiff = rangeEnd - rangeStart; 187 | 188 | if (this.props.items.length < rangeEnd) { 189 | rangeEnd = this.props.items.length; 190 | } 191 | 192 | if (this.props.items.length >= rangeStart && rangeEnd >= rangeStart) { 193 | for (var i = rangeStart - 1; i < rangeEnd; i++) { 194 | var key = this.props.items[i].key; 195 | var val = this.props.items[i].val; 196 | rows.push(); 197 | }; 198 | } 199 | 200 | return ( 201 |
202 | 203 | 204 | 205 | {/* Spacer */} 206 | 208 | {headers} 209 | {/* Save Button */} 210 | 211 | {/* Spacer */} 212 | 214 | 215 | 216 | 217 | {rows} 218 | 219 |
207 | 213 |
220 |
221 | ); 222 | } 223 | }); 224 | -------------------------------------------------------------------------------- /src/components/row.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | var elemental = require('elemental'); 5 | var Button = elemental.Button; 6 | 7 | import configuration from '../../firestation.config.js'; 8 | import {getNestedValue, setNestedValue, getNestedRef} from '../utils.js'; 9 | 10 | import './cells.jsx'; 11 | 12 | export default React.createClass({ 13 | getInitialState: function () { 14 | return { 15 | changed: false 16 | } 17 | }, 18 | componentWillMount: function () { 19 | this.deltaVal = {}; 20 | }, 21 | save: function () { 22 | console.log('Saving', this.props.itemKey); 23 | 24 | var key = this.props.itemKey; 25 | 26 | const config = configuration.refs[this.props.refIndex]; 27 | var rootRef = (config.batchRef || config.ref || config.getRef()).ref(); 28 | 29 | var ref = rootRef.child(key); 30 | 31 | // Update each modified key individually, since need to account for nested keys 32 | for (var key in this.deltaVal) { 33 | var childRef = getNestedRef(ref, key); 34 | childRef.set(this.deltaVal[key]); 35 | }; 36 | 37 | this.deltaVal = {}; 38 | 39 | this.setState({ 40 | changed: false 41 | }); 42 | }, 43 | saveClick: function () { 44 | this.save(); 45 | }, 46 | valueChanged: function (key, value) { 47 | this.deltaVal[key] = value; 48 | this.setState({ 49 | changed: true 50 | }); 51 | }, 52 | render: function () { 53 | var refConfiguration = configuration.refs[this.props.refIndex].children; 54 | var columns = []; 55 | 56 | for (var i = 0; i < refConfiguration.length; i++) { 57 | var config = refConfiguration[i]; 58 | var KeyCell = config.cell; 59 | 60 | // get nested keys 61 | var value = getNestedValue(this.props.item, config.key); 62 | 63 | columns.push( 64 | 65 | 74 | 75 | ); 76 | }; 77 | 78 | return ( 79 | 80 | 81 | 82 | {columns} 83 | 84 | 85 | 86 | 87 | 88 | 89 | ); 90 | } 91 | }); 92 | -------------------------------------------------------------------------------- /src/entry.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './app.jsx'; 4 | 5 | import configuration from '../firestation.config.js'; 6 | 7 | configuration.auth(function (ref) { 8 | ReactDOM.render(, document.getElementById('app')); 9 | }); 10 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Firestation Dashboard 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 153 | 154 | 155 | 156 |
157 | 158 | 159 | 160 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export var getNestedValue = function (rootVal, path) { 2 | var pathKeys = path.split('.'); 3 | var currentPathVal = rootVal; 4 | 5 | for (var j = 0; j < pathKeys.length; j++) { 6 | var key = pathKeys[j]; 7 | 8 | if (currentPathVal[key] != undefined) { 9 | currentPathVal = currentPathVal[key]; 10 | } else { 11 | currentPathVal = ''; 12 | }; 13 | }; 14 | 15 | return currentPathVal; 16 | }; 17 | 18 | // Takes an object and a dot-notated path, created the nested tree for that path 19 | // and setting a value 20 | export var setNestedValue = function (object, path, val) { 21 | var pathKeys = path.split('.'); 22 | var currentPathVal = object; 23 | 24 | for (var j = 0; j < pathKeys.length; j++) { 25 | var key = pathKeys[j]; 26 | 27 | if (j < pathKeys.length - 1) { 28 | currentPathVal[key] = {}; 29 | currentPathVal = currentPathVal[key]; 30 | } else { 31 | currentPathVal[key] = val; 32 | } 33 | }; 34 | 35 | currentPathVal[key] = val; 36 | 37 | return object; 38 | } 39 | 40 | // Get a nested firebase ref for a given dot-notated path 41 | export var getNestedRef = function (ref, path) { 42 | var pathKeys = path.split('.'); 43 | var currentRef = ref; 44 | 45 | for (var j = 0; j < pathKeys.length; j++) { 46 | var key = pathKeys[j]; 47 | currentRef = currentRef.child(key); 48 | }; 49 | 50 | return currentRef; 51 | } 52 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | var env = (process.env.NODE_ENV || 'dev') 5 | var deploying = (process.env.DEPLOYING || 'false'); 6 | var pathPref = (deploying === 'true'? 'dist' : ''); 7 | 8 | module.exports = { 9 | entry: './src/entry.js', 10 | output: { path: __dirname, filename: pathPref + '/bundle.js' }, 11 | resolve: { 12 | alias: { 13 | 'components': path.join(__dirname, 'src/components'), 14 | 'config': path.join(__dirname, 'config', env) 15 | } 16 | }, 17 | module: { 18 | loaders: [ 19 | { 20 | test: /.jsx?$/, 21 | loader: 'babel-loader', 22 | exclude: /node_modules/, 23 | query: { 24 | presets: ['es2015', 'react'] 25 | } 26 | }, 27 | { 28 | test: /\.less$/, 29 | loader: "style!css!less" 30 | }, 31 | { 32 | test: /\.css$/, 33 | loader: "style-loader!css-loader" 34 | }, 35 | ] 36 | }, 37 | }; 38 | --------------------------------------------------------------------------------