├── packages ├── _root │ ├── .npmignore │ ├── .gitignore │ ├── index.js │ ├── README.md │ └── package.json ├── _shared │ ├── .npmignore │ ├── tests │ │ ├── .eslintrc │ │ ├── reducers │ │ │ ├── user-reducer-test.js │ │ │ ├── play-reducer-test.js │ │ │ ├── actions-reducer-test.js │ │ │ └── authentication-reducer-test.js │ │ ├── create-firebase-handler-test.js │ │ └── export-test.js │ ├── .gitignore │ ├── nwb.config.js │ ├── src │ │ ├── reducers │ │ │ ├── index.js │ │ │ ├── user.reducer.js │ │ │ ├── play.reducer.js │ │ │ ├── actions.reducer.js │ │ │ ├── authentication.reducer.js │ │ │ └── cassettes.reducer.js │ │ ├── utils │ │ │ ├── get-query-param.js │ │ │ ├── polyfills.js │ │ │ └── create-firebase-handler.js │ │ ├── index.js │ │ ├── stubs │ │ │ └── firebase-stub-factory.js │ │ └── actions │ │ │ └── index.js │ ├── README.md │ └── package.json ├── capture │ ├── .npmignore │ ├── tests │ │ └── .eslintrc │ ├── .gitignore │ ├── src │ │ ├── index.js │ │ ├── helpers.js │ │ └── create-capture-middleware.js │ ├── nwb.config.js │ ├── package.json │ └── README.md ├── persist │ ├── .npmignore │ ├── tests │ │ ├── .eslintrc │ │ └── create-persist-handler-test.js │ ├── .gitignore │ ├── src │ │ ├── index.js │ │ └── create-persist-handler.js │ ├── nwb.config.js │ ├── CONTRIBUTING.md │ ├── README.md │ └── package.json ├── replay │ ├── .npmignore │ ├── tests │ │ └── .eslintrc │ ├── .gitignore │ ├── .storybook │ │ ├── config.js │ │ └── webpack.config.js │ ├── src │ │ ├── data │ │ │ ├── cassette-themes.js │ │ │ ├── cassette-offsets.js │ │ │ └── icon-map.js │ │ ├── components │ │ │ ├── Replay │ │ │ │ ├── index.scss │ │ │ │ └── index.js │ │ │ ├── _variables.scss │ │ │ ├── Backdrop │ │ │ │ ├── index.scss │ │ │ │ └── index.js │ │ │ ├── VCRPowerLight │ │ │ │ ├── index.js │ │ │ │ └── index.scss │ │ │ ├── SignInCTA │ │ │ │ ├── index.js │ │ │ │ └── index.scss │ │ │ ├── VCRDoor │ │ │ │ ├── index.js │ │ │ │ └── index.scss │ │ │ ├── Icon │ │ │ │ └── index.js │ │ │ ├── VCRButton │ │ │ │ ├── index.js │ │ │ │ └── index.scss │ │ │ ├── CassetteList │ │ │ │ ├── index.scss │ │ │ │ └── index.js │ │ │ ├── VCRScreen │ │ │ │ ├── index.scss │ │ │ │ └── index.js │ │ │ ├── VCR │ │ │ │ └── index.scss │ │ │ └── Cassette │ │ │ │ └── index.js │ │ ├── index.js │ │ ├── utils │ │ │ └── sample-with-probability.js │ │ ├── wrap-reducer.js │ │ ├── create-replay-handler.js │ │ └── create-replay-middleware.js │ ├── stories │ │ ├── index.js │ │ ├── index.scss │ │ ├── Centered.js │ │ ├── vcr-screen │ │ │ └── index.js │ │ ├── vcr-button │ │ │ └── index.js │ │ ├── cassette │ │ │ └── index.js │ │ ├── cassette-list │ │ │ └── index.js │ │ └── vcr │ │ │ └── index.js │ ├── nwb.config.js │ ├── README.md │ ├── package.json │ └── scripts │ │ └── add_component │ │ ├── add-component.js │ │ └── index.js ├── retrieve │ ├── .npmignore │ ├── tests │ │ ├── .eslintrc │ │ └── create-retrieve-handler-test.js │ ├── .gitignore │ ├── nwb.config.js │ ├── src │ │ ├── index.js │ │ ├── use-local.js │ │ └── create-retrieve-handler.js │ ├── CONTRIBUTING.md │ ├── package.json │ └── README.md └── _demo │ ├── config │ ├── flow │ │ ├── css.js.flow │ │ └── file.js.flow │ ├── polyfills.js │ ├── babel.dev.js │ ├── babel.prod.js │ ├── paths.js │ ├── webpack.config.dev.js │ └── webpack.config.prod.js │ ├── src │ ├── components │ │ ├── PollQuestion │ │ │ ├── index.css │ │ │ └── index.js │ │ ├── App │ │ │ ├── index.css │ │ │ └── index.js │ │ ├── Onboarding │ │ │ ├── index.css │ │ │ └── index.js │ │ ├── DevTools │ │ │ └── index.js │ │ └── Button │ │ │ ├── index.js │ │ │ └── index.css │ ├── reducers │ │ ├── index.js │ │ ├── onboarding.reducer.js │ │ └── answers.reducer.js │ ├── actions │ │ └── index.js │ └── index.js │ ├── favicon.ico │ ├── .gitignore │ ├── index.html │ ├── README.md │ ├── scripts │ ├── utils │ │ ├── chrome.applescript │ │ ├── prompt.js │ │ └── detectPort.js │ └── build.js │ └── package.json ├── .gitignore ├── lerna.json ├── documentation ├── images │ ├── vcr-demo.gif │ ├── vcr-image.png │ ├── firebase-config-rules.png │ ├── firebase-config-credentials-code.png │ ├── firebase-config-get-credentials.png │ ├── firebase-config-github-callback-url.png │ ├── firebase-config-github-client-keys.png │ └── firebase-config-anonymous-authentication.png ├── setting-up-development-environment.md ├── javascript-implementation.md └── firebase-config.md ├── .travis.yml ├── .eslintrc ├── CONTRIBUTING.md ├── package.json ├── ROADMAP.md ├── scripts └── bootstrap.js └── README.md /packages/_root/.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/_shared/.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/capture/.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/persist/.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/replay/.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/retrieve/.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/_root/.gitignore: -------------------------------------------------------------------------------- 1 | /lib 2 | -------------------------------------------------------------------------------- /packages/_demo/config/flow/css.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | -------------------------------------------------------------------------------- /packages/_demo/src/components/PollQuestion/index.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | lerna-debug.log 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "lerna": "2.0.0-beta.26", 3 | "version": "0.3.3" 4 | } 5 | -------------------------------------------------------------------------------- /packages/_demo/config/flow/file.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | declare export default string; 3 | -------------------------------------------------------------------------------- /packages/replay/tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/_shared/tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/capture/tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/persist/tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/retrieve/tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/_demo/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/redux-vcr/HEAD/packages/_demo/favicon.ico -------------------------------------------------------------------------------- /packages/replay/.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es6 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /documentation/images/vcr-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/redux-vcr/HEAD/documentation/images/vcr-demo.gif -------------------------------------------------------------------------------- /packages/_shared/.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es6 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /packages/capture/.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es6 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /packages/persist/.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es6 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /packages/retrieve/.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es6 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /documentation/images/vcr-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/redux-vcr/HEAD/documentation/images/vcr-image.png -------------------------------------------------------------------------------- /documentation/images/firebase-config-rules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/redux-vcr/HEAD/documentation/images/firebase-config-rules.png -------------------------------------------------------------------------------- /documentation/images/firebase-config-credentials-code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/redux-vcr/HEAD/documentation/images/firebase-config-credentials-code.png -------------------------------------------------------------------------------- /documentation/images/firebase-config-get-credentials.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/redux-vcr/HEAD/documentation/images/firebase-config-get-credentials.png -------------------------------------------------------------------------------- /documentation/images/firebase-config-github-callback-url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/redux-vcr/HEAD/documentation/images/firebase-config-github-callback-url.png -------------------------------------------------------------------------------- /documentation/images/firebase-config-github-client-keys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/redux-vcr/HEAD/documentation/images/firebase-config-github-client-keys.png -------------------------------------------------------------------------------- /packages/_root/index.js: -------------------------------------------------------------------------------- 1 | export * from 'redux-vcr.capture'; 2 | export * from 'redux-vcr.persist'; 3 | export * from 'redux-vcr.retrieve'; 4 | export * from 'redux-vcr.replay'; 5 | -------------------------------------------------------------------------------- /documentation/images/firebase-config-anonymous-authentication.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/redux-vcr/HEAD/documentation/images/firebase-config-anonymous-authentication.png -------------------------------------------------------------------------------- /packages/persist/src/index.js: -------------------------------------------------------------------------------- 1 | import createPersistHandler from './create-persist-handler'; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export { createPersistHandler }; 5 | -------------------------------------------------------------------------------- /packages/_shared/nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'web-module', 3 | build: { 4 | externals: {}, 5 | global: '', 6 | jsNext: false, 7 | umd: false, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/capture/src/index.js: -------------------------------------------------------------------------------- 1 | import createCaptureMiddleware from './create-capture-middleware'; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export { createCaptureMiddleware }; 5 | -------------------------------------------------------------------------------- /packages/capture/nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'web-module', 3 | build: { 4 | externals: {}, 5 | global: 'ReduxVCR_capture', 6 | jsNext: true, 7 | umd: true, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/persist/nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'web-module', 3 | build: { 4 | externals: {}, 5 | global: 'ReduxVCR_persist', 6 | jsNext: true, 7 | umd: true, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /packages/retrieve/nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'web-module', 3 | build: { 4 | externals: {}, 5 | global: 'ReduxVCR_retrieve', 6 | jsNext: true, 7 | umd: true, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/_demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # production 7 | build 8 | 9 | # misc 10 | .DS_Store 11 | npm-debug.log 12 | -------------------------------------------------------------------------------- /packages/replay/.storybook/config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | import { configure } from '@kadira/storybook'; 3 | 4 | function loadStories() { 5 | require('../stories'); 6 | } 7 | 8 | configure(loadStories, module); 9 | -------------------------------------------------------------------------------- /packages/_demo/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import answers from './answers.reducer'; 4 | import onboarding from './onboarding.reducer'; 5 | 6 | export default combineReducers({ answers, onboarding }); 7 | -------------------------------------------------------------------------------- /packages/replay/src/data/cassette-themes.js: -------------------------------------------------------------------------------- 1 | // The number represents their relative likelihood of being chosen. 2 | // The higher the number, the more likely it is. 3 | export default { 4 | generic: 6, 5 | polaroid: 2, 6 | kodak: 1, 7 | tdk: 3, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/replay/stories/index.js: -------------------------------------------------------------------------------- 1 | import './index.scss'; 2 | 3 | // Require all stories programmatically. This way we don't have to import manually. 4 | function requireAll(r) { r.keys().forEach(r); } 5 | 6 | requireAll(require.context('./', true, /\.js$/)); 7 | -------------------------------------------------------------------------------- /packages/replay/src/data/cassette-offsets.js: -------------------------------------------------------------------------------- 1 | // The number represents their relative likelihood of being chosen. 2 | // The higher the number, the more likely it is. 3 | export default { 4 | '0': 20, 5 | '-1': 1, 6 | '1': 1, 7 | '-2': 2, 8 | '2': 2, 9 | }; 10 | -------------------------------------------------------------------------------- /packages/retrieve/src/index.js: -------------------------------------------------------------------------------- 1 | import createRetrieveHandler from './create-retrieve-handler'; 2 | import createRetrieveMiddleware from './create-retrieve-middleware'; 3 | 4 | export { getQueryParam } from 'redux-vcr.shared'; 5 | export { createRetrieveHandler, createRetrieveMiddleware }; 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 6 4 | env: 5 | - PROJECT_NAME=capture 6 | - PROJECT_NAME=persist 7 | - PROJECT_NAME=retrieve 8 | - PROJECT_NAME=replay 9 | - PROJECT_NAME=_shared 10 | git: 11 | depth: 1 12 | script: cd packages/$PROJECT_NAME && npm install && npm test 13 | -------------------------------------------------------------------------------- /packages/replay/.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | module: { 5 | loaders: [ 6 | { 7 | test: /\.scss$/, 8 | loaders: [ 'style', 'css', 'sass' ], 9 | include: path.resolve(__dirname, '../') 10 | } 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/_demo/src/components/App/index.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | body, button, input, select { 6 | font-family: 'Lucida Grande', Tahoma, sans-serif; 7 | } 8 | 9 | header { 10 | background-color: #222; 11 | padding: 2rem; 12 | color: white; 13 | } 14 | 15 | section { 16 | margin: 4rem; 17 | } 18 | -------------------------------------------------------------------------------- /packages/_demo/src/actions/index.js: -------------------------------------------------------------------------------- 1 | export const SELECT_ANSWER = 'SELECT_ANSWER'; 2 | export const COMPLETE_ONBOARDING = 'COMPLETE_ONBOARDING'; 3 | 4 | 5 | export const selectAnswer = ({ id }) => ({ 6 | type: SELECT_ANSWER, 7 | id, 8 | }); 9 | 10 | export const completeOnboarding = () => ({ 11 | type: COMPLETE_ONBOARDING, 12 | }); 13 | -------------------------------------------------------------------------------- /packages/replay/src/components/Replay/index.scss: -------------------------------------------------------------------------------- 1 | @import '../variables'; 2 | 3 | .redux-vcr-component { 4 | position: fixed; 5 | z-index: 99999; 6 | bottom: 1rem; 7 | left: 0; 8 | right: 0; 9 | margin: 0 auto; 10 | width: $vcr-width; 11 | font-family: "Lucida Grande", verdana, sans-serif; 12 | 13 | *, *:before, *:after { 14 | box-sizing: border-box; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/replay/src/components/_variables.scss: -------------------------------------------------------------------------------- 1 | $white: #FFF; 2 | $offwhite: #f4f7f8; 3 | $verylightgray: #d5e0e4; 4 | $lightgray: #becbcd; 5 | $mediumgray: #606667; 6 | $darkgray: #2f3233; 7 | $black: #111212; 8 | 9 | $rca-yellow: #f1ce0e; 10 | $rca-red: #f11e0e; 11 | $rca-white: #f7f0ef; 12 | 13 | $neon-green: #65e41f; 14 | 15 | $vcr-width: 520px; 16 | $vcr-height: 100px; 17 | $vcr-button-radius: 3px; 18 | $vcr-standard-padding: 10px; 19 | -------------------------------------------------------------------------------- /packages/_shared/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import actions from './actions.reducer'; 4 | import cassettes from './cassettes.reducer'; 5 | import play from './play.reducer'; 6 | import user from './user.reducer'; 7 | import authentication from './authentication.reducer'; 8 | 9 | export default combineReducers({ 10 | actions, 11 | cassettes, 12 | play, 13 | user, 14 | authentication, 15 | }); 16 | -------------------------------------------------------------------------------- /packages/replay/src/components/Backdrop/index.scss: -------------------------------------------------------------------------------- 1 | .redux-vcr-component .backdrop-wrapper { 2 | z-index: 0; 3 | 4 | .backdrop { 5 | position: fixed; 6 | top: 0; 7 | left: 0; 8 | right: 0; 9 | bottom: 0; 10 | 11 | .close-backdrop { 12 | position: absolute; 13 | top: 2rem; 14 | right: 2rem; 15 | background: transparent; 16 | border: none; 17 | padding: 1rem; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/_demo/config/polyfills.js: -------------------------------------------------------------------------------- 1 | if (typeof Promise === 'undefined') { 2 | // Rejection tracking prevents a common issue where React gets into an 3 | // inconsistent state due to an error, but it gets swallowed by a Promise, 4 | // and the user has no idea what causes React's erratic future behavior. 5 | require('promise/lib/rejection-tracking').enable(); 6 | window.Promise = require('promise/lib/es6-extensions.js'); 7 | } 8 | 9 | require('whatwg-fetch'); 10 | -------------------------------------------------------------------------------- /packages/replay/nwb.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = { 4 | type: 'react-component', 5 | build: { 6 | externals: { 7 | 'react': 'React' 8 | }, 9 | global: 'ReduxVCR_replay', 10 | jsNext: true, 11 | umd: true 12 | }, 13 | webpack: { 14 | extra: { 15 | resolve: { 16 | alias: { 17 | '_icons': path.join(__dirname, 'src/icons') 18 | } 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/_shared/README.md: -------------------------------------------------------------------------------- 1 | # ReduxVCR.shared 2 | 3 | This module holds shared logic between various Redux VCR modules. Specifically, it: 4 | 5 | - exports FirebaseHandler, a class that ReduxVCR.persist and ReduxVCR.retrieve use to send and receive data through Firebase 6 | 7 | - Redux structure (reducers, actions, etc) for managing replays. Used by ReduxVCR.retrieve and ReduxVCR.replay, with possible future uses with the other modules 8 | 9 | - exports various helpers and polyfills 10 | -------------------------------------------------------------------------------- /packages/_shared/src/utils/get-query-param.js: -------------------------------------------------------------------------------- 1 | export default function getQueryParam({ param }) { 2 | const url = window.location.href; 3 | 4 | // eslint-disable-next-line no-param-reassign 5 | param = param.replace(/[\[\]]/g, '\\$&'); 6 | const regex = new RegExp('[?&]' + param + '(=([^&#]*)|&|#|$)'); 7 | const results = regex.exec(url); 8 | if (!results) return null; 9 | if (!results[2]) return ''; 10 | 11 | return decodeURIComponent(results[2].replace(/\+/g, ' ')); 12 | } 13 | -------------------------------------------------------------------------------- /packages/_demo/src/components/Onboarding/index.css: -------------------------------------------------------------------------------- 1 | .onboarding { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | z-index: 2; 8 | background: #FAFAFA; 9 | display: flex; 10 | flex-direction: column; 11 | align-items: center; 12 | justify-content: center; 13 | } 14 | 15 | .onboarding .main-content { 16 | height: 100%; 17 | width: 100%; 18 | max-height: 300px; 19 | max-width: 800px; 20 | } 21 | 22 | .onboarding h2 { 23 | font-size: 32px; 24 | } 25 | -------------------------------------------------------------------------------- /packages/_demo/src/reducers/onboarding.reducer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import { combineReducers } from 'redux'; 3 | import { COMPLETE_ONBOARDING } from '../actions'; 4 | 5 | const defaultStates = { 6 | completed: false, 7 | }; 8 | 9 | const completed = (state = defaultStates.completed, action) => { 10 | switch (action.type) { 11 | case COMPLETE_ONBOARDING: return true; 12 | default: return state; 13 | } 14 | }; 15 | 16 | 17 | export default combineReducers({ completed }); 18 | -------------------------------------------------------------------------------- /packages/_shared/src/reducers/user.reducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | SIGN_IN_SUCCESS, 3 | SIGN_OUT_SUCCESS, 4 | } from '../actions'; 5 | 6 | 7 | const defaultState = null; 8 | 9 | export default function userReducer(state = defaultState, action) { 10 | switch (action.type) { 11 | case SIGN_IN_SUCCESS: return action.user; 12 | case SIGN_OUT_SUCCESS: return null; 13 | default: return state; 14 | } 15 | } 16 | 17 | 18 | // //////////////////////// 19 | // SELECTORS ///////////// 20 | // ////////////////////// 21 | -------------------------------------------------------------------------------- /packages/_demo/src/components/DevTools/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createDevTools } from 'redux-devtools'; 3 | import LogMonitor from 'redux-devtools-log-monitor'; 4 | import DockMonitor from 'redux-devtools-dock-monitor'; 5 | 6 | 7 | const DevTools = createDevTools( 8 | 13 | 14 | 15 | ); 16 | 17 | export default DevTools; 18 | -------------------------------------------------------------------------------- /packages/persist/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | [Node.js](http://nodejs.org/) must be installed. 4 | 5 | ## Installation 6 | 7 | * Running `npm install` in the module's root directory will install everything you need for development. 8 | 9 | ## Running Tests 10 | 11 | * `npm test` will run the tests once. 12 | * `npm run test:watch` will run the tests on every change. 13 | 14 | ## Building 15 | 16 | * `npm run build` will build the module for publishing to npm. 17 | * `npm run clean` will delete built resources. 18 | -------------------------------------------------------------------------------- /packages/retrieve/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | [Node.js](http://nodejs.org/) must be installed. 4 | 5 | ## Installation 6 | 7 | * Running `npm install` in the module's root directory will install everything you need for development. 8 | 9 | ## Running Tests 10 | 11 | * `npm test` will run the tests once. 12 | * `npm run test:watch` will run the tests on every change. 13 | 14 | ## Building 15 | 16 | * `npm run build` will build the module for publishing to npm. 17 | * `npm run clean` will delete built resources. 18 | -------------------------------------------------------------------------------- /packages/retrieve/src/use-local.js: -------------------------------------------------------------------------------- 1 | // DO NOT TOUCH THIS. 2 | export default false; 3 | 4 | // This is a (hopefully temporary) way of dealing with the fact that these 5 | // modules share a common dependency (the /shared directory), and it's a 6 | // pain to need to build and publish a new version whenever we want to make 7 | // changes. 8 | // 9 | // The publish script (/scripts/publish-all.js) will update this file before 10 | // and after it does its work, so that we have the convenience of a relative 11 | // import in development, but NPM exports a working module. 12 | -------------------------------------------------------------------------------- /packages/_demo/src/components/Onboarding/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import Button from '../Button'; 4 | import './index.css'; 5 | 6 | const Onboarding = ({ completeOnboarding }) => ( 7 |
8 |
9 |

Welcome to this poll thing!

10 | 13 |
14 |
15 | ); 16 | 17 | Onboarding.propTypes = { 18 | completeOnboarding: PropTypes.func, 19 | }; 20 | 21 | export default Onboarding; 22 | -------------------------------------------------------------------------------- /packages/_demo/config/babel.dev.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | babelrc: false, 3 | cacheDirectory: true, 4 | presets: [ 5 | 'babel-preset-es2015', 6 | 'babel-preset-es2016', 7 | 'babel-preset-react' 8 | ].map(require.resolve), 9 | plugins: [ 10 | 'babel-plugin-syntax-trailing-function-commas', 11 | 'babel-plugin-transform-class-properties', 12 | 'babel-plugin-transform-object-rest-spread' 13 | ].map(require.resolve).concat([ 14 | [require.resolve('babel-plugin-transform-runtime'), { 15 | helpers: false, 16 | polyfill: false, 17 | regenerator: true 18 | }] 19 | ]) 20 | }; 21 | -------------------------------------------------------------------------------- /packages/_root/README.md: -------------------------------------------------------------------------------- 1 | # ReduxVCR 2 | ### A Redux devtool that lets you replay user sessions in real-time. 3 | [![build status](https://travis-ci.org/joshwcomeau/redux-vcr.svg?branch=master)](https://travis-ci.org/joshwcomeau/redux-vcr) 4 | [![npm version](https://img.shields.io/npm/v/redux-vcr.svg)](https://www.npmjs.com/package/redux-vcr) 5 | [![npm monthly downloads](https://img.shields.io/npm/dm/redux-vcr.svg)](https://www.npmjs.com/package/redux-vcr) 6 | 7 | 8 | 9 | ### View the documentation 10 | 11 | Documentation for this project lives [on GitHub](https://github.com/joshwcomeau/redux-vcr). 12 | -------------------------------------------------------------------------------- /packages/_demo/config/babel.prod.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | babelrc: false, 3 | presets: [ 4 | 'babel-preset-es2015', 5 | 'babel-preset-es2016', 6 | 'babel-preset-react' 7 | ].map(require.resolve), 8 | plugins: [ 9 | 'babel-plugin-syntax-trailing-function-commas', 10 | 'babel-plugin-transform-class-properties', 11 | 'babel-plugin-transform-object-rest-spread', 12 | 'babel-plugin-transform-react-constant-elements', 13 | ].map(require.resolve).concat([ 14 | [require.resolve('babel-plugin-transform-runtime'), { 15 | helpers: false, 16 | polyfill: false, 17 | regenerator: true 18 | }] 19 | ]) 20 | }; 21 | -------------------------------------------------------------------------------- /packages/_demo/src/components/Button/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import './index.css'; 5 | 6 | const Button = ({ children, value, toggled, primary, grouped, onClick }) => ( 7 | 14 | ); 15 | 16 | Button.propTypes = { 17 | children: PropTypes.node, 18 | value: PropTypes.string, 19 | toggled: PropTypes.bool, 20 | primary: PropTypes.bool, 21 | grouped: PropTypes.bool, 22 | onClick: PropTypes.func.isRequired, 23 | }; 24 | 25 | export default Button; 26 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | extends: 'airbnb', 3 | parserOptions: { 4 | ecmaVersion: 7, 5 | sourceType: 'module', 6 | ecmaFeatures: { 7 | jsx: true, 8 | impliedStrict: true, 9 | experimentalObjectRestSpread: true 10 | } 11 | }, 12 | rules: { 13 | "import/no-unresolved": 0, 14 | "import/no-extraneous-dependencies": 0, 15 | "react/jsx-filename-extension": 0, 16 | "new-cap": 0, 17 | "arrow-body-style": 0, 18 | "no-return-assign": 0, 19 | "no-mixed-operators": 0, 20 | "no-console": ["warn", {allow: ["info", "warn", "error"] }], 21 | "prefer-template": 0, 22 | "quote-props": 0, 23 | }, 24 | env: { 25 | browser: true, 26 | node: true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/replay/src/components/VCRPowerLight/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import './index.scss'; 5 | 6 | 7 | const VCRPowerLight = ({ mode }) => { 8 | const classes = classNames('vcr-power-light', { 9 | 'light-off': mode === 'stopped', 10 | 'light-amber': mode === 'paused', 11 | 'light-green': mode === 'playing', 12 | }); 13 | 14 | return ( 15 |
16 |
17 |
18 | ); 19 | }; 20 | 21 | VCRPowerLight.propTypes = { 22 | mode: PropTypes.oneOf([ 23 | 'stopped', 24 | 'playing', 25 | 'paused', 26 | ]), 27 | }; 28 | 29 | export default VCRPowerLight; 30 | -------------------------------------------------------------------------------- /packages/_demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Redux VCR Demo 7 | 8 | 9 |
10 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /packages/replay/src/components/SignInCTA/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import Icon from '../Icon'; 4 | import './index.scss'; 5 | 6 | const SignInCTA = ({ onClick }) => { 7 | return ( 8 |
9 | 13 |
14 | {"Redux VCR requires authentication, to protect users' privacy. "} 15 | Read more. 16 |
17 |
18 | ); 19 | }; 20 | 21 | SignInCTA.propTypes = { 22 | onClick: PropTypes.func.isRequired, 23 | }; 24 | 25 | export default SignInCTA; 26 | -------------------------------------------------------------------------------- /packages/replay/stories/index.scss: -------------------------------------------------------------------------------- 1 | @import '../src/components/variables'; 2 | 3 | body { 4 | background: #FAFAFA; 5 | } 6 | 7 | *, *:before, *:after { 8 | box-sizing: border-box; 9 | } 10 | 11 | html, body, #root, .redux-vcr-component { 12 | padding: 0; 13 | margin: 0; 14 | height: 100%; 15 | } 16 | 17 | .redux-vcr-component.at-bottom { 18 | position: fixed; 19 | z-index: 99999; 20 | bottom: 1rem; 21 | left: 0; 22 | right: 0; 23 | margin: 0 auto; 24 | height: auto; 25 | width: $vcr-width; 26 | } 27 | 28 | 29 | .centered.story-vertically-centered { 30 | display: flex; 31 | flex-direction: column; 32 | min-height: 100%; 33 | justify-content: center; 34 | } 35 | 36 | .centered.story-horizontally-centered { 37 | display: flex; 38 | flex-direction: column; 39 | align-items: center; 40 | 41 | } 42 | -------------------------------------------------------------------------------- /packages/replay/src/components/VCRDoor/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import './index.scss'; 5 | 6 | 7 | const VCRDoor = ({ label, isOpen, onClick }) => { 8 | return ( 9 |
10 |
11 | 12 | {label} 13 | 14 |
15 |
16 |
17 | ); 18 | }; 19 | 20 | VCRDoor.propTypes = { 21 | label: PropTypes.string, 22 | isOpen: PropTypes.bool, 23 | onClick: PropTypes.func.isRequired, 24 | }; 25 | 26 | VCRDoor.defaultProps = { 27 | label: 'HI-FI STEREO SYSTEM', 28 | isOpen: false, 29 | }; 30 | 31 | 32 | export default VCRDoor; 33 | -------------------------------------------------------------------------------- /packages/replay/stories/Centered.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | const Centered = ({ children, vertical, horizontal, className, styles }) => { 5 | const classes = classNames(className, 'centered', { 6 | 'story-horizontally-centered': horizontal, 7 | 'story-vertically-centered': vertical, 8 | }); 9 | 10 | return ( 11 |
12 | {children} 13 |
14 | ); 15 | }; 16 | 17 | Centered.propTypes = { 18 | children: PropTypes.node, 19 | vertical: PropTypes.bool, 20 | horizontal: PropTypes.bool, 21 | className: PropTypes.string, 22 | styles: PropTypes.object, 23 | }; 24 | 25 | Centered.defaultProps = { 26 | styles: { padding: '0 2rem' }, 27 | vertical: true, 28 | horizontal: true, 29 | }; 30 | 31 | export default Centered; 32 | -------------------------------------------------------------------------------- /packages/replay/src/index.js: -------------------------------------------------------------------------------- 1 | // Components 2 | import Backdrop from './components/Backdrop'; 3 | import Cassette from './components/Cassette'; 4 | import CassetteList from './components/CassetteList'; 5 | import Icon from './components/Icon'; 6 | import Replay from './components/Replay'; 7 | import VCR from './components/VCR'; 8 | import VCRButton from './components/VCRButton'; 9 | import VCRPowerLight from './components/VCRPowerLight'; 10 | 11 | // Core logic 12 | import createReplayHandler from './create-replay-handler'; 13 | import createReplayMiddleware from './create-replay-middleware'; 14 | import wrapReducer from './wrap-reducer'; 15 | 16 | 17 | export { 18 | Backdrop, 19 | Cassette, 20 | CassetteList, 21 | Icon, 22 | Replay, 23 | VCR, 24 | VCRButton, 25 | VCRPowerLight, 26 | createReplayHandler, 27 | createReplayMiddleware, 28 | wrapReducer, 29 | }; 30 | -------------------------------------------------------------------------------- /packages/_root/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-vcr", 3 | "version": "0.3.3", 4 | "description": "", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "build": "babel index.js --out-file lib/index.js", 8 | "prepublish": "npm run build", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "Joshua Comeau", 12 | "license": "MIT", 13 | "dependencies": { 14 | "redux-vcr.capture": "^0.3.3", 15 | "redux-vcr.persist": "^0.3.3", 16 | "redux-vcr.replay": "^0.3.3", 17 | "redux-vcr.retrieve": "^0.3.3" 18 | }, 19 | "babel": { 20 | "presets": [ 21 | "es2015" 22 | ], 23 | "plugins": [ 24 | "transform-object-rest-spread" 25 | ] 26 | }, 27 | "devDependencies": { 28 | "babel-cli": "^6.16.0", 29 | "babel-plugin-transform-object-rest-spread": "^6.16.0", 30 | "babel-preset-es2015": "^6.16.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/replay/src/components/VCRPowerLight/index.scss: -------------------------------------------------------------------------------- 1 | @import '../variables'; 2 | 3 | .redux-vcr-component .vcr-power-light { 4 | position: absolute; 5 | right: 9px; 6 | bottom: 9px; 7 | width: 12px; 8 | height: 4px; 9 | border-radius: 4px; 10 | border-bottom: 1px solid rgba(255,255,255,0.6); 11 | box-shadow: 0px 0px 2px rgba(0,0,0,0.9); 12 | overflow: hidden; 13 | 14 | &.light-off .lightbulb { 15 | background: rgba(255,255,255,0.15); 16 | } 17 | 18 | &.light-amber .lightbulb { 19 | background: $rca-yellow; 20 | } 21 | 22 | &.light-green .lightbulb { 23 | background: $neon-green; 24 | } 25 | 26 | .lightbulb { 27 | position: absolute; 28 | top: 0; 29 | left: 0; 30 | right: 0; 31 | bottom: 0; 32 | animation: pulse 2s infinite; 33 | } 34 | } 35 | 36 | @keyframes pulse { 37 | 0% { opacity: 1; } 38 | 50% { opacity: 0.4; } 39 | 100% { opacity: 1; } 40 | } 41 | -------------------------------------------------------------------------------- /packages/replay/src/components/SignInCTA/index.scss: -------------------------------------------------------------------------------- 1 | @import '../variables'; 2 | 3 | .redux-vcr-component .sign-in-cta { 4 | margin: 16px 0; 5 | text-align: center; 6 | 7 | button { 8 | position: relative; 9 | width: 180px; 10 | height: 46px; 11 | background: #1c8e4d; 12 | color: $white; 13 | font-size: 16px; 14 | border: 0; 15 | border-bottom: 3px solid rgba(0,0,0,0.25); 16 | border-radius: 4px; 17 | 18 | &:hover { 19 | background: #19ac57; 20 | } 21 | 22 | .icon { 23 | position: absolute; 24 | width: 32px; 25 | height: 32px; 26 | left: 8px; 27 | top: 0; 28 | bottom: 0; 29 | margin: auto; 30 | } 31 | } 32 | .explanation { 33 | line-height: 3em; 34 | font-size: 12px; 35 | color: $white; 36 | font-family: sans-serif; 37 | text-shadow: 1px 1px 3px #000; 38 | 39 | a { 40 | color: $white; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/_demo/README.md: -------------------------------------------------------------------------------- 1 | # Redux VCR - Demo 2 | 3 | ### A very simple demo, used in the development of other ReduxVCR modules 4 | 5 | When working on ReduxVCR's core modules, it is necessary to be able to view how all the modules work together in a real application. This demo application, while incredibly straightforward and simple, provides a good base for testing various ReduxVCR features. 6 | 7 | It isn't really meant as an example for integration. For that, we've created a [TodoMVC repo](https://github.com/joshwcomeau/redux-vcr-todomvc) that showcases several different integration patterns: 8 | 9 | - [Quickstart](https://github.com/joshwcomeau/redux-vcr-todomvc/pull/1) 10 | - [Production-ready](https://github.com/joshwcomeau/redux-vcr-todomvc/pull/2) 11 | - [Quickstart without authentication](https://github.com/joshwcomeau/redux-vcr-todomvc/pull/3) 12 | - [Quickstart with initial cassette load](https://github.com/joshwcomeau/redux-vcr-todomvc/pull/4) 13 | -------------------------------------------------------------------------------- /packages/_demo/scripts/utils/chrome.applescript: -------------------------------------------------------------------------------- 1 | on run argv 2 | set theURL to item 1 of argv 3 | 4 | tell application "Chrome" 5 | 6 | if (count every window) = 0 then 7 | make new window 8 | end if 9 | 10 | -- Find a tab currently running the debugger 11 | set found to false 12 | set theTabIndex to -1 13 | repeat with theWindow in every window 14 | set theTabIndex to 0 15 | repeat with theTab in every tab of theWindow 16 | set theTabIndex to theTabIndex + 1 17 | if theTab's URL is theURL then 18 | set found to true 19 | exit repeat 20 | end if 21 | end repeat 22 | 23 | if found then 24 | exit repeat 25 | end if 26 | end repeat 27 | 28 | if found then 29 | tell theTab to reload 30 | set index of theWindow to 1 31 | set theWindow's active tab index to theTabIndex 32 | else 33 | tell window 1 34 | activate 35 | make new tab with properties {URL:theURL} 36 | end tell 37 | end if 38 | end tell 39 | end run 40 | -------------------------------------------------------------------------------- /packages/_demo/src/components/Button/index.css: -------------------------------------------------------------------------------- 1 | .button { 2 | position: relative; 3 | background: #F2F2F2; 4 | padding: 1rem 2rem; 5 | font-size: 18px; 6 | border: 0; 7 | border-right: 1px solid rgba(0,0,0,0.05); 8 | border-radius: 0; 9 | box-shadow: 0px 4px 0px #CCC; 10 | outline: none; 11 | transition: background 100ms; 12 | } 13 | 14 | .button:active { 15 | transform: translateY(3px); 16 | box-shadow: 0px 1px 0px #AAA; 17 | } 18 | 19 | .button.primary { 20 | background: #22ceff; 21 | color: #FFF; 22 | box-shadow: 0px 4px 0px #399cca; 23 | } 24 | 25 | .button.primary:active { 26 | box-shadow: 0px 1px 0px #399cca; 27 | } 28 | 29 | .button.grouped:first-of-type { 30 | border-radius: 10px 0 0 10px; 31 | } 32 | 33 | .button.grouped:last-of-type { 34 | border-radius: 0 10px 10px 0; 35 | border-right: 0; 36 | } 37 | 38 | .button:not(.grouped) { 39 | border-radius: 10px; 40 | border: 0; 41 | } 42 | 43 | .button.toggled { 44 | background: #333; 45 | color: #FFF; 46 | transform: translateY(2px); 47 | box-shadow: 0px 2px 0px #111; 48 | } 49 | -------------------------------------------------------------------------------- /packages/_demo/src/reducers/answers.reducer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import { combineReducers } from 'redux'; 3 | import { SELECT_ANSWER } from '../actions'; 4 | 5 | // NOTE: This is provided because this demo app is contrived. 6 | // In a real app, we would have actions for populating this state. 7 | const defaultAnswers = { 8 | hcl: 'Hillary Clinton', 9 | dtr: 'Donald Trump', 10 | gjo: 'Gary Johnson', 11 | }; 12 | 13 | // Because we don't have ways of adding/removing answers, 14 | // we don't even need the switch for these reducers. 15 | const byId = (state = defaultAnswers, action) => state; 16 | const allIds = (state = Object.keys(defaultAnswers), action) => state; 17 | 18 | const selected = (state = null, action) => { 19 | switch (action.type) { 20 | case SELECT_ANSWER: return action.id; 21 | default: return state; 22 | } 23 | }; 24 | 25 | 26 | export default combineReducers({ byId, allIds, selected }); 27 | 28 | 29 | export const getAnswers = state => ( 30 | state.allIds.map(id => ({ 31 | id, 32 | name: state.byId[id], 33 | })) 34 | ); 35 | -------------------------------------------------------------------------------- /packages/_demo/scripts/utils/prompt.js: -------------------------------------------------------------------------------- 1 | var rl = require('readline'); 2 | 3 | // Convention: "no" should be the conservative choice. 4 | // If you mistype the answer, we'll always take it as a "no". 5 | // You can control the behavior on with `isYesDefault`. 6 | module.exports = function (question, isYesDefault) { 7 | if (typeof isYesDefault !== 'boolean') { 8 | throw new Error('Provide explicit boolean isYesDefault as second argument.'); 9 | } 10 | return new Promise(resolve => { 11 | var rlInterface = rl.createInterface({ 12 | input: process.stdin, 13 | output: process.stdout, 14 | }); 15 | 16 | var hint = isYesDefault === true ? '[Y/n]' : '[y/N]'; 17 | var message = question + ' ' + hint + '\n'; 18 | 19 | rlInterface.question(message, function(answer) { 20 | rlInterface.close(); 21 | 22 | var useDefault = answer.trim().length === 0; 23 | if (useDefault) { 24 | return resolve(isYesDefault); 25 | } 26 | 27 | var isYes = answer.match(/^(yes|y)$/i); 28 | return resolve(isYes); 29 | }); 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /packages/_demo/src/components/PollQuestion/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import Button from '../Button'; 4 | import './index.css'; 5 | 6 | 7 | const PollQuestion = ({ answers, selected, handleClick }) => { 8 | return ( 9 |
10 |
11 |

Who would you like to see become the next US President?

12 |
13 | 14 |
15 | {answers.map(({ id, name }) => ( 16 | 25 | ))} 26 |
27 |
28 | ); 29 | }; 30 | 31 | PollQuestion.propTypes = { 32 | answers: PropTypes.arrayOf(PropTypes.shape({ 33 | id: PropTypes.string, 34 | name: PropTypes.string, 35 | })).isRequired, 36 | selected: PropTypes.string, 37 | handleClick: PropTypes.func.isRequired, 38 | }; 39 | 40 | export default PollQuestion; 41 | -------------------------------------------------------------------------------- /packages/replay/stories/vcr-screen/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable semi, no-unused-vars */ 2 | import React, { Component } from 'react'; 3 | import { storiesOf, action } from '@kadira/storybook'; 4 | 5 | import Centered from '../Centered'; 6 | import { VCRScreen } from '../../src/components/VCRScreen'; 7 | 8 | storiesOf('VCRScreen', module) 9 | .addDecorator(story => ( 10 |
11 | {story()} 12 |
13 | )) 14 | .add('Default', () => ( 15 | 16 | Welcome to Redux VCR 17 | 18 | )) 19 | .add('Waiting for authentication', () => ( 20 | 24 | Please authenticate. 25 | 26 | )) 27 | .add('Displaying error', () => ( 28 | 32 | Please authenticate. 33 | 34 | )) 35 | .add('With label', () => ( 36 | 39 | Cassette #1234 40 | 41 | )) 42 | -------------------------------------------------------------------------------- /packages/persist/README.md: -------------------------------------------------------------------------------- 1 | # ReduxVCR.persist 2 | 3 | [![build status](https://travis-ci.org/joshwcomeau/redux-vcr.svg?branch=master)](https://travis-ci.org/joshwcomeau/redux-vcr) 4 | [![npm version](https://img.shields.io/npm/v/redux-vcr.persist.svg)](https://www.npmjs.com/package/redux-vcr.persist) 5 | [![npm monthly downloads](https://img.shields.io/npm/dm/redux-vcr.persist.svg)](https://www.npmjs.com/package/redux-vcr.persist) 6 | 7 | ReduxVCR.persist exposes a handler that receives a Cassette object from ReduxVCR.capture, and is responsible for syncing it with Firebase. 8 | 9 | This module includes a middleware that : 10 | 11 | - Debouncing updates from ReduxVCR.capture 12 | - Anonymously authenticating with Firebase for write access 13 | - Persisting cassette data to Firebase. 14 | 15 | ## More Info 16 | 17 | This package belongs to the [ReduxVCR monolithic repo](https://github.com/joshwcomeau/redux-vcr). You'll find full information about this and other core modules there. 18 | 19 | You can also jump straight to the [ReduxVCR.persist API reference](https://github.com/joshwcomeau/redux-vcr/blob/master/documentation/API-reference.md#persist). 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing to ReduxVCR! 4 | 5 | ## Issues 6 | 7 | If you encounter any problems, simply open an issue. Because this project is new (and in alpha), we expect lots of issues to be raised. 8 | 9 | ## Development 10 | 11 | For development instructions, please see (the development docs)[https://github.com/joshwcomeau/redux-vcr/documentation/setting-up-development-environment.md]. 12 | 13 | 14 | ### Testing 15 | 16 | To run the tests, you can run any of the following commands from the root directory: 17 | 18 | ```bash 19 | npm run test:capture 20 | npm run test:persist 21 | npm run test:retrieve 22 | npm run test:replay 23 | npm run test:shared 24 | 25 | npm run test 26 | ``` 27 | 28 | That last command will run the whole suite of tests. 29 | 30 | By `cd`ing into an individual package's repo, you can also run a test watcher: 31 | 32 | ```bash 33 | npm run test:watch 34 | ``` 35 | 36 | 37 | 38 | ### Linting 39 | 40 | We use a slightly-modified version of AirBnb's ESLint rules. 41 | 42 | 43 | ### Docs 44 | 45 | Improvements to the docs are greatly appreciated! 46 | 47 | 48 | ## Thank you for contributing! 49 | -------------------------------------------------------------------------------- /packages/replay/README.md: -------------------------------------------------------------------------------- 1 | # ReduxVCR.replay 2 | 3 | [![build status](https://travis-ci.org/joshwcomeau/redux-vcr.svg?branch=master)](https://travis-ci.org/joshwcomeau/redux-vcr) 4 | [![npm version](https://img.shields.io/npm/v/redux-vcr.replay.svg)](https://www.npmjs.com/package/redux-vcr.replay) 5 | [![npm monthly downloads](https://img.shields.io/npm/dm/redux-vcr.replay.svg)](https://www.npmjs.com/package/redux-vcr.replay) 6 | 7 | ReduxVCR.replay is responsible for letting you, the admin, replay the actions of your users. 8 | 9 | Its responsibilities include: 10 | 11 | - Providing a cute little VCR interface for controlling the replays. 12 | - Letting you pick from a list of recent sessions (known as 'cassettes') 13 | - Hooking into your Redux state, to be able to reset it when new cassettes are loaded, prepare their initial state, etc. 14 | 15 | -------- 16 | 17 | ## More Info 18 | 19 | This package belongs to the [ReduxVCR monolithic repo](https://github.com/joshwcomeau/redux-vcr). You'll find full information about this and other core modules there. 20 | 21 | You can also jump straight to the [ReduxVCR.replay API reference](https://github.com/joshwcomeau/redux-vcr/blob/master/documentation/API-reference.md#replay). 22 | -------------------------------------------------------------------------------- /packages/capture/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-vcr.capture", 3 | "version": "0.3.3", 4 | "description": "ReduxVCR capture module", 5 | "main": "lib/index.js", 6 | "jsnext:main": "es6/index.js", 7 | "files": [ 8 | "es6", 9 | "lib", 10 | "umd" 11 | ], 12 | "scripts": { 13 | "build": "nwb build", 14 | "clean": "nwb clean", 15 | "test": "nwb test", 16 | "test:watch": "nwb test --server", 17 | "lint": "eslint -c ../.eslintrc src/** --ext .js", 18 | "prepublish": "npm run build" 19 | }, 20 | "dependencies": { 21 | "redux-vcr.shared": "^0.3.3" 22 | }, 23 | "devDependencies": { 24 | "chai": "^3.5.0", 25 | "eslint": "^3.2.2", 26 | "eslint-config-airbnb": "^10.0.0", 27 | "eslint-plugin-import": "^1.12.0", 28 | "eslint-plugin-jsx-a11y": "^2.1.0", 29 | "eslint-plugin-react": "^6.0.0", 30 | "invariant": "^2.2.1", 31 | "nwb": "0.11.x", 32 | "redux": "^3.5.2", 33 | "sinon": "^2.0.0-pre.2" 34 | }, 35 | "author": "Joshua Comeau", 36 | "homepage": "https://github.com/joshwcomeau/redux-vcr/tree/master/capture", 37 | "license": "MIT", 38 | "repository": { 39 | "type": "git", 40 | "url": "https://github.com/joshwcomeau/redux-vcr.git" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/retrieve/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-vcr.retrieve", 3 | "version": "0.3.3", 4 | "description": "ReduxVCR retrieve module", 5 | "main": "lib/index.js", 6 | "jsnext:main": "es6/index.js", 7 | "files": [ 8 | "es6", 9 | "lib", 10 | "umd" 11 | ], 12 | "scripts": { 13 | "build": "nwb build", 14 | "clean": "nwb clean", 15 | "test": "nwb test", 16 | "test:watch": "nwb test --server", 17 | "lint": "eslint -c ../.eslintrc src/** --ext .js", 18 | "prepublish": "npm run build" 19 | }, 20 | "dependencies": { 21 | "redux-vcr.shared": "^0.3.3" 22 | }, 23 | "devDependencies": { 24 | "chai": "^3.5.0", 25 | "eslint": "^3.2.2", 26 | "eslint-config-airbnb": "^10.0.0", 27 | "eslint-plugin-import": "^1.12.0", 28 | "eslint-plugin-jsx-a11y": "^2.1.0", 29 | "eslint-plugin-react": "^6.0.0", 30 | "invariant": "^2.2.1", 31 | "nwb": "0.11.x", 32 | "redux": "^3.5.2", 33 | "sinon": "^2.0.0-pre.2" 34 | }, 35 | "author": "Joshua Comeau", 36 | "homepage": "https://github.com/joshwcomeau/redux-vcr/tree/master/persist", 37 | "license": "MIT", 38 | "repository": { 39 | "type": "git", 40 | "url": "https://github.com/joshwcomeau/redux-vcr.git" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/retrieve/README.md: -------------------------------------------------------------------------------- 1 | # ReduxVCR.retrieve 2 | 3 | [![build status](https://travis-ci.org/joshwcomeau/redux-vcr.svg?branch=master)](https://travis-ci.org/joshwcomeau/redux-vcr) 4 | [![npm version](https://img.shields.io/npm/v/redux-vcr.retrieve.svg)](https://www.npmjs.com/package/redux-vcr.retrieve) 5 | [![npm monthly downloads](https://img.shields.io/npm/dm/redux-vcr.retrieve.svg)](https://www.npmjs.com/package/redux-vcr.retrieve) 6 | 7 | ReduxVCR.retrieve handles fetching the cassettes so that they are available to ReduxVCR.replay. It also handles authentication, to ensure that only the admin of the application can watch user sessions. 8 | 9 | Its responsibilities include: 10 | 11 | - Connecting to Firebase 12 | - Fetching a list of cassettes 13 | - Fetching the actions for a specific cassette, when it's selected 14 | - Handling developer authentication, with GitHub 15 | 16 | -------- 17 | 18 | ## More Info 19 | 20 | This package belongs to the [ReduxVCR monolithic repo](https://github.com/joshwcomeau/redux-vcr). You'll find full information about this and other core modules there. 21 | 22 | You can also jump straight to the [ReduxVCR.replay API reference](https://github.com/joshwcomeau/redux-vcr/blob/master/documentation/API-reference.md#replay). 23 | -------------------------------------------------------------------------------- /packages/capture/README.md: -------------------------------------------------------------------------------- 1 | # ReduxVCR.capture 2 | 3 | [![build status](https://travis-ci.org/joshwcomeau/redux-vcr.svg?branch=master)](https://travis-ci.org/joshwcomeau/redux-vcr) 4 | [![npm version](https://img.shields.io/npm/v/redux-vcr.capture.svg)](https://www.npmjs.com/package/redux-vcr.capture) 5 | [![npm monthly downloads](https://img.shields.io/npm/dm/redux-vcr.capture.svg)](https://www.npmjs.com/package/redux-vcr.capture) 6 | 7 | ReduxVCR.capture is responsible for collecting and preparing the Redux actions you'd like to persist. Its responsibilities include: 8 | 9 | - Intercepting all actions dispatched to the Redux store 10 | - Filtering out any actions that do not need to be captured 11 | - Appending time information to actions, so that they can be replayed in real-time. 12 | - Creating the Cassette, an object that holds the sequence of actions as well as metadata (such as timestamp). 13 | 14 | 15 | ## More Info 16 | 17 | This package belongs to the [ReduxVCR monolithic repo](https://github.com/joshwcomeau/redux-vcr). You'll find full information about this and other core modules there. 18 | 19 | You can also jump straight to the [ReduxVCR.capture API reference](https://github.com/joshwcomeau/redux-vcr/blob/master/documentation/API-reference.md#capture). 20 | -------------------------------------------------------------------------------- /packages/_shared/tests/reducers/user-reducer-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import { expect } from 'chai'; 3 | 4 | import reducer from '../../src/reducers/user.reducer'; 5 | import { 6 | SIGN_IN_SUCCESS, 7 | SIGN_OUT_SUCCESS, 8 | signInSuccess, 9 | signOutSuccess, 10 | } from '../../src/actions'; 11 | 12 | 13 | describe('user reducer', () => { 14 | describe(SIGN_IN_SUCCESS, () => { 15 | it('updates the state with the user data', () => { 16 | const state = reducer({}, {}); 17 | 18 | // TODO: Figure out what Firebase actually returns for these calls. 19 | const action = signInSuccess({ user: { 20 | uid: '1234', 21 | } }); 22 | 23 | const expectedState = { 24 | uid: '1234', 25 | }; 26 | const actualState = reducer(state, action); 27 | 28 | expect(actualState).to.deep.equal(expectedState); 29 | }); 30 | }); 31 | 32 | describe(SIGN_OUT_SUCCESS, () => { 33 | it('removes the user data', () => { 34 | const state = reducer({}, {}); 35 | 36 | const action = signOutSuccess(); 37 | 38 | const expectedState = null; 39 | const actualState = reducer(state, action); 40 | 41 | expect(actualState).to.equal(expectedState); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /packages/_shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-vcr.shared", 3 | "version": "0.3.3", 4 | "description": "Shared Redux logic for various Redux VCR modules", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "README.md", 8 | "lib" 9 | ], 10 | "scripts": { 11 | "build": "nwb build", 12 | "clean": "nwb clean", 13 | "test": "nwb test", 14 | "test:watch": "nwb test --server", 15 | "lint": "eslint -c ../.eslintrc src/** --ext .js", 16 | "prepublish": "npm run build" 17 | }, 18 | "dependencies": { 19 | "firebase": "3.2.1", 20 | "reselect": "^2.5.3" 21 | }, 22 | "peerDependencies": { 23 | "invariant": "2.2.1", 24 | "redux": "3.x" 25 | }, 26 | "devDependencies": { 27 | "chai": "^3.5.0", 28 | "eslint": "^3.2.2", 29 | "eslint-config-airbnb": "^10.0.0", 30 | "eslint-plugin-import": "^1.12.0", 31 | "eslint-plugin-jsx-a11y": "^2.1.0", 32 | "eslint-plugin-react": "^6.0.0", 33 | "invariant": "^2.2.1", 34 | "lodash": "^4.15.0", 35 | "nwb": "0.11.x", 36 | "redux": "^3.5.2", 37 | "sinon": "^2.0.0-pre.2" 38 | }, 39 | "author": "Joshua Comeau", 40 | "license": "MIT", 41 | "repository": { 42 | "type": "git", 43 | "url": "https://github.com/joshwcomeau/redux-vcr.git" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/_shared/src/reducers/play.reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import { 4 | CHANGE_PLAYBACK_SPEED, 5 | CHANGE_MAXIMUM_DELAY, 6 | EJECT_CASSETTE, 7 | PAUSE_CASSETTE, 8 | PLAY_CASSETTE, 9 | STOP_CASSETTE, 10 | } from '../actions'; 11 | 12 | 13 | const defaultStates = { 14 | status: 'stopped', 15 | speed: 1, 16 | maximumDelay: Infinity, 17 | }; 18 | 19 | 20 | function statusReducer(state = defaultStates.status, action) { 21 | switch (action.type) { 22 | case EJECT_CASSETTE: 23 | case STOP_CASSETTE: return 'stopped'; 24 | case PAUSE_CASSETTE: return 'paused'; 25 | case PLAY_CASSETTE: return 'playing'; 26 | default: return state; 27 | } 28 | } 29 | 30 | function speedReducer(state = defaultStates.speed, action) { 31 | switch (action.type) { 32 | case CHANGE_PLAYBACK_SPEED: return action.playbackSpeed; 33 | default: return state; 34 | } 35 | } 36 | 37 | function maximumDelayReducer(state = defaultStates.maximumDelay, action) { 38 | switch (action.type) { 39 | case CHANGE_MAXIMUM_DELAY: return action.maximumDelay; 40 | default: return state; 41 | } 42 | } 43 | 44 | 45 | export default combineReducers({ 46 | status: statusReducer, 47 | speed: speedReducer, 48 | maximumDelay: maximumDelayReducer, 49 | }); 50 | -------------------------------------------------------------------------------- /packages/persist/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-vcr.persist", 3 | "version": "0.3.3", 4 | "description": "ReduxVCR persist module", 5 | "main": "lib/index.js", 6 | "jsnext:main": "es6/index.js", 7 | "files": [ 8 | "es6", 9 | "lib", 10 | "umd" 11 | ], 12 | "scripts": { 13 | "build": "nwb build", 14 | "clean": "nwb clean", 15 | "test": "nwb test", 16 | "test:watch": "nwb test --server", 17 | "lint": "eslint -c ../.eslintrc src/** --ext .js", 18 | "prepublish": "npm run build" 19 | }, 20 | "dependencies": { 21 | "invariant": "2.2.1", 22 | "lodash.debounce": "4.0.7", 23 | "redux-vcr.shared": "^0.3.3" 24 | }, 25 | "devDependencies": { 26 | "chai": "^3.5.0", 27 | "eslint": "^3.2.2", 28 | "eslint-config-airbnb": "^10.0.0", 29 | "eslint-plugin-import": "^1.12.0", 30 | "eslint-plugin-jsx-a11y": "^2.1.0", 31 | "eslint-plugin-react": "^6.0.0", 32 | "lodash": "^4.15.0", 33 | "nwb": "0.11.x", 34 | "redux": "^3.5.2", 35 | "sinon": "^2.0.0-pre.2" 36 | }, 37 | "author": "Joshua Comeau", 38 | "homepage": "https://github.com/joshwcomeau/redux-vcr/tree/master/persist", 39 | "license": "MIT", 40 | "repository": { 41 | "type": "git", 42 | "url": "https://github.com/joshwcomeau/redux-vcr.git" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/replay/src/components/Icon/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import iconMap from '../../data/icon-map'; 4 | 5 | 6 | const Icon = ({ color, size, value, ...delegated }) => { 7 | const divStyles = { 8 | display: 'inline-block', 9 | width: size, 10 | height: size, 11 | }; 12 | 13 | // Our default viewBox, used for all Material Design icons. 14 | // Can be overridden by iconMap 15 | const defaultViewBox = '0 0 24 24'; 16 | 17 | // Our iconData can either be a the SVG data itself, as a string, 18 | // or it can be an object containing a `path` and `viewBox`. 19 | const iconData = iconMap[value]; 20 | 21 | const path = iconData.path || iconData; 22 | const viewBox = iconData.viewBox || defaultViewBox; 23 | 24 | return ( 25 |
26 | 32 |
33 | ); 34 | }; 35 | 36 | Icon.propTypes = { 37 | color: PropTypes.string, 38 | size: PropTypes.number, 39 | value: PropTypes.string.isRequired, 40 | }; 41 | 42 | Icon.defaultProps = { 43 | color: '#2f3233', 44 | size: 24, 45 | }; 46 | 47 | export default Icon; 48 | -------------------------------------------------------------------------------- /packages/replay/stories/vcr-button/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable semi, no-unused-vars */ 2 | import React, { Component } from 'react'; 3 | import { storiesOf, action } from '@kadira/storybook'; 4 | 5 | import Centered from '../Centered'; 6 | import VCRButton from '../../src/components/VCRButton'; 7 | 8 | storiesOf('VCRButton', module) 9 | .addDecorator(story => ( 10 |
11 | 12 | {story()} 13 | 14 |
15 | )) 16 | .add('Play', () => ( 17 | 18 | )) 19 | 20 | .add('Toggleable Play/Pause', () => { 21 | class Toggleable extends Component { 22 | constructor(props) { 23 | super(props); 24 | this.state = { 25 | toggled: false, 26 | }; 27 | } 28 | render() { 29 | return ( 30 | { 33 | this.setState({ toggled: !this.state.toggled }) 34 | }} 35 | iconSize={48} 36 | /> 37 | ); 38 | } 39 | } 40 | 41 | return ; 42 | }) 43 | 44 | .add('Glowing', () => ( 45 | 46 | )) 47 | -------------------------------------------------------------------------------- /packages/_shared/src/reducers/actions.reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { createSelector } from 'reselect'; 3 | 4 | import { 5 | CASSETTE_ACTIONS_RECEIVE, 6 | INCREMENT_ACTIONS_PLAYED, 7 | STOP_CASSETTE, 8 | } from '../actions'; 9 | 10 | 11 | const defaultStates = { 12 | byId: {}, 13 | currentIndex: 0, 14 | }; 15 | 16 | 17 | function byIdReducer(state = defaultStates.byId, action) { 18 | switch (action.type) { 19 | case CASSETTE_ACTIONS_RECEIVE: 20 | return { 21 | ...state, 22 | [action.id]: action.cassetteActions, 23 | }; 24 | default: return state; 25 | } 26 | } 27 | 28 | function currentIndexReducer(state = defaultStates.currentIndex, action) { 29 | switch (action.type) { 30 | case INCREMENT_ACTIONS_PLAYED: return state + 1; 31 | case STOP_CASSETTE: return 0; 32 | default: return state; 33 | } 34 | } 35 | 36 | 37 | export default combineReducers({ 38 | byId: byIdReducer, 39 | currentIndex: currentIndexReducer, 40 | }); 41 | 42 | // //////////////////////// 43 | // SELECTORS ///////////// 44 | // ////////////////////// 45 | const actionsByIdSelector = state => state.reduxVCR.actions.byId; 46 | 47 | export const actionsListSelector = createSelector( 48 | actionsByIdSelector, 49 | (byId) => Object.keys(byId).map(actionId => ({ 50 | ...byId[actionId], 51 | id: actionId, 52 | })) 53 | ); 54 | -------------------------------------------------------------------------------- /packages/replay/src/utils/sample-with-probability.js: -------------------------------------------------------------------------------- 1 | import seed from 'seed-random'; 2 | /** 3 | * Returns a random value, using the seed if provided. 4 | * @param {number} min - the minimum (inclusive) value to generate 5 | * @param {number} max - the maximum (inclusive) value to generate 6 | * @param {string} seedValue - a string used for predictable randomization 7 | */ 8 | function getRandomValue(min = 0, max = 1, seedValue) { 9 | const seedFunction = typeof seedValue !== 'undefined' ? seed(seedValue) : seed(); 10 | const initialRandomVal = seedFunction(seedValue); 11 | 12 | return Math.floor(initialRandomVal * (max - min) + min); 13 | } 14 | 15 | /** 16 | * Returns a random key from the supplied object, weighted by the key's value. 17 | * @param {object} object - The set of key/values to sample from 18 | * @example sampleWithProbability({ common: 9, rare: 1 }); 19 | * -> 'common' or 'rare', with 'common' occurring 10x more often. 20 | */ 21 | export default function sampleWithProbability(object, seedValue) { 22 | const weightedCollectionOfKeys = []; 23 | 24 | Object.keys(object).forEach(key => { 25 | const weight = object[key]; 26 | 27 | for (let i = 0; i < weight; i++) { 28 | weightedCollectionOfKeys.push(key); 29 | } 30 | }); 31 | 32 | const index = getRandomValue(0, weightedCollectionOfKeys.length, seedValue); 33 | 34 | return weightedCollectionOfKeys[index]; 35 | } 36 | -------------------------------------------------------------------------------- /packages/_shared/src/reducers/authentication.reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import { 4 | SIGN_IN_REQUEST, 5 | SIGN_IN_SUCCESS, 6 | SIGN_IN_FAILURE, 7 | SIGN_OUT_SUCCESS, 8 | CASSETTES_LIST_FAILURE, 9 | SET_AUTH_REQUIREMENT, 10 | } from '../actions'; 11 | 12 | 13 | const defaultStates = { 14 | loggedIn: false, 15 | requiresAuth: true, 16 | error: null, 17 | }; 18 | 19 | function requiresAuthReducer(state = defaultStates.requiresAuth, action) { 20 | switch (action.type) { 21 | case SET_AUTH_REQUIREMENT: return action.requiresAuth; 22 | default: return state; 23 | } 24 | } 25 | 26 | function loggedInReducer(state = defaultStates.loggedIn, action) { 27 | switch (action.type) { 28 | case SIGN_IN_SUCCESS: return true; 29 | case SIGN_OUT_SUCCESS: return false; 30 | default: return state; 31 | } 32 | } 33 | 34 | function errorReducer(state = defaultStates.error, action) { 35 | switch (action.type) { 36 | // NOTE: Signing out doesn't clear the error, because we sign users 37 | // out immediately in response to certain errors. 38 | case SIGN_IN_REQUEST: 39 | case SIGN_IN_SUCCESS: return null; 40 | case CASSETTES_LIST_FAILURE: 41 | case SIGN_IN_FAILURE: return action.error; 42 | default: return state; 43 | } 44 | } 45 | 46 | export default combineReducers({ 47 | loggedIn: loggedInReducer, 48 | error: errorReducer, 49 | requiresAuth: requiresAuthReducer, 50 | }); 51 | -------------------------------------------------------------------------------- /packages/replay/src/wrap-reducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | actionTypes, 3 | reduxVCRReducer, 4 | cassetteSelectors, 5 | } from 'redux-vcr.shared'; 6 | 7 | const { REWIND_CASSETTE_AND_RESTORE_APP } = actionTypes; 8 | const { selectedCassetteSelector } = cassetteSelectors; 9 | 10 | 11 | // A higher-order reducer that tackles all ReduxVCR actions. 12 | // It works by merging in the original reducer's returned state with 13 | // our own reducer's returned state. 14 | // One special action exists, to reset the overall state of the app, 15 | // when replaying cassettes. 16 | export default function wrapReducer(reducer) { 17 | return (state = {}, action) => { 18 | // Otherwise, delegate to the original reducer. 19 | const { reduxVCR, ...otherState } = state; 20 | 21 | switch (action.type) { 22 | // When our special action is dispatched, we want to re-initialize 23 | // the state, so that our cassette can be played from the correct state. 24 | case REWIND_CASSETTE_AND_RESTORE_APP: { 25 | // If our cassette has an initialState, use that. 26 | const { initialState } = selectedCassetteSelector(state); 27 | 28 | return { 29 | ...reducer(initialState, {}), 30 | reduxVCR: reduxVCRReducer(reduxVCR, action), 31 | }; 32 | } 33 | 34 | default: { 35 | return { 36 | ...reducer(otherState, action), 37 | reduxVCR: reduxVCRReducer(reduxVCR, action), 38 | }; 39 | } 40 | } 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /packages/replay/src/components/VCRButton/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import Icon from '../Icon'; 5 | import './index.scss'; 6 | 7 | 8 | const VCRButton = ({ 9 | children, 10 | className, 11 | iconValue, 12 | iconSize, 13 | glowing, 14 | rounded, 15 | onClick, 16 | toggled, 17 | toggleable, 18 | }) => { 19 | const classes = classNames('vcr-button', className, { 20 | 'vcr-button-glowing': glowing, 21 | 'vcr-button-rounded': rounded, 22 | 'vcr-button-toggleable': toggleable, 23 | }); 24 | 25 | let toggleIndicator; 26 | if (toggleable) { 27 | const toggleIndicatorClasses = classNames('toggle-indicator', { toggled }); 28 | 29 | toggleIndicator =
; 30 | } 31 | 32 | return ( 33 | 37 | ); 38 | }; 39 | 40 | VCRButton.propTypes = { 41 | children: PropTypes.node, 42 | className: PropTypes.string, 43 | iconValue: PropTypes.string, 44 | iconSize: PropTypes.number, 45 | glowing: PropTypes.bool, 46 | rounded: PropTypes.bool, 47 | toggled: PropTypes.bool, 48 | toggleable: PropTypes.bool, 49 | onClick: PropTypes.func.isRequired, 50 | }; 51 | 52 | VCRButton.defaultProps = { 53 | iconSize: 12, 54 | handleClick() {}, 55 | }; 56 | 57 | export default VCRButton; 58 | -------------------------------------------------------------------------------- /packages/replay/stories/cassette/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable semi, no-unused-vars */ 2 | import React, { Component } from 'react'; 3 | import { storiesOf, action } from '@kadira/storybook'; 4 | 5 | import Centered from '../Centered'; 6 | import { Cassette } from '../../src/components/Cassette'; 7 | 8 | storiesOf('Cassette', module) 9 | .addDecorator(story => ( 10 |
11 | 12 | {story()} 13 | 14 |
15 | )) 16 | .add('Default (without name)', () => ( 17 | 23 | )) 24 | .add('With name', () => ( 25 | 32 | )) 33 | .add('Theme: polaroid', () => ( 34 | 41 | )) 42 | .add('Theme: Kodak', () => ( 43 | 50 | )) 51 | .add('Theme: tdk', () => ( 52 | 59 | )) 60 | -------------------------------------------------------------------------------- /packages/persist/src/create-persist-handler.js: -------------------------------------------------------------------------------- 1 | import debounce from 'lodash.debounce'; 2 | import invariant from 'invariant'; 3 | 4 | import { createFirebaseHandler, errors } from 'redux-vcr.shared'; 5 | 6 | 7 | export default function createPersistHandler({ 8 | firebaseAuth, 9 | debounceLength = 0, 10 | }) { 11 | const firebaseHandler = createFirebaseHandler({ 12 | firebaseAuth, 13 | source: 'persist', 14 | }); 15 | 16 | const debouncedPersist = debounce(cassette => { 17 | const { sessionId } = firebaseHandler; 18 | 19 | const { actions, ...cassetteData } = cassette; 20 | const database = firebaseHandler.firebase.database(); 21 | 22 | // Store our cassettes separately from our cassette actions. 23 | // There are performance reasons for this: by keeping our /cassettes 24 | // light, they can easily be sorted or filtered on the client. 25 | database.ref(`cassettes/${sessionId}`).set({ 26 | ...cassetteData, 27 | numOfActions: actions.length, 28 | }); 29 | 30 | database.ref(`actions/${sessionId}`).set(actions); 31 | }, debounceLength); 32 | 33 | return { 34 | firebaseHandler, 35 | persist(cassette) { 36 | invariant( 37 | typeof cassette === 'object', 38 | errors.persistedCassetteNotAnObject(cassette) 39 | ); 40 | 41 | invariant( 42 | typeof cassette.timestamp === 'number', 43 | errors.persistedCassetteInvalidTimestamp(cassette.timestamp) 44 | ); 45 | 46 | invariant( 47 | Array.isArray(cassette.actions), 48 | errors.persistedCassetteInvalidActions(cassette.actions) 49 | ); 50 | 51 | debouncedPersist(cassette); 52 | }, 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /packages/replay/src/components/VCRButton/index.scss: -------------------------------------------------------------------------------- 1 | @import '../variables'; 2 | 3 | 4 | .redux-vcr-component .vcr-button { 5 | position: relative; 6 | padding: 2px 10px; 7 | border: 0; 8 | border-top: 1px solid rgba(255,255,255,0.2); 9 | box-shadow: 0px 0px 1px rgba(0,0,0,0.8); 10 | border-radius: $vcr-button-radius; 11 | background: $darkgray; 12 | color: $white; 13 | font-size: 10px; 14 | cursor: pointer; 15 | outline: none; 16 | 17 | &.vcr-button-rounded { 18 | border-radius: 100%; 19 | padding: 5px; 20 | border-top: 1px solid rgba(255,255,255,0.4); 21 | border-bottom: 1px solid rgba(0,0,0,0.6); 22 | } 23 | 24 | &.vcr-button-glowing { 25 | animation: vcrButtonGlow 3s linear infinite; 26 | } 27 | 28 | &.vcr-button-toggleable { 29 | padding-bottom: 10px; 30 | } 31 | 32 | &:active { 33 | background: darken($darkgray, 2%); 34 | border-top: none; 35 | border-bottom: 1px solid rgba(255,255,255,0.05); 36 | transform: translateY(1px); 37 | 38 | .icon { 39 | transform: translateY(1px); 40 | } 41 | } 42 | 43 | &.eject-button { 44 | position: absolute; 45 | top: $vcr-standard-padding; 46 | left: $vcr-standard-padding * 2; 47 | } 48 | 49 | .toggle-indicator { 50 | position: absolute; 51 | height: 3px; 52 | left: 2px; 53 | right: 2px; 54 | bottom: 2px; 55 | background: rgba(0,0,0,0.3); 56 | border-radius: 1px; 57 | 58 | &.toggled { 59 | background: $neon-green; 60 | } 61 | } 62 | } 63 | 64 | @keyframes vcrButtonGlow { 65 | 0% { background: darken($darkgray, 3%); } 66 | 50% { background: lighten($darkgray, 5%); } 67 | 100% { background: darken($darkgray, 3%); } 68 | } 69 | -------------------------------------------------------------------------------- /packages/replay/src/components/Backdrop/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import FlipMove from 'react-flip-move'; 3 | 4 | import Icon from '../Icon'; 5 | import './index.scss'; 6 | 7 | const Backdrop = ({ 8 | isShown, 9 | handleClickClose, 10 | animation, 11 | opacity, 12 | background, 13 | closeColor, 14 | }) => { 15 | let backdropMarkup; 16 | 17 | if (isShown) { 18 | backdropMarkup = ( 19 | 20 | {/* Nesting inside a span because FlipMove applies opacity to it. */} 21 | {/* We avoid having it overwrite our custom opacity this way. */} 22 |
23 | 26 |
27 |
28 | ); 29 | } 30 | 31 | return ( 32 | 37 | {isShown ? backdropMarkup :
} 38 | 39 | ); 40 | }; 41 | 42 | Backdrop.propTypes = { 43 | isShown: PropTypes.bool, 44 | handleClickClose: PropTypes.func, 45 | animation: PropTypes.oneOf([ 46 | 'elevator', 47 | 'fade', 48 | 'accordionHorizontal', 49 | 'accordionVertical', 50 | ]), 51 | opacity: PropTypes.number, 52 | background: PropTypes.string, 53 | closeColor: PropTypes.string, 54 | }; 55 | 56 | Backdrop.defaultProps = { 57 | handleClickClose() {}, 58 | animation: 'fade', 59 | opacity: 0.75, 60 | background: '#000', 61 | closeColor: '#F11E0E', 62 | }; 63 | 64 | export default Backdrop; 65 | -------------------------------------------------------------------------------- /packages/replay/src/components/CassetteList/index.scss: -------------------------------------------------------------------------------- 1 | .redux-vcr-component .cassette-list { 2 | position: fixed; 3 | z-index: 3; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | width: 500px; 9 | height: 300px; 10 | margin: auto; 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | justify-content: center; 15 | 16 | .cassette-wrapper { 17 | @for $i from 1 through 20 { 18 | &:nth-of-type(#{$i}) { 19 | z-index: #{20 - $i}; 20 | } 21 | } 22 | 23 | &.fading-away { 24 | animation: fadeAway 1s forwards; 25 | } 26 | 27 | &.selected { 28 | animation: selected 1s forwards; 29 | } 30 | } 31 | 32 | .vcr-pagination-control { 33 | position: absolute; 34 | border: 0; 35 | background: none; 36 | right: 0; 37 | outline: none; 38 | 39 | &:disabled { 40 | opacity: 0.5; 41 | } 42 | 43 | &.previous { 44 | top: 0; 45 | 46 | &:active:not(:disabled) svg { 47 | transform: translateY(-10px); 48 | transition: transform 500ms; 49 | } 50 | } 51 | 52 | &.next { 53 | bottom: 0; 54 | 55 | &:active:not(:disabled) svg { 56 | transform: translateY(10px); 57 | } 58 | } 59 | 60 | &.fade-away { 61 | animation: fadeAway 500ms forwards; 62 | } 63 | 64 | svg { 65 | transition: transform 250ms; 66 | } 67 | } 68 | } 69 | 70 | @keyframes fadeAway { 71 | from { opacity: 1 } 72 | to { opacity: 0; } 73 | } 74 | 75 | @keyframes selected { 76 | 0% { transform: scale(1) translateY(0); } 77 | 75% { transform: scale(1.25) translateY(50px); opacity: 1; } 78 | 100% {transform: scale(1.25) translateY(250px); opacity: 0; } 79 | } 80 | -------------------------------------------------------------------------------- /packages/capture/src/helpers.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export const isActionBlacklisted = ({ action, blacklist }) => ( 3 | !!blacklist.find(blacklistedAction => { 4 | let blacklistedActionType; 5 | let matchingCriteria; 6 | 7 | // `blacklistedAction` can either be a string, or an object containing 8 | // a `type` and a `matchingCriteria`. 9 | // Use sensible defaults if a string is provided. 10 | if (typeof blacklistedAction === 'object') { 11 | blacklistedActionType = blacklistedAction.type; 12 | matchingCriteria = blacklistedAction.matchingCriteria; 13 | } else { 14 | blacklistedActionType = blacklistedAction; 15 | matchingCriteria = 'perfectMatch'; 16 | } 17 | 18 | let regexMatcher; 19 | switch (matchingCriteria) { 20 | case 'contains': 21 | regexMatcher = blacklistedActionType; 22 | break; 23 | case 'startsWith': 24 | regexMatcher = `^${blacklistedActionType}`; 25 | break; 26 | case 'endsWith': 27 | regexMatcher = `${blacklistedActionType}$`; 28 | break; 29 | case 'perfectMatch': 30 | regexMatcher = `^${blacklistedActionType}$`; 31 | break; 32 | default: 33 | // eslint-disable-next-line no-console 34 | console.warn(` 35 | WARNING from ReduxVCR/capture. 36 | You neglected to provide suitable matching criteria for your 37 | blacklisted actions. Acceptable values are "contains", "startsWith", 38 | "endsWith", and "perfectMatch". You provided ${matchingCriteria}. 39 | 40 | Defaulting to "perfectMatch". 41 | `); 42 | 43 | regexMatcher = `^${blacklistedActionType}$`; 44 | } 45 | 46 | return action.type.match(new RegExp(regexMatcher)); 47 | }) 48 | ); 49 | -------------------------------------------------------------------------------- /packages/retrieve/src/create-retrieve-handler.js: -------------------------------------------------------------------------------- 1 | import { createFirebaseHandler } from 'redux-vcr.shared'; 2 | 3 | 4 | export default function createRetrieveHandler({ firebaseAuth }) { 5 | const firebaseHandler = createFirebaseHandler({ 6 | firebaseAuth, 7 | source: 'retrieve', 8 | }); 9 | 10 | return { 11 | // Authenticate developers with a specified provider source 12 | // returns a promise, that the middleware can use to dispatch whichever 13 | // action (success or failure) is appropriate. 14 | signInWithPopup(authMethod) { 15 | const provider = firebaseHandler.createProvider(authMethod); 16 | 17 | return firebaseHandler 18 | .firebase 19 | .auth() 20 | .signInWithPopup(provider); 21 | }, 22 | 23 | // Sign in using a saved credential. 24 | // This allows "remember me" functionality. Once a developer has 25 | // authenticated with `signInWithPopup`, we store the access creds. 26 | // On load, we check for their existence, and sign in using this method: 27 | signInWithCredential(rawCredential) { 28 | const credential = firebaseHandler.buildCredential(rawCredential); 29 | 30 | return firebaseHandler 31 | .firebase 32 | .auth() 33 | .signInWithCredential(credential); 34 | }, 35 | 36 | signOut() { 37 | return firebaseHandler 38 | .firebase 39 | .auth() 40 | .signOut(); 41 | }, 42 | 43 | retrieveList() { 44 | return firebaseHandler 45 | .firebase 46 | .database() 47 | .ref('cassettes') 48 | .once('value'); 49 | }, 50 | 51 | retrieveActions({ id }) { 52 | return firebaseHandler 53 | .firebase 54 | .database() 55 | .ref(`actions/${id}`) 56 | .once('value'); 57 | }, 58 | 59 | firebaseHandler, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /packages/_shared/src/utils/polyfills.js: -------------------------------------------------------------------------------- 1 | // window 2 | if (typeof window === 'undefined') { 3 | global.window = {}; 4 | } 5 | 6 | // localStorage 7 | if (typeof window.localStorage === 'undefined') { 8 | window.localStorage = global.localStorage = { 9 | getItem() {}, 10 | setItem() {}, 11 | }; 12 | } 13 | 14 | // performance.now 15 | (function polyfillPerformanceNow() { 16 | if (!window.performance) { window.performance = {}; } 17 | 18 | if (!window.performance.now) { 19 | if (!Date.now) { Date.now = () => new Date().getTime(); } 20 | 21 | let nowOffset; 22 | if ( 23 | window.performance.timing && window.performance.timing.navigationStart 24 | ) { 25 | nowOffset = window.performance.timing.navigationStart; 26 | } else { 27 | nowOffset = Date.now(); 28 | } 29 | 30 | window.performance.now = function now() { 31 | return Date.now() - nowOffset; 32 | }; 33 | } 34 | }()); 35 | 36 | 37 | // Array#find 38 | if (!Array.prototype.find) { 39 | // eslint-disable-next-line no-extend-native 40 | Array.prototype.find = function find(predicate, ...args) { 41 | if (this == null) { 42 | throw new TypeError('Array.prototype.find called on null or undefined'); 43 | } 44 | if (typeof predicate !== 'function') { 45 | throw new TypeError('predicate must be a function'); 46 | } 47 | const list = Object(this); 48 | const length = list.length >>> 0; 49 | const [thisArg] = args; 50 | let value; 51 | 52 | for (let i = 0; i < length; i++) { 53 | value = list[i]; 54 | if (predicate.call(thisArg, value, i, list)) { 55 | return value; 56 | } 57 | } 58 | return undefined; 59 | }; 60 | } 61 | 62 | 63 | // Array#isArray 64 | if (!Array.isArray) { 65 | Array.isArray = arg => ( 66 | Object.prototype.toString.call(arg) === '[object Array]' 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /packages/replay/src/data/icon-map.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | 3 | const iconMap = { 4 | close: ` 5 | 6 | 7 | `, 8 | 9 | arrow_up: ` 10 | 11 | 12 | `, 13 | 14 | arrow_down: ` 15 | 16 | 17 | `, 18 | 19 | eject: ` 20 | 21 | 22 | `, 23 | 24 | play: ` 25 | 26 | 27 | `, 28 | 29 | pause: ` 30 | 31 | 32 | `, 33 | 34 | stop: ` 35 | 36 | 37 | `, 38 | 39 | github: { 40 | path: ` 41 | 42 | `, 43 | viewBox: '0 0 512 512', 44 | }, 45 | }; 46 | 47 | 48 | export default iconMap; 49 | -------------------------------------------------------------------------------- /packages/_demo/src/components/App/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Replay } from 'redux-vcr.replay'; 4 | 5 | import { selectAnswer, completeOnboarding } from '../../actions'; 6 | import { getAnswers } from '../../reducers/answers.reducer'; 7 | import Onboarding from '../Onboarding'; 8 | import PollQuestion from '../PollQuestion'; 9 | import DevTools from '../DevTools'; 10 | 11 | import './index.css'; 12 | 13 | 14 | class App extends Component { 15 | constructor(props) { 16 | super(props); 17 | this.handleClick = this.handleClick.bind(this); 18 | } 19 | 20 | handleClick(ev) { 21 | const buttonId = ev.target.value; 22 | this.props.selectAnswer({ id: buttonId }); 23 | } 24 | 25 | render() { 26 | return ( 27 |
28 | {!this.props.hasCompletedOnboarding ? ( 29 | 32 | ) : ( 33 | 38 | )} 39 | 40 | 41 |
42 | ); 43 | } 44 | } 45 | 46 | App.propTypes = { 47 | answers: PropTypes.arrayOf(PropTypes.shape({ 48 | id: PropTypes.string, 49 | name: PropTypes.string, 50 | })), 51 | selected: PropTypes.string, 52 | hasCompletedOnboarding: PropTypes.bool, 53 | selectAnswer: PropTypes.func.isRequired, 54 | completeOnboarding: PropTypes.func.isRequired, 55 | }; 56 | 57 | const mapStateToProps = state => ({ 58 | answers: getAnswers(state.answers), 59 | selected: state.answers.selected, 60 | hasCompletedOnboarding: state.onboarding.completed, 61 | }); 62 | 63 | 64 | export default connect( 65 | mapStateToProps, 66 | { selectAnswer, completeOnboarding } 67 | )(App); 68 | -------------------------------------------------------------------------------- /packages/replay/stories/cassette-list/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable semi, no-unused-vars */ 2 | import React, { Component } from 'react'; 3 | import { storiesOf, action } from '@kadira/storybook'; 4 | import { Provider } from 'react-redux'; 5 | import { createStore, combineReducers } from 'redux'; 6 | import _ from 'lodash'; 7 | import { reduxVCRReducer, actionCreators } from 'redux-vcr.shared'; 8 | 9 | import Centered from '../Centered'; 10 | import CassetteList from '../../src/components/CassetteList'; 11 | import sampleWithProbability from '../../src/utils/sample-with-probability'; 12 | import themes from '../../src/data/cassette-themes'; 13 | import offsets from '../../src/data/cassette-offsets'; 14 | 15 | 16 | const cassettesById = _.range(23) 17 | .map(i => ({ 18 | id: Math.random() * 1000 + '', 19 | timestamp: Date.now(), 20 | numOfActions: Math.floor(Math.random() * 100), 21 | theme: sampleWithProbability(themes, i), 22 | offset: sampleWithProbability(offsets), 23 | })) 24 | .reduce((memo, cassette) => { 25 | // eslint-disable-next-line no-param-reassign 26 | memo[cassette.id] = cassette; 27 | return memo; 28 | }, {}); 29 | 30 | const reducer = combineReducers({ reduxVCR: reduxVCRReducer }); 31 | const store = createStore(reducer); 32 | 33 | store.dispatch(actionCreators.cassettesListSuccess({ 34 | cassettes: cassettesById, 35 | })); 36 | 37 | storiesOf('CassetteList', module) 38 | .addDecorator(story => ( 39 | 40 |
41 | 42 | {story()} 43 | 44 |
45 |
46 | )) 47 | .add('Default', () => ( 48 | 54 | )) 55 | -------------------------------------------------------------------------------- /packages/replay/src/create-replay-handler.js: -------------------------------------------------------------------------------- 1 | import { actionCreators } from 'redux-vcr.shared'; 2 | 3 | const { incrementActionsPlayed, stopCassette } = actionCreators; 4 | 5 | 6 | export default function createReplayHandler() { 7 | const replayHandler = { 8 | play({ store, next }) { 9 | const state = store.getState().reduxVCR; 10 | 11 | // If no cassette is selected, we cannot possibly play it. 12 | const selectedId = state.cassettes.selected; 13 | if (selectedId === null || typeof selectedId === 'undefined') { 14 | return; 15 | } 16 | 17 | const { status, maximumDelay } = state.play; 18 | 19 | // if the cassette has stopped playing, we can bail. 20 | if (status !== 'playing') { 21 | return; 22 | } 23 | 24 | const { currentIndex } = state.actions; 25 | const actions = state.actions.byId[selectedId]; 26 | const [currentAction, nextAction] = actions.slice(currentIndex); 27 | 28 | next(currentAction); 29 | next(incrementActionsPlayed()); 30 | 31 | // If we're at the end of the cassette, set status to stopped. 32 | if (currentIndex === (actions.length - 1)) { 33 | next(stopCassette()); 34 | return; 35 | } 36 | 37 | // handle any playbackSpeed config. 38 | // The value is expressed as a speed multiplier - eg. 2x, 0.5x. 39 | // Because we're dealing with delay, not speed, we want to invert it. 40 | // eg. a 2x speed increase = a 0.5x delay decrease 41 | const playbackMultiplier = 1 / state.play.speed; 42 | let delay = nextAction.delay * playbackMultiplier; 43 | 44 | // If our delay is larger than our maximum specified delay, 45 | // clamp it to that max. 46 | if (delay > maximumDelay) { 47 | delay = maximumDelay; 48 | } 49 | 50 | setTimeout( 51 | () => replayHandler.play({ store, next }), 52 | delay 53 | ); 54 | }, 55 | }; 56 | 57 | return replayHandler; 58 | } 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "author": "Joshua Comeau", 4 | "homepage": "https://github.com/joshwcomeau/redux-vcr", 5 | "license": "MIT", 6 | "scripts": { 7 | "demo": "npm run start --prefix ./packages/_demo", 8 | 9 | "test:capture": "npm run test --prefix ./packages/capture", 10 | "test:persist": "npm run test --prefix ./packages/persist", 11 | "test:retrieve": "npm run test --prefix ./packages/retrieve", 12 | "test:replay": "npm run test --prefix ./packages/replay", 13 | "test:shared": "npm run test --prefix ./packages/_shared", 14 | "test": "npm run test:capture && npm run test:persist && npm run test:retrieve && npm run test:replay && npm run test:shared", 15 | 16 | "install:capture": "npm install --prefix ./packages/capture", 17 | "install:persist": "npm install --prefix ./packages/persist", 18 | "install:retrieve": "npm install --prefix ./packages/retrieve", 19 | "install:replay": "npm install --prefix ./packages/replay", 20 | "install:shared": "npm install --prefix ./packages/_shared", 21 | "install": "npm run install:capture && npm run install:persist && npm run install:retrieve && npm run install:replay && npm run install:shared", 22 | 23 | "publish": "lerna publish --force-publish=*", 24 | 25 | "bootstrap": "node scripts/bootstrap.js" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/joshwcomeau/redux-vcr.git" 30 | }, 31 | "devDependencies": { 32 | "chai": "^3.5.0", 33 | "eslint": "3.3.0", 34 | "eslint-config-airbnb": "10.0.0", 35 | "eslint-loader": "1.5.0", 36 | "eslint-plugin-import": "1.13.0", 37 | "eslint-plugin-jsx-a11y": "2.0.1", 38 | "eslint-plugin-react": "6.0.0", 39 | "invariant": "^2.2.1", 40 | "lerna": "2.0.0-beta.26", 41 | "npm-watch": "^0.1.6", 42 | "nwb": "0.11.x", 43 | "react": "^15.3.1", 44 | "react-dom": "^15.3.1", 45 | "redux": "^3.5.2", 46 | "semver": "^5.3.0", 47 | "shelljs": "^0.7.3", 48 | "sinon": "^2.0.0-pre.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/replay/src/components/VCRScreen/index.scss: -------------------------------------------------------------------------------- 1 | @import '../variables'; 2 | 3 | .redux-vcr-component { 4 | .vcr-screen { 5 | position: absolute; 6 | bottom: $vcr-standard-padding; 7 | left: 80px; 8 | height: 30px; 9 | width: 295px; 10 | background: rgba(0,0,0,0.5); 11 | color: $neon-green; 12 | font-family: "Lucida Console", Monaco5, monospace; 13 | font-size: 10px; 14 | overflow: hidden; 15 | text-overflow: ellipsis; 16 | white-space: nowrap; 17 | padding: 0 12px; 18 | border-radius: 2px; 19 | border-bottom: 1px solid rgba(255,255,255,0.3); 20 | cursor: pointer; 21 | 22 | .red { 23 | color: #ef4b3f; 24 | } 25 | 26 | .green { 27 | color: $neon-green; 28 | } 29 | 30 | .vcr-screen-label { 31 | position: absolute; 32 | top: 0; 33 | left: 10px; 34 | 35 | font-size: 8px; 36 | line-height: 16px; 37 | color: $white; 38 | } 39 | 40 | .vcr-screen-buffer { 41 | &.vertical { 42 | margin: 5px auto; 43 | width: 66%; 44 | overflow: hidden; 45 | } 46 | } 47 | 48 | .vcr-screen-contents { 49 | line-height: 30px; 50 | text-align: left; 51 | 52 | &.edged-down { 53 | margin-top: 4px; 54 | } 55 | 56 | &.flashing { 57 | animation: flashing 1s both; 58 | } 59 | 60 | &.scrolling { 61 | line-height: 20px; 62 | animation: scrolling 10s linear infinite; 63 | } 64 | 65 | &.centered { 66 | text-align: center; 67 | } 68 | } 69 | } 70 | } 71 | 72 | @keyframes flashing { 73 | 0% { opacity: 0; } 74 | 9% { opacity: 0; } 75 | 10% { opacity: 1; } 76 | 29% { opacity: 1; } 77 | 30% { opacity: 0; } 78 | 39% { opacity: 0; } 79 | 40% { opacity: 1; } 80 | 59% { opacity: 1; } 81 | 60% { opacity: 0; } 82 | 69% { opacity: 0; } 83 | 70% { opacity: 1; } 84 | 100% { opacity: 1; } 85 | } 86 | 87 | @keyframes scrolling { 88 | 0% { transform: translateX(110%); } 89 | 100% { transform: translateX(-110%); } 90 | } 91 | -------------------------------------------------------------------------------- /packages/_shared/src/index.js: -------------------------------------------------------------------------------- 1 | import './utils/polyfills.js'; 2 | 3 | // This module is a little confusing because it contains some redux structure 4 | // (action types, and action creators), as well as a reducer that holds 5 | // recorded redux actions. 6 | 7 | // These are all the actions that a user can do related to any of 8 | // our reducers: 9 | import * as actions from './actions'; 10 | 11 | // This, on the other hand, is the reducer and selectors for dealing with 12 | // our special 'actions' reducer. 13 | import actionsReducer, * as actionSelectors from './reducers/actions.reducer'; 14 | 15 | import cassettesReducer, * as cassetteSelectors from './reducers/cassettes.reducer'; 16 | import playReducer, * as playSelectors from './reducers/play.reducer'; 17 | import userReducer, * as userSelectors from './reducers/user.reducer'; 18 | 19 | // In addition to all the individual reducers, we want to export their combined 20 | // root reducer. 21 | import reduxVCRReducer from './reducers'; 22 | 23 | import createFirebaseHandler from './utils/create-firebase-handler'; 24 | 25 | import getQueryParam from './utils/get-query-param'; 26 | import * as errors from './utils/errors'; 27 | 28 | 29 | // We want to split our action types from our action creators. 30 | const actionTypes = {}; 31 | const actionCreators = {}; 32 | 33 | // While for...in loops without checking the prototype is frowned upon, 34 | // it's safe to use here because I'm explicitly creating the object above. 35 | // eslint-disable-next-line guard-for-in, no-restricted-syntax 36 | for (const key in actions) { 37 | const actionItem = actions[key]; 38 | 39 | if (typeof actionItem === 'function') { 40 | actionCreators[key] = actionItem; 41 | } else { 42 | actionTypes[key] = actionItem; 43 | } 44 | } 45 | 46 | export { 47 | actionTypes, 48 | actionCreators, 49 | actionsReducer, 50 | actionSelectors, 51 | cassettesReducer, 52 | cassetteSelectors, 53 | playReducer, 54 | playSelectors, 55 | userReducer, 56 | userSelectors, 57 | reduxVCRReducer, 58 | createFirebaseHandler, 59 | getQueryParam, 60 | errors, 61 | }; 62 | -------------------------------------------------------------------------------- /packages/retrieve/tests/create-retrieve-handler-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { expect } from 'chai'; 3 | 4 | import { createRetrieveHandler } from '../src'; 5 | 6 | const firebaseAuth = { 7 | apiKey: 'abc123', 8 | authDomain: 'test.firebaseapp.com', 9 | databaseURL: 'https://test.firebaseio.com', 10 | }; 11 | 12 | describe('RetrieveHandler', () => { 13 | describe('retrieveList', () => { 14 | let handler; 15 | let firebase; 16 | beforeEach(() => { 17 | handler = createRetrieveHandler({ firebaseAuth }); 18 | firebase = handler.firebaseHandler.firebase; 19 | 20 | handler.retrieveList(); 21 | }); 22 | 23 | it('gets a database reference', () => { 24 | expect(firebase.database.callCount).to.equal(1); 25 | }); 26 | 27 | it('gets the ref for the cassettes', () => { 28 | expect(firebase.ref.callCount).to.equal(1); 29 | 30 | const cassettesRef = firebase.ref.args[0][0]; 31 | expect(cassettesRef).to.equal('cassettes'); 32 | }); 33 | 34 | it('calls "once" to receive a snapshot', () => { 35 | expect(firebase.once.callCount).to.equal(1); 36 | expect(firebase.once.firstCall.args[0]).to.equal('value'); 37 | }); 38 | }); 39 | 40 | describe('retrieveActions', () => { 41 | let handler; 42 | let firebase; 43 | beforeEach(() => { 44 | handler = createRetrieveHandler({ firebaseAuth }); 45 | firebase = handler.firebaseHandler.firebase; 46 | 47 | handler.retrieveActions({ id: '123' }); 48 | }); 49 | 50 | it('gets a database reference', () => { 51 | expect(firebase.database.callCount).to.equal(1); 52 | }); 53 | 54 | it('gets the ref for the actions', () => { 55 | expect(firebase.ref.callCount).to.equal(1); 56 | 57 | const actionsRef = firebase.ref.args[0][0]; 58 | expect(actionsRef).to.equal('actions/123'); 59 | }); 60 | 61 | it('calls "once" to receive a snapshot', () => { 62 | expect(firebase.once.callCount).to.equal(1); 63 | expect(firebase.once.firstCall.args[0]).to.equal('value'); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/replay/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-vcr.replay", 3 | "version": "0.3.3", 4 | "description": "ReduxVCR replay module", 5 | "main": "lib/index.js", 6 | "jsnext:main": "es6/index.js", 7 | "files": [ 8 | "css", 9 | "es6", 10 | "lib", 11 | "umd" 12 | ], 13 | "scripts": { 14 | "build": "nwb build && npm run scss:transpile", 15 | "clean": "nwb clean", 16 | "start": "USE_LOCAL=true nwb serve", 17 | "test": "nwb test", 18 | "test:watch": "nwb test --server", 19 | "scss:transpile": "node-sass src -o lib && replace .scss .css ./lib -r", 20 | "lint": "eslint -c ../.eslintrc src/** --ext .js", 21 | "storybook": "start-storybook -p 9100", 22 | "add-component": "node scripts/add_component/index.js", 23 | "prepublish": "npm run build" 24 | }, 25 | "dependencies": { 26 | "classnames": "^2.2.5", 27 | "lodash.merge": "^4.6.0", 28 | "react-draggable": "^2.2.1", 29 | "react-flip-move": "^2.5.0", 30 | "redux-vcr.shared": "^0.3.3", 31 | "seed-random": "^2.2.0" 32 | }, 33 | "peerDependencies": { 34 | "redux": "3.x", 35 | "react": "0.13.x || 0.14.x || 15.x", 36 | "react-redux": "4.x" 37 | }, 38 | "devDependencies": { 39 | "@kadira/storybook": "^2.3.0", 40 | "babel-register": "^6.11.6", 41 | "chai": "^3.5.0", 42 | "cpx": "^1.3.2", 43 | "eslint": "^3.2.2", 44 | "eslint-config-airbnb": "^10.0.0", 45 | "eslint-plugin-import": "^1.12.0", 46 | "eslint-plugin-jsx-a11y": "^2.1.0", 47 | "eslint-plugin-react": "^6.0.0", 48 | "lodash": "^4.15.0", 49 | "node-sass": "^3.8.0", 50 | "nwb": "0.11.x", 51 | "nwb-sass": "^0.5.0", 52 | "react": "15.3.0", 53 | "react-dom": "15.3.0", 54 | "react-redux": "^4.4.5", 55 | "redux": "^3.5.2", 56 | "replace": "^0.3.0", 57 | "sass-loader": "^4.0.0", 58 | "sinon": "^2.0.0-pre.2" 59 | }, 60 | "author": "Joshua Comeau", 61 | "homepage": "https://github.com/joshwcomeau/redux-vcr/tree/master/persist", 62 | "license": "MIT", 63 | "repository": { 64 | "type": "git", 65 | "url": "https://github.com/joshwcomeau/redux-vcr.git" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/replay/src/components/VCRDoor/index.scss: -------------------------------------------------------------------------------- 1 | @import '../variables'; 2 | 3 | .redux-vcr-component .vcr-door { 4 | .cassette-slot-door, .cassette-slot { 5 | position: absolute; 6 | top: $vcr-standard-padding - 2px; 7 | left: 75px; 8 | height: 48px; 9 | line-height: 44px; 10 | width: 305px; 11 | } 12 | 13 | .cassette-slot-door { 14 | pointer-events: none; 15 | z-index: 2; 16 | border-top: 2px solid rgba(0,0,0,0.2); 17 | border-left: 1px solid rgba(0,0,0,0.2); 18 | border-right: 1px solid rgba(0,0,0,0.2); 19 | border-bottom: 2px solid rgba(0,0,0,0.2); 20 | border-radius: 2px; 21 | background: $darkgray; 22 | text-transform: uppercase; 23 | font-size: 12px; 24 | letter-spacing: -0.5px; 25 | font-weight: bold; 26 | text-align: center; 27 | transition: transform 500ms; 28 | transform-origin: top center; 29 | transform: perspective(300px); 30 | 31 | &:before, &:after { 32 | content: ''; 33 | position: absolute; 34 | top: 5px; 35 | bottom: 5px; 36 | width: 1px; 37 | margin: auto; 38 | background: $mediumgray; 39 | opacity: 0.5; 40 | } 41 | 42 | &:before { 43 | left: 10px; 44 | } 45 | 46 | &:after { 47 | right: 10px; 48 | } 49 | 50 | &.is-open { 51 | transform: perspective(300px) rotateX(-90deg); 52 | transform-origin: top center; 53 | 54 | .cassette-slot-door-label { 55 | opacity: 0; 56 | } 57 | } 58 | 59 | .cassette-slot-door-label { 60 | opacity: 1; 61 | transition: opacity 500ms; 62 | } 63 | } 64 | 65 | .cassette-slot { 66 | z-index: 1; 67 | cursor: default; 68 | background: darken($darkgray, 13%); 69 | border-radius: 2px; 70 | border-bottom: 15px solid darken($darkgray, 10%); 71 | border-left: 15px solid darken($darkgray, 5%); 72 | border-right: 15px solid darken($darkgray, 5%); 73 | border-top: 1px solid rgba(255,255,255,0.2); 74 | 75 | 76 | &:after { 77 | content: ''; 78 | position: absolute; 79 | top: 0; 80 | left: 0; 81 | right: 0; 82 | bottom: 0; 83 | box-shadow: 0px 0px 20px rgba(0,0,0,0.5); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /packages/_demo/config/paths.js: -------------------------------------------------------------------------------- 1 | // TODO: we can split this file into several files (pre-eject, post-eject, test) 2 | // and use those instead. This way we don't need to branch here. 3 | 4 | const path = require('path'); 5 | 6 | // True after ejecting, false when used as a dependency 7 | const isEjected = ( 8 | path.resolve(path.join(__dirname, '..')) === 9 | path.resolve(process.cwd()) 10 | ); 11 | 12 | // Are we developing create-react-app locally? 13 | const isInCreateReactAppSource = ( 14 | process.argv.some(arg => arg.indexOf('--debug-template') > -1) 15 | ); 16 | 17 | function resolveOwn(relativePath) { 18 | return path.resolve(__dirname, relativePath); 19 | } 20 | 21 | function resolveApp(relativePath) { 22 | return path.resolve(relativePath); 23 | } 24 | 25 | if (isInCreateReactAppSource) { 26 | // create-react-app development: we're in ./config/ 27 | module.exports = { 28 | appBuild: resolveOwn('../build'), 29 | appHtml: resolveOwn('../template/index.html'), 30 | appFavicon: resolveOwn('../template/favicon.ico'), 31 | appPackageJson: resolveOwn('../package.json'), 32 | appSrc: resolveOwn('../template/src'), 33 | appNodeModules: resolveOwn('../node_modules'), 34 | ownNodeModules: resolveOwn('../node_modules'), 35 | }; 36 | } else if (!isEjected) { 37 | // before eject: we're in ./node_modules/react-scripts/config/ 38 | module.exports = { 39 | appBuild: resolveApp('build'), 40 | appHtml: resolveApp('index.html'), 41 | appFavicon: resolveApp('favicon.ico'), 42 | appPackageJson: resolveApp('package.json'), 43 | appSrc: resolveApp('src'), 44 | appNodeModules: resolveApp('node_modules'), 45 | // this is empty with npm3 but node resolution searches higher anyway: 46 | ownNodeModules: resolveOwn('../node_modules'), 47 | }; 48 | } else { 49 | // after eject: we're in ./config/ 50 | module.exports = { 51 | appBuild: resolveApp('build'), 52 | appHtml: resolveApp('index.html'), 53 | appFavicon: resolveApp('favicon.ico'), 54 | appPackageJson: resolveApp('package.json'), 55 | appSrc: resolveApp('src'), 56 | appNodeModules: resolveApp('node_modules'), 57 | ownNodeModules: resolveApp('node_modules'), 58 | parentNodeModules: resolveApp('../../node_modules'), 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /packages/_shared/src/stubs/firebase-stub-factory.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | 3 | let stateChangeCallback; 4 | 5 | const firebaseStubFactory = () => { 6 | const firebaseStub = { 7 | initializeApp() { 8 | return this; 9 | }, 10 | 11 | auth() { 12 | return this; 13 | }, 14 | 15 | database() { 16 | return this; 17 | }, 18 | 19 | signInAnonymously() { 20 | // asynchronously invoke the authStateChanged callback 21 | const session = { uid: 'abc123' }; 22 | setTimeout(() => { 23 | stateChangeCallback(session); 24 | }, 10); 25 | }, 26 | 27 | signInWithPopup(provider) { 28 | return new Promise((resolve) => { 29 | resolve({ 30 | user: {}, 31 | credential: {}, 32 | }); 33 | }); 34 | }, 35 | 36 | signInWithCredential(credential) { 37 | return new Promise((resolve, reject) => { 38 | if (credential.accessToken !== 'good-credential') { 39 | // TODO: Figure out what Firebase error happens. 40 | reject(new Error('oh no!')); 41 | } 42 | 43 | return resolve({ 44 | email: 'josh@person.com', 45 | }); 46 | }); 47 | }, 48 | 49 | onAuthStateChanged(callback) { 50 | stateChangeCallback = callback; 51 | }, 52 | 53 | ref() { 54 | return this; 55 | }, 56 | 57 | set() {}, 58 | 59 | once() { 60 | return new Promise((resolve) => { 61 | resolve({ 62 | val() { 63 | return [{ id: 'cassette1' }]; 64 | }, 65 | }); 66 | }); 67 | }, 68 | }; 69 | 70 | firebaseStub.auth.GithubAuthProvider = () => { 71 | return {}; 72 | }; 73 | 74 | firebaseStub.auth.GithubAuthProvider.credential = accessToken => { 75 | return { accessToken }; 76 | }; 77 | 78 | sinon.spy(firebaseStub, 'initializeApp'); 79 | sinon.spy(firebaseStub, 'auth'); 80 | sinon.spy(firebaseStub, 'database'); 81 | sinon.spy(firebaseStub, 'signInAnonymously'); 82 | sinon.spy(firebaseStub, 'onAuthStateChanged'); 83 | sinon.spy(firebaseStub, 'ref'); 84 | sinon.spy(firebaseStub, 'set'); 85 | sinon.spy(firebaseStub, 'once'); 86 | sinon.spy(firebaseStub.auth, 'GithubAuthProvider'); 87 | 88 | return firebaseStub; 89 | }; 90 | 91 | 92 | export default firebaseStubFactory; 93 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | There are some pretty big plans for Redux VCR! 4 | 5 | 6 | ### Better usage of Firebase, or a new service. 7 | 8 | The way we're using Firebase right now is wasteful. We're importing this big library (~200kb before minification/gzip) that specializes in realtime 2-way communication, and we aren't doing anything realtime. 9 | 10 | There are also some quality-of-life issues; the fact that it temporarily blocks you in dev if you refresh too often. 11 | 12 | The plan should either be: 13 | 14 | - Improve Firebase, by: 15 | - Not anonymously authenticating until the user has taken some actions (based on `startTrigger`, maybe?) 16 | - Making straightforward API requests instead of using Firebase's realtime client (cut out the big dependency) 17 | 18 | OR, we should look for a different, more lightweight service. 19 | 20 | OR, we should build alternate persist/retrieve layers, that include a server-side component and hook into the developer's own auth/db. 21 | 22 | 23 | ### UMD build 24 | 25 | This is surprisingly nontrivial, because of how the shared dependency works. 26 | 27 | When you bundle up, say, ReduxVCR.capture, it's going to include ReduxVCR.shared, which is a 250kb module. ReduxVCR.persist is _also_ going to bundle up the shared dependency. 28 | 29 | The quick solution is to provide the entirety of ReduxVCR as a single dependency, since this is good enough for codepen demos, but at some point we should allow individual modules. 30 | 31 | 32 | ### Better documentation 33 | 34 | Ideally, developers should be encouraged to write their own modules or addons. We can make it easier by providing better documentation. 35 | 36 | We should also provide more examples, a codepen demo, etc. 37 | 38 | 39 | ### Better Replay controls 40 | 41 | The VCR is cute, but it's not very powerful. We should add a slider that lets the user "scrub" to a specific point in the timeline, replay from a specific point, etc. 42 | 43 | 44 | ### URL integration 45 | 46 | It would be swell if actions updated the query string in the URL, so that a specific point in the cassette can be linked to. 47 | 48 | 49 | ### Admin web app 50 | 51 | This is ambitious, but I would like a companion app. 52 | 53 | The VCR should not be the main way that users can administrate their cassette collection. A separate interface for CRUD operations could be really helpful. 54 | 55 | Bonus points if we can find a way, if both the user's app and the admin app are open in separate tabs in the same Chrome window, to load a cassette in the user's app _from_ the admin app. Maybe via a chrome extension? 56 | -------------------------------------------------------------------------------- /packages/_demo/scripts/utils/detectPort.js: -------------------------------------------------------------------------------- 1 | /* ================================================================ 2 | * detect-port by xdf(xudafeng[at]126.com) 3 | * 4 | * first created at : Tue Mar 17 2015 00:16:10 GMT+0800 (CST) 5 | * 6 | * ================================================================ 7 | * Copyright xdf 8 | * 9 | * Licensed under the MIT License 10 | * You may not use this file except in compliance with the License. 11 | * 12 | * ================================================================ */ 13 | 14 | // We are forking this temporarily to resolve 15 | // https://github.com/facebookincubator/create-react-app/issues/302. 16 | 17 | // We can replace this fork with `detect-port` package when this is merged: 18 | // https://github.com/xudafeng/detect-port/pull/4. 19 | 20 | 'use strict'; 21 | 22 | var net = require('net'); 23 | 24 | var inject = function(port) { 25 | var options = global.__detect ? global.__detect.options : {}; 26 | 27 | if (options.verbose) { 28 | console.info('port %d was occupied', port); 29 | } 30 | }; 31 | 32 | function detect(port, fn) { 33 | 34 | var _detect = function(port) { 35 | return new Promise(function(resolve, reject) { 36 | var socket = new net.Socket(); 37 | socket.once('error', function() { 38 | socket.removeAllListeners('connect'); 39 | socket.removeAllListeners('error'); 40 | socket.end(); 41 | socket.destroy(); 42 | socket.unref(); 43 | var server = new net.Server(); 44 | server.on('error', function() { 45 | inject(port); 46 | port++; 47 | resolve(_detect(port)); 48 | }); 49 | 50 | server.listen(port, function() { 51 | server.once('close', function() { 52 | resolve(port); 53 | }); 54 | server.close(); 55 | }); 56 | }); 57 | socket.once('connect', function() { 58 | inject(port); 59 | port++; 60 | resolve(_detect(port)); 61 | socket.removeAllListeners('connect'); 62 | socket.removeAllListeners('error'); 63 | socket.end(); 64 | socket.destroy(); 65 | socket.unref(); 66 | }); 67 | socket.connect({ 68 | port: port 69 | }); 70 | }); 71 | } 72 | 73 | var _detect_with_cb = function(_fn) { 74 | _detect(port) 75 | .then(function(result) { 76 | _fn(null, result); 77 | }) 78 | .catch(function(e) { 79 | _fn(e); 80 | }); 81 | }; 82 | 83 | return fn ? _detect_with_cb(fn) : _detect(port); 84 | } 85 | 86 | module.exports = detect; 87 | -------------------------------------------------------------------------------- /packages/_shared/tests/reducers/play-reducer-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import reducer from '../../src/reducers/play.reducer'; 4 | import { 5 | CHANGE_PLAYBACK_SPEED, 6 | EJECT_CASSETTE, 7 | PAUSE_CASSETTE, 8 | PLAY_CASSETTE, 9 | STOP_CASSETTE, 10 | changePlaybackSpeed, 11 | ejectCassette, 12 | pauseCassette, 13 | playCassette, 14 | stopCassette, 15 | } from '../../src/actions'; 16 | 17 | 18 | describe('play reducer', () => { 19 | describe(CHANGE_PLAYBACK_SPEED, () => { 20 | it('updates speed', () => { 21 | const state = reducer({}, {}); 22 | const action = changePlaybackSpeed({ playbackSpeed: 2 }); 23 | 24 | const expectedState = { 25 | ...state, 26 | speed: 2, 27 | }; 28 | const actualState = reducer(state, action); 29 | 30 | expect(actualState).to.deep.equal(expectedState); 31 | }); 32 | }); 33 | 34 | describe(EJECT_CASSETTE, () => { 35 | it('updates status to stopped', () => { 36 | const state = reducer({ status: 'playing' }, {}); 37 | const action = ejectCassette(); 38 | 39 | const expectedState = { 40 | ...state, 41 | status: 'stopped', 42 | }; 43 | const actualState = reducer(state, action); 44 | 45 | expect(actualState).to.deep.equal(expectedState); 46 | }); 47 | }); 48 | 49 | describe(PLAY_CASSETTE, () => { 50 | it('updates status to playing', () => { 51 | const state = reducer({}, {}); 52 | const action = playCassette(); 53 | 54 | const expectedState = { 55 | ...state, 56 | status: 'playing', 57 | }; 58 | const actualState = reducer(state, action); 59 | 60 | expect(actualState).to.deep.equal(expectedState); 61 | }); 62 | }); 63 | 64 | describe(PAUSE_CASSETTE, () => { 65 | it('updates status to paused', () => { 66 | const state = reducer({}, {}); 67 | const action = pauseCassette(); 68 | 69 | const expectedState = { 70 | ...state, 71 | status: 'paused', 72 | }; 73 | const actualState = reducer(state, action); 74 | 75 | expect(actualState).to.deep.equal(expectedState); 76 | }); 77 | }); 78 | 79 | describe(STOP_CASSETTE, () => { 80 | it('updates status to stopped', () => { 81 | const state = reducer({ status: 'playing' }, {}); 82 | const action = stopCassette(); 83 | 84 | const expectedState = { 85 | ...state, 86 | status: 'stopped', 87 | }; 88 | const actualState = reducer(state, action); 89 | 90 | expect(actualState).to.deep.equal(expectedState); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /documentation/setting-up-development-environment.md: -------------------------------------------------------------------------------- 1 | # Setting up the Development Environment 2 | 3 | ### About ReduxVCR's setup 4 | Redux VCR is a [monolithic repo](https://github.com/babel/babel/blob/master/doc/design/monorepo.md) that holds 7 different NPM packages: 5 | * Capture 6 | * Persist 7 | * Retrieve 8 | * Replay 9 | * Shared (a shared dependency which holds Firebase logic as well as redux actions/reducers) 10 | * Root (this is just a wrapper that bundles the above so that it can be published as a single package) 11 | * Demo (this is just used for local development) 12 | 13 | Their version numbers are all tied; a change to a single package will increment the suite's version. This is to avoid confusion of knowing which versions are compatible with which versions. 14 | 15 | 16 | ### Linking the packages 17 | Maintaining a quick, iterative development flow is important, but it's surprisingly tricky when dealing with multiple independent packages. 18 | 19 | The best solution I've found so far is [Lerna](https://lernajs.io/). 20 | 21 | We tweak Lerna's default slightly by directly linking the /src files (as opposed to the built /lib files). This means that all you have to do is save a file in one of the sibling repos and the demo should update automatically. 22 | 23 | ### Getting set up 24 | 25 | Once you've forked and cloned the repo, run: 26 | 27 | ```bash 28 | npm run install 29 | ``` 30 | 31 | That isn't a typo: You want to run the NPM script `install`, not the traditional `npm install`. 32 | 33 | This will install all the dependencies for the children packages. 34 | 35 | Once it installs, run: 36 | 37 | ```bash 38 | npm run bootstrap 39 | ``` 40 | 41 | This links all the repos together, allowing for the aforementioned development environment. 42 | 43 | Finally, to start working, run: 44 | 45 | ```bash 46 | npm run demo 47 | ``` 48 | 49 | This will launch the very simple demo app, which allows you to see and test the changes you make. 50 | 51 | 52 | ### Tests 53 | 54 | Tests are an important part of ReduxVCR. Please add tests for any new code that you add. 55 | 56 | While not required, I've found TDD to be effective when developing new features for ReduxVCR. 57 | 58 | To run the tests, you can run any of the following commands from the root directory: 59 | 60 | ```bash 61 | npm run test:capture 62 | npm run test:persist 63 | npm run test:retrieve 64 | npm run test:replay 65 | npm run test:shared 66 | 67 | npm run test 68 | ``` 69 | 70 | That last command will run the whole suite of tests. 71 | 72 | By `cd`ing into an individual package's repo, you can also run a test watcher: 73 | 74 | ```bash 75 | npm run test:watch 76 | ``` 77 | -------------------------------------------------------------------------------- /packages/_shared/tests/create-firebase-handler-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { expect } from 'chai'; 3 | import omit from 'lodash'; 4 | 5 | import createFirebaseHandler from '../src/utils/create-firebase-handler'; 6 | 7 | const firebaseAuth = { 8 | apiKey: 'abc123', 9 | authDomain: 'test.firebaseapp.com', 10 | databaseURL: 'https://test.firebaseio.com', 11 | }; 12 | 13 | const source = 'persist'; 14 | 15 | 16 | describe('FirebaseHandler', () => { 17 | describe('initialization', () => { 18 | it('throws when no arguments are provided', () => { 19 | expect(() => createFirebaseHandler()).to.throw(/firebaseAuth/); 20 | }); 21 | 22 | it('throws when no source is provided', () => { 23 | const config = { firebaseAuth }; 24 | 25 | expect(() => createFirebaseHandler(config)).to.throw(/source/); 26 | }); 27 | 28 | // Check that all 3 firebase keys are required 29 | Object.keys(firebaseAuth).forEach(missingKey => { 30 | const inadequateFirebaseAuth = omit(firebaseAuth, missingKey); 31 | 32 | it(`fails when firebaseAuth is missing ${missingKey}`, () => { 33 | const auth = { 34 | firebaseAuth: inadequateFirebaseAuth, 35 | source, 36 | }; 37 | 38 | const genHandler = () => createFirebaseHandler(auth); 39 | expect(genHandler).to.throw(/firebaseAuth/); 40 | }); 41 | }); 42 | 43 | context('with a valid firebaseAuth object', () => { 44 | let handler; 45 | 46 | beforeEach(() => { 47 | handler = createFirebaseHandler({ firebaseAuth, source }); 48 | }); 49 | 50 | it('invokes `initializeApp` with the supplied auth', () => { 51 | expect(handler.firebase.initializeApp.callCount).to.equal(1); 52 | 53 | const call = handler.firebase.initializeApp.getCall(0); 54 | expect(call.args[0]).to.equal(firebaseAuth); 55 | expect(call.args[1]).to.equal(source); 56 | }); 57 | 58 | it('invokes `auth` once to retrieve auth methods', () => { 59 | expect(handler.firebase.auth.callCount).to.equal(1); 60 | }); 61 | 62 | it('invokes the `signInAnonymously` method', () => { 63 | expect(handler.firebase.signInAnonymously.callCount).to.equal(1); 64 | }); 65 | 66 | it('registers the `onAuthStateChanged` callback', () => { 67 | expect(handler.firebase.onAuthStateChanged.callCount).to.equal(1); 68 | }); 69 | 70 | it('sets the session ID', done => { 71 | // This happens asynchronously, since we need to wait for Firebase 72 | // to generate the ID. 73 | expect(handler.sessionId).to.be.undefined; 74 | 75 | setTimeout(() => { 76 | expect(handler.sessionId).to.equal('abc123'); 77 | done(); 78 | }, 100); 79 | }); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /packages/capture/src/create-capture-middleware.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { errors } from 'redux-vcr.shared'; 3 | 4 | import { isActionBlacklisted } from './helpers'; 5 | 6 | 7 | // eslint-disable-next-line import/prefer-default-export 8 | const createCaptureMiddleware = ({ 9 | blacklist = [], 10 | persistHandler, 11 | startTrigger, 12 | minimumActionsToPersist = 0, 13 | } = {}) => { 14 | const cassette = { 15 | timestamp: Date.now(), 16 | data: {}, 17 | actions: [], 18 | initialState: {}, 19 | }; 20 | 21 | // Ensure that the data handler we've supplied is valid 22 | invariant( 23 | !!persistHandler && typeof persistHandler.persist === 'function', 24 | errors.captureMiddlewareGivenInvalidPersistHandler() 25 | ); 26 | 27 | // In addition to any user-specified actions, we want to ignore any actions 28 | // emitted from ReduxVCR/replay. 29 | blacklist.push({ type: 'REDUX_VCR', matchingCriteria: 'startsWith' }); 30 | 31 | // We've polyfilled performance.now to run in all environments. 32 | let timeSinceLastEvent = window.performance.now(); 33 | 34 | let waitingForActionToStartCapturing = typeof startTrigger !== 'undefined'; 35 | 36 | // eslint-disable-next-line no-unused-vars 37 | return store => next => action => { 38 | if (waitingForActionToStartCapturing && action.type === startTrigger) { 39 | // If so, we want to dispatch it, so that the state is updated, 40 | // and then use the newly-computed state as our baseline. 41 | next(action); 42 | 43 | cassette.timestamp = Date.now(); 44 | cassette.initialState = { ...store.getState() }; 45 | delete cassette.initialState.reduxVCR; 46 | 47 | timeSinceLastEvent = window.performance.now(); 48 | waitingForActionToStartCapturing = false; 49 | 50 | // Bail out early. We don't want to persist the cassette yet, 51 | // since there are no recorded actions. 52 | return null; 53 | } 54 | 55 | if (waitingForActionToStartCapturing) { 56 | return next(action); 57 | } 58 | 59 | if (isActionBlacklisted({ action, blacklist })) { 60 | return next(action); 61 | } 62 | 63 | // If the action has any metadata for us, apply it to the cassette 64 | if (action.meta && action.meta.capture) { 65 | cassette.data = { 66 | ...cassette.data, 67 | ...action.meta.capture, 68 | }; 69 | } 70 | 71 | const now = window.performance.now(); 72 | const delay = now - timeSinceLastEvent; 73 | timeSinceLastEvent = now; 74 | 75 | cassette.actions.push({ 76 | ...action, 77 | delay, 78 | }); 79 | 80 | if (cassette.actions.length >= minimumActionsToPersist) { 81 | persistHandler.persist(cassette); 82 | } 83 | 84 | return next(action); 85 | }; 86 | }; 87 | 88 | export default createCaptureMiddleware; 89 | -------------------------------------------------------------------------------- /scripts/bootstrap.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { exec } = require('shelljs'); 3 | 4 | // Lerna does a pretty good job of bootstrapping the project; 5 | // The only flaw is it points at the compiled (/lib) code, and I want it to 6 | // point at the /src. 7 | // We also need to bootstrap React, so that it doesn't try to load multiple 8 | // copies in development. 9 | 10 | // TODO: add cmd-line fallback to lerna if we want to test the /lib 11 | 12 | exec('lerna bootstrap'); 13 | 14 | const packageIndices = [ 15 | './packages/_demo/node_modules/redux-vcr.capture/index.js', 16 | './packages/_demo/node_modules/redux-vcr.persist/index.js', 17 | './packages/_demo/node_modules/redux-vcr.retrieve/index.js', 18 | './packages/_demo/node_modules/redux-vcr.replay/index.js', 19 | 20 | './packages/_root/node_modules/redux-vcr.capture/index.js', 21 | './packages/_root/node_modules/redux-vcr.persist/index.js', 22 | './packages/_root/node_modules/redux-vcr.retrieve/index.js', 23 | './packages/_root/node_modules/redux-vcr.replay/index.js', 24 | 25 | './packages/capture/node_modules/redux-vcr.shared/index.js', 26 | './packages/persist/node_modules/redux-vcr.shared/index.js', 27 | './packages/replay/node_modules/redux-vcr.shared/index.js', 28 | './packages/retrieve/node_modules/redux-vcr.shared/index.js', 29 | ]; 30 | 31 | packageIndices.forEach(indexPath => { 32 | const indexContents = fs.readFileSync(indexPath, 'utf8'); 33 | 34 | // If we've already updated it to point at /src, do nothing! 35 | if (indexContents.match(/\/src/)) { 36 | return; 37 | } 38 | 39 | const updatedContents = ( 40 | indexContents.slice(0, -4) + 41 | '/src' + 42 | indexContents.slice(-4) 43 | ); 44 | 45 | console.info('Updating index contents to', updatedContents); 46 | 47 | fs.writeFileSync(indexPath, updatedContents); 48 | }); 49 | 50 | // To prevent multiple copies of React from loading in dev, 51 | // we want to point both /demo and /replay to the parent dependency. 52 | const reactPaths = [ 53 | './packages/_demo/node_modules/react', 54 | './packages/replay/node_modules/react', 55 | ]; 56 | 57 | reactPaths.forEach(reactPath => { 58 | // Start by deleting the directory, replacing it with an empty one. 59 | exec(`rm -rf ${reactPath}`); 60 | exec(`mkdir ${reactPath}`); 61 | 62 | console.info(`Updating React path at ${reactPath}`); 63 | 64 | // TODO: Fetch React version number from parent package.json 65 | const REACT_VERSION = '15.3.0'; 66 | 67 | const packageJson = `{ 68 | "name": "react", 69 | "version": "${REACT_VERSION}" 70 | }`; 71 | 72 | const indexJs = "module.exports = require('../../../../node_modules/react');"; 73 | 74 | exec(`echo '${packageJson}' >> ${reactPath}/package.json`); 75 | exec(`echo "${indexJs}" >> ${reactPath}/index.js`); 76 | }); 77 | 78 | console.info("Done! You've been bootstrapped."); 79 | -------------------------------------------------------------------------------- /packages/_demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-vcr.demo", 3 | "version": "0.3.3", 4 | "dependencies": { 5 | "babel-eslint": "^6.1.2", 6 | "classnames": "^2.2.5", 7 | "react": "15.3.0", 8 | "react-dom": "15.3.0", 9 | "react-flip-move": "^2.5.0", 10 | "react-redux": "^4.4.5", 11 | "redux": "^3.5.2", 12 | "redux-vcr.capture": "^0.3.3", 13 | "redux-vcr.persist": "^0.3.3", 14 | "redux-vcr.retrieve": "^0.3.3", 15 | "redux-vcr.replay": "^0.3.3" 16 | }, 17 | "devDependencies": { 18 | "autoprefixer": "6.3.7", 19 | "babel-core": "6.11.4", 20 | "babel-eslint": "6.1.2", 21 | "babel-loader": "6.2.4", 22 | "babel-plugin-syntax-trailing-function-commas": "6.8.0", 23 | "babel-plugin-transform-class-properties": "6.11.5", 24 | "babel-plugin-transform-object-rest-spread": "6.8.0", 25 | "babel-plugin-transform-react-constant-elements": "6.9.1", 26 | "babel-plugin-transform-runtime": "6.12.0", 27 | "babel-preset-es2015": "6.9.0", 28 | "babel-preset-es2016": "6.11.3", 29 | "babel-preset-react": "6.11.1", 30 | "babel-runtime": "6.11.6", 31 | "case-sensitive-paths-webpack-plugin": "1.1.2", 32 | "chalk": "1.1.3", 33 | "cross-spawn": "4.0.0", 34 | "css-loader": "0.23.1", 35 | "eslint": "3.3.0", 36 | "eslint-config-airbnb": "10.0.0", 37 | "eslint-loader": "1.5.0", 38 | "eslint-plugin-import": "1.13.0", 39 | "eslint-plugin-jsx-a11y": "2.0.1", 40 | "eslint-plugin-react": "6.0.0", 41 | "extract-text-webpack-plugin": "1.0.1", 42 | "file-loader": "0.9.0", 43 | "filesize": "3.3.0", 44 | "fs-extra": "0.30.0", 45 | "gzip-size": "3.0.0", 46 | "html-webpack-plugin": "2.22.0", 47 | "json-loader": "0.5.4", 48 | "lodash": "^4.15.0", 49 | "node-sass": "^3.8.0", 50 | "opn": "4.0.2", 51 | "postcss-loader": "0.9.1", 52 | "promise": "7.1.1", 53 | "redux-devtools": "^3.3.1", 54 | "redux-devtools-dock-monitor": "^1.1.1", 55 | "redux-devtools-log-monitor": "^1.0.11", 56 | "rimraf": "2.5.4", 57 | "sass-loader": "^4.0.0", 58 | "sinon": "^2.0.0-pre.2", 59 | "style-loader": "0.13.1", 60 | "url-loader": "0.5.7", 61 | "webpack": "1.13.1", 62 | "webpack-dev-server": "1.14.1", 63 | "whatwg-fetch": "1.0.0" 64 | }, 65 | "scripts": { 66 | "start": "node ./scripts/start.js", 67 | "build": "node ./scripts/build.js", 68 | "lint": "node ./scripts/lint.js", 69 | "refresh:replay": "npm r -S redux-vcr.replay && cd .. && npm run build:replay && cd demo && npm i -S redux-vcr.replay" 70 | }, 71 | "author": "Joshua Comeau", 72 | "homepage": "https://github.com/joshwcomeau/redux-vcr/tree/master/persist", 73 | "license": "MIT", 74 | "repository": { 75 | "type": "git", 76 | "url": "https://github.com/joshwcomeau/redux-vcr.git" 77 | }, 78 | "eslintConfig": { 79 | "extends": "./config/eslint.js" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/replay/src/components/Replay/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import FlipMove from 'react-flip-move'; 4 | 5 | import { actionCreators } from 'redux-vcr.shared'; 6 | import VCR from '../VCR'; 7 | import CassetteList from '../CassetteList'; 8 | import Backdrop from '../Backdrop'; 9 | 10 | import './index.scss'; 11 | 12 | const cassetteListAnimation = { 13 | enter: { 14 | from: { 15 | transform: 'translateY(50px)', 16 | opacity: 0.01, 17 | }, 18 | to: { 19 | transform: 'translateY(0)', 20 | opacity: 1, 21 | }, 22 | }, 23 | leave: { 24 | from: { 25 | transform: 'translateY(8px)', 26 | opacity: 1, 27 | }, 28 | to: { 29 | transform: 'translateY(50px)', 30 | opacity: 0.01, 31 | }, 32 | }, 33 | }; 34 | 35 | class Replay extends Component { 36 | componentDidMount() { 37 | const { requiresAuth, cassettesListRequest } = this.props; 38 | 39 | // If we don't need to authenticate, we want to request our 40 | // initial cassette list ASAP! 41 | if (!requiresAuth) { 42 | cassettesListRequest(); 43 | } 44 | } 45 | 46 | render() { 47 | const { 48 | doorLabel, 49 | cassettesBackdropColor, 50 | cassettesBackdropOpacity, 51 | cassetteStatus, 52 | hideCassettes, 53 | } = this.props; 54 | 55 | return ( 56 |
57 | 58 | 59 | 63 | { cassetteStatus === 'selecting' ? : null } 64 | 65 | 66 | 72 |
73 | ); 74 | } 75 | } 76 | 77 | 78 | Replay.propTypes = { 79 | doorLabel: PropTypes.string, 80 | cassettesBackdropColor: PropTypes.string, 81 | cassettesBackdropOpacity: PropTypes.number, 82 | cassetteStatus: PropTypes.string.isRequired, 83 | requiresAuth: PropTypes.bool, 84 | hideCassettes: PropTypes.func, 85 | cassettesListRequest: PropTypes.func, 86 | }; 87 | 88 | Replay.defaultProps = { 89 | cassettesBackdropColor: '#FFF', 90 | cassettesBackdropOpacity: 0.9, 91 | }; 92 | 93 | const mapStateToProps = state => ({ 94 | cassetteStatus: state.reduxVCR.cassettes.status, 95 | requiresAuth: state.reduxVCR.authentication.requiresAuth, 96 | }); 97 | 98 | 99 | export default connect( 100 | mapStateToProps, 101 | { 102 | hideCassettes: actionCreators.hideCassettes, 103 | cassettesListRequest: actionCreators.cassettesListRequest, 104 | } 105 | )(Replay); 106 | -------------------------------------------------------------------------------- /packages/_shared/tests/reducers/actions-reducer-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import reducer from '../../src/reducers/actions.reducer'; 4 | import { 5 | CASSETTE_ACTIONS_RECEIVE, 6 | INCREMENT_ACTIONS_PLAYED, 7 | STOP_CASSETTE, 8 | cassetteActionsReceive, 9 | incrementActionsPlayed, 10 | stopCassette, 11 | } from '../../src/actions'; 12 | 13 | 14 | describe('actions reducer', () => { 15 | describe(CASSETTE_ACTIONS_RECEIVE, () => { 16 | const cassetteActions = [ 17 | { type: 'DO_THING', delay: 0 }, 18 | { type: 'DO_OTHER_THING', delay: 0 }, 19 | ] 20 | 21 | it('populates the list of actions', () => { 22 | const state = reducer({}, {}); 23 | const action = cassetteActionsReceive({ 24 | id: 'cba', 25 | cassetteActions, 26 | }); 27 | 28 | const expectedState = { 29 | ...state, 30 | byId: { 31 | cba: cassetteActions, 32 | }, 33 | }; 34 | const actualState = reducer(state, action); 35 | 36 | expect(actualState).to.deep.equal(expectedState); 37 | }); 38 | 39 | it("doesn't clobber other cassette's actions", () => { 40 | const state = reducer({ 41 | byId: { 42 | zyx: [{ type: 'STUFF', delay: 10 }], 43 | }, 44 | }, {}); 45 | const action = cassetteActionsReceive({ 46 | id: 'cba', 47 | cassetteActions, 48 | }); 49 | 50 | const expectedState = { 51 | ...state, 52 | byId: { 53 | zyx: [{ type: 'STUFF', delay: 10 }], 54 | cba: cassetteActions, 55 | }, 56 | }; 57 | const actualState = reducer(state, action); 58 | 59 | expect(actualState).to.deep.equal(expectedState); 60 | }); 61 | }); 62 | 63 | describe(INCREMENT_ACTIONS_PLAYED, () => { 64 | it('increments currentIndex from a pre-existing value', () => { 65 | const state = reducer({ currentIndex: 5 }, {}); 66 | const action = incrementActionsPlayed(); 67 | 68 | const expectedState = { 69 | ...state, 70 | currentIndex: 6, 71 | }; 72 | const actualState = reducer(state, action); 73 | 74 | expect(actualState).to.deep.equal(expectedState); 75 | }); 76 | it('increments currentIndex from 0', () => { 77 | const state = reducer({}, {}); 78 | const action = incrementActionsPlayed(); 79 | 80 | const expectedState = { 81 | ...state, 82 | currentIndex: 1, 83 | }; 84 | const actualState = reducer(state, action); 85 | 86 | expect(actualState).to.deep.equal(expectedState); 87 | }); 88 | }); 89 | 90 | describe(STOP_CASSETTE, () => { 91 | it('resets currentIndex', () => { 92 | const state = reducer({ currentIndex: 5 }, {}); 93 | const action = stopCassette(); 94 | 95 | const expectedState = { 96 | ...state, 97 | currentIndex: 0, 98 | }; 99 | const actualState = reducer(state, action); 100 | 101 | expect(actualState).to.deep.equal(expectedState); 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /packages/replay/stories/vcr/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable semi, no-unused-vars */ 2 | import React, { Component } from 'react'; 3 | import { createStore } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import { storiesOf, action } from '@kadira/storybook'; 6 | 7 | import Centered from '../Centered'; 8 | import { VCR } from '../../src/components/VCR'; 9 | 10 | const dummyReducer = (state = {}, action) => { 11 | return state; 12 | }; 13 | 14 | const store = createStore(dummyReducer); 15 | 16 | storiesOf('VCR', module) 17 | .addDecorator(story => ( 18 | 19 |
20 | {story()} 21 |
22 |
23 | )) 24 | .add('Default (stopped, idle, unauthenticated)', () => ( 25 | 40 | )) 41 | .add('Authenticated', () => ( 42 | 57 | )) 58 | .add('Errored', () => ( 59 | 74 | )) 75 | 76 | .add('Loaded and stopped', () => ( 77 | 92 | )) 93 | -------------------------------------------------------------------------------- /packages/_demo/src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { createStore, applyMiddleware, compose } from 'redux'; 6 | 7 | 8 | import { createCaptureMiddleware } from 'redux-vcr.capture'; 9 | import { createPersistHandler } from 'redux-vcr.persist'; 10 | import { 11 | getQueryParam, 12 | createRetrieveHandler, 13 | createRetrieveMiddleware, 14 | } from 'redux-vcr.retrieve'; 15 | import { 16 | createReplayMiddleware, 17 | wrapReducer, 18 | } from 'redux-vcr.replay'; 19 | 20 | 21 | import { COMPLETE_ONBOARDING } from './actions'; 22 | import DevTools from './components/DevTools'; 23 | import App from './components/App'; 24 | import reducer from './reducers'; 25 | 26 | const settings = { 27 | runAsUser: true, 28 | runAsAdmin: true, 29 | }; 30 | 31 | 32 | // Firebase credentials are safe to distribute in the client; 33 | // on their own, they don't grant any authorization. 34 | // It's for this reason that Firebase was chosen, so that no server-side 35 | // authentication is required :) 36 | const firebaseAuth = { 37 | apiKey: 'AIzaSyDPq76JUdNtZcnileNl0fRpVtwGD4zgpjY', 38 | authDomain: 'redux-vcr-demo.firebaseapp.com', 39 | databaseURL: 'https://redux-vcr-demo.firebaseio.com', 40 | }; 41 | 42 | const middlewares = []; 43 | 44 | if (settings.runAsUser) { 45 | // The PersistHandler handles submitting captured actions to Firebase. 46 | // The only required config is the firebaseAuth object. 47 | // This should be distributed to your users in production. 48 | 49 | middlewares.push( 50 | // The capture middleware chronicles, filters, and timestamps actions 51 | // as they're dispatched to the store. It needs to be passed the 52 | // PersistHandler so it can send them to Firebase. 53 | createCaptureMiddleware({ 54 | persistHandler: createPersistHandler({ firebaseAuth }), 55 | startTrigger: COMPLETE_ONBOARDING, 56 | }), 57 | ); 58 | } 59 | 60 | if (settings.runAsAdmin) { 61 | // Inversely, the createRetrieveHandler pulls actions from Firebase, allowing 62 | // them to be replayed. It should only be included in development. 63 | middlewares.push( 64 | // The retrieve middleware listens for specific actions dispatched 65 | // from the Replay components, to fetch the recordings needed. 66 | createRetrieveMiddleware({ 67 | retrieveHandler: createRetrieveHandler({ firebaseAuth }), 68 | initialCassetteId: getQueryParam({ param: 'cassetteId' }), 69 | }), 70 | 71 | // Finally, the replay middleware is in charge of intercepting the 72 | // PLAY_CASSETTE action, which allows previously-recorded sessions 73 | // to be replayed. 74 | createReplayMiddleware({ maximumDelay: 500 }), 75 | ); 76 | } 77 | 78 | 79 | const store = createStore( 80 | // This higher-order reducer exists purely to tackle resetting the state 81 | // before a cassette is played. It ensures recordings will run smoothly. 82 | wrapReducer(reducer), 83 | compose( 84 | applyMiddleware.apply(this, middlewares), 85 | DevTools.instrument() 86 | ) 87 | ); 88 | 89 | ReactDOM.render( 90 | 91 | 92 | , 93 | document.getElementById('root') 94 | ); 95 | -------------------------------------------------------------------------------- /packages/_demo/scripts/build.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'production'; 2 | 3 | var chalk = require('chalk'); 4 | var fs = require('fs'); 5 | var path = require('path'); 6 | var filesize = require('filesize'); 7 | var gzipSize = require('gzip-size').sync; 8 | var rimrafSync = require('rimraf').sync; 9 | var webpack = require('webpack'); 10 | var config = require('../config/webpack.config.prod'); 11 | var paths = require('../config/paths'); 12 | 13 | // Remove all content but keep the directory so that 14 | // if you're in it, you don't end up in Trash 15 | rimrafSync(paths.appBuild + '/*'); 16 | 17 | console.info('Creating an optimized production build...'); 18 | webpack(config).run(function(err, stats) { 19 | if (err) { 20 | console.error('Failed to create a production build. Reason:'); 21 | console.error(err.message || err); 22 | process.exit(1); 23 | } 24 | 25 | console.info(chalk.green('Compiled successfully.')); 26 | console.info(); 27 | 28 | console.info('File sizes after gzip:'); 29 | console.info(); 30 | var assets = stats.toJson().assets 31 | .filter(asset => /\.(js|css)$/.test(asset.name)) 32 | .map(asset => { 33 | var fileContents = fs.readFileSync(paths.appBuild + '/' + asset.name); 34 | var size = gzipSize(fileContents); 35 | return { 36 | folder: path.join('build', path.dirname(asset.name)), 37 | name: path.basename(asset.name), 38 | size: size, 39 | sizeLabel: filesize(size) 40 | }; 41 | }); 42 | assets.sort((a, b) => b.size - a.size); 43 | 44 | var longestSizeLabelLength = Math.max.apply(null, 45 | assets.map(a => a.sizeLabel.length) 46 | ); 47 | assets.forEach(asset => { 48 | var sizeLabel = asset.sizeLabel; 49 | if (sizeLabel.length < longestSizeLabelLength) { 50 | var rightPadding = ' '.repeat(longestSizeLabelLength - sizeLabel.length); 51 | sizeLabel += rightPadding; 52 | } 53 | console.info( 54 | ' ' + chalk.green(sizeLabel) + 55 | ' ' + chalk.dim(asset.folder + path.sep) + chalk.cyan(asset.name) 56 | ); 57 | }); 58 | console.info(); 59 | 60 | var openCommand = process.platform === 'win32' ? 'start' : 'open'; 61 | var homepagePath = require(paths.appPackageJson).homepage; 62 | if (homepagePath) { 63 | console.info('You can now publish them at ' + homepagePath + '.'); 64 | console.info('For example, if you use GitHub Pages:'); 65 | console.info(); 66 | console.info(' git commit -am "Save local changes"'); 67 | console.info(' git checkout -B gh-pages'); 68 | console.info(' git add -f build'); 69 | console.info(' git commit -am "Rebuild website"'); 70 | console.info(' git filter-branch -f --prune-empty --subdirectory-filter build'); 71 | console.info(' git push -f origin gh-pages'); 72 | console.info(' git checkout -'); 73 | console.info(); 74 | } else { 75 | console.info('You can now serve them with any static server.'); 76 | console.info('For example:'); 77 | console.info(); 78 | console.info(' npm install -g pushstate-server'); 79 | console.info(' pushstate-server build'); 80 | console.info(' ' + openCommand + ' http://localhost:9000'); 81 | console.info(); 82 | console.info(chalk.dim('The project was built assuming it is hosted at the root.')); 83 | console.info(chalk.dim('Set the "homepage" field in package.json to override this.')); 84 | console.info(chalk.dim('For example, "homepage": "http://user.github.io/project".')); 85 | } 86 | console.info(); 87 | }); 88 | -------------------------------------------------------------------------------- /packages/_shared/src/utils/create-firebase-handler.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | import invariant from 'invariant'; 3 | 4 | import { errors } from '../index'; 5 | 6 | let firebase; 7 | let firebaseStubFactory; 8 | if (process.env.NODE_ENV === 'test') { 9 | firebaseStubFactory = require('../stubs/firebase-stub-factory'); 10 | } else { 11 | firebase = require('firebase/app'); 12 | require('firebase/auth'); 13 | require('firebase/database'); 14 | } 15 | 16 | 17 | export default function createFirebaseHandler({ firebaseAuth, source }) { 18 | // Validate the firebaseAuth object 19 | invariant( 20 | typeof firebaseAuth === 'object' && 21 | typeof firebaseAuth.apiKey === 'string' && 22 | typeof firebaseAuth.authDomain === 'string' && 23 | typeof firebaseAuth.databaseURL === 'string', 24 | errors.invalidFirebaseAuth(firebaseAuth) 25 | ); 26 | 27 | // Ensure we're given a 'source' (typically either 'persist' or 'retrieve') 28 | invariant( 29 | typeof source === 'string', 30 | errors.firebaseHandlerMissingSource() 31 | ); 32 | 33 | // If we're running in test mode, we want to generate a fresh stub. 34 | if (process.env.NODE_ENV === 'test') { 35 | firebase = firebaseStubFactory(); 36 | } 37 | 38 | const firebaseHandler = { 39 | firebaseConnection: firebase.initializeApp(firebaseAuth, source), 40 | initialize() { 41 | const auth = this.firebaseConnection.auth(); 42 | 43 | // For retrieval, we want to send github-authenticated requests. 44 | // For persist, though, we can use anonymous authentication. 45 | if (source === 'persist') { 46 | auth.signInAnonymously(); 47 | } 48 | 49 | auth.onAuthStateChanged(user => { 50 | this.firebaseUser = user; 51 | this.firebaseSessionId = user.uid; 52 | }); 53 | }, 54 | createProvider(provider) { 55 | invariant( 56 | provider === 'github.com', 57 | errors.firebaseHandlerInvalidProvider(provider) 58 | ); 59 | 60 | switch (provider) { 61 | case 'github.com': return new firebase.auth.GithubAuthProvider(); 62 | 63 | // the default case should never actually be hit. It's a fallback in case 64 | // the invariant above misses something. 65 | default: throw new Error('Please supply a valid provider'); 66 | } 67 | }, 68 | 69 | buildCredential({ accessToken, provider }) { 70 | invariant( 71 | provider === 'github.com', 72 | errors.firebaseHandlerInvalidProvider(provider) 73 | ); 74 | 75 | switch (provider) { 76 | case 'github.com': 77 | return firebase.auth.GithubAuthProvider.credential(accessToken); 78 | 79 | // the default case should never actually be hit. It's a fallback in case 80 | // the invariant above misses something. 81 | default: throw new Error('Please supply a valid provider'); 82 | } 83 | }, 84 | 85 | get sessionId() { 86 | return this.firebaseSessionId; 87 | }, 88 | 89 | get user() { 90 | return this.firebaseUser; 91 | }, 92 | 93 | get firebase() { 94 | // Normally, we wouldn't want to expose an internal module like this. 95 | // However, the entire firebaseHandler object is itself an internal module. 96 | // Because access to firebaseHandler is so limited, I feel alright with it. 97 | return this.firebaseConnection; 98 | }, 99 | }; 100 | 101 | firebaseHandler.initialize(); 102 | 103 | return firebaseHandler; 104 | } 105 | -------------------------------------------------------------------------------- /packages/_shared/tests/reducers/authentication-reducer-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import { expect } from 'chai'; 3 | 4 | import reducer from '../../src/reducers/authentication.reducer'; 5 | import { 6 | SET_AUTH_REQUIREMENT, 7 | SIGN_IN_REQUEST, 8 | SIGN_IN_SUCCESS, 9 | SIGN_IN_FAILURE, 10 | SIGN_OUT_SUCCESS, 11 | setAuthRequirement, 12 | signInRequest, 13 | signInSuccess, 14 | signInFailure, 15 | signOutSuccess, 16 | } from '../../src/actions'; 17 | 18 | 19 | describe('authentication reducer', () => { 20 | describe('initialization', () => { 21 | it('populates with default values', () => { 22 | const initialState = reducer({}, {}); 23 | const expectedState = { 24 | loggedIn: false, 25 | error: null, 26 | requiresAuth: true, 27 | }; 28 | 29 | expect(initialState).to.deep.equal(expectedState); 30 | }); 31 | }); 32 | 33 | describe(SET_AUTH_REQUIREMENT, () => { 34 | it('updates `requiresAuth`', () => { 35 | const state = reducer({}, {}); 36 | 37 | const action = setAuthRequirement({ requiresAuth: false }); 38 | 39 | const expectedState = { 40 | loggedIn: false, 41 | error: null, 42 | requiresAuth: false, 43 | }; 44 | const actualState = reducer(state, action); 45 | 46 | expect(actualState).to.deep.equal(expectedState); 47 | }); 48 | }); 49 | 50 | describe(SIGN_IN_REQUEST, () => { 51 | it('removes any leftover errors', () => { 52 | const state = reducer({ 53 | loggedIn: false, 54 | error: 'yadda', 55 | }, {}); 56 | 57 | const action = signInRequest({ authMethod: 'github.com' }); 58 | 59 | const expectedState = { 60 | loggedIn: false, 61 | error: null, 62 | requiresAuth: true, 63 | }; 64 | const actualState = reducer(state, action); 65 | 66 | expect(actualState).to.deep.equal(expectedState); 67 | }); 68 | }); 69 | 70 | describe(SIGN_IN_SUCCESS, () => { 71 | it('resets the error and sets loggedIn to true', () => { 72 | const state = reducer({ 73 | loggedIn: false, 74 | error: 'yadda', 75 | }, {}); 76 | 77 | const action = signInSuccess({ user: {} }); 78 | 79 | const expectedState = { 80 | loggedIn: true, 81 | error: null, 82 | requiresAuth: true, 83 | }; 84 | const actualState = reducer(state, action); 85 | 86 | expect(actualState).to.deep.equal(expectedState); 87 | }); 88 | }); 89 | 90 | describe(SIGN_IN_FAILURE, () => { 91 | it('sets the error', () => { 92 | const state = reducer({}, {}); 93 | 94 | const action = signInFailure({ error: 'blew up' }); 95 | 96 | const expectedState = { 97 | loggedIn: false, 98 | error: 'blew up', 99 | requiresAuth: true, 100 | }; 101 | const actualState = reducer(state, action); 102 | 103 | expect(actualState).to.deep.equal(expectedState); 104 | }); 105 | }); 106 | 107 | 108 | describe(SIGN_OUT_SUCCESS, () => { 109 | it('sets loggedIn to false', () => { 110 | const state = reducer({ loggedIn: true }, {}); 111 | 112 | const action = signOutSuccess(); 113 | 114 | const expectedState = { 115 | loggedIn: false, 116 | error: null, 117 | requiresAuth: true, 118 | }; 119 | const actualState = reducer(state, action); 120 | 121 | expect(actualState).to.deep.equal(expectedState); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /packages/_shared/tests/export-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { 4 | actionTypes, 5 | actionCreators, 6 | actionsReducer, 7 | actionSelectors, 8 | cassettesReducer, 9 | cassetteSelectors, 10 | playReducer, 11 | playSelectors, 12 | userReducer, 13 | userSelectors, 14 | createFirebaseHandler, 15 | } from '../src'; 16 | 17 | 18 | describe('shared export', () => { 19 | it('exports the action types', () => { 20 | expect(actionTypes).to.be.an('object'); 21 | expect(actionTypes).to.include.keys([ 22 | 'CASSETTES_LIST_REQUEST', 23 | 'CASSETTES_LIST_SUCCESS', 24 | ]); 25 | 26 | const numOfNonStrings = Object.keys(actionTypes).filter(key => ( 27 | typeof actionTypes[key] !== 'string' 28 | )); 29 | 30 | expect(numOfNonStrings).to.have.length.of(0); 31 | }); 32 | 33 | it('exports the action creators', () => { 34 | expect(actionCreators).to.be.an('object'); 35 | expect(actionCreators).to.include.keys([ 36 | 'cassettesListRequest', 37 | 'cassettesListSuccess', 38 | ]); 39 | 40 | const numOfNonFunctions = Object.keys(actionCreators).filter(key => ( 41 | typeof actionCreators[key] !== 'function' 42 | )); 43 | 44 | expect(numOfNonFunctions).to.have.length.of(0); 45 | }); 46 | 47 | it('exports all reducer functions', () => { 48 | // For most reducers, we use `combineReducers`, which creates functions 49 | // that don't really look like reducers. 50 | // The exception ATM is `user`, which uses a regular reducer. 51 | const combinedReducers = [ 52 | actionsReducer, 53 | cassettesReducer, 54 | playReducer, 55 | ]; 56 | 57 | const regularReducers = [ 58 | userReducer, 59 | ]; 60 | 61 | combinedReducers.forEach(reducer => { 62 | expect(reducer).to.be.a('function'); 63 | expect(reducer.length).to.equal(0); 64 | expect(reducer.toString()).to.match(/combination\(\)/i); 65 | }); 66 | 67 | regularReducers.forEach(reducer => { 68 | expect(reducer).to.be.a('function'); 69 | expect(reducer.length).to.equal(2); 70 | expect(reducer.toString()).to.match(/reducer\(state, action\)/i); 71 | }); 72 | }); 73 | 74 | it('exports action selectors', () => { 75 | const selectors = Object.keys(actionSelectors).filter(selector => ( 76 | selector !== 'default' 77 | )); 78 | 79 | expect(selectors).to.have.length.above(0); 80 | expect(selectors).to.include( 81 | 'actionsListSelector' 82 | ); 83 | }); 84 | 85 | it('exports cassette selectors', () => { 86 | const selectors = Object.keys(cassetteSelectors).filter(selector => ( 87 | selector !== 'default' 88 | )); 89 | 90 | expect(selectors).to.have.length.above(0); 91 | expect(selectors).to.include( 92 | 'cassetteListSelector', 93 | 'paginatedCassetteListSelector', 94 | 'isFirstPageSelector', 95 | 'isLastPageSelector' 96 | ); 97 | }); 98 | 99 | it('exports empty play selectors', () => { 100 | const selectors = Object.keys(playSelectors).filter(selector => ( 101 | selector !== 'default' 102 | )); 103 | 104 | expect(selectors).to.have.length.of(0); 105 | }); 106 | 107 | it('exports user selectors', () => { 108 | const selectors = Object.keys(userSelectors).filter(selector => ( 109 | selector !== 'default' 110 | )); 111 | 112 | expect(selectors).to.have.length.of(0); 113 | }); 114 | 115 | 116 | it('exports createFirebaseHandler', () => { 117 | expect(createFirebaseHandler).to.be.a('function'); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /packages/replay/scripts/add_component/add-component.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console, no-use-before-define */ 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | import changeCase from 'change-case'; 5 | 6 | 7 | function run(componentName) { 8 | if (!componentName) { 9 | throw new Error(` 10 | Please supply a component name! 11 | 'npm run add-component -- YourComponentName' 12 | `); 13 | } else if (!changeCase.upperCaseFirst(componentName)) { 14 | throw new Error(` 15 | Custom React components need to be in PascalCase. 16 | You provided ${componentName}. 17 | Please capitalize the first letter! 18 | `); 19 | } 20 | 21 | const componentDirectory = path.join( 22 | __dirname, 23 | '../../src/components', 24 | componentName 25 | ); 26 | createDirectory(componentDirectory); 27 | 28 | const className = changeCase.paramCase(componentName); 29 | 30 | // Create and write JS to file 31 | const componentPath = path.join(componentDirectory, 'index.js'); 32 | const componentTemplate = buildJSTemplate(componentName, className); 33 | fs.writeFileSync(componentPath, componentTemplate); 34 | 35 | // Create and write SCSS to file 36 | const scssPath = path.join(componentDirectory, 'index.scss'); 37 | const scssTemplate = buildCSSTemplate(componentName, className); 38 | fs.writeFileSync(scssPath, scssTemplate); 39 | 40 | // Create and write a Story to file 41 | const storyDirectory = path.join( 42 | __dirname, 43 | '../../src/stories', 44 | className 45 | ); 46 | createDirectory(storyDirectory); 47 | 48 | const storyPath = path.join(storyDirectory, 'index.js'); 49 | const storyTemplate = buildStoryTemplate(componentName); 50 | fs.writeFileSync(storyPath, storyTemplate); 51 | 52 | console.info(`Component ${componentName} successfully created!`); 53 | return true; 54 | } 55 | 56 | 57 | // Helper Methods 58 | function createDirectory(componentDirectory) { 59 | try { 60 | fs.mkdirSync(componentDirectory); 61 | } catch (err) { 62 | throw new Error(`Sorry, it appears the component ${componentName} already exists!`); 63 | } 64 | 65 | return componentDirectory; 66 | } 67 | 68 | function buildJSTemplate(componentName, className) { 69 | // Not digging the break in indentation here, 70 | // but it's needed for the file to render correctly :( 71 | return `\ 72 | // eslint-disable-next-line no-unused-vars 73 | import React, { Component, PropTypes } from 'react'; 74 | import classNames from 'classnames'; 75 | 76 | import './index.scss'; 77 | 78 | 79 | const ${componentName} = () => { 80 | const classes = classNames('${className}'); 81 | 82 | return ( 83 |
84 | Your Component Here :) 85 |
86 | ); 87 | }; 88 | 89 | ${componentName}.propTypes = { 90 | 91 | }; 92 | 93 | ${componentName}.defaultProps = { 94 | 95 | }; 96 | 97 | export default ${componentName};\n`; 98 | } 99 | 100 | function buildCSSTemplate(componentName, className) { 101 | return `\ 102 | @import '../variables'; 103 | 104 | .${className} { 105 | 106 | }\n`; 107 | } 108 | 109 | function buildStoryTemplate(componentName) { 110 | return `\ 111 | /* eslint-disable semi, no-unused-vars */ 112 | import React from 'react'; 113 | import { storiesOf, action } from '@kadira/storybook'; 114 | import ${componentName} from '../src/components/${componentName}'; 115 | 116 | storiesOf('${componentName}', module) 117 | .add('default', () => ( 118 | <${componentName} /> 119 | )) 120 | `; 121 | } 122 | 123 | const componentName = process.argv[2]; 124 | run(componentName); 125 | -------------------------------------------------------------------------------- /packages/replay/scripts/add_component/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console, no-use-before-define */ 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const changeCase = require('change-case'); 5 | 6 | 7 | function run(componentName) { 8 | if (!componentName) { 9 | throw new Error(` 10 | Please supply a component name! 11 | 'npm run add-component -- YourComponentName' 12 | `); 13 | } else if (!changeCase.upperCaseFirst(componentName)) { 14 | throw new Error(` 15 | Custom React components need to be in PascalCase. 16 | You provided ${componentName}. 17 | Please capitalize the first letter! 18 | `); 19 | } 20 | 21 | const componentDirectory = path.join( 22 | __dirname, 23 | '../../src/components', 24 | componentName 25 | ); 26 | createDirectory(componentDirectory); 27 | 28 | const className = changeCase.paramCase(componentName); 29 | 30 | // Create and write JS to file 31 | const componentPath = path.join(componentDirectory, 'index.js'); 32 | const componentTemplate = buildJSTemplate(componentName, className); 33 | fs.writeFileSync(componentPath, componentTemplate); 34 | 35 | // Create and write SCSS to file 36 | const scssPath = path.join(componentDirectory, 'index.scss'); 37 | const scssTemplate = buildCSSTemplate(componentName, className); 38 | fs.writeFileSync(scssPath, scssTemplate); 39 | 40 | // Create and write a Story to file 41 | const storyDirectory = path.join( 42 | __dirname, 43 | '../../stories', 44 | className 45 | ); 46 | createDirectory(storyDirectory); 47 | 48 | const storyPath = path.join(storyDirectory, 'index.js'); 49 | const storyTemplate = buildStoryTemplate(componentName); 50 | fs.writeFileSync(storyPath, storyTemplate); 51 | 52 | console.info(`Component ${componentName} successfully created!`); 53 | return true; 54 | } 55 | 56 | 57 | // Helper Methods 58 | function createDirectory(componentDirectory) { 59 | console.info('Creating directory', componentDirectory); 60 | try { 61 | fs.mkdirSync(componentDirectory); 62 | } catch (err) { 63 | throw new Error(`Sorry, it appears the component ${componentName} already exists!`); 64 | } 65 | 66 | return componentDirectory; 67 | } 68 | 69 | function buildJSTemplate(componentName, className) { 70 | // Not digging the break in indentation here, 71 | // but it's needed for the file to render correctly :( 72 | return `\ 73 | // eslint-disable-next-line no-unused-vars 74 | import React, { Component, PropTypes } from 'react'; 75 | import classNames from 'classnames'; 76 | 77 | import './index.scss'; 78 | 79 | 80 | const ${componentName} = () => { 81 | const classes = classNames('${className}'); 82 | 83 | return ( 84 |
85 | Your Component Here :) 86 |
87 | ); 88 | }; 89 | 90 | ${componentName}.propTypes = { 91 | 92 | }; 93 | 94 | ${componentName}.defaultProps = { 95 | 96 | }; 97 | 98 | export default ${componentName};\n`; 99 | } 100 | 101 | function buildCSSTemplate(componentName, className) { 102 | return `\ 103 | @import '../variables'; 104 | 105 | .${className} { 106 | 107 | }\n`; 108 | } 109 | 110 | function buildStoryTemplate(componentName) { 111 | return `\ 112 | import React from 'react'; 113 | import { storiesOf } from '@kadira/storybook'; 114 | import ${componentName} from '../components/${componentName}'; 115 | 116 | storiesOf('${componentName}', module) 117 | .add('default', () => ( 118 | <${componentName} /> 119 | )); 120 | `; 121 | } 122 | 123 | const componentName = process.argv[2]; 124 | run(componentName); 125 | -------------------------------------------------------------------------------- /packages/replay/src/components/VCR/index.scss: -------------------------------------------------------------------------------- 1 | @import '../variables'; 2 | 3 | $primary-actions-width: 100px; 4 | $primary-action-button-height: 30px; 5 | 6 | .redux-vcr-component .vcr { 7 | position: relative; 8 | z-index: 2; 9 | width: $vcr-width; 10 | height: $vcr-height; 11 | color: $white; 12 | border-radius: 8px 8px 5px 5px; 13 | 14 | &.asleep { 15 | .vcr-button { 16 | opacity: 0.5; 17 | cursor: default; 18 | 19 | .toggle-indicator.toggled { 20 | background: rgba(0, 0, 0, 0.3); 21 | } 22 | } 23 | } 24 | 25 | .primary-action-buttons, .secondary-action-buttons { 26 | position: absolute; 27 | width: $primary-actions-width; 28 | right: $vcr-standard-padding * 2; 29 | display: flex; 30 | } 31 | 32 | .primary-action-buttons { 33 | justify-content: space-between; 34 | top: $vcr-standard-padding; 35 | 36 | .vcr-button { 37 | height: $primary-action-button-height; 38 | width: ($primary-actions-width / 2) - ($vcr-standard-padding / 2); 39 | } 40 | } 41 | 42 | .secondary-action-buttons { 43 | top: $primary-action-button-height + $vcr-standard-padding * 2; 44 | 45 | .vcr-button { 46 | border-radius: 0; 47 | flex: 1; 48 | } 49 | 50 | .vcr-button:first-of-type { 51 | border-radius: $vcr-button-radius 0 0 $vcr-button-radius; 52 | } 53 | 54 | .vcr-button:last-of-type { 55 | border-radius: 0 $vcr-button-radius $vcr-button-radius 0; 56 | } 57 | } 58 | 59 | .decorative-outputs { 60 | position: absolute; 61 | left: $vcr-standard-padding + 3px; 62 | bottom: $vcr-standard-padding; 63 | border-radius: 30px; 64 | background: rgba(0,0,0,0.3); 65 | padding: 4px; 66 | display: flex; 67 | border-bottom: 1px solid rgba(255,255,255,0.2); 68 | 69 | .decorative-output { 70 | position: relative; 71 | width: 12px; 72 | height: 12px; 73 | margin-right: 4px; 74 | // border: 1px solid rgba(255,255,255,0.25); 75 | box-shadow: inset 0px 1px 0 rgba(255,255,255,0.75); 76 | border-radius: 100%; 77 | 78 | &:after { 79 | content: ''; 80 | position: absolute; 81 | top: 0; 82 | left: 0; 83 | right: 0; 84 | bottom: 0; 85 | margin: auto; 86 | width: 4px; 87 | height: 4px; 88 | border-radius: 100%; 89 | background: $darkgray; 90 | } 91 | 92 | &:last-of-type { 93 | margin-right: 0; 94 | } 95 | 96 | &.yellow { 97 | background: $rca-yellow; 98 | } 99 | &.red { 100 | background: $rca-red; 101 | box-shadow: inset 0px 1px 0 rgba(255,255,255,0.35); 102 | } 103 | &.white { 104 | background: $rca-white; 105 | } 106 | } 107 | } 108 | 109 | .vcr-bg { 110 | position: absolute; 111 | top: 0; 112 | left: 0; 113 | right: 0; 114 | bottom: 0; 115 | z-index: -1; 116 | border-radius: 8px 8px 5px 5px; 117 | border-top: 1px solid rgba(255,255,255,0.3); 118 | background: $darkgray; 119 | } 120 | 121 | .vcr-top { 122 | position: absolute; 123 | width: 100%; 124 | height: 30px; 125 | top: -26px; 126 | z-index: -1; 127 | background: lighten($darkgray, 13%); 128 | border-radius: 4px; 129 | transform: perspective(250px) rotateX(45deg); 130 | transform-origin: bottom center; 131 | } 132 | 133 | .vcr-top-edge { 134 | position: absolute; 135 | top: -1px; 136 | left: 0; 137 | right: 0; 138 | height: 5px; 139 | border-radius: 5px 5px 0 0; 140 | border-top: 1px solid rgba(255,255,255,0.05); 141 | } 142 | 143 | .vcr-foot { 144 | position: absolute; 145 | bottom: -4px; 146 | height: 4px; 147 | width: 45px; 148 | border-radius: 0 0 4px 4px; 149 | background: $black; 150 | 151 | &.vcr-foot-left { 152 | left: 4%; 153 | } 154 | 155 | &.vcr-foot-right { 156 | right: 4%; 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /packages/_demo/config/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var path = require('path'); 3 | var autoprefixer = require('autoprefixer'); 4 | var webpack = require('webpack'); 5 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | var CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin'); 7 | var paths = require('./paths'); 8 | 9 | 10 | var reduxVCRProjectNames = [ 11 | 'capture', 'persist', 'retrieve', 'replay', '_shared', 12 | ]; 13 | 14 | var reduxVCRPaths = reduxVCRProjectNames.map(function(projectName) { 15 | return path.join(__dirname, '../../' + projectName + '/src'); 16 | }); 17 | 18 | 19 | module.exports = { 20 | devtool: 'eval', 21 | entry: [ 22 | require.resolve('webpack-dev-server/client') + '?/', 23 | require.resolve('webpack/hot/dev-server'), 24 | require.resolve('./polyfills'), 25 | path.join(paths.appSrc, 'index') 26 | ], 27 | output: { 28 | // Next line is not used in dev but WebpackDevServer crashes without it: 29 | path: paths.appBuild, 30 | pathinfo: true, 31 | filename: 'static/js/bundle.js', 32 | publicPath: '/' 33 | }, 34 | resolve: { 35 | extensions: ['', '.js', '.json'], 36 | alias: { 37 | // This `alias` section can be safely removed after ejection. 38 | // We do this because `babel-runtime` may be inside `react-scripts`, 39 | // so when `babel-plugin-transform-runtime` imports it, it will not be 40 | // available to the app directly. This is a temporary solution that lets 41 | // us ship support for generators. However it is far from ideal, and 42 | // if we don't have a good solution, we should just make `babel-runtime` 43 | // a dependency in generated projects. 44 | // See https://github.com/facebookincubator/create-react-app/issues/255 45 | 'babel-runtime/regenerator': require.resolve('babel-runtime/regenerator') 46 | } 47 | }, 48 | resolveLoader: { 49 | root: paths.ownNodeModules, 50 | moduleTemplates: ['*-loader'] 51 | }, 52 | module: { 53 | preLoaders: [ 54 | { 55 | test: /\.js$/, 56 | loader: 'eslint', 57 | include: paths.appSrc, 58 | } 59 | ], 60 | loaders: [ 61 | { 62 | test: /\.js$/, 63 | include: [paths.appSrc].concat(reduxVCRPaths), 64 | loader: 'babel', 65 | query: require('./babel.dev') 66 | }, 67 | { 68 | test: /\.css$/, 69 | include: [ 70 | paths.appSrc, 71 | paths.appNodeModules, 72 | ].concat(reduxVCRPaths), 73 | loader: 'style!css!postcss' 74 | }, 75 | { 76 | test: /\.scss$/, 77 | include: reduxVCRPaths, 78 | loader: 'style!css!postcss!sass' 79 | }, 80 | { 81 | test: /\.json$/, 82 | include: [paths.appSrc, paths.appNodeModules], 83 | loader: 'json' 84 | }, 85 | { 86 | test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/, 87 | include: [paths.appSrc, paths.appNodeModules], 88 | loader: 'file', 89 | query: { 90 | name: 'static/media/[name].[ext]' 91 | } 92 | }, 93 | { 94 | test: /\.(mp4|webm)(\?.*)?$/, 95 | include: [paths.appSrc, paths.appNodeModules], 96 | loader: 'url', 97 | query: { 98 | limit: 10000, 99 | name: 'static/media/[name].[ext]' 100 | } 101 | } 102 | ] 103 | }, 104 | eslint: { 105 | configFile: path.join(__dirname, 'eslint.js'), 106 | useEslintrc: false 107 | }, 108 | postcss: function() { 109 | return [autoprefixer]; 110 | }, 111 | plugins: [ 112 | new HtmlWebpackPlugin({ 113 | inject: true, 114 | template: paths.appHtml, 115 | favicon: paths.appFavicon, 116 | }), 117 | new webpack.DefinePlugin({ 'process.env.NODE_ENV': '"development"' }), 118 | // Note: only CSS is currently hot reloaded 119 | new webpack.HotModuleReplacementPlugin(), 120 | new CaseSensitivePathsPlugin() 121 | ] 122 | }; 123 | -------------------------------------------------------------------------------- /packages/replay/src/create-replay-middleware.js: -------------------------------------------------------------------------------- 1 | import merge from 'lodash.merge'; 2 | import invariant from 'invariant'; 3 | import { actionTypes, actionCreators, errors } from 'redux-vcr.shared'; 4 | 5 | import createReplayHandler from './create-replay-handler'; 6 | 7 | const { 8 | PLAY_CASSETTE, 9 | PAUSE_CASSETTE, 10 | STOP_CASSETTE, 11 | EJECT_CASSETTE, 12 | } = actionTypes; 13 | const { 14 | rewindCassetteAndRestoreApp, 15 | changeMaximumDelay, 16 | updateCassetteInitialState, 17 | } = actionCreators; 18 | const { 19 | playWithNoCassetteSelected, 20 | playWithInvalidCassetteSelected, 21 | } = errors; 22 | 23 | const createReplayMiddleware = ({ 24 | replayHandler = createReplayHandler(), 25 | maximumDelay, 26 | overwriteCassetteState, 27 | onPlay, 28 | onPause, 29 | onStop, 30 | onEject, 31 | } = {}) => store => next => { 32 | if (typeof maximumDelay !== 'undefined') { 33 | next(changeMaximumDelay({ maximumDelay })); 34 | } 35 | 36 | return action => { 37 | switch (action.type) { 38 | case PLAY_CASSETTE: { 39 | const state = store.getState().reduxVCR; 40 | const { 41 | play: { status }, 42 | cassettes: { byId, selected }, 43 | } = state; 44 | 45 | // If the cassette is already playing, no action is needed. 46 | if (status === 'playing') { 47 | break; 48 | } 49 | 50 | // Ensure that a valid cassette is selected. 51 | invariant( 52 | !!selected, 53 | playWithNoCassetteSelected() 54 | ); 55 | 56 | invariant( 57 | !!byId[selected], 58 | playWithInvalidCassetteSelected(selected, byId) 59 | ); 60 | 61 | // If the cassette is currently `paused`, we can just start playing it. 62 | // However, if the cassette is `stopped`, we need to reset the state, 63 | // so that we can be sure it plays in the right context. 64 | if (status === 'stopped') { 65 | // Update our initial state if an overwrite is provided 66 | if (overwriteCassetteState) { 67 | const { initialState } = byId[selected]; 68 | 69 | // If the provided value is an object, we want to deep merge it. 70 | // Otherwise, if the provided value is a function, we want to 71 | // apply it with the initial state. 72 | const newState = typeof overwriteCassetteState === 'function' 73 | ? overwriteCassetteState(initialState) 74 | : merge({}, initialState, overwriteCassetteState); 75 | 76 | next(updateCassetteInitialState({ selected, newState })); 77 | } 78 | 79 | next(rewindCassetteAndRestoreApp()); 80 | } 81 | 82 | next(action); 83 | 84 | replayHandler.play({ 85 | store, 86 | next, 87 | }); 88 | 89 | if (onPlay) { onPlay(store.dispatch, store.getState); } 90 | 91 | break; 92 | } 93 | 94 | case PAUSE_CASSETTE: { 95 | const { status } = store.getState().reduxVCR.play; 96 | 97 | if (status !== 'paused' && onPause) { 98 | onPause(store.dispatch, store.getState); 99 | } 100 | 101 | next(action); 102 | break; 103 | } 104 | 105 | case STOP_CASSETTE: { 106 | const { status } = store.getState().reduxVCR.play; 107 | 108 | next(action); 109 | 110 | if (status !== 'stopped') { 111 | next(rewindCassetteAndRestoreApp()); 112 | 113 | if (onStop) { onStop(store.dispatch, store.getState); } 114 | } 115 | 116 | break; 117 | } 118 | 119 | case EJECT_CASSETTE: { 120 | const { status } = store.getState().reduxVCR.cassettes; 121 | 122 | next(action); 123 | 124 | if (status === 'loaded' && onEject) { 125 | onEject(store.dispatch, store.getState); 126 | } 127 | 128 | break; 129 | } 130 | 131 | default: { 132 | next(action); 133 | } 134 | } 135 | }; 136 | }; 137 | 138 | export default createReplayMiddleware; 139 | -------------------------------------------------------------------------------- /packages/replay/src/components/Cassette/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import themes from '../../data/cassette-themes'; 5 | import './index.scss'; 6 | 7 | 8 | const Cassette = ({ 9 | id, 10 | label, 11 | timestamp, 12 | numOfActions, 13 | handleClick, 14 | theme, 15 | offset, 16 | }) => { 17 | const classes = classNames( 18 | 'cassette', 19 | `theme-${theme}` 20 | ); 21 | 22 | const styles = { 23 | transform: `translateX(${offset}px)`, 24 | }; 25 | 26 | 27 | let labelHeader; 28 | switch (theme) { 29 | case 'kodak': { 30 | labelHeader = ( 31 |
32 | ); 33 | break; 34 | } 35 | case 'tdk': { 36 | labelHeader = ( 37 |
38 |
39 |
40 | ); 41 | break; 42 | } 43 | default: { 44 | // nothing 45 | } 46 | } 47 | 48 | let labelFooter; 49 | switch (theme) { 50 | case 'polaroid': { 51 | labelFooter = ( 52 |
53 |
54 |
55 |
56 | Polaroid 57 |
58 | ); 59 | break; 60 | } 61 | 62 | case 'kodak': { 63 | labelFooter = ( 64 |
Kodak
65 | ); 66 | break; 67 | } 68 | 69 | case 'tdk': { 70 | labelFooter = ( 71 |
72 |
TDK
73 |
Made in Japan
74 |
75 | ); 76 | break; 77 | } 78 | 79 | default: { 80 | labelFooter =
VHS
; 81 | break; 82 | } 83 | } 84 | 85 | const dateObject = new Date(timestamp); 86 | const dateString = dateObject.toDateString(); 87 | const timeString = dateObject.toTimeString().substr(0, 5); 88 | const dateDisplayString = `${dateString} ${timeString}`; 89 | 90 | return ( 91 |
handleClick({ id })} 95 | > 96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | 106 |
107 |
108 |
109 |
110 |
111 |
112 | {labelHeader} 113 |
114 |
115 | Name: 116 | {label || id} 117 |
118 |
119 |
120 |
121 | Recorded: 122 | {dateDisplayString} 123 |
124 |
125 | Actions: 126 | {numOfActions} 127 |
128 |
129 | {labelFooter} 130 |
131 |
132 |
133 | ); 134 | }; 135 | 136 | Cassette.propTypes = { 137 | id: PropTypes.string.isRequired, 138 | label: PropTypes.string, 139 | timestamp: PropTypes.number.isRequired, 140 | numOfActions: PropTypes.number.isRequired, 141 | handleClick: PropTypes.func, 142 | theme: PropTypes.oneOf(Object.keys(themes)), 143 | offset: PropTypes.oneOfType([ 144 | PropTypes.number, 145 | PropTypes.string, 146 | ]), 147 | }; 148 | 149 | Cassette.defaultProps = { 150 | handleClick() {}, 151 | theme: 'generic', 152 | offset: 0, 153 | }; 154 | 155 | export { Cassette }; 156 | export default Cassette; 157 | -------------------------------------------------------------------------------- /packages/_shared/src/reducers/cassettes.reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { createSelector } from 'reselect'; 3 | 4 | import { 5 | CASSETTES_LIST_SUCCESS, 6 | CASSETTES_LIST_FAILURE, 7 | EJECT_CASSETTE, 8 | GO_TO_NEXT_CASSETTE_PAGE, 9 | GO_TO_PREVIOUS_CASSETTE_PAGE, 10 | UPDATE_CASSETTE_INITIAL_STATE, 11 | HIDE_CASSETTES, 12 | SELECT_CASSETTE, 13 | VIEW_CASSETTES, 14 | } from '../actions'; 15 | 16 | 17 | const defaultStates = { 18 | status: 'idle', 19 | selected: null, 20 | byId: {}, 21 | page: { 22 | number: 0, 23 | limit: 5, 24 | }, 25 | }; 26 | 27 | 28 | function statusReducer(state = defaultStates.status, action) { 29 | switch (action.type) { 30 | case VIEW_CASSETTES: return 'selecting'; 31 | case CASSETTES_LIST_FAILURE: 32 | case EJECT_CASSETTE: 33 | case HIDE_CASSETTES: return 'idle'; 34 | case SELECT_CASSETTE: return 'loaded'; 35 | default: return state; 36 | } 37 | } 38 | 39 | function selectedReducer(state = defaultStates.selected, action) { 40 | switch (action.type) { 41 | case SELECT_CASSETTE: return action.id; 42 | case EJECT_CASSETTE: return null; 43 | default: return state; 44 | } 45 | } 46 | 47 | function byIdReducer(state = defaultStates.byId, action) { 48 | switch (action.type) { 49 | case CASSETTES_LIST_SUCCESS: return action.cassettes; 50 | case UPDATE_CASSETTE_INITIAL_STATE: { 51 | const newCassette = { 52 | ...state[action.selected], 53 | initialState: action.newState, 54 | }; 55 | 56 | return { 57 | ...state, 58 | [action.selected]: newCassette, 59 | }; 60 | } 61 | default: return state; 62 | } 63 | } 64 | 65 | function pageNumberReducer(state = defaultStates.page.number, action) { 66 | switch (action.type) { 67 | case GO_TO_NEXT_CASSETTE_PAGE: return state + 1; 68 | case GO_TO_PREVIOUS_CASSETTE_PAGE: return state - 1; 69 | default: return state; 70 | } 71 | } 72 | 73 | function pageLimitReducer(state = defaultStates.page.limit, action) { 74 | switch (action.type) { 75 | default: return state; 76 | } 77 | } 78 | 79 | 80 | export default combineReducers({ 81 | status: statusReducer, 82 | selected: selectedReducer, 83 | byId: byIdReducer, 84 | page: combineReducers({ 85 | number: pageNumberReducer, 86 | limit: pageLimitReducer, 87 | }), 88 | }); 89 | 90 | 91 | // //////////////////////// 92 | // SELECTORS ///////////// 93 | // ////////////////////// 94 | const cassettesByIdSelector = state => state.reduxVCR.cassettes.byId; 95 | const selectedIdSelector = state => state.reduxVCR.cassettes.selected; 96 | const cassettePageNumberSelector = state => state.reduxVCR.cassettes.page.number; 97 | const cassettePageLimitSelector = state => state.reduxVCR.cassettes.page.limit; 98 | 99 | export const selectedCassetteSelector = createSelector( 100 | selectedIdSelector, 101 | cassettesByIdSelector, 102 | (selectedId, cassettesById) => cassettesById[selectedId] 103 | ); 104 | 105 | export const cassetteListSelector = createSelector( 106 | cassettesByIdSelector, 107 | (cassettesById) => { 108 | const cassetteIds = Object.keys(cassettesById); 109 | 110 | return cassetteIds 111 | .map(id => ({ id, ...cassettesById[id] })) 112 | .sort((a, b) => b.timestamp - a.timestamp); 113 | } 114 | ); 115 | 116 | export const paginatedCassetteListSelector = createSelector( 117 | cassetteListSelector, 118 | cassettePageNumberSelector, 119 | cassettePageLimitSelector, 120 | (cassetteList, pageNumber, pageLimit) => { 121 | const startIndex = pageNumber * pageLimit; 122 | const endIndex = startIndex + pageLimit; 123 | 124 | return cassetteList.slice(startIndex, endIndex); 125 | } 126 | ); 127 | 128 | export const isFirstPageSelector = createSelector( 129 | cassettePageNumberSelector, 130 | (pageNumber) => pageNumber === 0 131 | ); 132 | 133 | export const isLastPageSelector = createSelector( 134 | cassetteListSelector, 135 | cassettePageNumberSelector, 136 | cassettePageLimitSelector, 137 | (cassetteList, pageNumber, pageLimit) => { 138 | // Page numbers are zero-indexed, but we want them to be one-indexed. 139 | const truePageNumber = pageNumber + 1; 140 | 141 | const numOfCassettes = cassetteList.length; 142 | const numOfPages = Math.ceil(numOfCassettes / pageLimit); 143 | 144 | return truePageNumber >= numOfPages; 145 | } 146 | ); 147 | -------------------------------------------------------------------------------- /packages/replay/src/components/CassetteList/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import classNames from 'classnames'; 4 | 5 | import { actionCreators, cassetteSelectors } from 'redux-vcr.shared'; 6 | import sampleWithProbability from '../../utils/sample-with-probability'; 7 | import cassetteThemes from '../../data/cassette-themes'; 8 | import cassetteOffsets from '../../data/cassette-offsets'; 9 | import Cassette from '../Cassette'; 10 | import Icon from '../Icon'; 11 | import './index.scss'; 12 | 13 | const { 14 | isFirstPageSelector, 15 | isLastPageSelector, 16 | paginatedCassetteListSelector, 17 | } = cassetteSelectors; 18 | 19 | 20 | class CassetteList extends Component { 21 | constructor(props) { 22 | super(props); 23 | 24 | this.animateCassetteSelection = this.animateCassetteSelection.bind(this); 25 | this.renderCassette = this.renderCassette.bind(this); 26 | 27 | // While the actual `selectedCassette` value lives in the redux store, 28 | // we want to animate the selection process. Therefore, we first set this 29 | // component's local state, base the animation off of it, and when it's 30 | // complete we dispatch the action to change the actual value. 31 | this.state = { 32 | selectedCassette: null, 33 | }; 34 | } 35 | 36 | componentWillMount() { 37 | // Fetch an up-to-date list of the cassettes. 38 | this.props.cassettesListRequest(); 39 | } 40 | 41 | animateCassetteSelection({ id }) { 42 | this.setState({ 43 | selectedCassette: id, 44 | }); 45 | 46 | setTimeout(() => { 47 | this.props.selectCassette({ id }); 48 | }, 1000); 49 | } 50 | 51 | renderCassette(cassette) { 52 | const { selectedCassette } = this.state; 53 | const { id } = cassette; 54 | 55 | const classes = classNames({ 56 | 'cassette-wrapper': true, 57 | 'fading-away': selectedCassette && selectedCassette !== id, 58 | 'selected': selectedCassette === id, 59 | }); 60 | 61 | return ( 62 |
63 | 69 |
70 | ); 71 | } 72 | 73 | render() { 74 | const { 75 | cassettes, 76 | isFirstPage, 77 | isLastPage, 78 | goToNextCassettePage, 79 | goToPreviousCassettePage, 80 | } = this.props; 81 | 82 | const nextButtonClasses = classNames([ 83 | 'vcr-pagination-control', 84 | 'next', 85 | { 'fade-away': !!this.state.selectedCassette }, 86 | ]); 87 | 88 | const previousButtonClasses = classNames([ 89 | 'vcr-pagination-control', 90 | 'previous', 91 | { 'fade-away': !!this.state.selectedCassette }, 92 | ]); 93 | 94 | return ( 95 |
96 | {cassettes.map(this.renderCassette)} 97 | 98 | 105 | 112 |
113 | ); 114 | } 115 | } 116 | 117 | CassetteList.propTypes = { 118 | cassettes: PropTypes.array, 119 | isFirstPage: PropTypes.bool, 120 | isLastPage: PropTypes.bool, 121 | cassettesListRequest: PropTypes.func.isRequired, 122 | selectCassette: PropTypes.func.isRequired, 123 | goToNextCassettePage: PropTypes.func.isRequired, 124 | goToPreviousCassettePage: PropTypes.func.isRequired, 125 | }; 126 | 127 | CassetteList.defaultProps = { 128 | }; 129 | 130 | const mapStateToProps = state => { 131 | return { 132 | cassettes: paginatedCassetteListSelector(state), 133 | isFirstPage: isFirstPageSelector(state), 134 | isLastPage: isLastPageSelector(state), 135 | }; 136 | }; 137 | 138 | export { CassetteList }; 139 | 140 | export default connect(mapStateToProps, { 141 | cassettesListRequest: actionCreators.cassettesListRequest, 142 | selectCassette: actionCreators.selectCassette, 143 | goToNextCassettePage: actionCreators.goToNextCassettePage, 144 | goToPreviousCassettePage: actionCreators.goToPreviousCassettePage, 145 | })(CassetteList); 146 | -------------------------------------------------------------------------------- /packages/replay/src/components/VCRScreen/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import classNames from 'classnames'; 4 | 5 | import './index.scss'; 6 | 7 | 8 | class VCRScreen extends Component { 9 | getScreenLabel() { 10 | switch (this.props.screenMode) { 11 | case 'loaded': return 'Selected'; 12 | case 'playing': return 'Playing'; 13 | case 'paused': return 'Paused'; 14 | default: return ''; 15 | } 16 | } 17 | 18 | getScreenEffects() { 19 | switch (this.props.screenMode) { 20 | case 'error': 21 | return ['flashing', 'centered']; 22 | case 'unauthenticated': 23 | return ['scrolling', 'centered']; 24 | case 'idle': 25 | case 'selecting': 26 | return ['centered']; 27 | default: 28 | return []; 29 | } 30 | } 31 | 32 | getScreenContents() { 33 | switch (this.props.screenMode) { 34 | case 'error': 35 | return 'ERROR. See console for details.'; 36 | case 'unauthenticated': 37 | return 'Click to authenticate with GitHub'; 38 | case 'idle': 39 | return 'Click to select a cassette'; 40 | case 'selecting': 41 | return 'Selecting...'; 42 | case 'loaded': 43 | return this.props.selectedCassetteId; 44 | default: 45 | // TODO: Scrubber 46 | return this.props.selectedCassetteId; 47 | } 48 | } 49 | 50 | render() { 51 | const { screenMode, onClick } = this.props; 52 | 53 | const textColor = screenMode === 'error' ? 'red' : 'green'; 54 | const label = this.getScreenLabel(); 55 | const effects = this.getScreenEffects(); 56 | const contents = this.getScreenContents(); 57 | 58 | const bufferClasses = classNames('vcr-screen-buffer', { 59 | vertical: effects.includes('scrolling'), 60 | }); 61 | const contentsClasses = classNames([ 62 | 'vcr-screen-contents', 63 | textColor, 64 | ...effects, 65 | // If we've supplied a label, we need to make space for it by 66 | // edging our main content down a bit. 67 | { 'edged-down': !!label }, 68 | ]); 69 | 70 | return ( 71 |
72 |
73 |
{label}
74 |
75 | {contents} 76 |
77 |
78 |
79 | ); 80 | } 81 | } 82 | 83 | VCRScreen.propTypes = { 84 | screenMode: PropTypes.oneOf([ 85 | 'error', 86 | 'unauthenticated', 87 | 'idle', 88 | 'selecting', 89 | 'loaded', 90 | 'playing', 91 | 'paused', 92 | ]).isRequired, 93 | selectedCassetteId: PropTypes.string, 94 | numOfActions: PropTypes.number, 95 | onClick: PropTypes.func.isRequired, 96 | }; 97 | 98 | VCRScreen.defaultProps = { 99 | textColor: 'green', 100 | effects: [], 101 | }; 102 | 103 | const mapStateToProps = state => { 104 | if (process.env.NODE_ENV === 'test') { 105 | return {}; 106 | } 107 | // Our VCR screen is capable of displaying a ton of different stuff, 108 | // depending on our state. Initially I was going to use selectors in 109 | // the reducer files, but they span multiple concerns. 110 | // 111 | // If performance is a problem, consider memoizing these computations. 112 | const { 113 | reduxVCR: { 114 | cassettes: { 115 | status: cassetteStatus, 116 | selected: selectedCassetteId, 117 | byId: cassettesById, 118 | }, 119 | play: { 120 | status: playStatus, 121 | }, 122 | authentication: { 123 | loggedIn: isLoggedIn, 124 | error: hasAuthError, 125 | requiresAuth, 126 | }, 127 | }, 128 | } = state; 129 | 130 | let screenMode; 131 | if (hasAuthError) { 132 | screenMode = 'error'; 133 | } else if (!isLoggedIn && requiresAuth) { 134 | screenMode = 'unauthenticated'; 135 | } else if (playStatus === 'playing') { 136 | screenMode = 'playing'; 137 | } else if (playStatus === 'paused') { 138 | screenMode = 'paused'; 139 | } else { 140 | screenMode = cassetteStatus; // idle, selecting, loaded 141 | } 142 | 143 | const selectedCassette = cassettesById[selectedCassetteId]; 144 | const numOfActions = selectedCassette ? selectedCassette.numOfActions : 0; 145 | 146 | return { 147 | screenMode, 148 | selectedCassetteId, 149 | numOfActions, 150 | }; 151 | }; 152 | 153 | 154 | export { VCRScreen }; 155 | 156 | export default connect(mapStateToProps)(VCRScreen); 157 | -------------------------------------------------------------------------------- /packages/_demo/config/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var autoprefixer = require('autoprefixer'); 3 | var webpack = require('webpack'); 4 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | var ExtractTextPlugin = require('extract-text-webpack-plugin'); 6 | var url = require('url'); 7 | var paths = require('./paths'); 8 | 9 | 10 | var homepagePath = require(paths.appPackageJson).homepage; 11 | var publicPath = homepagePath ? url.parse(homepagePath).pathname : '/'; 12 | if (!publicPath.endsWith('/')) { 13 | // Prevents incorrect paths in file-loader 14 | publicPath += '/'; 15 | } 16 | 17 | module.exports = { 18 | bail: true, 19 | devtool: 'source-map', 20 | entry: [ 21 | require.resolve('./polyfills'), 22 | path.join(paths.appSrc, 'index') 23 | ], 24 | output: { 25 | path: paths.appBuild, 26 | filename: 'static/js/[name].[chunkhash:8].js', 27 | chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js', 28 | publicPath: publicPath 29 | }, 30 | resolve: { 31 | extensions: ['', '.js', '.json'], 32 | alias: { 33 | // This `alias` section can be safely removed after ejection. 34 | // We do this because `babel-runtime` may be inside `react-scripts`, 35 | // so when `babel-plugin-transform-runtime` imports it, it will not be 36 | // available to the app directly. This is a temporary solution that lets 37 | // us ship support for generators. However it is far from ideal, and 38 | // if we don't have a good solution, we should just make `babel-runtime` 39 | // a dependency in generated projects. 40 | // See https://github.com/facebookincubator/create-react-app/issues/255 41 | 'babel-runtime/regenerator': require.resolve('babel-runtime/regenerator') 42 | } 43 | }, 44 | resolveLoader: { 45 | root: paths.ownNodeModules, 46 | moduleTemplates: ['*-loader'] 47 | }, 48 | module: { 49 | preLoaders: [ 50 | { 51 | test: /\.js$/, 52 | loader: 'eslint', 53 | include: paths.appSrc 54 | } 55 | ], 56 | loaders: [ 57 | { 58 | test: /\.js$/, 59 | include: paths.appSrc, 60 | loader: 'babel', 61 | query: require('./babel.prod') 62 | }, 63 | { 64 | test: /\.css$/, 65 | include: [paths.appSrc, paths.appNodeModules], 66 | // Disable autoprefixer in css-loader itself: 67 | // https://github.com/webpack/css-loader/issues/281 68 | // We already have it thanks to postcss. 69 | loader: ExtractTextPlugin.extract('style', 'css?-autoprefixer!postcss') 70 | }, 71 | { 72 | test: /\.json$/, 73 | include: [paths.appSrc, paths.appNodeModules], 74 | loader: 'json' 75 | }, 76 | { 77 | test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/, 78 | include: [paths.appSrc, paths.appNodeModules], 79 | loader: 'file', 80 | query: { 81 | name: 'static/media/[name].[hash:8].[ext]' 82 | } 83 | }, 84 | { 85 | test: /\.(mp4|webm)(\?.*)?$/, 86 | include: [paths.appSrc, paths.appNodeModules], 87 | loader: 'url', 88 | query: { 89 | limit: 10000, 90 | name: 'static/media/[name].[hash:8].[ext]' 91 | } 92 | } 93 | ] 94 | }, 95 | eslint: { 96 | // TODO: consider separate config for production, 97 | // e.g. to enable no-console and no-debugger only in prod. 98 | configFile: path.join(__dirname, 'eslint.js'), 99 | useEslintrc: false 100 | }, 101 | postcss: function() { 102 | return [autoprefixer]; 103 | }, 104 | plugins: [ 105 | new HtmlWebpackPlugin({ 106 | inject: true, 107 | template: paths.appHtml, 108 | favicon: paths.appFavicon, 109 | minify: { 110 | removeComments: true, 111 | collapseWhitespace: true, 112 | removeRedundantAttributes: true, 113 | useShortDoctype: true, 114 | removeEmptyAttributes: true, 115 | removeStyleLinkTypeAttributes: true, 116 | keepClosingSlash: true, 117 | minifyJS: true, 118 | minifyCSS: true, 119 | minifyURLs: true 120 | } 121 | }), 122 | new webpack.DefinePlugin({ 'process.env.NODE_ENV': '"production"' }), 123 | new webpack.optimize.OccurrenceOrderPlugin(), 124 | new webpack.optimize.DedupePlugin(), 125 | new webpack.optimize.UglifyJsPlugin({ 126 | compress: { 127 | screw_ie8: true, 128 | warnings: false 129 | }, 130 | mangle: { 131 | screw_ie8: true 132 | }, 133 | output: { 134 | comments: false, 135 | screw_ie8: true 136 | } 137 | }), 138 | new ExtractTextPlugin('static/css/[name].[contenthash:8].css') 139 | ] 140 | }; 141 | -------------------------------------------------------------------------------- /documentation/javascript-implementation.md: -------------------------------------------------------------------------------- 1 | # Javascript Implementation 2 | 3 | *Note: It is recommended you start with [Firebase configuration](firebase-config.md). This guide assumes you have your `config` object from Firebase ready to paste.* 4 | 5 | ReduxVCR can be implemented a few different ways. This guide will go through a simple, "quickstart" version. 6 | 7 | ## Other Implementations 8 | 9 | To help demonstrate various implementations, I've created a repo, Redux VCR TodoMVC. It has several pull requests open that showcase the diffs needed for different strategies: 10 | 11 | - [Quickstart](https://github.com/joshwcomeau/redux-vcr-todomvc/pull/1) 12 | - [Production-ready](https://github.com/joshwcomeau/redux-vcr-todomvc/pull/2) 13 | - [Quickstart without authentication](https://github.com/joshwcomeau/redux-vcr-todomvc/pull/3) 14 | - [Initial Cassette Pre-loaded](https://github.com/joshwcomeau/redux-vcr-todomvc/pull/4) 15 | - More to come! 16 | 17 | ## Quickstart Implementation 18 | 19 | #### Install 20 | Start with a good, old-fashioned NPM install. 21 | 22 | ```bash 23 | $ npm i -S redux-vcr 24 | ``` 25 | 26 | #### Integrate with Redux store 27 | As per the [Firebase configuration instructions](firebase-config.md), you should have a `config` object ready, which will allow your application to connect with Firebase. 28 | 29 | ```js 30 | var config = { 31 | apiKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32 | authDomain: "your-project-name.firebaseapp.com", 33 | databaseURL: "https://your-project-name.firebaseio.com", 34 | storageBucket: "your-project-name.appspot.com", 35 | messagingSenderId: "1234567890" 36 | }; 37 | ``` 38 | 39 | Let's modernize this a bit, and trim off the stuff we don't need: 40 | 41 | ```js 42 | const firebaseAuth = { 43 | apiKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 44 | authDomain: "your-project-name.firebaseapp.com", 45 | databaseURL: "https://your-project-name.firebaseio.com", 46 | }; 47 | ``` 48 | 49 | This next part will vary depending on your setup, but assuming a conventional 'configure-store.js' file, you'll want to add the following lines: 50 | 51 | ```js 52 | // If you aren't already, be sure to import `applyMiddleware` from redux. 53 | import { createStore, applyMiddleware } from 'redux'; 54 | 55 | import { 56 | createCaptureMiddleware, 57 | createPersistHandler, 58 | createRetrieveHandler, 59 | createRetrieveMiddleware, 60 | createReplayMiddleware, 61 | wrapReducer, 62 | } from 'redux-vcr'; 63 | 64 | const firebaseAuth = { 65 | apiKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 66 | authDomain: "your-project-name.firebaseapp.com", 67 | databaseURL: "https://your-project-name.firebaseio.com", 68 | }; 69 | 70 | // The persist handler is what sends our data to Firebase 71 | const persistHandler = createPersistHandler({ firebaseAuth }); 72 | 73 | // Similarly, the retrieve handler fetches those actions so that they can 74 | // be replayed by you and your team 75 | const retrieveHandler = createRetrieveHandler({ firebaseAuth }); 76 | 77 | // We need quite a few middlewares. You can learn more about each of them 78 | // in our API reference docs. 79 | const middlewares = [ 80 | createCaptureMiddleware({ persistHandler }), 81 | createRetrieveMiddleware({ retrieveHandler }), 82 | createReplayMiddleware({ maximumDelay: 1000 }), 83 | // Feel free to add in your own middlewares here 84 | ]; 85 | 86 | // Finally, create our store. 87 | // We need to use our higher-order reducer, which allows us to reset the 88 | // global app state when a new cassette is loaded or ejected. 89 | const store = createStore( 90 | wrapReducer(reducer), 91 | applyMiddleware(...middlewares) 92 | ); 93 | ``` 94 | 95 | #### Integrate the Replay component 96 | 97 | Finally, we want to add the UI to interact with our recorded cassettes! 98 | 99 | It is recommended to place it as high in your app as possible, similar to the official Redux devtools. 100 | 101 | ```js 102 | import { Replay } from 'redux-vcr'; 103 | 104 | const YourApp = () => ( 105 |
106 | {/* Your app stuff here */} 107 | 108 |
109 | ) 110 | ``` 111 | 112 | Replay requires no configuration via props, as all of the logic can be configured in the middlewares above. There are some aesthetic tweaks you can make, though. 113 | 114 | **That's it! You should be up and running :)** 115 | 116 | 117 | ## Advanced configuration 118 | 119 | This setup is good for quick experimentation, but it is ill-suited for production. 120 | 121 | You'll want to check out the [production-ready](https://github.com/joshwcomeau/redux-vcr-todomvc/pull/2) diff to see how a production-ready configuration varies. 122 | 123 | You can also check out our [API reference docs](API-reference.md), to learn about all the neat configuration Redux VCR currently supports. 124 | -------------------------------------------------------------------------------- /documentation/firebase-config.md: -------------------------------------------------------------------------------- 1 | # Firebase Configuration 2 | 3 | By default, ReduxVCR uses Firebase v3.2 for data persistence. 4 | 5 | 6 | 7 | ### Step 1: Sign up for Firebase and create a project 8 | 9 | Head on over to firebase.google.com and sign up for a free account. Once you make it to [the console](https://console.firebase.google.com/), click 'CREATE NEW PROJECT'. 10 | 11 | Select a name and region, and click 'CREATE PROJECT'. 12 | 13 | 14 | 15 | ### Step 2: Set up Authentication 16 | 17 | This devtool needs to support two different "types" of users: 18 | 19 | - We want anonymous, "background" authentication for the users of our sites. We want to record their sessions regardless of whether they're logged in or not. 20 | 21 | - We want proper, third-party authentication for us developers, so that only privileged users can view the sessions we've recorded. 22 | 23 | 24 | #### 2A - Anonymous authentication 25 | 26 | Firebase supports anonymous authentication. 27 | 28 | The idea is pretty simple: You make a request, on page load, to authenticate anonymously. The server generates a temporary ID, and the client can then use that ID to make authenticated requests. The ID is lost when the session ends, or the page is refreshed, which makes it perfect for our needs. 29 | 30 | We need to enable anonymous authentication. In the left-hand menu, select 'Auth'. Then, from the header, select 'SIGN-IN METHOD'. 31 | 32 | Click on 'Anonymous', the final option in the list of providers, and enable it. 33 | 34 | 35 | 36 | 37 | 38 | #### 2B - Developer authentication 39 | 40 | Next, we need to set up Github OAuth so that we can authenticate ourselves. 41 | 42 | If you aren't already there, navigate to the 'SIGN-IN METHOD' page under 'Auth', and click on 'GitHub'. Click the 'Enable' toggle. 43 | 44 | In a new tab, [head on over to GitHub and create a new application](https://github.com/settings/applications/new). Give your application a name, and paste in the "authorization callback URL" from Firebase. Click 'Register Application'. 45 | 46 | GitHub will now have client keys for you. Copy and paste the ID and secret from GitHub into Firebase, and save your authorization details in Firebase. 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ### Step 3: Configure the rules for the project 55 | 56 | We've now authenticated all users who connect to Firebase, but we need to set up authorization. 57 | 58 | Firebase works with a JSON-like set of rules for controlling access, as well as other config like setting up indexes, or validating write requests. 59 | 60 | For our anonymous users, we want them to be able to _write_ to their own slice of the database, but we don't want them to be able to _read_ from the database at all. 61 | 62 | For our developers, we want them to have full _read_ access, but they don't need to be able to _write_. 63 | 64 | #### Setup 65 | 66 | In the left-hand menu, click 'Database', and then select 'RULES' from the main header. 67 | 68 | Here are the rules you'll want to implement: 69 | 70 | ```js 71 | { 72 | "rules": { 73 | ".read": "auth.token.email === 'your@domain.com'", 74 | "cassettes": { 75 | ".indexOn": ["timestamp"], 76 | "$uid": { 77 | ".write": "$uid === auth.uid" 78 | } 79 | }, 80 | "actions": { 81 | "$uid": { 82 | ".write": "$uid === auth.uid" 83 | } 84 | } 85 | } 86 | } 87 | ``` 88 | 89 | Firebase rules allow you to use logical operators like `&&` and `||`. For example, if you need multiple developers to be able to use the devtool, you could write: 90 | 91 | ```js 92 | { 93 | "rules": { 94 | ".read": "auth.token.email === 'your@email.com' || auth.token.email === 'other@email.com'", 95 | } 96 | } 97 | ``` 98 | 99 | You also have access to regular expressions, so you could structure it like: 100 | 101 | ```js 102 | { 103 | "rules": { 104 | ".read": "auth.token.email.matches(/.*@your_company.com$/)", 105 | } 106 | } 107 | ``` 108 | 109 | Once you're satisfied, click 'PUBLISH' to save the rules. 110 | 111 | 112 | 113 | 114 | 115 | ### Step 4: Get credentials 116 | 117 | Finally, we need to get a copy of the credentials, so that our web app can connect to Firebase. Click on the name of your project in the top-left to go back to the root page, and then click 'Add Firebase to your web app'. 118 | 119 | 120 | 121 | Copy just the `config` object: 122 | 123 | 124 | 125 | ------ 126 | 127 | That's it! You'll use the `config` object in your clipboard in the next section, [Javascript Implementation](javascript-implementation.md). 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReduxVCR 2 | ### A Redux devtool that lets you replay user sessions in real-time. 3 | [![build status](https://travis-ci.org/joshwcomeau/redux-vcr.svg?branch=master)](https://travis-ci.org/joshwcomeau/redux-vcr) 4 | [![npm version](https://img.shields.io/npm/v/redux-vcr.svg)](https://www.npmjs.com/package/redux-vcr) 5 | [![npm monthly downloads](https://img.shields.io/npm/dm/redux-vcr.svg)](https://www.npmjs.com/package/redux-vcr) 6 | 7 | 8 | 9 | 10 | _NOTE: This project is in **early alpha**. I've been using it in production in [Key&Pad](https://github.com/joshwcomeau/key-and-pad), but it has not been tested in larger/more complex applications._ 11 | 12 | ----------- 13 | 14 | ## Demo 15 | 16 | You can see a live demo of real, in-production data at **[Key&Pad](http://keyandpad.com?adminMode=true)**. Click on the VCR screen, select a cassette, and hear some dynamically-recreated music! 17 | 18 | ## Blog Post 19 | 20 | Check out the [Medium post](https://medium.com/@joshuawcomeau/introducing-redux-vcr-cad57b37540a) that details how and why this is being built. 21 | 22 | 23 | ## Features 24 | 25 | #### Insights 26 | 27 | By re-watching a recorded session in real-time, you learn tons about how users use your application. Great for gauging UX, spotting bugs, etc. 28 | 29 | 30 | #### Developer Experience 31 | 32 | Features quality-of-life configuration: 33 | - Max delay between actions: Set a maximum wait time, to remove long gaps when users go idle. 34 | - Speed controls: play your cassettes in 0.5x or 2x speed. 35 | - More to come! 36 | 37 | 38 | #### Serverless Security 39 | 40 | The goal for ReduxVCR was to not need any server-side integration for developers creating front-end-only applications, while still being secure. Using Firebase, we're able to automatically authenticate all users, assuring that they can only edit their own slice of the state, and not read from or write to any other users' sessions. 41 | 42 | For replaying the sessions, GitHub OAuth is used. You set which GitHub email you want to have read access within Firebase. 43 | 44 | 45 | #### Modular Architecture 46 | 47 | Rather than create one monolithic package, ReduxVCR consists of 4 individual packages: 48 | 49 | - **Capture** 50 | The capture layer is responsible for watching the stream of actions, selecting the ones that you'd like to record, and augmenting them with timestamps and metadata. 51 | 52 | - **Persist** 53 | The persist layer receives the data from Capture and persists it to Firebase. It handles all anonymous authentication concerns. 54 | 55 | - **Retrieve** 56 | The retrieve layer, meant to be used in development only, fetches the data from Firebase, and handles all admin authentication concerns. 57 | 58 | - **Replay** 59 | Finally, the replay layer is your interface to navigating and watching the recorded cassettes. 60 | 61 | An effort has been made to ensure that packages can be swapped out. For example, you may wish to use custom database storage, in which case you'd build your own Persist and Retrieve modules. You may wish to have a different UX, in which case you'd build your own Replay module. 62 | 63 | 64 | #### Straightforward Integration 65 | 66 | A fair amount of work has been put into making the integration as simple as possible. For simple apps, the process shouldn't take more than a couple of minutes. 67 | 68 | 69 | -------- 70 | 71 | 72 | ## Getting Started 73 | 74 | There are two parts to integrating Redux VCR into your application. 75 | 76 | * First, we need to set up Firebase to store our data, and ensure that your users' sessions are secured. **[Read the instructions for Firebase configuration.](documentation/firebase-config.md)** 77 | 78 | * Second, we need to add the code needed to hook into Firebase and display the UI. **[Read the instructions for Javascript implementation.](documentation/javascript-implementation.md)** 79 | 80 | 81 | ## Troubleshooting 82 | 83 | As common issues arise, their solutions will be shared here. 84 | 85 | 86 | ## Roadmap 87 | 88 | There are a few pretty blatant things missing from ReduxVCR. 89 | 90 | - Event Recording 91 | 92 | Right now, we're _only_ recording the series of Redux actions. Ideally, we'd want our scroll position to mirror the user's, and it would be nice to know where their cursor is. 93 | 94 | - Scrubbing and Navigation 95 | 96 | When replaying cassettes, there is currently no way to "jump" to a specific portion in the cassette, to see how far along you are in the sequence, etc. A media-player-style scrubber would help quickly analyze and replay crucial moments in a cassette. 97 | 98 | - Support for common tools and environments 99 | 100 | Immutable.js and React Native are both currently untested/unsupported. 101 | 102 | I have limited free time at the moment, so if anybody would like to contribute to this project, these would be great places to start :) 103 | -------------------------------------------------------------------------------- /packages/_shared/src/actions/index.js: -------------------------------------------------------------------------------- 1 | export const CASSETTES_LIST_REQUEST = 'REDUX_VCR/CASSETTES_LIST_REQUEST'; 2 | export const CASSETTES_LIST_SUCCESS = 'REDUX_VCR/CASSETTES_LIST_SUCCESS'; 3 | export const CASSETTES_LIST_FAILURE = 'REDUX_VCR/CASSETTES_LIST_FAILURE'; 4 | export const VIEW_CASSETTES = 'REDUX_VCR/VIEW_CASSETTES'; 5 | export const HIDE_CASSETTES = 'REDUX_VCR/HIDE_CASSETTES'; 6 | export const SELECT_CASSETTE = 'REDUX_VCR/SELECT_CASSETTE'; 7 | export const EJECT_CASSETTE = 'REDUX_VCR/EJECT_CASSETTE'; 8 | export const PLAY_CASSETTE = 'REDUX_VCR/PLAY_CASSETTE'; 9 | export const PAUSE_CASSETTE = 'REDUX_VCR/PAUSE_CASSETTE'; 10 | export const STOP_CASSETTE = 'REDUX_VCR/STOP_CASSETTE'; 11 | export const REWIND_CASSETTE_AND_RESTORE_APP = 'REDUX_VCR/REWIND_CASSETTE_AND_RESTORE_APP'; 12 | export const UPDATE_CASSETTE_INITIAL_STATE = 'REDUX_VCR/UPDATE_CASSETTE_INITIAL_STATE'; 13 | export const GO_TO_NEXT_CASSETTE_PAGE = 'REDUX_VCR/GO_TO_NEXT_CASSETTE_PAGE'; 14 | export const GO_TO_PREVIOUS_CASSETTE_PAGE = 'REDUX_VCR/GO_TO_PREVIOUS_CASSETTE_PAGE'; 15 | export const CASSETTE_ACTIONS_RECEIVE = 'REDUX_VCR/CASSETTE_ACTIONS_RECEIVE'; 16 | export const TOGGLE_PLAY_PAUSE = 'REDUX_VCR/TOGGLE_PLAY_PAUSE'; 17 | export const INCREMENT_ACTIONS_PLAYED = 'REDUX_VCR/INCREMENT_ACTIONS_PLAYED'; 18 | export const CHANGE_PLAYBACK_SPEED = 'REDUX_VCR/CHANGE_PLAYBACK_SPEED'; 19 | export const CHANGE_MAXIMUM_DELAY = 'REDUX_VCR/CHANGE_MAXIMUM_DELAY'; 20 | export const SIGN_IN_REQUEST = 'REDUX_VCR/SIGN_IN_REQUEST'; 21 | export const SIGN_IN_SUCCESS = 'REDUX_VCR/SIGN_IN_SUCCESS'; 22 | export const SIGN_IN_FAILURE = 'REDUX_VCR/SIGN_IN_FAILURE'; 23 | export const SIGN_OUT_REQUEST = 'REDUX_VCR/SIGN_OUT_REQUEST'; 24 | export const SIGN_OUT_SUCCESS = 'REDUX_VCR/SIGN_OUT_SUCCESS'; 25 | export const SIGN_OUT_FAILURE = 'REDUX_VCR/SIGN_OUT_FAILURE'; 26 | export const SET_AUTH_REQUIREMENT = 'REDUX_VCR/SET_AUTH_REQUIREMENT'; 27 | 28 | // //////////////////////// 29 | // ACTION CREATORS /////// 30 | // ////////////////////// 31 | export const cassettesListRequest = () => ({ 32 | type: CASSETTES_LIST_REQUEST, 33 | }); 34 | 35 | export const cassettesListSuccess = ({ cassettes }) => ({ 36 | type: CASSETTES_LIST_SUCCESS, 37 | cassettes, 38 | }); 39 | 40 | export const cassettesListFailure = ({ error }) => ({ 41 | type: CASSETTES_LIST_FAILURE, 42 | error, 43 | }); 44 | 45 | export const viewCassettes = () => ({ 46 | type: VIEW_CASSETTES, 47 | }); 48 | 49 | export const hideCassettes = () => ({ 50 | type: HIDE_CASSETTES, 51 | }); 52 | 53 | export const selectCassette = ({ id }) => ({ 54 | type: SELECT_CASSETTE, 55 | id, 56 | }); 57 | 58 | export const ejectCassette = () => ({ 59 | type: EJECT_CASSETTE, 60 | }); 61 | 62 | export const playCassette = () => ({ 63 | type: PLAY_CASSETTE, 64 | }); 65 | 66 | export const pauseCassette = () => ({ 67 | type: PAUSE_CASSETTE, 68 | }); 69 | 70 | export const stopCassette = () => ({ 71 | type: STOP_CASSETTE, 72 | }); 73 | 74 | // This is a special action, used by our higher-order reducer to wipe the state. 75 | // It ensures that when a tape is played, it plays in the right context. 76 | export const rewindCassetteAndRestoreApp = () => ({ 77 | type: REWIND_CASSETTE_AND_RESTORE_APP, 78 | }); 79 | 80 | export const updateCassetteInitialState = ({ selected, newState }) => ({ 81 | type: UPDATE_CASSETTE_INITIAL_STATE, 82 | selected, 83 | newState, 84 | }); 85 | 86 | export const goToNextCassettePage = () => ({ 87 | type: GO_TO_NEXT_CASSETTE_PAGE, 88 | }); 89 | 90 | export const goToPreviousCassettePage = () => ({ 91 | type: GO_TO_PREVIOUS_CASSETTE_PAGE, 92 | }); 93 | 94 | export const cassetteActionsReceive = ({ id, cassetteActions }) => ({ 95 | type: CASSETTE_ACTIONS_RECEIVE, 96 | id, 97 | cassetteActions, 98 | }); 99 | 100 | export const incrementActionsPlayed = () => ({ 101 | type: INCREMENT_ACTIONS_PLAYED, 102 | }); 103 | 104 | export const changePlaybackSpeed = ({ playbackSpeed }) => ({ 105 | type: CHANGE_PLAYBACK_SPEED, 106 | playbackSpeed, 107 | }); 108 | 109 | export const changeMaximumDelay = ({ maximumDelay }) => ({ 110 | type: CHANGE_MAXIMUM_DELAY, 111 | maximumDelay, 112 | }); 113 | 114 | export const signInRequest = ({ authMethod }) => ({ 115 | type: SIGN_IN_REQUEST, 116 | authMethod, 117 | }); 118 | 119 | export const signInSuccess = ({ user, credential }) => ({ 120 | type: SIGN_IN_SUCCESS, 121 | user, 122 | credential, 123 | }); 124 | 125 | export const signInFailure = ({ error }) => ({ 126 | type: SIGN_IN_FAILURE, 127 | error, 128 | }); 129 | 130 | export const signOutRequest = () => ({ 131 | type: SIGN_OUT_REQUEST, 132 | }); 133 | 134 | export const signOutSuccess = () => ({ 135 | type: SIGN_OUT_SUCCESS, 136 | }); 137 | 138 | export const signOutFailure = () => ({ 139 | type: SIGN_OUT_FAILURE, 140 | }); 141 | 142 | export const setAuthRequirement = ({ requiresAuth }) => ({ 143 | type: SET_AUTH_REQUIREMENT, 144 | requiresAuth, 145 | }); 146 | -------------------------------------------------------------------------------- /packages/persist/tests/create-persist-handler-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { expect } from 'chai'; 3 | 4 | import { createPersistHandler } from '../src'; 5 | 6 | const firebaseAuth = { 7 | apiKey: 'abc123', 8 | authDomain: 'test.firebaseapp.com', 9 | databaseURL: 'https://test.firebaseio.com', 10 | }; 11 | 12 | 13 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) 14 | 15 | describe('createPersistHandler', () => { 16 | describe('cassette validation', () => { 17 | let handler; 18 | before(done => { 19 | handler = createPersistHandler({ firebaseAuth }); 20 | setTimeout(done, 100); 21 | }); 22 | 23 | it('fails when no cassette is provided', () => { 24 | expect(handler.persist).to.throw(/cassette/); 25 | }); 26 | 27 | it('fails when no action array is provided', () => { 28 | const faultyCassette = { data: {}, timestamp: 1472558962525 }; 29 | 30 | expect(() => ( 31 | handler.persist(faultyCassette) 32 | )).to.throw(/cassette/); 33 | }); 34 | 35 | it('fails when no timestamp is provided', () => { 36 | const faultyCassette = { data: {}, actions: [{ type: 'STUFF' }] }; 37 | 38 | expect(() => ( 39 | handler.persist(faultyCassette) 40 | )).to.throw(/timestamp/); 41 | }); 42 | 43 | it('succeeds when the actions array is empty', () => { 44 | const cassette = { actions: [], timestamp: 1472558962525 }; 45 | expect(() => handler.persist(cassette)).to.not.throw(); 46 | }); 47 | 48 | it('succeeds when no data is provided (it is optional)', () => { 49 | const cassette = { 50 | actions: [{ type: 'STUFF' }], 51 | timestamp: 1472558962525, 52 | }; 53 | 54 | expect(() => handler.persist(cassette)).to.not.throw(); 55 | }); 56 | }); 57 | 58 | describe('firebase integration', () => { 59 | let handler; 60 | let firebase; 61 | const cassette = { 62 | data: { label: "Josh's great session" }, 63 | actions: [{ type: 'DO_GREAT_THINGS' }], 64 | timestamp: 1472558962525, 65 | initialState: { 66 | auth: { 67 | loggedIn: true, 68 | }, 69 | }, 70 | }; 71 | 72 | beforeEach(async function(done) { 73 | handler = createPersistHandler({ firebaseAuth }); 74 | firebase = handler.firebaseHandler.firebase; 75 | 76 | await delay(200); 77 | 78 | handler.persist(cassette); 79 | 80 | await delay(200); 81 | 82 | done(); 83 | }); 84 | 85 | it('gets a database reference', () => { 86 | expect(firebase.database.callCount).to.equal(1); 87 | }); 88 | 89 | it('gets the ref for the cassettes and actions paths', () => { 90 | expect(firebase.ref.callCount).to.equal(2); 91 | 92 | const cassettesRef = firebase.ref.args[0][0]; 93 | expect(cassettesRef).to.equal('cassettes/abc123'); 94 | 95 | const actionsRef = firebase.ref.args[1][0]; 96 | expect(actionsRef).to.equal('actions/abc123'); 97 | }); 98 | 99 | it('sets the cassette and the actions', () => { 100 | const set = firebase.set; 101 | expect(set.callCount).to.equal(2); 102 | }); 103 | 104 | it('passes along the right data for the cassette', () => { 105 | const setCassette = firebase.set.args[0][0]; 106 | 107 | expect(setCassette.data).to.equal(cassette.data); 108 | expect(setCassette.timestamp).to.be.a('number'); 109 | expect(setCassette.numOfActions).to.equal(cassette.actions.length); 110 | expect(setCassette.initialState).to.equal(cassette.initialState); 111 | }); 112 | 113 | it('passes along the actions as-is', () => { 114 | const setActions = firebase.set.args[1][0]; 115 | 116 | expect(setActions).to.equal(cassette.actions); 117 | }); 118 | }); 119 | 120 | describe('debounce timing', () => { 121 | let handler; 122 | let firebase; 123 | const cassette = { 124 | data: { label: "Josh's great session" }, 125 | actions: [{ type: 'DO_GREAT_THINGS' }], 126 | timestamp: 1472558962525, 127 | }; 128 | 129 | beforeEach(done => { 130 | handler = createPersistHandler({ firebaseAuth }); 131 | firebase = handler.firebaseHandler.firebase; 132 | 133 | setTimeout(done, 200); 134 | }); 135 | 136 | it('debounces the persist method when set', done => { 137 | // In this test, we'll invoke `persist` several times very quickly, 138 | // and check to see that: 139 | // - it isn't invoked at all right away 140 | // - it is only invoked once, at the end of the debounce. 141 | for (let i = 0; i <= 5; i++) { 142 | handler.persist(cassette); 143 | } 144 | 145 | expect(firebase.database.callCount).to.equal(0); 146 | 147 | setTimeout(() => { 148 | expect(firebase.database.callCount).to.equal(1); 149 | done(); 150 | }, 250); 151 | }); 152 | }); 153 | }); 154 | --------------------------------------------------------------------------------