1import * as React from 'react' 2import * as Kb from '../common-adapters' 3import * as Styles from '../styles' 4import * as Container from '../util/container' 5import {Provider} from 'react-redux' 6import {createStore, applyMiddleware} from 'redux' 7import {GatewayProvider, GatewayDest} from '@chardskarth/react-gateway' 8import {action} from '@storybook/addon-actions' 9import Box from '../common-adapters/box' 10import Text from '../common-adapters/text' 11import ClickableBox from '../common-adapters/clickable-box' 12import RandExp from 'randexp' 13import * as PP from './prop-providers' 14 15export type SelectorMap = {[K in string]: (arg0: any) => any | Object} 16 17const unexpected = (name: string) => () => { 18 throw new Error(`unexpected ${name}`) 19} 20 21// On mobile the GatewayDest wrapper needs to fill the entire screen, so we set fillAbsolute 22// However on desktop, if the wrapper takes the full screen it will cover the other components 23const styleDestBox = Styles.platformStyles({ 24 isElectron: { 25 position: 'absolute', 26 }, 27 isMobile: { 28 ...Styles.globalStyles.fillAbsolute, 29 }, 30}) 31 32// we set pointerEvents to 'box-none' so that the wrapping box will not catch 33// touch events and they will be passed down to the child (popup) 34class DestBox extends React.Component { 35 render() { 36 return <Kb.Box pointerEvents="box-none" style={styleDestBox} {...this.props} /> 37 } 38} 39 40/** 41 * Creates a provider using a faux store of closures that compute derived viewProps 42 * @param {SelectorMap} map an object of the form {DisplayName: Function(ownProps)} with 43 * each closure returning the derived viewProps for the connected component 44 * @returns {React.Node} a <Provider /> that creates a store from the supplied map of closures. 45 * The Provider will ignore all dispatched actions. It also wraps the component 46 * tree in an <ErrorBoundary /> that adds auxiliary info in case of an error. 47 */ 48// Redux doesn't allow swapping the store given a single provider so we use a new key to force a new provider to 49// work around this issue 50// TODO remove this and move to use MockStore instead 51let uniqueProviderKey = 1 52const createPropProvider = (...maps: SelectorMap[]) => { 53 const merged: SelectorMap = maps.reduce((obj, merged) => ({...obj, ...merged}), {}) 54 55 /* 56 * GatewayDest and GatewayProvider need to be wrapped by the Provider here in 57 * order for storybook to correctly mock connected components inside of 58 * popups. 59 * React.Fragment is used to render StorybookErrorBoundary and GatewayDest as 60 * children to GatewayProvider which only takes one child 61 */ 62 return (story: () => React.ReactNode) => ( 63 <Provider 64 key={`provider:${uniqueProviderKey++}`} 65 store={ 66 // @ts-ignore 67 createStore(state => state, merged) 68 } 69 // @ts-ignore 70 merged={merged} 71 > 72 <GatewayProvider> 73 <> 74 <StorybookErrorBoundary children={story()} /> 75 <GatewayDest component={DestBox} name="popup-root" /> 76 </> 77 </GatewayProvider> 78 </Provider> 79 ) 80} 81 82// Plumb dispatches through storybook actions panel 83const actionLog = () => (next: any) => (a: any) => { 84 action('ReduxDispatch')(a) 85 return next(a) 86} 87 88// Includes common old-style propProvider temporarily 89export const MockStore = ({store, children}: any): any => ( 90 <Provider 91 key={`storyprovider:${uniqueProviderKey++}`} 92 store={createStore(state => state, {...store, ...PP.Common()}, applyMiddleware(actionLog))} 93 // @ts-ignore 94 merged={store} 95 > 96 <GatewayProvider> 97 <> 98 <StorybookErrorBoundary children={children} /> 99 <GatewayDest component={DestBox} name="popup-root" /> 100 </> 101 </GatewayProvider> 102 </Provider> 103) 104 105type updateFn = (draftState: Container.TypedState) => void 106export const updateStoreDecorator = (store: Container.TypedState, update: updateFn) => (story: any) => ( 107 <MockStore store={Container.produce(store, update)}>{story()}</MockStore> 108) 109 110export const createNavigator = (params: any) => ({ 111 navigation: { 112 getParam: (key: any) => params[key], 113 }, 114}) 115 116class StorybookErrorBoundary extends React.Component< 117 any, 118 { 119 hasError: boolean 120 error: Error | null 121 info: { 122 componentStack: string 123 } | null 124 } 125> { 126 constructor(props: any) { 127 super(props) 128 this.state = {error: null, hasError: false, info: null} 129 130 // Disallow catching errors when snapshot testing 131 if (!__STORYSHOT__) { 132 this.componentDidCatch = ( 133 error: Error, 134 info: { 135 componentStack: string 136 } 137 ) => { 138 this.setState({error, hasError: true, info}) 139 } 140 } else { 141 this.componentDidCatch = undefined 142 } 143 } 144 145 render() { 146 if (this.state.hasError) { 147 return ( 148 <Kb.Box 149 style={{ 150 ...Styles.globalStyles.flexBoxColumn, 151 borderColor: Styles.globalColors.red_75, 152 borderStyle: 'solid', 153 borderWidth: 2, 154 padding: 10, 155 }} 156 > 157 <Kb.Text type="Terminal" style={{color: Styles.globalColors.black, marginBottom: 8}}> 158 An error occurred in a connected child component. Did you supply all props the child expects? 159 </Kb.Text> 160 <Kb.Box 161 style={{ 162 ...Styles.globalStyles.flexBoxColumn, 163 backgroundColor: Styles.globalColors.blueDarker2, 164 borderRadius: Styles.borderRadius, 165 padding: 10, 166 whiteSpace: 'pre-line', 167 }} 168 > 169 <Kb.Text type="Terminal" negative={true} selectable={true}> 170 {this.state.error && this.state.error.toString()} 171 {this.state.info && this.state.info.componentStack} 172 </Kb.Text> 173 </Kb.Box> 174 </Kb.Box> 175 ) 176 } 177 return this.props.children 178 } 179} 180 181/** 182 * Utilities for writing stories 183 */ 184 185class Rnd { 186 _seed = 0 187 constructor(seed: number | string) { 188 if (typeof seed === 'string') { 189 this._seed = seed.split('').reduce((acc, _, i) => seed.charCodeAt(i) + acc, 0) 190 } else { 191 this._seed = seed 192 } 193 } 194 195 next = () => { 196 this._seed = (this._seed * 16807) % 2147483647 197 return this._seed 198 } 199 200 // Inclusive 201 randInt = (low: number, high: number) => (this.next() % (high + 1 - low)) + low 202 203 generateString = (regex: RegExp): string => { 204 const r = new RandExp(regex) 205 r.randInt = this.randInt 206 return r.gen() 207 } 208} 209 210const scrollViewDecorator = (story: any) => ( 211 <Kb.ScrollView style={{height: '100%', width: '100%'}}>{story()}</Kb.ScrollView> 212) 213 214class PerfBox extends React.Component< 215 { 216 copiesToRender: number 217 children: React.ReactNode 218 }, 219 { 220 key: number 221 } 222> { 223 state = {key: 1} 224 _text = null 225 _startTime = 0 226 _endTime = 0 227 228 _incrementKey = () => { 229 this.setState(old => ({key: old.key + 1})) 230 } 231 232 _updateTime = () => { 233 this._endTime = this._getTime() 234 const diff = this._endTime - this._startTime 235 console.log('PerfTiming: ', diff) 236 } 237 238 _getTime = () => { 239 const perf: any = window ? window.performance : undefined 240 if (typeof perf !== 'undefined') { 241 return perf.now() 242 } else { 243 return Date.now() 244 } 245 } 246 247 render() { 248 this._startTime = this._getTime() 249 setTimeout(this._updateTime, 0) 250 return ( 251 <Box key={this.state.key}> 252 <ClickableBox onClick={this._incrementKey}> 253 <Text type="Body">Refresh: #{this.state.key}</Text> 254 </ClickableBox> 255 {new Array(this.props.copiesToRender).fill(0).map((_, idx) => ( 256 <Box key={idx}>{this.props.children}</Box> 257 ))} 258 </Box> 259 ) 260 } 261} 262 263const perfDecorator = (copiesToRender: number = 100) => (story: any) => ( 264 <PerfBox copiesToRender={copiesToRender}>{story()} </PerfBox> 265) 266 267// Used to pass extra props to a component in a story without flow typing 268const propOverridesForStory = (p: any): {} => ({ 269 storyProps: p, 270}) 271 272export { 273 unexpected, 274 createPropProvider, 275 propOverridesForStory, 276 StorybookErrorBoundary, 277 Rnd, 278 scrollViewDecorator, 279 action, 280 perfDecorator, 281} 282