├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── documentation.md │ ├── feature-request.md │ └── maintenance.md ├── PULL_REQUEST_TEMPLATE.md ├── SUPPORT └── workflows │ └── node-test.yml ├── .gitignore ├── .storybook ├── config.js └── webpack.config.js ├── .travis.yml ├── CNAME ├── LICENSE ├── README.md ├── __tests__ ├── __test_utils__.js ├── floodgate.test.js ├── functions.test.js └── helpers.test.js ├── index.js ├── package.json ├── rollup.config.js ├── src ├── classes.ts ├── functions.tsx ├── helpers.tsx ├── index.tsx └── types.d.ts ├── stories ├── demos.js └── index.js └── tsconfig.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @geoffdavis92 -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | This project adheres to No Code of Conduct. We are all adults. We accept anyone's contributions. Nothing else matters. 4 | 5 | For more information please visit the [No Code of Conduct](https://nocodeofconduct.com) homepage. 6 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to react-floodgate 2 | 3 | Welcome! As a contributor to this project, it is expected that you abide by the [Code of Conduct](./CODE_OF_CONDUCT.md). 4 | 5 | ## Setup 6 | 7 | This project uses [Yarn](https://yarnpkg.com) as its package manager; it is highly recommended that contributors use Yarn if possible. 8 | 9 | 1. Fork and clone the repo 10 | 2. Run `yarn` to install dependencies 11 | 3. Create a branch for your PR 12 | 13 | ### Creating a branch 14 | 15 | When creating a branch, please adhere to the following branch name specification: 16 | 17 | `{username}/{branch_name}` 18 | 19 | Where: 20 | 21 | - `{username}`: your Github username 22 | - `{branch_name}`: desdcriptive branch name in camelCase 23 | 24 | This makes it clear 1. who is making the PR and 2. what problem is being solved/feature being added/etc when reviewing PRs on Github. 25 | 26 | ## Creating a Pull Request 27 | 28 | After you have forked the repo and created a feature branch, you can submit a [pull request](https://github.com/geoffdavis92/react-floodgate/pulls) directly to this repo. 29 | 30 | ## Yarn scripts 31 | 32 | Any `yarn`/`npm` scripts run locally-installed CLI binaries, so there is no worry about making sure your global binaries of the tools used are up to date. Note, this project primarily uses `yarn`, which is recommended, so ensuring Yarn is properly installed and up-to-date is important in the development process. 33 | 34 | 35 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [geoffdavis92] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # otechie: # Replace with a single Otechie username 12 | # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | - `react` version: 2 | - `react-floodgate` version: 3 | - `node` version: 4 | - `npm` (or `yarn`) version: 5 | 6 | Relevant code or config 7 | 8 | ```javascript 9 | 10 | ``` 11 | 12 | What you did: 13 | 14 | 15 | 16 | What happened: 17 | 18 | 19 | Reproduction repository: 20 | 23 | 24 | Problem description: 25 | 26 | 27 | 28 | Suggested solution: -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | ## Documentation Request 2 | 3 | 4 | ### Description 5 | 6 | 7 | ### Motivation 8 | 9 | ## Project Management 10 | 11 | 16 | Branch: `documentation/` 17 | 22 | Floodgate version bump: `` 23 | 24 | 31 | ## Checklist: 32 | - [ ] Merge into `master` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | ## Feature Request 2 | 3 | 4 | ### Description 5 | 6 | 7 | ### Motivation 8 | 9 | 10 | ### Proposed API 11 | 12 | ```diff 13 | + addition 14 | - subtraction 15 | ``` 16 | 17 | ## Project Management 18 | 19 | 24 | Branch: `feature/` 25 | 31 | Floodgate version bump: `` 32 | 39 | 40 | 47 | ## Checklist: 48 | - [ ] Relevant tests written and pass 49 | - [ ] Merge into `master` 50 | - [ ] Dist files built 51 | - [ ] Published to npm -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/maintenance.md: -------------------------------------------------------------------------------- 1 | ## Maintenance Request 2 | 3 | 4 | ### Description 5 | 6 | 7 | ### Motivation 8 | 9 | 10 | ### Affected API 11 | 12 | ```diff 13 | + addition 14 | - subtraction 15 | ``` 16 | 17 | ## Project Management 18 | 19 | 24 | Branch: `maintenance/` 25 | 30 | Floodgate version bump: `` 31 | 32 | 39 | ## Checklist: 40 | - [ ] Relevant tests written and pass 41 | - [ ] Merge into `master` 42 | - [ ] Dist files built 43 | - [ ] Published to npm -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What: 2 | 3 | 4 | ### Why: 5 | 6 | 7 | ### How: 8 | 9 | 10 | -------------------------------------------------------------------------------- /.github/SUPPORT: -------------------------------------------------------------------------------- 1 | # SUPPORT 2 | 3 | The following is a non-exhaustive list of how to get help with this project: 4 | 5 | * [Tweet at Geoff](https://twitter.com/gdavis92) -------------------------------------------------------------------------------- /.github/workflows/node-test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [8.x, 10.x, 12.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm run build --if-present 22 | - run: npm test 23 | env: 24 | CI: true 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # react-floodgate ignores... 2 | .rpt2_cache/ 3 | __tests__/__snapshots__/ 4 | archive/ 5 | dist/ 6 | node_modules/ 7 | *log 8 | .DS_Store 9 | .travis.yml.swp 10 | package-lock.json 11 | demo.js 12 | yarn.lock -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | function loadStories() { 4 | require('../stories'); 5 | } 6 | 7 | configure(loadStories, module); 8 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | // you can use this file to add your custom webpack plugins, loaders and anything you like. 2 | // This is just the basic way to add additional webpack configurations. 3 | // For more information refer the docs: https://storybook.js.org/configurations/custom-webpack-config 4 | 5 | // IMPORTANT 6 | // When you add this file, we won't add the default configurations which is similar 7 | // to "React Create App". This only has babel loader to load JavaScript. 8 | 9 | const webpack = require("@storybook/react/node_modules/webpack"); 10 | 11 | module.exports = { 12 | plugins: [ 13 | // your custom plugins 14 | ], 15 | module: { 16 | rules: [ 17 | // add your custom rules. 18 | { test: /\.tsx?$/, use: "ts-loader", exclude: /node_modules/ } 19 | ] 20 | }, 21 | resolve: { 22 | alias: { 23 | classes: "../src/classes.ts", 24 | functions: "../src/functions.tsx", 25 | helpers: "../src/helpers.tsx", 26 | floodgate: "../dist/floodgate.esm.js" 27 | } 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8.9 4 | sudo: enabled 5 | branches: 6 | except: 7 | - /documentation\/.+/ 8 | install: 9 | - yarn 10 | script: 11 | - yarn run build 12 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | floodgate.js.org -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Geoff Davis 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 | ## Floodgate and it's maintainer [@geoffdavis92](https://github.com/geoffdavis92) stand with the Black community 2 | 3 | If you are financially able, please consider donating to [Black Lives Matter](https://secure.actblue.com/donate/ms_blm_homepage_2019) or any number of metropolitan bail funds ([list in this link](https://bit.ly/bailfundslegalhelp)). 4 | 5 | Open source takes cooperation and expertise from all over the world, from people of all ethnic, racial, and national backgrounds; so does fighting for freedom and against police/state brutality and murder. 6 | 7 | Black. Lives. Matter. 8 | 9 | --- 10 | 11 |

react-floodgate 🌊

12 |

Configurable and flexible "load more" component for React

13 | 14 | --- 15 | 16 |

17 | npm version 18 | GitHub release 19 | npm downloads 20 | npm license 21 |

22 | 23 | ## The motivation 24 | 25 | I have worked on a few client sites and side projects where serialized data is to be displayed concatenated to a given length, with the ability to load more entries after a respective user interaction. 26 | 27 | This can easily result in a complicated mixture of `Array.splice`-ing, potential data mutation, and overly complicated component methods. 28 | 29 | Surely there can be a more elegant solution? 30 | 31 | ## This solution 32 | 33 | Enter `react-floodgate`; like its namesake, this component allows for the precise and safe control of resources. Using an [ES2015 generator function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*) as the control mechanism and the [function-as-child](http://mxstbr.blog/2017/02/react-children-deepdive/#function-as-a-child) pattern for flexible and developer-controlled rendering, one can load serialized data into `react-floodgate`, render their desired components, and safely and programmatically iterate through the data as needed. 34 | 35 | ## The inspiration 36 | 37 | This project was inspired by [Kent Dodd's](https://twitter.com/kentcdodd) [Downshift](https://github.com/paypal/downshift), [this talk](https://www.youtube.com/watch?v=hEGg-3pIHlE) by [Ryan Florence](https://twitter.com/ryanflorence), and [this blog post](http://mxstbr.blog/2017/02/react-children-deepdive/#function-as-a-child) by [Max Stoiber](https://twitter.com/mxstbr). 38 | 39 | This README file modeled after the [Downshift README](https://github.com/paypal/downshift/blob/master/README.md). 40 | 41 | ## Installation 42 | 43 | You can install the package via [**`npm`**](https://npmjs.org/) or [**`yarn`**](https://yarnpkg.com/): 44 | 45 | `$ yarn add react-floodgate` 46 | 47 | or 48 | 49 | `$ npm i --save react-floodgate` 50 | 51 | ## Usage 52 | 53 | This is a basic example of Floodgate, showcasing an uncontrolled implementation: 54 | 55 | ```javascript 56 | const BasicExample = props => ( 57 | console.log(stateAtLoadNext)} 63 | onLoadAll={(stateAtLoadAll) => console.log(stateAtLoadAll)} 64 | onReset={(stateAtReset) => console.log(stateAtReset)}> 65 | {({ items, loadNext, loadAll, reset, loadComplete }) => ( 66 |
67 | 70 | 71 | 72 | {loadComplete ? : null} 73 |
74 | )} 75 |
76 | ) 77 | ``` 78 | 79 | Uncontrolled Floodgate components are entirely static, and their state will be complete lost/reset when unmounting and re-mounting. In order to ensure internal state is saved during these scenarios, and in order to create dynamic Floodgate components, Floodgate has to be controlled. 80 | 81 | ### Controlled Floodgate 82 | 83 | The following is a basic example of a controlled Floodgate implementation; this component has a location to save Floodgate state, and uses those values as Floodgate's props. In order to make sure this component does save Floodgate's state, the `onExportState` prop will have to have a function passed to it that saves desired Floodgate state properties to the controlling component's state. 84 | 85 | ```javascript 86 | class FloodgateController extends React.Component { 87 | constructor(props) { 88 | super(); 89 | this.state = { 90 | showFloodgate: true, 91 | FGState: { 92 | data: props.data, 93 | initial: 3, 94 | increment: 3 95 | } 96 | }; 97 | this.toggle = this.toggle.bind(this); 98 | } 99 | toggle() { 100 | this.setState(prevState => ({ 101 | showFloodgate: !prevState.showFloodgate 102 | })); 103 | } 104 | render() { 105 | return ( 106 |
107 | 108 | {this.state.showFloodgate ? this.setState(prevState => ({ 114 | FGState: { 115 | ...prevState.FGState, 116 | ...newFGState, 117 | initial: newFGState.currentIndex 118 | } 119 | }))}> 120 | {({ items, loadNext, loadAll, reset, loadComplete }) => ( 121 |
122 |
    123 | {items.map(number =>
  • {number}
  • )} 124 |
125 | 126 | 127 | {loadComplete ? : null} 128 |
129 | )} 130 |
: null } 131 |
132 | ); 133 | } 134 | } 135 | 136 | const ControlledFGInstance = ; 137 | ``` 138 | 139 | This strategy can also be employed to fetch data to pass into Floodgate's `data` prop, or alongside some settings dialogue to allow end-users control over how this feed behaves. 140 | 141 | 176 | 177 | ## API 178 | 179 | ### `Floodgate` props 180 | 181 | | name | type | default | description | 182 | |------------------------|-------------|--------------|-------------------------------------------------------------------------------------------------------------| 183 | | `data` | Array\ | `null` | The array of items to be processed by `Floodgate`| | 184 | | `initial` | number | `5` | How many items are initially available in the render function| | 185 | | `increment` | number | `5` | How many items are added when calling `loadNext`| | 186 | | `exportStateOnUnmount` | boolean | *(optional)* | Toggle if `exportState` will be called during `componentWillUnmount` | 187 | | `onExportState` | Function | *(optional)* | Function to pass up Floodgate's internal state when `componentWillUnmount` fires or `exportState` is called | 188 | | `onLoadNext` | Function | *(optional)* | Callback function to run after `loadNext`; runs after inline `callback` argument prop | 189 | | `onLoadComplete` | Function | *(optional)* | Callback function to run after `loadComplete`; runs after inline `callback` argument prop | 190 | | `onReset` | Function | *(optional)* | Callback function to run after `reset`; runs after inline `callback` argument prop | 191 | 192 | #### `data` 193 | 194 | *Type:* `Array = null` 195 | 196 | The array of items to be processed by the `Floodgate` internal queue. 197 | 198 | This array will accept any type of element, but it is recommended to either provide elements with a uniform type, or normalize elements before they get consumed by `Floodgate`. This best practice is to safeguard against the possibility of performing side effects on an element in Floodgate's `render` function that are incompatible with a given element's type; e.g. an element with a type of `{ name: 'Jane Doe', email: 'jane@doe.com' }`, but in the `render` function performing `exampleItem.toUpperCase()`. 199 | 200 | 201 | 202 | #### `initial` 203 | 204 | *Type:* `number = 5` 205 | 206 | The length of the first set of items that will be rendered from Floodgate. 207 | 208 | 213 | 214 | #### `increment` 215 | 216 | *Type:* `number = 5` 217 | 218 | The length of subsequent sets of items when calling `loadNext`. 219 | 220 | #### `exportStateOnUnmount` 221 | 222 | *Type:* `boolean = false` 223 | 224 | Flag to configure the calling of `props.onExportState` when Floodgate triggers the `componentWillUnmount` component lifecycle event. 225 | 226 | #### `onExportState` 227 | 228 | *Arguments:* `{ currentIndex: number, renderedItems: any[], allItemsRendered: boolean }` 229 | 230 | Prop callback function that executes when Floodgate triggers the `componentWillUnmount` component lifecycle event, or when the [`exportState`](#exportState) is called from the render prop function. It provides a single object argument that represents a set of internal state properties that can be exported to a different component; this is best used on instances that will be toggled (un)mounted, such as in tabs or a single page application. 231 | 232 | `currentIndex` is a number representing the index of the last item passed through the queue to `state.renderedItems`. 233 | 234 | `renderedItems` is an array of all items that have been passed through the queue from `props.data`. 235 | 236 | `allItemsRendered` a boolean describing if all items have been processed by the queue. 237 | 238 | #### `onLoadNext` 239 | 240 | *Arguments:* [`Floodgate.state`](https://github.com/geoffdavis92/react-floodgate/blob/8f9ffe83aaae987246d12533671a335032e9f6dd/src/types.d.ts#L33-L43) 241 | 242 | Callback property that fires after the `loadNext` method is called. This is executed after `loadNext`'s `callback` method is executed. 243 | 244 | #### `onLoadComplete` 245 | 246 | *Arguments:* [`Floodgate.state`](https://github.com/geoffdavis92/react-floodgate/blob/8f9ffe83aaae987246d12533671a335032e9f6dd/src/types.d.ts#L33-L43) 247 | 248 | Callback property that fires after the `loadComplete` method is called. This is executed after `loadComplete`'s `callback` method is executed. 249 | 250 | #### `onReset` 251 | 252 | *Arguments:* [`Floodgate.state`](https://github.com/geoffdavis92/react-floodgate/blob/8f9ffe83aaae987246d12533671a335032e9f6dd/src/types.d.ts#L33-L43) 253 | 254 | Callback property that fires after the `reset` method is called. This is executed after `reset`'s `callback` method is executed. 255 | 256 | 257 | ### `render` function 258 | 259 | **Note:** the `render` function uses a single object argument to expose the following values/functions. Use the ES2015 destructuring syntax to get the most of this pattern. (see the [Usage](#usage) and [Examples](#examples) sections on how to do this) 260 | 261 | | name | type | default | parameters | description | 262 | |----------------|-------------|---------|-------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 263 | | `items` | Array\ | `null` | n/a | State: the subset of items determined by the `intitial` and `increment` props| | 264 | | `loadComplete` | boolean | `false` | n/a | State: describes if all items have been processed by the `Floodgate` instance| | 265 | | `loadAll` | Function | n/a | `{callback?: Function}` | Action: loads all `items`; `callback` prop in argument fires immediately after invocation| | 266 | | `loadNext` | Function | n/a | `{silent?: boolean, callback?: Function}` | Action: loads the next set of items; `callback` prop in argument fires immediately after invocation, `silent` determinse if `onLoadNext` callback is fired after calling `loadNext`| | 267 | | `reset` | Function | n/a | `{callback?: Function}` | Action: resets the state of the `Floodgate` instance to the initial state; `callback` prop in argument fires immediately after invocation| | 268 | | `exportState` | Function | n/a | `null` | Action: calls the `onExportState` prop callback| | 269 | #### `items` 270 | 271 | *Type:* `Array = null` 272 | 273 | Subset of all elements in the `props.data` array, based on the values of the `initial` and `increment` props. 274 | 275 | Elements of `items` do not have to be rendered at all; for example, `props.data` could be comprised of string manipulation methods, and each member of `items` would then call the respective method on a static value. 276 | 277 | #### `loadComplete` 278 | 279 | *Type:* `boolean = false` 280 | 281 | Describes if all elements of the `props.data` array have been processed by the internal queue and passed to `items`. 282 | 283 | #### `loadAll` 284 | 285 | *Arguments:* `{ suppressWarning?: boolean, callback?: Function } = { suppressWarning: false }` 286 | 287 | Appends all elements currently in the `data` prop to the `items` array. When called, the `render` argument's `loadComplete` property will be set to `true`, and the `currentIndex` state property will be updated to the length of `Floodgate.props.data`. 288 | 289 | The `supressWarning` argument property determines if a warning should be emitted when all items are rendered`. 290 | 291 | The `callback` argument method will be called after `loadAll` has set the component's state; it will have access to this updated Floodgate `state`. 292 | 293 | #### `loadNext` 294 | 295 | *Arguments:* `{ silent?: boolean, callback?: Function } = { silent: false }` 296 | 297 | Appends the next elements in the `data` prop to the `items` array, length equal to the `increment` prop. When called, will update the `currentIndex` state property; if this increment is equal to or exceeds the length of `data`, the `render` argument's `loadComplete` property will be set to `true`. 298 | 299 | The `silent` argument property determines if this call triggers the `onLoadNext` prop callback. 300 | 301 | The `callback` argument method will be called after `loadNext` has set the component's state; it will have access to this updated Floodgate `state`. 302 | 303 | #### `reset` 304 | 305 | *Arguments:* `{ initial?: number, callback?: Function } = {}` 306 | 307 | Resets Floodgate's state to the current instance's `data` and `initial` prop values. 308 | 309 | The `initial` argument property provides the ability to pass in a custom `initial` value to the next rendering after `reset` is called; this is most useful when writing a [controlled Floodgate component](#controlled-floodgate) and the `onExportState` prop is used. For more information on why this is needed, see [pull request #42](https://github.com/geoffdavis92/react-floodgate/pull/42). 310 | 311 | The `callback` argument method will be called after `reset` has set the component's state; it will have access to this updated Floodgate `state`. 312 | 313 | #### `exportState` 314 | 315 | *Arguments:* `n/a` 316 | 317 | Calls the `onExportState` prop callback. Any logic to manipulate and/or save Floodgate's state to a parent component should happen in that prop; since the `onExportState` arguments are not configurable, there are no arguments for `exportState`. 318 | 319 | ### Using `FloodgateContext` 320 | 321 | Starting in `v0.6.0`, Floodgate provides a named export `FloodgateContext` that affords the use of the [React Context API](https://reactjs.org/docs/context.html). 322 | 323 | The `FloodgateContext`'s `Consumer` component exposes the same object argument as the [`Floodgate#render` function](#render-function). 324 | 325 | #### Usage 326 | 327 | This `FloodgateContext` object can be used anywhere in the render prop function of a `Floodgate` instance. 328 | 329 | First, define a component that uses the `Consumer` component: 330 | ```javascript 331 | // DeepChildControls.js 332 | import { FloodgateContext } from "react-floodgate"; 333 | 334 | const DeepChildControls = (props) => { 335 | return ( 336 |
337 | 338 | {({ loadNext, loadAll, reset }) => ( 339 | 340 | 341 | 342 | 343 | 344 | )} 345 | 346 |
347 | ) 348 | } 349 | ``` 350 | 351 | Then, import and use this component under a Floodgate render prop: 352 | ```javascript 353 | // LoadMoreArticles.js 354 | import Floodgate from "react-floodgate"; 355 | import DeepChildControls from "./DeepChildControls"; 356 | 357 | export default function LoadMoreArticles(props) { 358 | return ( 359 | 360 | {({ items }) => ( 361 |
362 |

Articles

363 |
364 | {items.map((story) => ( 365 |
366 |

{story.title}

367 |

{story.excerpt}

368 |
369 | ))} 370 |
371 | {/* Use DeepChildControls here */} 372 | 373 |
374 |
375 |
376 | )} 377 |
378 | ) 379 | } 380 | ``` 381 | 382 | ## Examples 383 | 384 | ### [Codesandbox Examples](https://codesandbox.io/search?query=&page=1&configure%5BhitsPerPage%5D=12&refinementList%5Btags%5D%5B0%5D=react-floodgate-examples) 385 | 386 | - [Floodgate basics](https://codesandbox.io/embed/floodgate-basics-ighcr?fontsize=14&module=%2Fsrc%2Findex.tsx) 387 | - [Parent-controlled Floodgate](https://codesandbox.io/embed/controlled-floodgate-pp2ww?fontsize=14&module=%2Fsrc%2Findex.tsx) 388 | - [Floodgate with Context API](https://codesandbox.io/embed/floodgate-context-go1ft?fontsize=14&module=%2Fsrc%2Findex.tsx) 389 | - [Floodgate with async data polling](https://codesandbox.io/embed/floodgate-polling-app-462lp?fontsize=14&module=%2Fsrc%2Findex.tsx) 390 | 391 | ### Older Examples: 392 | 393 | - [Proof of Concept](https://codesandbox.io/embed/jlzxplj2z9) 394 | 395 | ## Contributors 396 | 397 | ### Creating Issues 398 | 399 | **[Request a feature](https://github.com/geoffdavis92/react-floodgate/issues/new?template=feature-request.md&projects=geoffdavis92/react-floodgate/1&labels=feature)** 400 | 401 | **[Request maintenance](https://github.com/geoffdavis92/react-floodgate/issues/new?template=maintenance.md&projects=geoffdavis92/react-floodgate/1&labels=dx)** 402 | 403 | **[Request a documentation update](https://github.com/geoffdavis92/react-floodgate/issues/new?template=documentation.md&projects=geoffdavis92/react-floodgate/3&labels=github,dx)** 404 | 405 | ### Setup for Development 406 | 407 | 1. Clone/fork this repository 408 | 2. Install dependencies using yarn or npm. 409 | 3. Run any of the following commands: 410 | - `npm run start`: Starts the Rollup watch script for building from `/src` 411 | - `npm run storybook`: Starts the [Storybook](https://storybook.js.org/) development environment 412 | - `npm run test`: Runs [Jest](https://jestjs.io/) tests once 413 | - `npm run test:watch`: Same as `test`, but sets up Jest and watches for changes 414 | 415 | ## LICENSE 416 | 417 | [MIT](blob/master/.github/LICENSE) 418 | -------------------------------------------------------------------------------- /__tests__/__test_utils__.js: -------------------------------------------------------------------------------- 1 | global.requestAnimationFrame = function(callback) { 2 | setTimeout(callback, 0); 3 | }; 4 | 5 | export default global; 6 | -------------------------------------------------------------------------------- /__tests__/floodgate.test.js: -------------------------------------------------------------------------------- 1 | import "./__test_utils__"; 2 | import React from "react"; 3 | import jest from "jest"; 4 | import jest_mock from "jest-mock"; 5 | import Enzyme, { render, mount } from "enzyme"; 6 | import Adapter from "enzyme-adapter-react-16"; 7 | import toJSON from "enzyme-to-json"; 8 | 9 | import Floodgate, { FloodgateContext } from "../dist/floodgate.esm"; 10 | import { loopSimulation, theOfficeData } from "../src/helpers"; 11 | 12 | // configure Enzyme 13 | Enzyme.configure({ adapter: new Adapter() }); 14 | 15 | // Wrapped instance for unMount 16 | class WrappedFloodgate extends React.Component { 17 | static defaultProps = { 18 | floodgateExportStateOnUnmount: true 19 | }; 20 | constructor() { 21 | super(); 22 | this.state = { 23 | showFloodgate: true, 24 | savedState: { 25 | data: theOfficeData, 26 | initial: 3, 27 | increment: 3 28 | } 29 | }; 30 | this.spliceRandomEntries = this.spliceRandomEntries.bind(this); 31 | this.toggleFloodgate = this.toggleFloodgate.bind(this); 32 | this.cacheFloodgateState = this.cacheFloodgateState.bind(this); 33 | } 34 | spliceRandomEntries() { 35 | const data = [...this.state.savedState.data]; 36 | const randomNumToSplice = Math.ceil(Math.random() * data.length - 1); 37 | const randomIndexToSplice = () => Math.floor(Math.random() * data.length); 38 | for (let i = 0; i < randomNumToSplice; i++) { 39 | data.splice(randomIndexToSplice(), 1); 40 | } 41 | this.setState(prevState => ({ 42 | ...prevState, 43 | savedState: { 44 | ...prevState.savedState, 45 | data 46 | } 47 | })); 48 | return data.length; 49 | } 50 | toggleFloodgate() { 51 | this.setState(prevState => ({ 52 | ...prevState, 53 | showFloodgate: !prevState.showFloodgate 54 | })); 55 | } 56 | cacheFloodgateState({ currentIndex: initial }) { 57 | this.setState(prevState => { 58 | return { 59 | ...prevState, 60 | savedState: { 61 | ...prevState.savedState, 62 | initial 63 | } 64 | }; 65 | }); 66 | } 67 | render() { 68 | const ResetButton = ({ reset }) => ( 69 | 72 | ); 73 | return ( 74 |
75 | 78 | {this.state.showFloodgate && ( 79 | 86 | {({ items, loadNext, loadAll, reset, loadComplete }) => ( 87 |
88 | {items.map(({ name }) => ( 89 |

{name}

90 | ))} 91 | {(!loadComplete && ( 92 | 93 | 96 | 99 | 100 | 101 | )) || ( 102 |
103 | All items loaded. 104 |
105 | 106 |
107 | )} 108 |
109 | )} 110 |
111 | )} 112 |
113 | ); 114 | } 115 | } 116 | 117 | function FCCTest(props) { 118 | return

{props.chidlren}

; 119 | } 120 | 121 | // Wrapped instance for prop updates 122 | class ControlledFloodgate extends React.Component { 123 | constructor() { 124 | super(); 125 | this.state = { 126 | fetchComplete: false, 127 | fetchActive: false, 128 | data: [0, 1, 2] 129 | }; 130 | this.exportState = this.exportState.bind(this); 131 | this.handleClick = this.handleClick.bind(this); 132 | this.addDataToState = this.addDataToState.bind(this); 133 | } 134 | exportState(FloodgateState) { 135 | this.setState(prevState => ({ 136 | cachedFloodgateState: FloodgateState 137 | })); 138 | } 139 | addDataToState() { 140 | this.setState(prevState => { 141 | return { 142 | fetchActive: false, 143 | fetchComplete: false, 144 | data: [...prevState.data, prevState.data.length] 145 | }; 146 | }); 147 | } 148 | handleClick() { 149 | this.setState( 150 | () => ({ fetchActive: true }), 151 | () => { 152 | this.addDataToState(); 153 | } 154 | ); 155 | } 156 | render() { 157 | return ( 158 |
159 | 160 | {({ items, loadNext, loadComplete }) => ( 161 |
162 |
    163 | {items.map(n => ( 164 |
  • {n}
  • 165 | ))} 166 |
167 | 170 | 177 |
178 | )} 179 |
180 |
181 | ); 182 | } 183 | } 184 | 185 | // Floodgate instance 186 | const FloodgateInstance = ({ 187 | increment = 3, 188 | initial = 3, 189 | silentLoadNext = false, 190 | ...restProps 191 | }) => ( 192 | 193 | {({ items, loadNext, loadAll, reset, loadComplete }) => ( 194 |
195 | {items.map(({ name }) => ( 196 |

197 | {name} 198 |

199 | ))} 200 | {(!loadComplete && ( 201 | 202 | 208 | 211 | 214 | 215 | )) || ( 216 |

217 | All items loaded. 218 |
219 | 222 |

223 | )} 224 |
225 | )} 226 |
227 | ); 228 | 229 | describe("A. Floodgate", () => { 230 | // simple check to make sure Floodgate renders 231 | it("1. Should render the Floodgate component", () => { 232 | const fgi = render(); 233 | expect(toJSON(fgi)).toMatchSnapshot(); 234 | }); 235 | 236 | // test instance has correct children 237 | it("2. Should render 3 `p` children and 3 `button` child", () => { 238 | const fgi = mount(); 239 | expect(fgi.find("p").length).toBe(3); 240 | expect(fgi.find("button").length).toBe(3); 241 | expect(toJSON(fgi)).toMatchSnapshot(); 242 | }); 243 | 244 | // test instance's children's text values 245 | it("3. Should render `p` children that have text matching [Jim Halpert,Pam Halpert,Ed Truck]", () => { 246 | const testTextValues = ["Jim Halpert", "Pam Halpert", "Ed Truck"]; 247 | const renderedParagraphTextValues = []; 248 | const fgi = mount(); 249 | fgi.find("p").forEach(p => { 250 | renderedParagraphTextValues.push(p.text()); 251 | }); 252 | expect(renderedParagraphTextValues).toMatchObject(testTextValues); 253 | }); 254 | 255 | // test instance renders non-default lengths of initial 256 | it("4. Should render with 4 `p` children", () => { 257 | const fgi = mount(); 258 | expect(fgi.find("p").length).toBe(4); 259 | expect(toJSON(fgi)).toMatchSnapshot(); 260 | }); 261 | 262 | // test instance loads new items 263 | it("5. Should render with 3 `p` children and load 3 `p` children `onClick()`", () => { 264 | const fgi = mount(); 265 | expect(fgi.find("p").length).toBe(3); 266 | expect(toJSON(fgi)).toMatchSnapshot(); 267 | 268 | // simulate click 269 | fgi.find("button#load").simulate("click"); 270 | expect(fgi.find("p").length).toBe(6); 271 | expect(toJSON(fgi)).toMatchSnapshot(); 272 | }); 273 | // test instance loads different lengths of increment 274 | it("6. Should render with 2 `p` children and load 1 `p` children `onClick()`", () => { 275 | const fgi = mount(); 276 | const loadButton = fgi.find("button#load"); 277 | const p = (prop = false) => (prop ? fgi.find("p")[prop] : fgi.find("p")); 278 | expect(p("length")).toBe(2); 279 | expect(toJSON(fgi)).toMatchSnapshot(); 280 | 281 | // simulate click 282 | loadButton.simulate("click"); 283 | expect(p("length")).toBe(3); 284 | expect(toJSON(fgi)).toMatchSnapshot(); 285 | 286 | loopSimulation(2, () => loadButton.simulate("click")); 287 | expect(p("length")).toBe(5); 288 | expect(fgi.find("button").length).toBe(3); 289 | expect(toJSON(fgi)).toMatchSnapshot(); 290 | 291 | loopSimulation(3, () => loadButton.simulate("click")); 292 | expect(p("length")).toBe(8); 293 | expect( 294 | p() 295 | .last() 296 | .text() 297 | ).toMatch(theOfficeData[7].name); 298 | expect(fgi.find("button").length).toBe(3); 299 | expect(toJSON(fgi)).toMatchSnapshot(); 300 | }); 301 | it("7. Should render with 2 `p` children, load 1 `p` child, and reset state to original load", () => { 302 | const fgi = mount(); 303 | const loadButton = fgi.find("button#load"); 304 | const resetButton = fgi.find("button#reset"); 305 | const p = (prop = false) => (prop ? fgi.find("p")[prop] : fgi.find("p")); 306 | expect(p("length")).toBe(2); 307 | expect( 308 | p() 309 | .first() 310 | .text() 311 | ).toMatch("Jim Halpert"); 312 | expect( 313 | p() 314 | .last() 315 | .text() 316 | ).toMatch("Pam Halpert"); 317 | expect(toJSON(fgi)).toMatchSnapshot(); 318 | 319 | loadButton.simulate("click"); 320 | expect(p("length")).toBe(3); 321 | expect(toJSON(fgi)).toMatchSnapshot(); 322 | 323 | resetButton.simulate("click"); 324 | expect(p("length")).toBe(2); 325 | expect( 326 | p() 327 | .first() 328 | .text() 329 | ).toMatch("Jim Halpert"); 330 | expect( 331 | p() 332 | .last() 333 | .text() 334 | ).toMatch("Pam Halpert"); 335 | expect(fgi.find("button").length).toBe(3); 336 | expect(toJSON(fgi)).toMatchSnapshot(); 337 | }); 338 | 339 | it("8. Should render 1 `p` child, click to load all then reset", () => { 340 | const fgi = mount(); 341 | const loadButton = () => fgi.find("button#load"); 342 | const loadAllButton = () => fgi.find("button#loadall"); 343 | const resetButton = () => fgi.find("button#reset"); 344 | const p = (prop = false) => (prop ? fgi.find("p")[prop] : fgi.find("p")); 345 | expect(p("length")).toBe(1); 346 | 347 | loadAllButton().simulate("click"); 348 | expect(p("length")).toBe(theOfficeData.length + 1); 349 | expect( 350 | p() 351 | .first() 352 | .text() 353 | ).toMatch("Jim Halpert"); 354 | expect( 355 | p() 356 | .at(fgi.find(Floodgate).instance().props.data.length - 1) 357 | .text() 358 | ).toMatch("Angela Schrute"); 359 | expect(toJSON(fgi)).toMatchSnapshot(); 360 | expect(loadButton()).toHaveLength(0); 361 | expect(loadAllButton()).toHaveLength(0); 362 | expect(resetButton()).toHaveLength(1); 363 | expect(fgi.find(Floodgate).instance().state.allItemsRendered).toBe(true); 364 | expect(fgi.find(Floodgate).instance().state.currentIndex).toBe( 365 | fgi.find(Floodgate).instance().props.data.length 366 | ); 367 | 368 | expect(toJSON(fgi)).toMatchSnapshot(); 369 | 370 | resetButton().simulate("click"); 371 | expect(p("length")).toBe(1); 372 | expect(toJSON(fgi)).toMatchSnapshot(); 373 | }); 374 | 375 | it("9. Should fire props.onLoadNext during loadNext", () => { 376 | const mockedLoadNextCallback = jest_mock.fn(state => { 377 | expect(state).toMatchObject(fgi.find(Floodgate).instance().state); 378 | }); 379 | const fgi = mount( 380 | 385 | ); 386 | const loadButton = () => fgi.find("button#load"); 387 | 388 | loadButton().simulate("click"); 389 | expect(mockedLoadNextCallback.mock.calls.length).toEqual(1); 390 | }); 391 | it("10. Should fire props.onLoadComplete during loadAll", () => { 392 | const mockedLoadCompleteCallback = jest_mock.fn(state => { 393 | expect(state).toMatchObject(fgi.find(Floodgate).instance().state); 394 | }); 395 | const fgi = mount( 396 | 401 | ); 402 | const loadAllButton = () => fgi.find("button#loadall"); 403 | 404 | loadAllButton().simulate("click"); 405 | expect(fgi.find("p.officeMember").length).toEqual(theOfficeData.length); 406 | expect(mockedLoadCompleteCallback.mock.calls.length).toEqual(1); 407 | }); 408 | 409 | it("11. Should fire only props.onLoadComplete after loadNext to completion", () => { 410 | const mockedLoadNextCallback = jest_mock.fn(state => { 411 | expect(state).toMatchObject(fgi.find(Floodgate).instance().state); 412 | }); 413 | const mockedLoadCompleteCallback = jest_mock.fn(state => { 414 | expect(state).toMatchObject(fgi.find(Floodgate).instance().state); 415 | }); 416 | const fgi = mount( 417 | 423 | ); 424 | const loadButton = () => fgi.find("button#load"); 425 | 426 | loadButton().simulate("click"); 427 | expect(mockedLoadNextCallback.mock.calls.length).toEqual(1); 428 | loadButton().simulate("click"); 429 | expect(mockedLoadNextCallback.mock.calls.length).toEqual(1); 430 | expect(mockedLoadCompleteCallback.mock.calls.length).toEqual(1); 431 | }); 432 | 433 | it("12. Should fire props.onReset after reset", () => { 434 | const mockedLoadNextCallback = jest_mock.fn(state => { 435 | expect(state).toMatchObject(fgi.find(Floodgate).instance().state); 436 | }); 437 | const mockedResetCallback = jest_mock.fn(state => { 438 | expect(state).toMatchObject(fgi.find(Floodgate).instance().state); 439 | }); 440 | const fgi = mount( 441 | 447 | ); 448 | const loadButton = () => fgi.find("button#load"); 449 | const resetButton = () => fgi.find("button#reset"); 450 | 451 | loadButton().simulate("click"); 452 | expect(fgi.find("p.officeMember").length).toEqual(4); 453 | expect(mockedLoadNextCallback.mock.calls.length).toEqual(1); 454 | resetButton().simulate("click"); 455 | expect(fgi.find("p.officeMember").length).toEqual(1); 456 | expect(mockedResetCallback.mock.calls.length).toEqual(1); 457 | }); 458 | it("13. Should fire loadNext in silent mode", () => { 459 | const mockedLoadNextCallback = jest_mock.fn(state => { 460 | expect(state).toMatchObject(fgi.find(Floodgate).instance().state); 461 | }); 462 | const fgi = mount( 463 | 469 | ); 470 | const loadButton = () => fgi.find("button#load"); 471 | 472 | loadButton().simulate("click"); 473 | expect(mockedLoadNextCallback.mock.calls.length).toEqual(0); 474 | }); 475 | // it("14. Should give propType error when non-function value passed to event callback props", () => { 476 | // const fgi = mount( 477 | // 482 | // ); 483 | // const loadButton = fgi.find("button#load"); 484 | // const loadAllButton = () => fgi.find("button#loadall"); 485 | // const resetButton = () => fgi.find("button#reset"); 486 | 487 | // expect(() => loadButton.simulate("click")).toThrowError(); 488 | // expect(() => loadAllButton.simulate("click")).toThrowError(); 489 | // expect(() => resetButton.simulate("click")).toThrowError(); 490 | // }); 491 | }); 492 | 493 | describe("B. Wrapped Floodgate for saveState testing", () => { 494 | it("1. Should render a wrapped Floodgate instance", () => { 495 | const wfgi = mount(); 496 | expect(toJSON(wfgi)).toMatchSnapshot(); 497 | }); 498 | it("2. Should load 3 items, and save the currentIndex to the WrappedFloodgate state on cWU", () => { 499 | const wfgi = mount(); 500 | const fgi = wfgi.find(Floodgate).instance(); 501 | const loadBtn = wfgi.find("button#load"); 502 | const toggleBtn = wfgi.find("button#toggleFloodgate"); 503 | 504 | expect(fgi.state.currentIndex).toEqual(3); 505 | expect(wfgi.find("p")).toHaveLength(3); 506 | 507 | loadBtn.simulate("click"); 508 | 509 | expect(wfgi.find("p")).toHaveLength(6); 510 | expect(fgi.state.currentIndex).toEqual(6); 511 | 512 | toggleBtn.simulate("click"); 513 | expect(wfgi.find("p")).toHaveLength(0); 514 | expect(wfgi.state().showFloodgate).toBe(false); 515 | expect(wfgi.state("savedState")).toMatchObject({ 516 | data: theOfficeData, 517 | initial: 6, 518 | increment: 3 519 | }); 520 | }); 521 | it("2. Should load 3 items, click to load 3 more items, toggle Floodgate, and persist Floodgate state through mounting/re-mounting", () => { 522 | const wfgi = mount(); 523 | const getFgi = () => wfgi.find(Floodgate).instance(); 524 | const loadBtn = wfgi.find("button#load"); 525 | const toggleBtn = wfgi.find("button#toggleFloodgate"); 526 | 527 | expect(getFgi().state.currentIndex).toEqual(3); 528 | expect(wfgi.find("p")).toHaveLength(3); 529 | 530 | loadBtn.simulate("click"); 531 | 532 | expect(wfgi.find("p")).toHaveLength(6); 533 | expect(getFgi().state.currentIndex).toEqual(6); 534 | 535 | toggleBtn.simulate("click"); 536 | // wfgi.setState({ showFloodgate: false }) 537 | 538 | expect(wfgi.find("p")).toHaveLength(0); 539 | expect(wfgi.state().showFloodgate).toBe(false); 540 | expect(wfgi.state("savedState")).toMatchObject({ 541 | data: theOfficeData, 542 | initial: 6, 543 | increment: 3 544 | }); 545 | 546 | toggleBtn.simulate("click"); 547 | expect(wfgi.state().showFloodgate).toBe(true); 548 | expect(getFgi().state.currentIndex).toBe(6); 549 | }); 550 | it("3. Should load 3 items, toggle Floodgate, randomly splice data entries, then persist Floodgate index state through mounting/re-mounting", () => { 551 | const wfgi = mount(); 552 | const getFgi = () => wfgi.find(Floodgate).instance(); 553 | const toggleBtn = wfgi.find("button#toggleFloodgate"); 554 | 555 | expect(getFgi().state.currentIndex).toEqual(3); 556 | expect(wfgi.find("p")).toHaveLength(3); 557 | 558 | toggleBtn.simulate("click"); 559 | 560 | expect(wfgi.find("p")).toHaveLength(0); 561 | expect(wfgi.state().showFloodgate).toBe(false); 562 | expect(wfgi.state("savedState")).toMatchObject({ 563 | data: theOfficeData, 564 | initial: 3, 565 | increment: 3 566 | }); 567 | 568 | wfgi.instance().spliceRandomEntries(); 569 | 570 | toggleBtn.simulate("click"); 571 | expect(wfgi.state().showFloodgate).toBe(true); 572 | expect(getFgi().state.renderedItems).toMatchObject( 573 | wfgi.state().savedState.data.slice(0, 3) 574 | ); 575 | }); 576 | it("4. Should load 3 items, click to load all items, toggle Floodgate, reset should update state based on newInitial argument prop", () => { 577 | const wfgi = mount(); 578 | const getFgi = () => wfgi.find(Floodgate).instance(); 579 | const getResetBtn = () => wfgi.find("button#reset"); 580 | const loadAllBtn = wfgi.find("button#loadall"); 581 | const toggleBtn = wfgi.find("button#toggleFloodgate"); 582 | 583 | expect(getFgi().state.currentIndex).toEqual(3); 584 | expect(wfgi.find("p")).toHaveLength(3); 585 | 586 | loadAllBtn.simulate("click"); 587 | 588 | expect(wfgi.find("p")).toHaveLength(getFgi().props.data.length); 589 | expect(getFgi().state.allItemsRendered).toBe(true); 590 | 591 | toggleBtn.simulate("click"); 592 | wfgi.setState({ showFloodgate: false }); 593 | 594 | expect(wfgi.find("p")).toHaveLength(0); 595 | expect(wfgi.state().showFloodgate).toBe(false); 596 | expect(wfgi.state("savedState")).toMatchObject({ 597 | data: theOfficeData, 598 | initial: theOfficeData.length, 599 | increment: 3 600 | }); 601 | 602 | toggleBtn.simulate("click"); 603 | expect(wfgi.state().showFloodgate).toBe(true); 604 | 605 | getResetBtn().simulate("click"); 606 | expect(getFgi().state.currentIndex).toBe(3); 607 | expect(wfgi.find("p")).toHaveLength(3); 608 | }); 609 | }); 610 | 611 | describe("C. Controlled Floodgate for parent state-controlled testing", () => { 612 | it("1. Should render 3 items", () => { 613 | const controlledFGI = mount(); 614 | const getFG = () => controlledFGI.find(Floodgate); 615 | const getLI = () => controlledFGI.find("li"); 616 | 617 | expect(getLI()).toHaveLength(3); 618 | }); 619 | 620 | it("2. Should fetch 1 items, then render on LoadNext", () => { 621 | const controlledFGI = mount(); 622 | const getFG = () => controlledFGI.find(Floodgate); 623 | const getLI = () => controlledFGI.find("li"); 624 | const getFGInstance = () => getFG().instance(); 625 | 626 | const getFetchButton = () => controlledFGI.find("button#fetch"); 627 | const getLoadButton = () => controlledFGI.find("button#loadNext"); 628 | 629 | expect(getLI()).toHaveLength(3); 630 | expect(getFGInstance().state.allItemsRendered).toEqual(true); 631 | 632 | // Fetch one number 633 | getFetchButton().simulate("click"); 634 | 635 | expect(getFGInstance().state.renderedItems).toHaveLength( 636 | getFGInstance().state.items.length - 1 637 | ); 638 | expect(getFGInstance().state.items).toHaveLength( 639 | getFGInstance().props.data.length 640 | ); 641 | 642 | // Load new item 643 | getLoadButton().simulate("click"); 644 | 645 | expect(getLI()).toHaveLength(getFGInstance().state.items.length); 646 | 647 | const fgState = getFGInstance().state; 648 | expect(fgState.items).toHaveLength(4); 649 | expect(fgState.renderedItems).toMatchObject(fgState.items); 650 | expect(fgState.currentIndex).toEqual(fgState.items.length); 651 | expect(fgState.allItemsRendered).toEqual(true); 652 | }); 653 | 654 | it("3. Should fetch 2 items, render 1 new item on LoadNext", () => { 655 | const controlledFGI = mount(); 656 | const getFG = () => controlledFGI.find(Floodgate); 657 | const getLI = () => controlledFGI.find("li"); 658 | const getFGInstance = () => getFG().instance(); 659 | 660 | const getFetchButton = () => controlledFGI.find("button#fetch"); 661 | const getLoadButton = () => controlledFGI.find("button#loadNext"); 662 | 663 | expect(getLI()).toHaveLength(3); 664 | expect(getFGInstance().state.allItemsRendered).toEqual(true); 665 | 666 | // Fetch two numbers 667 | getFetchButton().simulate("click"); 668 | getFetchButton().simulate("click"); 669 | 670 | expect(getFGInstance().state.renderedItems).toHaveLength( 671 | getFGInstance().state.items.length - 2 672 | ); 673 | expect(getFGInstance().state.items).toHaveLength( 674 | getFGInstance().props.data.length 675 | ); 676 | 677 | // Load new item 678 | expect(getFGInstance().props.data).toMatchObject( 679 | getFGInstance().state.items 680 | ); 681 | 682 | getLoadButton().simulate("click"); 683 | getFGInstance().loadNext(); 684 | expect(getLI()).toHaveLength(getFGInstance().state.items.length - 1); 685 | 686 | getLoadButton().simulate("click"); 687 | expect(getLI()).toHaveLength(getFGInstance().state.items.length); 688 | 689 | const fgState = getFGInstance().state; 690 | expect(fgState.items).toHaveLength(5); 691 | expect(fgState.renderedItems).toMatchObject(fgState.items); 692 | expect(fgState.currentIndex).toEqual(fgState.items.length); 693 | expect(fgState.allItemsRendered).toEqual(true); 694 | }); 695 | }); 696 | 697 | describe("D. Context-Wrapped Floodgate", () => { 698 | it("1. Should provide FloodgateInternals via Context API", () => { 699 | const fgi = mount( 700 | 701 | {() => ( 702 | 703 | {ctxProps => } 704 | 705 | )} 706 | 707 | ); 708 | expect(toJSON(fgi)).toMatchSnapshot(); 709 | expect(fgi.find(FCCTest).props().ctxProps).toMatchObject({ 710 | items: fgi.instance().state.renderedItems, 711 | loadComplete: fgi.instance().state.allItemsRendered, 712 | loadAll: fgi.instance().loadAll, 713 | loadNext: fgi.instance().loadNext, 714 | reset: fgi.instance().reset, 715 | exportState: fgi.instance().exportState 716 | }); 717 | }); 718 | 719 | it("2. Should display 5 items, load 3 more from Context controls", () => { 720 | const fgi = mount( 721 | 722 | {({ items }) => ( 723 |
724 |
    725 | {items.map(item => ( 726 |
  • {item.name}
  • 727 | ))} 728 |
729 |
730 | 731 | {({ loadNext, loadAll }) => ( 732 | 733 | 736 | 739 | 740 | )} 741 | 742 |
743 |
744 | )} 745 |
746 | ); 747 | expect(toJSON(fgi)).toMatchSnapshot(); 748 | expect(fgi.find("li")).toHaveLength(5); 749 | 750 | fgi.find("#load-next").simulate("click"); 751 | 752 | expect(fgi.find("li")).toHaveLength(8); 753 | 754 | fgi.find("#load-all").simulate("click"); 755 | 756 | expect(fgi.find("li")).toHaveLength(theOfficeData.length); 757 | }); 758 | 759 | it("3. Updates Async Data from Props", () => { 760 | function* stringGen() { 761 | while (true) { 762 | yield Math.floor(Math.random() * 1000).toString(); 763 | } 764 | } 765 | 766 | const StringGeneratorContext = React.createContext([]); 767 | class StringGenerator extends React.Component { 768 | constructor(props) { 769 | super(props); 770 | this.state = { 771 | strings: [] 772 | }; 773 | this.generator = stringGen(); 774 | this.iterate = this.iterate.bind(this); 775 | } 776 | componentDidMount() { 777 | this.iterate(); 778 | } 779 | iterate() { 780 | if (this.state.strings.length < this.props.count) { 781 | return this.setState(prevState => ({ 782 | strings: [...prevState.strings, this.generator.next().value] 783 | })); 784 | } 785 | } 786 | render() { 787 | return ( 788 | 789 | {this.props.children} 790 | 791 | ); 792 | } 793 | } 794 | const fgi = mount( 795 | 796 | 797 | {strings => ( 798 | 799 | {strings.length ? ( 800 | 801 | {({ items, loadNext, loadAll, reset, loadComplete }) => ( 802 | 803 |
    804 | {items.map(s => ( 805 |
  • 811 | {s} 812 |
  • 813 | ))} 814 |
815 | 818 | 821 | 824 |
825 | )} 826 |
827 | ) : null} 828 |
829 | )} 830 |
831 |
832 | ); 833 | const getFG = () => fgi.find(Floodgate).instance(); 834 | const getStringGen = () => fgi.find(StringGenerator).instance(); 835 | const getNextButton = () => fgi.find("#next"); 836 | const getAllButton = () => fgi.find("#all"); 837 | const getResetButton = () => fgi.find("#reset"); 838 | const getStringList = () => fgi.find("#string-list"); 839 | 840 | getStringGen().iterate(); 841 | getStringGen().iterate(); 842 | expect(getFG().props.data).toHaveLength( 843 | getStringGen().state.strings.length 844 | ); 845 | expect(getStringList().children.length).toBe(1); 846 | 847 | getNextButton().simulate("click"); 848 | 849 | expect(getStringList().children().length).toBe(2); 850 | getStringGen().iterate(); 851 | getStringGen().iterate(); 852 | getStringGen().iterate(); 853 | 854 | expect(getFG().props.data).toHaveLength( 855 | getStringGen().state.strings.length 856 | ); 857 | 858 | getAllButton().simulate("click"); 859 | expect(getFG().state.items).toMatchObject(getFG().props.data); 860 | expect(getStringList().children().length).toBe(getFG().props.data.length); 861 | expect(getStringList().children().length).toBe( 862 | getFG().state.renderedItems.length 863 | ); 864 | 865 | getResetButton().simulate("click"); 866 | 867 | expect(getFG().props.initial).toBe(getFG().state.renderedItems.length); 868 | expect(getFG().props.initial).toBe(getStringList().children().length); 869 | }); 870 | }); 871 | -------------------------------------------------------------------------------- /__tests__/functions.test.js: -------------------------------------------------------------------------------- 1 | import jest from "jest"; 2 | import { generator } from "functions"; 3 | import { generateFilledArray } from "helpers"; 4 | 5 | const mockData = generateFilledArray(10); 6 | 7 | describe("generator", () => { 8 | it("Should return a generator object", () => { 9 | const gen = generator(mockData, 1, 1); 10 | expect(typeof gen).toBe("object"); 11 | //babel-polyfill expect(Object.keys(gen)[0]).toBe("_invoke"); 12 | }); 13 | 14 | it("Should yield inital object that matches {value:[0],done:false}", () => { 15 | const gen = generator(mockData, 1, 1); 16 | expect(gen.next()).toMatchObject({ value: [0], done: false }); 17 | }); 18 | 19 | it("Should yield an object with a value of [0,1,2] after 3 next() calls", () => { 20 | const gen = generator(mockData, 1, 1); 21 | const sum = []; 22 | sum.push(gen.next().value[0]); 23 | expect(sum).toMatchObject([0]); 24 | sum.push(gen.next().value[0]); 25 | expect(sum).toMatchObject([0, 1]); 26 | sum.push(gen.next().value[0]); 27 | expect(sum).toMatchObject([0, 1, 2]); 28 | }); 29 | 30 | it("Should yield 3 items per next() calls, starting with 1", () => { 31 | const gen = generator(mockData, 3, 1); 32 | const sum = []; 33 | 34 | const { value: it1Value } = gen.next(); 35 | sum.push(...it1Value); 36 | expect(it1Value.length).toBe(1); 37 | expect(sum).toMatchObject([0]); 38 | 39 | const { value: it2Value } = gen.next(); 40 | sum.push(...it2Value); 41 | expect(it2Value.length).toBe(3); 42 | expect(sum).toMatchObject([0, 1, 2, 3]); 43 | }); 44 | 45 | it("Should yield 2 items per next() calls, starting with 2", () => { 46 | const gen = generator(mockData, 2, 2); 47 | const sum = []; 48 | let { value: it1Value } = gen.next(); 49 | 50 | sum.push(...it1Value); 51 | expect(it1Value.length).toEqual(2); 52 | expect(sum).toMatchObject([0, 1]); 53 | 54 | let { value: it2Value } = gen.next(); 55 | 56 | sum.push(...it2Value); 57 | expect(it2Value.length).toEqual(2); 58 | expect(sum).toMatchObject([0, 1, 2, 3]); 59 | }); 60 | 61 | it("Should yield final object that matches {value:undefined,done:true}", () => { 62 | const gen = generator(mockData, 1, 1); 63 | for (let i = 0; i < mockData.length; i++) { 64 | gen.next(); 65 | } 66 | expect(gen.next()).toMatchObject({ value: undefined, done: true }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /__tests__/helpers.test.js: -------------------------------------------------------------------------------- 1 | import jest from "jest"; 2 | import { generateFilledArray, theOfficeData } from "helpers"; 3 | 4 | describe("generateFilledArray", () => { 5 | it("Should render an empty array", () => { 6 | expect(generateFilledArray()).toMatchObject([]); 7 | }); 8 | 9 | it("Should render an array of 10 entries filled with that entry's index", () => { 10 | const arr = generateFilledArray(10); 11 | expect(arr.length).toBe(10); 12 | expect(arr[0]).toBe(0); 13 | expect(arr[arr.length - 1]).toBe(9); 14 | }); 15 | }); 16 | 17 | describe("theOfficeData", () => { 18 | it(`Should have ${theOfficeData.length} entries`, () => { 19 | expect(theOfficeData.length).toEqual(theOfficeData.length); 20 | }); 21 | 22 | it("Should start with Jim Halpert and end with Angela Schrute", () => { 23 | expect(theOfficeData[0].name).toMatch("Jim Halpert"); 24 | expect(theOfficeData[theOfficeData.length - 1].name).toMatch( 25 | "Angela Schrute" 26 | ); 27 | }); 28 | 29 | it("Should have entries with keys that match this schema: {name,username,email,status}", () => { 30 | const schemaKeys = ["name", "username", "email", "status"]; 31 | theOfficeData.forEach(entry => 32 | expect(Object.keys(entry)).toMatchObject(schemaKeys) 33 | ); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import Floodgate from "./dist/floodgate"; 2 | 3 | export default Floodgate; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-floodgate", 3 | "version": "1.0.0", 4 | "description": "Configurable and flexible \"load more\" component for React", 5 | "keywords": [ 6 | "react", 7 | "javascript", 8 | "load more", 9 | "render props", 10 | "es2015" 11 | ], 12 | "main": "dist/floodgate.cjs.js", 13 | "jsnext:main": "dist/floodgate.esm.js", 14 | "module": "dist/floodgate.esm.js", 15 | "files": [ 16 | "LICENSE", 17 | "README.md", 18 | "index.js", 19 | "dist" 20 | ], 21 | "repository": "https://github.com/geoffdavis92/react-floodgate.git", 22 | "author": "Geoff Davis ", 23 | "license": "MIT", 24 | "private": false, 25 | "devDependencies": { 26 | "@storybook/addon-actions": "^3.2.12", 27 | "@storybook/addon-links": "^3.2.12", 28 | "@storybook/react": "^3.2.12", 29 | "@types/jest": "^23.3.2", 30 | "@types/lorem-ipsum": "^1.0.2", 31 | "@types/react": "^16.4.13", 32 | "@types/react-dom": "^16.0.7", 33 | "awesome-typescript-loader": "^5.2.1", 34 | "babel-jest": "^21.2.0", 35 | "babel-plugin-external-helpers": "^6.22.0", 36 | "babel-plugin-transform-class-properties": "^6.24.1", 37 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 38 | "babel-preset-env": "^1.6.0", 39 | "babel-preset-react": "^6.24.1", 40 | "enzyme": "^3.7.0", 41 | "enzyme-adapter-react-16": "^1.7.1", 42 | "enzyme-to-json": "^3.3.4", 43 | "flow-bin": "^0.57.2", 44 | "jest": "^21.2.1", 45 | "lorem-ipsum": "^1.0.4", 46 | "prettier": "^1.7.4", 47 | "react-dom": "^16.8.0", 48 | "react-test-renderer": "^16.0.0", 49 | "regenerator-runtime": "^0.12.1", 50 | "rollup": "^0.50.0", 51 | "rollup-plugin-alias": "^1.3.1", 52 | "rollup-plugin-babel": "^3.0.2", 53 | "rollup-plugin-commonjs": "^8.2.1", 54 | "rollup-plugin-node-resolve": "^3.0.0", 55 | "rollup-plugin-typescript2": "^0.17.0", 56 | "rollup-plugin-uglify": "^2.0.1", 57 | "styled-components": "^2.2.2", 58 | "ts-jest": "^23.1.4", 59 | "ts-loader": "3", 60 | "typescript": "^3.0.3", 61 | "uglify-es": "^3.1.3", 62 | "webpack": "^4.19.1" 63 | }, 64 | "dependencies": { 65 | "@types/prop-types": "^15.5.5", 66 | "prop-types": "^15.6.2", 67 | "react": "^16.8.0" 68 | }, 69 | "scripts": { 70 | "build": "NODE_ENV=rollup ./node_modules/.bin/rollup -c --bundle && yarn test", 71 | "build:storybook": "build-storybook", 72 | "clean": "rm -r dist/* .rpt2_cache/*", 73 | "demo": "./node_modules/.bin/httpster -p 8901 docs", 74 | "prettier": "./node_modules/.bin/prettier {.,src,stories,__tests__}/*.{js,json} --write", 75 | "start": "NODE_ENV=rollup ./node_modules/.bin/rollup -c --watch", 76 | "storybook": "start-storybook -p 6006", 77 | "test": "jest -u", 78 | "test:watch": "jest -u --watch" 79 | }, 80 | "babel": { 81 | "env": { 82 | "rollup": { 83 | "presets": [ 84 | [ 85 | "env", 86 | { 87 | "modules": false, 88 | "targets": { 89 | "browsers": [ 90 | "last 2 versions" 91 | ] 92 | } 93 | } 94 | ], 95 | "react" 96 | ], 97 | "plugins": [ 98 | "external-helpers", 99 | "transform-class-properties", 100 | "transform-object-rest-spread" 101 | ] 102 | }, 103 | "test": { 104 | "presets": [ 105 | [ 106 | "env", 107 | { 108 | "targets": { 109 | "browsers": [ 110 | "last 2 versions" 111 | ] 112 | } 113 | } 114 | ], 115 | "react" 116 | ], 117 | "plugins": [ 118 | "transform-class-properties", 119 | "transform-object-rest-spread" 120 | ] 121 | } 122 | } 123 | }, 124 | "jest": { 125 | "moduleDirectories": [ 126 | "node_modules", 127 | "src", 128 | "dist" 129 | ], 130 | "testPathIgnorePatterns": [ 131 | "node_modules", 132 | "__tests__/__*" 133 | ], 134 | "transform": { 135 | "^.+\\.js$": "babel-jest", 136 | "^.+\\.tsx?$": "ts-jest" 137 | }, 138 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 139 | "moduleFileExtensions": [ 140 | "ts", 141 | "tsx", 142 | "js", 143 | "jsx", 144 | "json", 145 | "node" 146 | ] 147 | }, 148 | "prettier": { 149 | "bracketSpacing": true, 150 | "jsxBracketSameLine": false, 151 | "parser": "flow", 152 | "printWidth": 80, 153 | "semi": true, 154 | "singleQuote": false, 155 | "useTabs": false 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | import alias from "rollup-plugin-alias"; 4 | import babel from "rollup-plugin-babel"; 5 | import commonjs from "rollup-plugin-commonjs"; 6 | import resolve from "rollup-plugin-node-resolve"; 7 | import ts from "rollup-plugin-typescript2"; 8 | 9 | const r = (filepath, encoding = "utf8") => 10 | JSON.parse(fs.readFileSync(filepath, { encoding })); 11 | const { version } = r("package.json"); 12 | 13 | const commonPlugins = [ 14 | alias({ 15 | floodgate: path.resolve(".", "src/index"), 16 | classes: path.resolve(".", "src/classes"), 17 | functions: path.resolve(".", "src/functions"), 18 | helpers: path.resolve(".", "src/helpers"), 19 | types: path.resolve(".", "src/types") 20 | }), 21 | ts({}), 22 | babel({ 23 | exclude: "node_modules/**" 24 | }), 25 | commonjs({ 26 | include: ["node_modules/**"], 27 | exclude: ["node_modules/process-es6/**"], 28 | namedExports: { 29 | "node_modules/prop-types/index.js": ["PropTypes"], 30 | "node_modules/react/index.js": ["Component"], 31 | "node_modules/react-dom/index.js": ["render"] 32 | } 33 | }), 34 | resolve() 35 | ]; 36 | 37 | const config = { 38 | input: "./src/index.tsx", 39 | output: [ 40 | { 41 | file: "dist/floodgate.cjs.js", 42 | format: "cjs", 43 | name: "Floodgate", 44 | banner: `/** floodgate v${version} : commonjs bundle **/`, 45 | exports: "named", 46 | sourcemap: false 47 | }, 48 | { 49 | file: "dist/floodgate.esm.js", 50 | format: "es", 51 | name: "Floodgate", 52 | banner: `/** floodgate v${version} : es bundle **/`, 53 | sourcemap: false 54 | }, 55 | { 56 | file: "dist/floodgate.js", 57 | format: "iife", 58 | name: "Floodgate", 59 | banner: `/** floodgate v${version} : iife bundle **/`, 60 | sourcemap: false 61 | } 62 | ], 63 | plugins: [...commonPlugins], 64 | external: [ 65 | "react", 66 | "react-dom", 67 | "prop-types", 68 | path.resolve("./src/types.d.ts") 69 | ] 70 | }; 71 | 72 | export default [config]; 73 | -------------------------------------------------------------------------------- /src/classes.ts: -------------------------------------------------------------------------------- 1 | import { ErrorBoundaryProps, ErrorBoundaryState } from "./types"; 2 | import * as React from "react"; 3 | import * as PropTypes from "prop-types"; 4 | import { ErrorMessage } from "functions"; 5 | 6 | class ErrorBoundary extends React.Component< 7 | ErrorBoundaryProps, 8 | ErrorBoundaryState 9 | > { 10 | // static props 11 | static defaultProps = { 12 | errorMessage: ErrorMessage 13 | }; 14 | static propTypes = { 15 | children: PropTypes.any.isRequired, 16 | errorMessage: PropTypes.func, 17 | fallbackUI: PropTypes.func 18 | }; 19 | 20 | // fields 21 | state = { 22 | treeHasError: false, 23 | treeError: { error: false, info: "" } 24 | }; 25 | 26 | // methods 27 | componentDidCatch(error: Error, info: any) { 28 | this.setState(prevState => ({ 29 | treeHasError: true, 30 | treeError: { 31 | error: error.toString(), 32 | info 33 | } 34 | })); 35 | } 36 | render() { 37 | const { children, fallbackUI } = this.props; 38 | const { treeHasError, treeError } = this.state; 39 | if (treeHasError) { 40 | return fallbackUI ? fallbackUI({ ...treeError }) : children; 41 | } else { 42 | return children; 43 | } 44 | } 45 | } 46 | 47 | export { ErrorBoundary }; 48 | -------------------------------------------------------------------------------- /src/functions.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FloodgateProps } from "./types"; 3 | 4 | const generator = function* generator( 5 | data: FloodgateProps['data'], 6 | yieldLength: FloodgateProps['increment'], 7 | initialYieldLength: FloodgateProps['initial'] 8 | ): Generator { 9 | let currentIndex: number = 0; 10 | while (currentIndex <= data.length - 1) { 11 | let firstYield = currentIndex === 0; 12 | yield [...data].splice( 13 | currentIndex, 14 | firstYield && initialYieldLength >= 0 ? initialYieldLength : yieldLength 15 | ); 16 | currentIndex = 17 | firstYield && initialYieldLength >= 0 18 | ? currentIndex + initialYieldLength 19 | : currentIndex + yieldLength; 20 | } 21 | }; 22 | 23 | const ErrorMessage = ({ 24 | children, 25 | callerDisplayName, 26 | text, 27 | ...rest 28 | }: { 29 | children?: Array, 30 | callerDisplayName?: string, 31 | text: string 32 | }): any => ( 33 | 37 | {text ? text : children} 38 | 39 | ); 40 | 41 | export { ErrorMessage, generator }; 42 | -------------------------------------------------------------------------------- /src/helpers.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as loremIpsum from "lorem-ipsum"; 3 | 4 | const loopSimulation = (amount: number, simulation: Function) => { 5 | for (let i = 0; i < amount; i++) { 6 | simulation(); 7 | } 8 | }; 9 | 10 | const generateFilledArray = (amount: number) => { 11 | const the_array: Array = []; 12 | for (let i = 0; i < amount; i++) { 13 | the_array.push(i); 14 | } 15 | return the_array; 16 | }; 17 | 18 | const generateLoremIpsum = (config: Object) => { 19 | return loremIpsum( 20 | Object.assign( 21 | { 22 | count: 16, 23 | format: "plain", 24 | units: "words" 25 | }, 26 | config 27 | ) 28 | ); 29 | }; 30 | 31 | const theOfficeData: Array<{ 32 | name: string, 33 | username: string, 34 | email: string, 35 | status: boolean 36 | }> = [ 37 | { 38 | name: "Jim Halpert", 39 | username: "jhalpert", 40 | email: "jim@athleap.co", 41 | status: false 42 | }, 43 | { 44 | name: "Pam Halpert", 45 | username: "phalpert", 46 | email: "phalpert79@gmail.com", 47 | status: false 48 | }, 49 | { 50 | name: "Ed Truck", 51 | username: "ed", 52 | email: "etruck@dundermifflin.com", 53 | status: false 54 | }, 55 | { 56 | name: "Michael Scott", 57 | username: "mscott", 58 | email: "admin@michaelscottfoundation.org", 59 | status: false 60 | }, 61 | { 62 | name: "Dwight Schrute", 63 | username: "bearsbeats74", 64 | email: "dschrute@dundermifflin.com", 65 | status: true 66 | }, 67 | { 68 | name: "Phyllis Vance", 69 | username: "pvance", 70 | email: "pvance@dundermifflin.com", 71 | status: true 72 | }, 73 | { 74 | name: "Stanley Hudson", 75 | username: "shudson", 76 | email: "pretzelday@aol.com", 77 | status: false 78 | }, 79 | { 80 | name: "Erin Hannon", 81 | username: "khannon", 82 | email: "khannon@dundermifflin.com", 83 | status: true 84 | }, 85 | { 86 | name: "Andrew Bernard", 87 | username: "the_nard_dog", 88 | email: "nard.dog@cornell.edu", 89 | status: false 90 | }, 91 | { 92 | name: "David Wallace", 93 | username: "dwallace", 94 | email: "dwallace@dundermifflin.com", 95 | status: true 96 | }, 97 | { 98 | name: "Meredith Palmer", 99 | username: "mpalmer", 100 | email: "supplierrelations@dundermifflin.com", 101 | status: true 102 | }, 103 | { 104 | name: "Angela Schrute", 105 | username: "aschrute", 106 | email: "aschrute@dundermifflin.com", 107 | status: true 108 | } 109 | ]; 110 | 111 | class StatefulToggle extends React.Component< 112 | { 113 | children: (args: { 114 | STState: object, 115 | toggle: Function, 116 | stashState: Function 117 | }) => JSX.Element, 118 | stateObj?: object 119 | }, 120 | { toggleChildren?: boolean, savedFloodgateState?: object | boolean } 121 | > { 122 | constructor(props) { 123 | super(props); 124 | this.state = { 125 | toggleChildren: true, 126 | savedFloodgateState: props.stateObj || false 127 | }; 128 | this.stashState = this.stashState.bind(this); 129 | this.toggle = this.toggle.bind(this); 130 | } 131 | stashState(stateKey: string, stateVal: object) { 132 | this.setState(prevState => ({ 133 | ...prevState, 134 | [stateKey]: { ...prevState[stateKey], ...stateVal } 135 | })); 136 | } 137 | toggle() { 138 | return this.setState(prevState => ({ 139 | toggleChildren: !prevState.toggleChildren 140 | })); 141 | } 142 | render() { 143 | const { state: STState, stashState, toggle } = this; 144 | return this.props.children({ STState, toggle, stashState }); 145 | } 146 | } 147 | 148 | export { 149 | generateFilledArray, 150 | generateLoremIpsum, 151 | loopSimulation, 152 | theOfficeData, 153 | StatefulToggle 154 | }; 155 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { FloodgateProps, FloodgateState } from "./types"; 2 | import "regenerator-runtime/runtime"; 3 | import * as React from "react"; 4 | import * as PropTypes from "prop-types"; 5 | import { generator } from "./functions"; 6 | 7 | const initGeneratorSymbol = Symbol.for("initGenerator"); 8 | 9 | const FloodgateContext = React.createContext({}); 10 | 11 | class Floodgate extends React.Component { 12 | // types 13 | queue: Generator | null; 14 | state: FloodgateState; 15 | 16 | // static props 17 | static propTypes = { 18 | children: PropTypes.func, 19 | data: PropTypes.array.isRequired, 20 | initial: PropTypes.number, 21 | increment: PropTypes.number, 22 | exportStateOnUnmount: PropTypes.bool, 23 | exportState: PropTypes.func, 24 | onLoadNext: PropTypes.func, 25 | onLoadComplete: PropTypes.func, 26 | onReset: PropTypes.func 27 | }; 28 | static defaultProps = { 29 | initial: 5, 30 | increment: 5, 31 | exportStateOnUnmount: true 32 | }; 33 | 34 | // methods 35 | constructor(props: FloodgateProps) { 36 | super(props); 37 | const { data, increment, initial } = props; 38 | this.queue = null; 39 | this.state = { 40 | items: data, 41 | renderedItems: [], 42 | currentIndex: 0, 43 | allItemsRendered: false, 44 | prevProps: { 45 | data, 46 | increment, 47 | initial 48 | } 49 | }; 50 | this[initGeneratorSymbol] = this[initGeneratorSymbol].bind(this); 51 | this.loadAll = this.loadAll.bind(this); 52 | this.loadNext = this.loadNext.bind(this); 53 | this.reset = this.reset.bind(this); 54 | this.exportState = this.exportState.bind(this); 55 | this[initGeneratorSymbol]( 56 | this.props.data, 57 | this.props.increment, 58 | this.props.initial 59 | ); 60 | } 61 | [initGeneratorSymbol](data, increment, initial) { 62 | this.queue = generator(data, increment, initial); 63 | } 64 | componentDidMount(): void { 65 | this.loadNext({ silent: true }); 66 | } 67 | componentDidUpdate(prevProps, prevState): void { 68 | const { data, increment } = this.props; 69 | if (this.props !== prevProps) { 70 | // Initialize new generator 71 | this[initGeneratorSymbol]( 72 | data.slice(prevState.currentIndex, data.length), 73 | increment, 74 | increment 75 | ); 76 | 77 | // Set new state 78 | const items = data; 79 | this.setState(() => ({ 80 | items, 81 | allItemsRendered: items.length === prevState.renderedItems.length 82 | })); 83 | } 84 | } 85 | componentWillUnmount(): void { 86 | // Prevent unwanted cacheing by setting exportStateOnUnmount to false 87 | this.props.exportStateOnUnmount && this.exportState(); 88 | } 89 | reset({ 90 | initial, 91 | callback 92 | }: { initial?: number, callback?: Function } = {}): void { 93 | this[initGeneratorSymbol]( 94 | this.props.data, 95 | this.props.increment, 96 | typeof initial !== "undefined" ? initial : this.props.initial 97 | ); 98 | 99 | this.setState( 100 | prevState => ({ 101 | renderedItems: [], 102 | allItemsRendered: false, 103 | prevProps: { 104 | ...prevState.prevProps, 105 | initial: typeof initial !== "undefined" ? initial : this.props.initial 106 | } 107 | }), 108 | () => { 109 | this.loadNext({ silent: true, callback }); 110 | this.props.onReset && this.props.onReset(this.state); 111 | } 112 | ); 113 | } 114 | loadAll( 115 | { 116 | callback, 117 | suppressWarning 118 | }: { callback?: Function, suppressWarning: boolean } = { 119 | suppressWarning: false 120 | } 121 | ): void { 122 | !this.state.allItemsRendered 123 | ? this.setState( 124 | prevState => { 125 | return { 126 | renderedItems: this.props.data, 127 | currentIndex: this.props.data.length, 128 | allItemsRendered: true 129 | }; 130 | }, 131 | () => { 132 | callback && callback(this.state); 133 | this.props.onLoadComplete && this.props.onLoadComplete(this.state); 134 | } 135 | ) 136 | : this.state.allItemsRendered && 137 | !suppressWarning && 138 | console.warn("Floodgate: All items are rendered"); 139 | } 140 | loadNext( 141 | { silent, callback }: { silent?: boolean, callback?: Function } = { 142 | silent: false 143 | } 144 | ): void { 145 | if (!this.state.allItemsRendered) { 146 | // Get next iteratable 147 | const { value } = this.queue.next(); 148 | // Check if array value exists and has at least one element 149 | const valueIsAvailable: boolean = value && value.length; 150 | 151 | this.setState( 152 | prevState => { 153 | // Apply new data if available 154 | const newRenderedData = [ 155 | ...prevState.renderedItems, 156 | ...(valueIsAvailable ? value : []) 157 | ]; 158 | // Check if all data items have been rendered 159 | const dataLengthMatches: boolean = 160 | newRenderedData.length === prevState.items.length; 161 | 162 | return { 163 | renderedItems: newRenderedData, 164 | currentIndex: newRenderedData.length, 165 | allItemsRendered: 166 | !valueIsAvailable || (valueIsAvailable && dataLengthMatches) 167 | ? true 168 | : false 169 | }; 170 | }, 171 | () => { 172 | callback && callback(this.state); 173 | if (this.state.allItemsRendered) { 174 | this.props.onLoadComplete && this.props.onLoadComplete(this.state); 175 | } else { 176 | this.props.onLoadNext && 177 | !silent && 178 | this.props.onLoadNext(this.state); 179 | } 180 | } 181 | ); 182 | } 183 | } 184 | exportState() { 185 | const { renderedItems, currentIndex, allItemsRendered } = this.state; 186 | this.props.onExportState && 187 | this.props.onExportState({ 188 | currentIndex, 189 | renderedItems, 190 | allItemsRendered 191 | }); 192 | } 193 | render() { 194 | const { loadAll, loadNext, reset, exportState } = this; 195 | const { renderedItems, allItemsRendered } = this.state; 196 | const floodgateInternals = { 197 | items: renderedItems, 198 | loadComplete: allItemsRendered, 199 | loadAll, 200 | loadNext, 201 | reset, 202 | exportState 203 | }; 204 | return ( 205 | 206 | {this.props.children({ 207 | ...floodgateInternals 208 | })} 209 | 210 | ); 211 | } 212 | } 213 | 214 | export default Floodgate; 215 | export { FloodgateContext }; 216 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | type ErrorBoundaryProps = { 2 | errorMessage?: Function, 3 | fallbackUI?: Function 4 | }; 5 | 6 | type ErrorBoundaryState = { 7 | treeHasError: boolean, 8 | treeError: { 9 | error: boolean | string, 10 | info: string 11 | } 12 | }; 13 | 14 | type FloodgateProps = { 15 | children: (args: { 16 | items: Array, 17 | loadComplete: boolean, 18 | loadAll: Function, 19 | loadNext: Function, 20 | reset: Function, 21 | exportState: Function 22 | }) => JSX.Element, 23 | data: any[], 24 | increment: number, 25 | initial: number, 26 | exportStateOnUnmount?: boolean, 27 | onExportState?: Function, 28 | onLoadNext?: Function, 29 | onLoadComplete?: Function, 30 | onReset?: Function 31 | }; 32 | 33 | type FloodgateState = { 34 | items: any[], 35 | renderedItems: any[], 36 | allItemsRendered: boolean, 37 | currentIndex?: number, 38 | prevProps: { 39 | data: FloodgateProps['data'], 40 | increment: FloodgateProps['increment'], 41 | initial: FloodgateProps['initial'] 42 | } 43 | }; 44 | 45 | export { 46 | ErrorBoundaryProps, 47 | ErrorBoundaryState, 48 | FloodgateProps, 49 | FloodgateState 50 | }; 51 | -------------------------------------------------------------------------------- /stories/demos.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import Floodgate from "floodgate"; 4 | import { 5 | generateFilledArray, 6 | generateLoremIpsum as LI, 7 | theOfficeData 8 | } from "helpers"; 9 | 10 | const Main = styled.main` 11 | color: #333; 12 | font-family: "Open Sans", "Roboto", "Arial", sans-serif; 13 | `; 14 | 15 | const LoadMore = styled.button` 16 | background-color: palevioletred; 17 | border: 0px solid transparent; 18 | color: #fff; 19 | cursor: pointer; 20 | display: inline-block; 21 | font-size: 24px; 22 | padding: 1em 0; 23 | text-align: center; 24 | width: 66%; 25 | `; 26 | 27 | const LoadAll = styled(LoadMore)` 28 | background-color: lightseagreen; 29 | display: inline-block; 30 | width: 34%; 31 | `; 32 | 33 | const Reset = styled(LoadMore)` 34 | background-color: lightgoldenrodyellow; 35 | color: #333; 36 | display: block; 37 | width: 100%; 38 | `; 39 | 40 | const LoadsArticles = () => { 41 | return ( 42 | { 44 | const title = LI({ count: 8 }); 45 | const content = LI({ count: 55, units: "words" }); 46 | return { 47 | id: title 48 | .toLowerCase() 49 | .replace(/[\s\-\_]+/g, "_") 50 | .substr(0, 16) 51 | .replace(/\_$/g, ""), 52 | title, 53 | content 54 | }; 55 | })} 56 | > 57 | {({ items, loadNext, loadAll, loadComplete, reset }) => ( 58 |
59 | {items.map((article, i, allArticles) => ( 60 |
69 |

{article.title}

70 |

71 | {article.content.length > 360 72 | ? `${article.content.substr(0, 359)}…` 73 | : `${article.content}.`} 74 |

75 |
76 | ))} 77 | {(!loadComplete && ( 78 | 79 | 81 | loadNext({ callback: s => (window.location = "#last") })} 82 | > 83 | Load More 84 | 85 | 87 | loadAll({ callback: s => (window.location = "#last") })} 88 | > 89 | Load All 90 | 91 | 92 | )) || ( 93 | 95 | reset({ callback: s => (window.location = "#first") })} 96 | > 97 | Reset 98 | 99 | )} 100 |
101 | )} 102 |
103 | ); 104 | }; 105 | 106 | const LoadsCards = () => { 107 | const CardWrapper = styled.section` 108 | margin: 1em auto; 109 | display: flex; 110 | flex-wrap: wrap; 111 | @supports (display: grid) { 112 | display: grid; 113 | grid-template-columns: repeat(1, calc(100%)); 114 | grid-gap: 0.5em; 115 | @media only screen and (min-width: 600px) { 116 | grid-template-columns: repeat(2, calc(50% - 0.2em)); 117 | } 118 | @media only screen and (min-width: 940px) { 119 | grid-template-columns: repeat(3, calc(33% - 0.2em)); 120 | } 121 | } 122 | `; 123 | 124 | const Card = styled.article` 125 | background-color: #efefef; 126 | margin: 0.5em; 127 | text-align: center; 128 | width: 100%; 129 | @media only screen and (min-width: 600px) { 130 | width: calc(50% - 1em); 131 | } 132 | @media only screen and (min-width: 940px) { 133 | width: calc(33% - 1em); 134 | } 135 | @supports (display: grid) { 136 | margin: auto; 137 | width: 100%; 138 | min-width: none; 139 | } 140 | `; 141 | 142 | const Name = styled.h2` 143 | font-size: 18px; 144 | font-weight: 600; 145 | margin: 1em auto 0.25em; 146 | text-shadow: 1px 1px #ddd; 147 | `; 148 | 149 | const Email = styled.p` 150 | color: #aaa; 151 | font-size: 12px; 152 | margin: 0 auto 1em; 153 | `; 154 | 155 | const Status = styled(Email)` 156 | color: ${({ isActive }) => (isActive ? "#11ca12" : "#ca1211")}; 157 | font-weight: 600; 158 | `; 159 | return ( 160 | 161 | {({ items, loadNext, loadAll, loadComplete, reset }) => ( 162 |
163 |

The Office Rolodex

164 | 165 | {items.map(character => ( 166 | 167 | {character.name} 168 | {character.email} 169 | 170 | {character.status ? "ACTIVE" : "INACTIVE"} 171 | 172 | 173 | ))} 174 | 175 | {(!loadComplete && ( 176 | 177 | More 178 | All 179 | 180 | )) || Reset} 181 |
182 | )} 183 |
184 | ); 185 | }; 186 | 187 | export { LoadsArticles, LoadsCards }; 188 | -------------------------------------------------------------------------------- /stories/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { storiesOf } from "@storybook/react"; 4 | import { action } from "@storybook/addon-actions"; 5 | import { linkTo } from "@storybook/addon-links"; 6 | 7 | import { Button, Welcome } from "@storybook/react/demo"; 8 | import { LoadsArticles, LoadsCards } from "./demos"; 9 | 10 | import Floodgate from "floodgate"; 11 | import { ErrorMessage } from "../src/functions.tsx"; 12 | import { generateFilledArray, StatefulToggle } from "../src/helpers.tsx"; 13 | 14 | const PreProps = { 15 | style: { 16 | backgroundColor: "#eee", 17 | color: "#3a3a3a", 18 | display: "inline-block", 19 | fontSize: "18px", 20 | padding: ".66em" 21 | } 22 | }; 23 | 24 | storiesOf("Welcome", module).add("to Storybook", () => ( 25 | 26 | )); 27 | 28 | storiesOf("Floodgate/styled", module) 29 | .add("loads articles", LoadsArticles) 30 | .add("loads cards", LoadsCards); 31 | 32 | storiesOf("Floodgate/simple", module) 33 | .add( 34 | "Displays numbers up to 9, loads every 2, with initial load of 2", 35 | () => ( 36 | 37 | {({ items, loadNext, loadComplete }) => ( 38 |
39 | {items.map(n => ( 40 |

{n}

41 | ))} 42 | {(!loadComplete && ( 43 |

44 | 45 |

46 | )) ||

All loaded.

} 47 |
48 | )} 49 |
50 | ) 51 | ) 52 | .add( 53 | "Displays numbers up to 9, loads every 3, with initial load of 3", 54 | () => ( 55 | 56 | {({ items, loadNext, loadComplete }) => ( 57 |
58 | {items.map(n => ( 59 |

{n}

60 | ))} 61 | {(!loadComplete && ( 62 |

63 | 64 |

65 | )) ||

All loaded.

} 66 |
67 | )} 68 |
69 | ) 70 | ) 71 | .add( 72 | "Displays numbers up to 9, loads every 3, with initial load of 4", 73 | () => ( 74 | 75 | {({ items, loadNext, loadComplete }) => ( 76 |
77 | {items.map(n => ( 78 |

{n}

79 | ))} 80 | {(!loadComplete && ( 81 |

82 | 83 |

84 | )) ||

All loaded.

} 85 |
86 | )} 87 |
88 | ) 89 | ) 90 | .add("Displays numbers up to 9, loads every 9", () => ( 91 | 92 | {({ items, loadNext, loadComplete }) => ( 93 |
94 | {items.map(n => ( 95 |

{n}

96 | ))} 97 | {(!loadComplete && ( 98 |

99 | 100 |

101 | )) ||

All loaded.

} 102 |
103 | )} 104 |
105 | )) 106 | .add("Has reset button", () => ( 107 | 108 | {({ items, loadNext, loadComplete, reset }) => ( 109 |
110 | {items.map(n => ( 111 |

{n}

112 | ))} 113 | {(!loadComplete && ( 114 |

115 | 116 | 117 |

118 | )) || ( 119 |

120 | All loaded. 121 |
122 | 123 |

124 | )} 125 |
126 | )} 127 |
128 | )) 129 | .add("With render prop callback callbacks", () => ( 130 | 131 | {({ items, loadNext, loadComplete, reset }) => ( 132 |
133 | {items.map((item, i, allItems) => ( 134 |

138 | {item} 139 |

140 | ))} 141 | {(!loadComplete && ( 142 |

143 | 157 | 164 |

165 | )) || ( 166 |

167 | All loaded. 168 |
169 | 179 |

180 | )} 181 |
182 | )} 183 |
184 | )) 185 | .add("With loadAll callback", () => ( 186 | 187 | {({ items, loadAll, loadNext, loadComplete, reset }) => { 188 | console.log({ loadComplete }); 189 | return ( 190 |
191 | {items.map((item, i, allItems) => ( 192 |

196 | {item} 197 |

198 | ))} 199 | {(!loadComplete && ( 200 |

201 | 215 | 225 | 234 |

235 | )) || ( 236 |

237 | All loaded. 238 |
239 | 249 |

250 | )} 251 |
252 | ); 253 | }} 254 |
255 | )) 256 | .add("Parent-Controlled Floodgate", () => { 257 | class LoadMore extends React.Component { 258 | constructor() { 259 | super(); 260 | this.state = { 261 | fetchComplete: false, 262 | fetchActive: false, 263 | data: [0, 1, 2] 264 | }; 265 | this.saveState = this.saveState.bind(this); 266 | this.handleClick = this.handleClick.bind(this); 267 | this.addDataToState = this.addDataToState.bind(this); 268 | } 269 | saveState(FloodgateState) { 270 | this.setState(prevState => ({ 271 | cachedFloodgateState: FloodgateState 272 | })); 273 | } 274 | addDataToState() { 275 | setTimeout( 276 | () => 277 | this.setState( 278 | prevState => { 279 | return { 280 | fetchActive: false, 281 | fetchComplete: false, 282 | data: [...prevState.data, prevState.data.length] 283 | }; 284 | }, 285 | () => {} 286 | ), 287 | 500 288 | ); 289 | } 290 | handleClick() { 291 | this.setState( 292 | () => ({ fetchActive: true }), 293 | () => { 294 | this.addDataToState(); 295 | } 296 | ); 297 | } 298 | render() { 299 | return ( 300 |
301 | 302 | {({ items, loadNext, loadComplete }) => ( 303 |
304 |
    305 | {items.map(n => ( 306 |
  • {n}
  • 307 | ))} 308 |
309 | 312 | 318 |
319 | )} 320 |
321 |
322 | ); 323 | } 324 | } 325 | return ; 326 | }) 327 | .add("With saveLoadState", () => { 328 | return ( 329 | 332 | {({ STState, toggle, stashState }) => ( 333 |
334 | 335 |
336 | {STState.toggleChildren && ( 337 | 342 | stashState("savedFloodgateState", { 343 | ...state, 344 | initial: state.currentIndex 345 | }) 346 | } 347 | > 348 | {({ items, loadNext, loadAll, loadComplete, reset }) => ( 349 |
350 | {items.map(n => ( 351 |

{n}

352 | ))} 353 |
354 | {!loadComplete ? ( 355 | 356 | 357 | 358 | 359 | ) : ( 360 | 363 | )} 364 |
365 | )} 366 |
367 | )} 368 |
369 | )} 370 |
371 | ); 372 | }) 373 | .add("With event callbacks", () => { 374 | return ( 375 | 378 | {({ STState, toggle, stashState }) => ( 379 |
380 | 381 |
382 | {STState.toggleChildren && ( 383 | console.log({ stateOnLoadNext: s })} 388 | onLoadComplete={s => console.log({ stateOnLoadComplete: s })} 389 | onReset={s => console.log({ stateOnReset: s })} 390 | exportState={state => 391 | stashState("savedFloodgateState", { 392 | ...state, 393 | initial: state.currentIndex 394 | }) 395 | } 396 | > 397 | {({ items, loadNext, loadComplete, reset }) => ( 398 |
399 | {items.map(n => ( 400 |

{n}

401 | ))} 402 |
403 | {!loadComplete ? ( 404 | 405 | ) : ( 406 | 409 | )} 410 |
411 | )} 412 |
413 | )} 414 |
415 | )} 416 |
417 | ); 418 | }) 419 | .add("Floodgate children wrapped in Context.Provider", () => { 420 | const Display = props => ( 421 |
    {props.items.map(n =>
  • {n}
  • )}
422 | ); 423 | const Controls = ({ ContextConsumer }) => ( 424 | 425 | 426 | {({ loadNext, loadAll, loadComplete, reset }) => ( 427 | 428 | {" "} 431 | 434 | {loadComplete && ( 435 | 436 |
437 | 438 |
439 | )} 440 |
441 | )} 442 |
443 |
444 | ); 445 | return ( 446 |
447 | 448 | {({ items, FloodgateContext }) => { 449 | return ( 450 |
451 | 452 |
453 | 454 |
455 |
456 | ); 457 | }} 458 |
459 |
460 | ); 461 | }); 462 | storiesOf("Utilities/functions/ErrorMessage", module) 463 | .add("Generic message", () => ( 464 | 468 | )) 469 | .add("No `text` provided, with children", () => ( 470 | 471 | Uncaught SyntaxError: {"{the error message}"} 472 | 473 | )) 474 | .add("No `text` provided, no children", () => ); 475 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "module": "es2015", 5 | "target": "es6", 6 | "noImplicitAny": false, 7 | "sourceMap": true, 8 | "inlineSourceMap": false, 9 | "jsx": "react", 10 | "moduleResolution": "node", 11 | "baseUrl": "./src", 12 | "paths": { 13 | "classes/*": ["./src/classes"], 14 | "functions/*": ["./src/functions"], 15 | "helpers/*": ["./src/helpers"], 16 | "types/*": ["./src/types"] 17 | } 18 | }, 19 | "include": [ 20 | "./src/*" 21 | ], 22 | "exclude": [ 23 | "__tests__", 24 | "node_modules", 25 | "rollup.config.js" 26 | ] 27 | } --------------------------------------------------------------------------------