├── .babelrc ├── .editorconfig ├── .eslintrc.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── built-in-helper-functions.md ├── cell-definitions.md ├── table-model-api.md └── table-model-demo.gif ├── package.json ├── src ├── ag-grid-utils.js ├── cell.js ├── cell.spec.js ├── demo │ ├── .babelrc │ ├── .eslintrc.yml │ ├── demo.js │ ├── grid.js │ ├── index.html │ ├── index.js │ ├── index.scss │ ├── row-defs.js │ └── webpack.config.js ├── example.spec.js ├── helper-functions.js ├── index.js ├── index.spec.js ├── model.js ├── table-model.js ├── table-model.spec.js ├── transaction.js ├── utils.js └── validation.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | 3 | env: 4 | mocha: true 5 | 6 | extends: 7 | - xo-space/esnext 8 | 9 | rules: 10 | curly: 11 | - error 12 | - multi-or-nest 13 | keyword-spacing: 14 | - error 15 | - overrides: 16 | catch: 17 | after: false 18 | for: 19 | after: false 20 | if: 21 | after: false 22 | while: 23 | after: false 24 | new-cap: 25 | - error 26 | - capIsNew: false 27 | no-return-assign: 28 | - error 29 | - except-parens 30 | object-curly-spacing: 31 | - error 32 | - always 33 | space-before-function-paren: 34 | - error 35 | - never 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | /.idea 4 | 5 | /*.iml 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/target/table-model/fdbc9054a5751617df1e0b32b961cf87dc789874/.npmignore -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.15.1 2 | 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | before_deploy: 4 | - yarn build 5 | 6 | deploy: 7 | on: 8 | branch: master 9 | provider: npm 10 | email: "GameDevFox@gmail.com" 11 | api_key: "$NPM_TOKEN" 12 | skip_cleanup: true 13 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at 59 | [TTS-OpenSource-Office@target.com](mailto:TTS-OpenSource-Office@target.com). All 60 | complaints will be reviewed and investigated and will result in a response that 61 | is deemed necessary and appropriate to the circumstances. The project team is 62 | obligated to maintain confidentiality with regard to the reporter of an incident. 63 | Further details of specific enforcement policies may be posted separately. 64 | 65 | Project maintainers who do not follow or enforce the Code of Conduct in good 66 | faith may face temporary or permanent repercussions as determined by other 67 | members of the project's leadership. 68 | 69 | ## Attribution 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 72 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 73 | 74 | [homepage]: https://www.contributor-covenant.org 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to table-model 2 | 3 | ### Issues 4 | Issues are always welcome! You can expect conversation. 5 | 6 | ### Pull Requests 7 | 8 | These rules must be followed for any contributions to be merged into master. A Git installation is required. 9 | 10 | 1. Fork this repo 11 | 1. Create a branch 12 | 1. Make a desired changes 13 | 1. Validate your changes meet your desired use case 14 | 1. Ensure documentation has been updated 15 | 1. Open a pull-request: you can expect discussion 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Target Brands, Inc. 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 | [](https://www.travis-ci.org/target/table-model) 2 | 3 | # TableModel - [Live Demo](https://target.github.io/table-model/) 4 | 5 | TableModel is an in-memory data model that automatically calculates your data based on provided equations. 6 | It keeps track of dependencies between data and equations and automatically updates cells quickly and efficiently. 7 | 8 | TableModel works especially well with UI components like AgGrid because it simplifies your data flow and can add 9 | additional features such as predicting which cells in your table are going to be impacted by a change (see below). 10 | 11 | ### Preview: AgGrid powered by TableModel ([AgGrid not included](https://www.ag-grid.com/)) 12 | 13 |  14 | 15 | ## Getting Started 16 | 17 | Install table-model: 18 | 19 | ``` 20 | npm install --save-dev table-model 21 | ``` 22 | 23 | Import the 'table-model' module into your project: 24 | 25 | ES6+ 26 | ``` 27 | import TableModel from 'table-model'; 28 | ``` 29 | CommonJS 30 | ``` 31 | const TableModel = require('table-model'); 32 | ``` 33 | 34 | ## Usage 35 | A unit test of the following example can be found at `src/example.spec.js` 36 | ``` 37 | // Formulas 38 | const totalEquation = d => d.row('a') + d.row('b') + d.row('c'); 39 | const extraEquation = d => d.row('b') * d.row('c'); 40 | 41 | // This builds a formula that matches a given color 42 | const colorTotals = color => { 43 | return d => { 44 | // Get the 'total's from all rows with the matching color 45 | const numbers = d.rowsWhere({ type: color }, 'total'); 46 | // Sum up the numbers to get to total 47 | return numbers.reduce((total, num) => total + num, 0); 48 | }; 49 | }; 50 | 51 | // This array contains a list of our rows and their cells 52 | const rowDefs = [ 53 | // Row 1 54 | { 55 | meta: { id: 1, type: 'red' }, 56 | cells: { a: 10, b: 20, c: 30, total: totalEquation } 57 | }, 58 | // Row 2 59 | { 60 | meta: { id: 2, type: 'green' }, 61 | // Rows don't have to have the same types of cells 62 | cells: { a: 12, b: 45, c: 38, d: 1234, total: totalEquation, extra: extraEquation } 63 | }, 64 | // Row 3 65 | { 66 | meta: { id: 3, type: 'red' }, 67 | cells: { a: 100, b: 200, c: 300, total: totalEquation } 68 | }, 69 | // Totals 70 | { 71 | meta: { id: 4 }, 72 | cells: { redTotal: colorTotals('red'), greenTotal: colorTotals('green') } 73 | } 74 | ]; 75 | 76 | const listener = updates => console.log('UPDATES:', updates); 77 | 78 | const table = TableModel({ rowDefs, listener }); 79 | 80 | // Output: All cell values are included in the first update 81 | 82 | // UPDATES: { 83 | // 1: { a: 10, b: 20, c: 30, total: 60 }, 84 | // 2: { a: 12, b: 45, c: 38, d: 1234, total: 95, extra: 1710 }, 85 | // 3: { a: 100, b: 200, c: 300, total: 600 }, 86 | // 4: { redTotal: 660, greenTotal: 95 } 87 | // }); 88 | 89 | table.update({ 2: { c: 123 } }); 90 | 91 | // Output: Only cells that were impacted by the update are included after that 92 | 93 | // UPDATES: { 94 | // 2: { c: 123, total: 180, extra: 5535 }, 95 | // 4: { greenTotal: 180 } 96 | // }); 97 | ``` 98 | 99 | ## Docs 100 | 101 | ### [Cell Definitions](./docs/cell-definitions.md) 102 | ### [TableModel API](./docs/table-model-api.md) 103 | ### [Built-in Helper Functions](./docs/built-in-helper-functions.md) 104 | 105 | ## Why? 106 | 107 | #### Fast 108 | > Because TableModel analyzes your equations, it knows which other values and equations are impacted by 109 | > any given change. This ensures that when a change happens, TableModel only does the minimum amount of work 110 | it needs to do. 111 | 112 | #### Testable 113 | > If you're using a UI library like AgGrid to do your calculations for you, it can add an additional 114 | > layer of complexity when trying to test. AgGrid requires a DOM to run and test with. This is not ideal 115 | > for the simplicity and performance of your tests. 116 | > 117 | > By using TableModel to handle your data, you can write units tests for your equations which are 118 | > simple and easy to write, and decoupled from the view. See an example TableModel unit test at `src/example.spec.js`. 119 | 120 | #### Maintainable 121 | > If your data model has a lot of calculations your code base can get complicated very quickly, 122 | > for example, maintaining the proper order of the calculations, and determining which values need to be recalculated 123 | > (or not) when another values changes. 124 | > 125 | > TableModel abstracts all this complexity away for you. All you have to do is define simple 126 | > equations, feed TableModel your data, and it'll take care of the rest. **TableModel automatically manages the 127 | > dependencies between your equations.** If you need to make changes down the road, you only have to update your 128 | > equations. 129 | 130 | #### Separation of Concerns -or- "Do one thing and do it well" 131 | > If you're using a library like AgGrid to render your data in a UI component, you might 132 | > realize that AgGrid is REALLY good at presenting data, but has some limitations when trying 133 | > to handle calculations, for example, trying to operate on data in multiple rows. 134 | > 135 | > By using TableModel with AgGrid, you can use AgGrid to do what it does best (display data - the View) 136 | > and let TableModel take care of what it does best (calculation and data management - the Model). 137 | 138 | ## Features 139 | 140 | * Automatic Updates 141 | * Setter Functions 142 | * Update Prediction 143 | * Custom Helpers Functions 144 | * Custom PreLink Callback for Optimization 145 | * AgGrid Support 146 | 147 | ## Who uses TableModel? 148 | 149 | * Target 150 | * ... that's it so far. (Send a PR if you use TableModel 😎) 151 | -------------------------------------------------------------------------------- /docs/built-in-helper-functions.md: -------------------------------------------------------------------------------- 1 | # Built-in Helper Functions 2 | 3 | ## Cell Helpers 4 | These functions return a cell's value or an array of cell values to be used in getter and setter functions. 5 | 6 | __Note:__ These helpers are the helper functions that you can supply at initialization that are wrapped to return cell values instead of the cells themselves. These helpers should not be used to supply cell arguments to setter helpers. 7 | 8 | * __row(cellName)__ 9 | 10 | Returns a single cell's value from the current row by name 11 | 12 | * __prevRow(cellName)__ 13 | 14 | Returns a single cell's value from the previous row by name 15 | 16 | * __rowsWhere(criteria, cellName)__ 17 | 18 | Returns a cell's value from each row by name where criteria matches the metadata attributes found on each row. For example if I call `rowsWhere({ color: red, even: true }, 'count')` it will return the `count` cell from every row that has at least the `color: red` and `even: true` properties in the row's `meta` object. 19 | 20 | Note: ALL properties given in `criteria` must match for the row to match the rule and return it's cell. 21 | 22 | ## Setter Helpers 23 | These functions update cells based on the given values. 24 | 25 | * __set(value, cells)__ 26 | 27 | Updates all cells with the given value. `cells` can either be a single cell or an array of cells 28 | 29 | * __spread(value, cells)__ 30 | 31 | Increments or decrements an array of cells proportionally to each other 32 | 33 | * __data.cells__ 34 | 35 | This in an object that contains pure copies of the cell helper functions that return cells instead of cell values. These should be used for getting references to cells to pass to setter helper functions. 36 | 37 | ## Other Helpers 38 | 39 | * __meta(name)__ 40 | 41 | Returns the meta property for the given row. For example, if the metadata for my current row is: 42 | ``` 43 | { a: 123, b: 'hello' } 44 | ``` 45 | calling `meta('a')` will return `123` for all cells in that row. 46 | 47 | * __preLink()__ 48 | 49 | Returns a reference to the preLink data returned by the `preLink` function provided at initialization. 50 | -------------------------------------------------------------------------------- /docs/cell-definitions.md: -------------------------------------------------------------------------------- 1 | # Cell Definitions 2 | 3 | You can define cell definitions in one of 3 flavors: 4 | 5 | ## 1. Base Value 6 | 7 | This is the easiest method. You simply specify an initial value that you'd like the cell to be. TableModel will automatically create a `getter` and `setter` formula that will get and set this value. 8 | 9 | ``` 10 | const numCell = 13; 11 | const stringCell = 'Hello World'; 12 | ``` 13 | 14 | Base Values can be an array or an object as well 15 | 16 | ``` 17 | const arrayCell = [1, 2, 3, 4, 5]; 18 | const objCell = { name: 'James', hp: '200', gold; '1200' }; 19 | ``` 20 | 21 | ## 2. Getter Function 22 | 23 | This defines a cell whose value is based off of other cells or data. The function you provide will be the getter for your cell. This getter function will be called any time the value of any of the dependant cell's values are updated. For example, the getter function will be called when either the `width` or `height` cells are updated in the same row. 24 | 25 | The getter function takes a single argument which in an object which you can call whatever you'd like. In this case we call it `d` for `data`. This object contains all your helper functions that you can use to make it easier to write getter functions. 26 | 27 | For example, if I wanted to reference a cell in the previous week (let's say I wanted to compare inventory levels to see how much I bought / sold) I could call `d.prevRow('inventoryCount)` which would be a lot easier than manually sorting through all the row data to find that row and cell yourself. It also makes your cell definition look cleaner and more like a mathematical formula. 28 | 29 | The value that's returned from the getter function is the new calculated value for that cell. 30 | 31 | Note: You will not be able to update any cell that only has a getter function. 32 | 33 | ``` 34 | const areaCell = d => d.row('width') * d.row('height'); 35 | ``` 36 | 37 | Note: The previous example is shorthand for the third method and is the same as not specifing a setter: 38 | 39 | ``` 40 | const areaCell = { 41 | get: d => d.row('width') * d.row('height'); 42 | } 43 | ``` 44 | 45 | Somtimes you may want to reference what the previous value of the cell was. 46 | Using `d.row('areaCell')` would cause infinitely recursing function calls so you can reference the old value like so: 47 | ``` 48 | const areaCell = { 49 | get: (d, oldValue) => // do something 50 | } 51 | ``` 52 | 53 | ## 3. Getter/Setter Object 54 | 55 | This is the option that gives you the most flexibility. This is done by defining an object with both a `get` property with the cell's getter function and a `set` property with the cell's setter function. This is useful if you want equations or formulas which can be manipulated both ways. 56 | 57 | For example: If I have a row that has `width`, `height` and `area`, a simple set of cell definitions might look like this: 58 | 59 | ``` 60 | const rowDefinition = { 61 | width: 100, 62 | height: 20, 63 | area: d => d.row('width') * d.row('height') 64 | } 65 | ``` 66 | 67 | However, we learned from the last section that I can't update a cell that only has a getter function, as is the case with `area`. However, we know that mathematically I __COULD__ change the area and then update the width or height to balance the equation. 68 | 69 | We can do this with TableModel be specifing a setter function with `set`: 70 | 71 | ``` 72 | const rowDefinition = { 73 | width: 100, 74 | height: 20, 75 | area: { 76 | get: d => d.row('width') * d.row('height') 77 | set: (d, value, oldValue) => d.set(value / d.row('width'), d.cells.row('height')) 78 | } 79 | } 80 | ``` 81 | 82 | Note: I'm using `data.cells.row('height')` instead of `data.row('height')` since the latter would return the value, but I want to reference the cell. `data.cells` includes a set of helper functions which return cells instead of cells values that you can use for these special setter function helpers. 83 | 84 | You should recognize the getter function from the previous example. It's exactly the same, we've just moved it to the `get` property in our object. 85 | 86 | We've also added a `set` property with our setter function. A setter function takes 3 arguments, `[data, value, oldValue]` where `data` is the same argument passed to the getter function. The `value` is the new value the cell is being updated to and the `oldValue` is the value of the cell before the update. 87 | 88 | ### The `data.set(value, cells)` helper 89 | 90 | What's going on in our setter function is that we're calling a special helper function called `set`. The `set` helper takes two arguments, a value for the first argument and a cell or array of cells for the second argument. Calling `set(value, cells)` will update all the given cells with the value provided. 91 | 92 | Going back to our example, we can see that the initial value of `area` would be `2000`. If we update `area` to `3000`, we see that the setter function would take that new value of `3000`, divide it by the current `width` which is `100` to get a value of `30`. We take this value of `30` and set it to the cell in the second argument of `set` which is height. After our update our row would look like this: 93 | 94 | ``` 95 | // Inital row values are: 96 | { 97 | width: 100, 98 | height: 20, 99 | area: 2000 100 | } 101 | 102 | tableModel.update({ myRow: { area: 3000 }}); 103 | 104 | // Final row values are 105 | { 106 | width: 100, 107 | height: 30, // Included in update 108 | area: 3000 // Included in update 109 | } 110 | ``` 111 | 112 | ### The `data.spread(value, cells)` helper 113 | 114 | The `spread` method is another special function you'll probably only ever use in setter functions. It takes similar arguments as `set` except it will *"spread"* the given value over the given cells instead of setting the value. What that means is that it would increase or decrease each cells value proportionally. This is seen from the following example: 115 | 116 | ``` 117 | const rowDefinition = { 118 | a: 100, 119 | b: 200, 120 | c: 300, 121 | d: 400 122 | total: { 123 | get: d => d.row('a') + d.row('b') + d.row('c') + d.row('d'); 124 | set: (d, value, oldValue) => { 125 | const allcells = [d.row('a'), d.row('b'), d.row('c'), d.row('d')]; 126 | d.spread(value - oldValue, allCells); 127 | } 128 | } 129 | } 130 | 131 | // Inital row values are: 132 | { 133 | a: 100, 134 | b: 200, 135 | c: 300, 136 | d: 400 137 | total: 1000 138 | } 139 | 140 | tableModel.update({ myRow: { total: 1500 }}); 141 | 142 | // Final row values are 143 | { 144 | a: 150, 145 | b: 300, 146 | c: 450, 147 | d: 600 148 | total: 1500 149 | } 150 | ``` 151 | 152 | Note: It's important to note that the `value` in `spread` is `500` which represents the amount of the change instead of the final value itself. 153 | -------------------------------------------------------------------------------- /docs/table-model-api.md: -------------------------------------------------------------------------------- 1 | # TableModel API 2 | 3 | ## const tableModel = TableModel({ rowDefs, helpers, listener, preLink }) 4 | 5 | - __rowDefs: (Required)__ This is an array of row definitions which define the data and/or formulas that will be used in the underlying cells. A row definition takes the form of an object `{ meta: {}, cells {} }` where `meta` is an object of generic data specific to the row and `cells` is an object where the keys are the cell name and the values are the cell definition. 6 | 7 | ``` 8 | const rowDefs = [ 9 | // Row 1 10 | { 11 | meta: { id: 1, type: 'red' }, 12 | cells: { a: 10, b: 20, c: 30, total: totalEquation } 13 | }, 14 | // Row 2 15 | { 16 | meta: { id: 2, type: 'green' }, 17 | // Rows don't have to have the same types of cells 18 | cells: { a: 12, b: 45, c: 38, d: 1234, total: totalEquation, extra: extraEquation } 19 | }, 20 | // Row 3 21 | { 22 | meta: { id: 3, type: 'red' }, 23 | cells: { a: 100, b: 200, c: 300, total: totalEquation } 24 | }, 25 | // Totals 26 | { 27 | meta: { id: 4 }, 28 | cells: { redTotal: colorTotals('red'), greenTotal: colorTotals('green') } 29 | } 30 | ]; 31 | ``` 32 | - __helpers:__ You can provide an optional object here to extend the built-in helper functions which are available for use in your cell definitions. This is an object where the keys are the helper name and the values are helper functions. The helper functions take the form of a function that returns a function which returns a cell or an array of cells. 33 | 34 | ``` 35 | const helpers = { 36 | byColor: input => { 37 | return cellName => { 38 | const thisColor = input.meta.color; 39 | const rows = input.rows; 40 | 41 | return rows.filter(row => row.meta.color === thisColor).map(row => row[cellName]); 42 | }; 43 | } 44 | } 45 | 46 | ``` 47 | 48 | The outer function gets called with an `input` argument that contains various data that is avilable to the helper method. The contents of the `input` may be different for each invocation depending on the cell in which the helper is called. 49 | 50 | The inner function is the function that actually gets used and called in the cell definitions. This function can have whatever arguments you'd like to provide to the user. 51 | 52 | __IMPORTANT:__ 53 | The return value of the inner function must be either a cell or an array of cells. Table model will automatically resolve the value of the cell for use in the formula. 54 | 55 | - __listener:__ You can specify an optional listener here at initialization that will be called with the first cell update that will include a complete set of all the values for all the cells in your table. If you use the `tableModel.listen(listenerFn)` to add a listener after TableModel has been initialized, it will receive later updates, but not the first complete update. 56 | 57 | - __prelink:__ You can specify an optional callback function here. This preLink function will be called __AFTER__ all the cells have been built but __BEFORE__ the linking and initial calculation process has started. The prelink function is called with an array of rows containing cell objects instead of cell definitions. Any data returned by this method is made available to helper functions via the `input` argument. This is useful if you'd like to build a cache of cells to use in helper functions or to perform any other optimizations here. 58 | 59 | An example of a good use case for this would be taking an array of rows that represent certain days and grouping them by month and storing the results in an object. A helper function can use this cache to do faster lookups for all days that are in a given week instead of searching the entire dataset over and over again each time the helper method is called. 60 | 61 | ``` 62 | const prelink = rows => { 63 | const daysInMonth = _.groupBy(rows, row => row.meta.month); 64 | return { daysInMonth }; 65 | } 66 | ``` 67 | ... and in your helper method ... 68 | 69 | ``` 70 | const helpers = { 71 | monthDays: input => { 72 | return (month, cellName) => { 73 | const daysInMonth = input.preLink.daysInMonth; 74 | const dayRows = daysInMonth(month); 75 | const cells = dayRows.map(row => row[cellName]); 76 | return cells; 77 | } 78 | } 79 | }; 80 | ``` 81 | ... and in your cell definition ... 82 | 83 | ``` 84 | const monthTotal = d => { 85 | const pointsForAllDaysInMonth = d.monthDays(10, 'points'); 86 | const total = pointsForAllDaysInMonth.reduce((x, y) => x + y); 87 | return total; 88 | } 89 | ``` 90 | 91 | ## tableModel.listen(listenerFn) 92 | This adds a listener function to TableModel that will be called with all subsequent updates. Each listener function gets called with an object where the keys are the row ids and the values are an object containing the new values for any cells in that row that were updated by the last update. The first update returned by TableModel will include a complete set of the values of all cells like so: 93 | 94 | ``` 95 | { 96 | 1: { a: 10, b: 20, c: 30, total: 60 }, 97 | 2: { a: 12, b: 45, c: 38, d: 1234, total: 95, extra: 1710 }, 98 | 3: { a: 100, b: 200, c: 300, total: 600 }, 99 | 4: { redTotal: 660, greenTotal: 95 } 100 | } 101 | ``` 102 | 103 | Subsequent updates will contain only the rows and cells that were impacted by the update: 104 | 105 | ``` 106 | { 107 | 2: { c: 123, total: 180, extra: 5535 }, 108 | 4: { greenTotal: 180 } 109 | } 110 | ``` 111 | 112 | Note: In order to catch the first complete update from TableModel, your listener must be added at initialization. If you use this method, your listener won't be added in time to receive the first complete update. 113 | 114 | ## tableModel.update(changes) 115 | This triggers an update on TableModel and will update or set all values supplied. The only argument is an object which is similar in structure to the objects received by the listener functions: the object keys being row ids and the object values being key-value pairs of the cellNames and values of those cells to be updated. 116 | 117 | ``` 118 | table.update({ 119 | 2: { a: 123, c: 456 } 120 | 3: { b: 789 } 121 | }); 122 | ``` 123 | 124 | If this update contains a cell that can't be updated (either because it's not a "base value" equation) or because it has no setter in its equation) it'll throw an exception with details. An exception will also be thrown if TableModel notices that you are trying to update a cell which would also be impacted by an update of another cell, since the values of these updates might not match. 125 | 126 | ## tableModel.willAffect(changes) 127 | This method takes the exact same argument type as `tableModel.update(changes)`. However, instead of triggering an update it will perform a "dry run" on the cells instead and keep track of which cells are impacted by the update without actually updating the values of any cells. This allows you to do "update prediction" allowing you check which cells will be affected by a call to `tableModel.update(changes)` before you actually make the update. 128 | 129 | The value returned by this function is an object where the keys are row ids and the values are an array of cellNames which represent the cells in that row that would be affected by the update. 130 | 131 | ``` 132 | let affectedCells = tableModel.willAffect({ 2: { asp: 10 } }); 133 | console.log(affectedCells); 134 | 135 | // Outputs: 136 | { 137 | 2: ['asp', 'doubleAsp', 'sales'], 138 | 3: ['prevDoubleAsp'] 139 | } 140 | ``` 141 | 142 | ## tableModel.rows 143 | You can access the rows object directly from the instance object. You should not make changes to this object after initialization. You should supply a `preLink` function if you'd like to make changes or optimizations to the cells before linking. 144 | 145 | ## tableModel.preLinkData 146 | You can access the preLinkData directly from the instance object. You should not make changes to this object after initialization. 147 | -------------------------------------------------------------------------------- /docs/table-model-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/target/table-model/fdbc9054a5751617df1e0b32b961cf87dc789874/docs/table-model-demo.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "table-model", 3 | "version": "1.6.0", 4 | "description": "A library to manage and automate data and calculations", 5 | "keywords": [ 6 | "table", 7 | "model", 8 | "data", 9 | "calculation", 10 | "formula", 11 | "aggrid" 12 | ], 13 | "repository": "https://github.com/target/table-model", 14 | "author": "Edward Nicholes Jr.", 15 | "license": "MIT", 16 | "main": "dist/table-model.js", 17 | "scripts": { 18 | "build": "webpack", 19 | "clean": "rm -rf ./dist", 20 | "test": "mocha-webpack --require jsdom-global/register --webpack-config webpack.config.js src/index.spec.js", 21 | "test:watch": "mocha-webpack --watch --require jsdom-global/register --webpack-config webpack.config.js src/index.spec.js" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.12.10", 25 | "@babel/preset-env": "^7.12.10", 26 | "ag-grid": "^17.1.1", 27 | "ag-grid-react": "^24.1.1", 28 | "babel-loader": "^8.2.2", 29 | "babel-preset-react": "^6.24.1", 30 | "eslint": "^7.15.0", 31 | "eslint-config-xo-react": "^0.23.0", 32 | "eslint-config-xo-space": "^0.25.0", 33 | "eslint-loader": "^4.0.2", 34 | "eslint-plugin-react": "^7.21.5", 35 | "html-webpack-plugin": "^4.5.0", 36 | "js-logger": "^1.6.1", 37 | "jsdom": "^16.4.0", 38 | "jsdom-global": "^3.0.2", 39 | "mocha": "^7.2.0", 40 | "mocha-webpack": "^2.0.0-beta.0", 41 | "node-sass": "^7.0.0", 42 | "prettier": "^2.2.1", 43 | "react": "^17.0.1", 44 | "react-dom": "^17.0.1", 45 | "react-dom-factories": "^1.0.2", 46 | "sass-loader": "^10.1.0", 47 | "should": "^13.2.3", 48 | "sinon": "^9.2.1", 49 | "source-map-support": "^0.5.19", 50 | "style-loader": "^2.0.0", 51 | "webpack": "^4.44.2", 52 | "webpack-cli": "^4.2.0", 53 | "webpack-dev-server": "^3.11.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ag-grid-utils.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | export const initAgGridData = (agGridApi, tableModelUpdate) => { 4 | const rowData = _.map(tableModelUpdate, (cells, rowId) => { 5 | const gridRow = { ...cells }; 6 | gridRow.id = parseInt(rowId, 10); 7 | return gridRow; 8 | }); 9 | 10 | agGridApi.setRowData(rowData); 11 | }; 12 | 13 | export const updateAgGrid = (agGridApi, tableModelUpdate) => { 14 | _.each(tableModelUpdate, (rowUpdates, rowId) => { 15 | const node = agGridApi.getRowNode(rowId); 16 | 17 | let { data } = node; 18 | data = Object.assign(data, rowUpdates); 19 | node.setData(data); 20 | }); 21 | }; 22 | 23 | const noValueChange = params => params.newValue === params.oldValue; 24 | 25 | export const updateTable = (table, agGridUpdate) => { 26 | const rowId = agGridUpdate.node.id; 27 | const cell = agGridUpdate.colDef.field; 28 | 29 | if(noValueChange(agGridUpdate)) 30 | return; 31 | 32 | const update = {}; 33 | update[rowId] = {}; 34 | update[rowId][cell] = agGridUpdate.newValue; 35 | 36 | table.update(update); 37 | }; 38 | 39 | export const buildHighlighter = tableModel => { 40 | let affectedRows = null; 41 | 42 | const begin = params => { 43 | const rowId = params.data.id; 44 | const cellName = params.colDef.field; 45 | const { api } = params; 46 | 47 | const update = {}; 48 | update[rowId] = {}; 49 | update[rowId][cellName] = null; 50 | 51 | affectedRows = tableModel.willAffect(update); 52 | 53 | const rowNodes = []; 54 | _.each(affectedRows, (affectedCells, rowId) => { 55 | const node = params.api.getRowNode(rowId); 56 | 57 | if(node) { 58 | const { data } = node; 59 | data.affectedCells = affectedCells; 60 | rowNodes.push(node); 61 | } 62 | }); 63 | api.refreshCells({ rowNodes, force: true }); 64 | }; 65 | 66 | const end = params => { 67 | const rowNodes = []; 68 | const { api } = params; 69 | _.each(affectedRows, (affectedCells, rowId) => { 70 | const node = params.api.getRowNode(rowId); 71 | if(node) { 72 | delete node.data.affectedCells; 73 | rowNodes.push(node); 74 | } 75 | }); 76 | api.refreshCells({ rowNodes, force: true }); 77 | affectedRows = null; 78 | }; 79 | 80 | return { begin, end }; 81 | }; 82 | -------------------------------------------------------------------------------- /src/cell.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import Model from './model'; 4 | import Transaction from './transaction'; 5 | 6 | const Cell = ({ id, formula, data, helperFactory }) => { 7 | const cell = { 8 | id, 9 | dependents: [], 10 | dependencies: [], 11 | setterDependents: [] 12 | }; 13 | 14 | const model = Model(formula); 15 | 16 | const get = (trx = {}) => { 17 | const { cellStack, dryRun, init, stale, updates } = trx; 18 | 19 | // Add and link dependant 20 | if(init) { 21 | const dependentCell = cellStack[cellStack.length - 1]; 22 | 23 | if(dependentCell) { 24 | cell.dependents.push(dependentCell); 25 | dependentCell.dependencies.push(cell); 26 | } 27 | } 28 | 29 | const isStale = stale && stale[id] !== undefined; 30 | if(!isStale) { 31 | // Only call this getter once per transaction 32 | if(updates && updates[id] !== undefined) 33 | return updates[id]; 34 | 35 | // If not stale, return cached model value 36 | if(!updates || !init) 37 | return model.value; 38 | } 39 | 40 | // Update helpers with current trx 41 | const helpers = helperFactory(trx); 42 | 43 | if(cellStack) 44 | cellStack.push(cell); 45 | const value = model.getter(helpers, model.value); 46 | if(cellStack) 47 | cellStack.pop(); 48 | 49 | // Update model 50 | if(!dryRun) 51 | model.value = value; 52 | 53 | // Update trx 54 | if(updates) { 55 | updates[id] = value; 56 | delete stale[id]; 57 | } 58 | 59 | // Mark dependents as stale and update 60 | if(updates && !init && stale) { 61 | cell.dependents.forEach(dep => { 62 | const cellId = dep.id; 63 | 64 | if(updates[cellId] === undefined) 65 | stale[cellId] = dep; 66 | }); 67 | } 68 | 69 | return value; 70 | }; 71 | 72 | let set; 73 | if(model.setter) { 74 | set = (value, trx) => { 75 | // Only call this setter once per transaction 76 | const { cellStack, dryRun, stale, updates } = trx; 77 | if(updates && updates[id]) 78 | return; 79 | 80 | // Update helpers with current trx 81 | const helpers = helperFactory(trx); 82 | 83 | // Placeholder 84 | updates[id] = true; 85 | 86 | // Update this cell 87 | if(cellStack) 88 | cellStack.push(cell); 89 | model.setter(helpers, value, model.value); 90 | if(cellStack) 91 | cellStack.pop(); 92 | 93 | // Update model and trx 94 | if(!dryRun) 95 | model.value = value; 96 | 97 | // Update trx 98 | updates[id] = value; 99 | delete stale[id]; 100 | 101 | // Update dependants 102 | if(stale) { 103 | cell.dependents.forEach(dep => { 104 | const cellId = dep.id; 105 | 106 | if(updates[cellId] === undefined) 107 | stale[cellId] = dep; 108 | }); 109 | } 110 | 111 | // Process stale cells 112 | if(cellStack.length === 0) { 113 | while(_.size(stale)) 114 | _.values(stale).forEach(cell => cell.get(trx)); 115 | } 116 | }; 117 | } else { 118 | set = () => { 119 | throw new Error('This cell doesn\'t have a setter'); 120 | }; 121 | } 122 | 123 | const willAffect = () => { 124 | const trx = Transaction({ dryRun: true }); 125 | set(model.value, trx); 126 | 127 | return trx.updates; 128 | }; 129 | 130 | const hasSetter = model.setter !== undefined; 131 | Object.assign(cell, { data, get, set, hasSetter, willAffect }); 132 | return cell; 133 | }; 134 | 135 | export default Cell; 136 | -------------------------------------------------------------------------------- /src/cell.spec.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import should from 'should'; 3 | 4 | import Cell from './cell'; 5 | import Transaction from './transaction'; 6 | 7 | import { buildHelperFactory } from './utils'; 8 | 9 | const helperFns = { 10 | cellHelpers: { 11 | row: input => { 12 | return cellNames => { 13 | const { testCells } = input; 14 | let result; 15 | if(_.isArray(cellNames)) 16 | result = cellNames.map(cellName => testCells[cellName]); 17 | else 18 | result = testCells[cellNames]; 19 | 20 | return result; 21 | }; 22 | } 23 | }, 24 | otherHelpers: { 25 | set: input => { 26 | return (value, cells) => { 27 | const { trx } = input; 28 | if(_.isArray(cells)) 29 | cells.forEach(cell => cell.set(value, trx)); 30 | else 31 | cells.set(value, trx); 32 | }; 33 | }, 34 | spread: input => { 35 | return (value, cells) => { 36 | const { trx } = input; 37 | const total = cells.reduce((total, cell) => total + cell.get(trx), 0); 38 | cells.forEach(cell => { 39 | const oldValue = cell.get(trx); 40 | const proportion = oldValue / total; 41 | const delta = value * proportion; 42 | const newValue = oldValue + delta; 43 | 44 | cell.set(newValue, trx); 45 | }); 46 | }; 47 | } 48 | } 49 | }; 50 | 51 | const helperFactory = buildHelperFactory(helperFns); 52 | 53 | describe('Cell', () => { 54 | let data; 55 | 56 | beforeEach(() => { 57 | const testCells = _.mapValues({ alpha: 10, beta: 20, delta: 100, omega: 1234 }, (value, name) => { 58 | return Cell({ id: name, formula: value, helperFactory }); 59 | }); 60 | data = { testCells }; 61 | }); 62 | 63 | it('should support `value` formula', () => { 64 | const helperFactory = buildHelperFactory(helperFns, data); 65 | const cell = Cell({ id: 'cell', formula: 100, helperFactory, data }); 66 | 67 | let trx = Transaction({ init: true }); 68 | cell.get(trx).should.eql(100); 69 | 70 | trx = Transaction(); 71 | cell.set(50, trx); 72 | 73 | cell.get().should.eql(50); 74 | }); 75 | 76 | it('should support complex `value` formulas', () => { 77 | const helperFactory = buildHelperFactory(helperFns, data); 78 | const cell = Cell({ id: 'cell', formula: { name: 'James', gold: 200 }, helperFactory, data }); 79 | 80 | let trx = Transaction({ init: true }); 81 | cell.get(trx).should.eql({ name: 'James', gold: 200 }); 82 | 83 | trx = Transaction(); 84 | cell.set({ name: 'Sarah', gold: 5000 }, trx); 85 | 86 | cell.get().should.eql({ name: 'Sarah', gold: 5000 }); 87 | }); 88 | 89 | it('should support `function` formula', () => { 90 | const helperFactory = buildHelperFactory(helperFns, data); 91 | const cell = Cell({ id: 'cell', formula: d => d.row('alpha') + d.row(['delta', 'omega']).reduce((x, y) => x + y, 0) + 10, helperFactory, data }); 92 | const cell2 = Cell({ id: 'cell2', formula: d => d.row('alpha') * d.row('beta'), helperFactory, data }); 93 | 94 | let trx = Transaction({ init: true }); 95 | cell.get(trx).should.eql(1354); 96 | cell2.get(trx).should.eql(200); 97 | 98 | should(() => { 99 | trx = Transaction(); 100 | cell.set(50, trx); 101 | }).throw('This cell doesn\'t have a setter'); 102 | 103 | cell.get().should.eql(1354); 104 | 105 | cell.dependencies.length.should.eql(3); 106 | cell2.dependencies.length.should.eql(2); 107 | 108 | const { alpha, beta, delta, omega } = data.testCells; 109 | 110 | alpha.dependents.length.should.eql(2); 111 | beta.dependents.length.should.eql(1); 112 | delta.dependents.length.should.eql(1); 113 | omega.dependents.length.should.eql(1); 114 | 115 | trx = Transaction(); 116 | alpha.set(123, trx); 117 | 118 | trx.updates.should.deepEqual({ 119 | alpha: 123, 120 | cell: 1467, 121 | cell2: 2460 122 | }); 123 | }); 124 | 125 | it('should only call a getter formula once per transaction', () => { 126 | let count = 0; 127 | const helperFactory = buildHelperFactory(helperFns, data); 128 | 129 | const cell = Cell({ id: 'cell', formula: d => { 130 | count++; 131 | return d.row('alpha') + 10; 132 | }, helperFactory, data }); 133 | 134 | const trx = Transaction({ init: true }); 135 | cell.get(trx).should.eql(20); 136 | 137 | cell.get().should.eql(20); 138 | cell.get().should.eql(20); 139 | cell.get().should.eql(20); 140 | cell.get().should.eql(20); 141 | cell.get().should.eql(20); 142 | cell.get().should.eql(20); 143 | 144 | count.should.eql(1); 145 | }); 146 | 147 | it('should only call a getter formula once per transaction when value 0', () => { 148 | const helperFactory = buildHelperFactory(helperFns, data); 149 | 150 | let count = 0; 151 | const formula = d => { 152 | count++; 153 | return d.row('alpha') * 0; 154 | }; 155 | 156 | data.testCells.gamma = Cell({ id: 'gamma', formula, helperFactory, data }); 157 | const cell = Cell({ id: 'cell', formula: d => { 158 | return d.row('gamma') + 10; 159 | }, helperFactory, data }); 160 | 161 | const trx = Transaction({ init: true }); 162 | data.testCells.gamma.get(trx).should.eql(0); 163 | cell.get(trx).should.eql(10); 164 | 165 | cell.get().should.eql(10); 166 | cell.get().should.eql(10); 167 | cell.get().should.eql(10); 168 | cell.get().should.eql(10); 169 | cell.get().should.eql(10); 170 | cell.get().should.eql(10); 171 | count.should.eql(1); 172 | }); 173 | 174 | it('should support `object` formula', () => { 175 | const helperFactory = buildHelperFactory(helperFns, data); 176 | const cell = Cell({ id: 'cell', formula: { 177 | get: d => d.row('alpha') + 10, 178 | set: (d, value) => d.set(value - 10, d.cells.row('alpha')) 179 | }, helperFactory, data }); 180 | 181 | let trx = Transaction({ init: true }); 182 | cell.get(trx).should.eql(20); 183 | 184 | trx = Transaction(); 185 | cell.set(50, trx); 186 | 187 | trx.updates.should.deepEqual({ 188 | alpha: 40, 189 | cell: 50 190 | }); 191 | 192 | cell.get().should.eql(50); 193 | data.testCells.alpha.get().should.eql(40); 194 | }); 195 | 196 | it('should return the previous value', () => { 197 | const cell = Cell({ id: 'cell', formula: { 198 | get: (d, oldValue) => oldValue || 0, 199 | set: (d, value) => value 200 | }, helperFactory, data }); 201 | 202 | let trx = Transaction({ init: true }); 203 | cell.get(trx).should.eql(0); 204 | 205 | trx = Transaction(); 206 | cell.set(10, trx); 207 | cell.get().should.eql(10); 208 | 209 | trx = Transaction(); 210 | cell.set(20, trx); 211 | cell.get().should.eql(20); 212 | }); 213 | }); 214 | -------------------------------------------------------------------------------- /src/demo/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /src/demo/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | 4 | extends: 5 | - xo-react/space 6 | -------------------------------------------------------------------------------- /src/demo/demo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Grid from './grid'; 3 | 4 | const Demo = () => ( 5 |