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