UNPKG

10.4 kBPlain TextView Raw
1/*
2 * The MIT License (MIT)
3 *
4 * Copyright (c) 2015 - present Instructure, Inc.
5 *
6 * Permission is hereby granted, free of charge, to any person obtaining a copy
7 * of this software and associated documentation files (the "Software"), to deal
8 * in the Software without restriction, including without limitation the rights
9 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 * copies of the Software, and to permit persons to whom the Software is
11 * furnished to do so, subject to the following conditions:
12 *
13 * The above copyright notice and this permission notice shall be included in all
14 * copies or substantial portions of the Software.
15 *
16 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 * SOFTWARE.
23 */
24
25import { nanoid } from 'nanoid'
26
27import { generatePropCombinations } from './generatePropCombinations'
28import React, { ComponentType, ReactNode } from 'react'
29
30export type StoryConfig<Props> = {
31 /**
32 * Used to divide the resulting examples into sections. It should correspond
33 * to an enumerated prop in the Component
34 */
35 sectionProp?: keyof Props
36 /**
37 * Specifies the max number of examples that can exist in a single page
38 * within a section
39 */
40 maxExamplesPerPage?: number | ((sectionName: string) => number)
41 /**
42 * Specifies the total max number of examples. Default: 500
43 */
44 maxExamples?: number
45 /**
46 * An object with keys that correspond to the component props. Each key has a
47 * corresponding value array. This array contains possible values for that prop.
48 */
49 propValues?: Partial<Record<keyof Props | string, any[]>>
50 /**
51 * Prop keys to exclude from propValues. Useful when generating propValues with code.
52 */
53 excludeProps?: (keyof Props)[]
54 /**
55 * The values returned by this function are passed to the component.
56 * A function called with the prop combination for the current example. It
57 * returns an object of props that will be passed into the `renderExample`
58 * function as componentProps.
59 */
60 getComponentProps?: (props: Props & Record<string, any>) => Partial<Props>
61 /**
62 * The values returned by this function are passed to a `View` that wraps the
63 * example.
64 * A function called with the prop combination for the current example. It
65 * returns an object of props that will be passed into the `renderExample`
66 * function as exampleProps.
67 */
68 getExampleProps?: (props: Props & Record<string, any>) => Record<string, any>
69 /**
70 * A function called with the examples and index for the current page of
71 * examples. It returns an object of parameters/metadata for that page of
72 * examples (e.g. to be passed in to a visual regression tool like chromatic).
73 */
74 getParameters?: (params: ExamplesPage<Props>) => {
75 [key: string]: any
76 delay?: number
77 disable?: boolean
78 }
79 filter?: (props: Props) => boolean
80}
81
82type ExampleSection<Props> = {
83 sectionName: string
84 propName: keyof Props
85 propValue: string
86 pages: ExamplesPage<Props>[]
87}
88
89export type ExamplesPage<Props> = {
90 examples: Example<Props>[]
91 index: number
92 renderExample?: (exampleProps: Example<Props>) => ReactNode
93 parameters?: Record<string, unknown>
94}
95
96export type Example<Props> = {
97 Component: ComponentType
98 componentProps: Partial<Props>
99 exampleProps: Record<string, any> // actually Partial<ViewProps>
100 key: string
101}
102
103/**
104 * Generates examples for the given component based on the given configuration.
105 * @param Component A React component
106 * @param config A configuration object (stored in xy.examples.jsx files in InstUI)
107 * @returns Array of examples broken into sections and pages if configured to do so.
108 * @module generateComponentExamples
109 * @private
110 *
111 */
112export function generateComponentExamples<Props>(
113 Component: ComponentType<any>,
114 config: StoryConfig<Props>
115) {
116 const { sectionProp, excludeProps, filter } = config
117
118 const PROPS_CACHE: string[] = []
119 const sections: ExampleSection<Props>[] = []
120 const maxExamples = config.maxExamples ? config.maxExamples : 500
121 let exampleCount = 0
122 let propValues: Partial<Record<keyof Props | string, any[]>> = {}
123
124 const getParameters = (page: ExamplesPage<Props>) => {
125 const examples = page.examples
126 const index = page.index
127 let parameters = {}
128 if (typeof config.getParameters === 'function') {
129 parameters = {
130 ...config.getParameters({ examples, index })
131 }
132 }
133 return parameters
134 }
135
136 /**
137 * Merges the auto-generated props with ones in the examples files specified
138 * by the `getComponentProps()` method; props from the example files have
139 * priority
140 */
141 const mergeComponentPropsFromConfig = (props: Props) => {
142 let componentProps = props
143 // TODO this code is so complicated because getComponentProps(props) can return
144 // different values based on its props parameter.
145 // If it would always return the same thing then we could reduce the
146 // number of combinations generated by generatePropCombinations() by
147 // getComponentProps() reducing some to 1 value, it would also remove the
148 // need of PROPS_CACHE and duplicate checks.
149 // InstUI is not using the 'props' param of getComponentProps(), but others are
150 if (typeof config.getComponentProps === 'function') {
151 componentProps = {
152 ...componentProps,
153 ...config.getComponentProps(props)
154 }
155 }
156 return componentProps
157 }
158
159 const getExampleProps = (props: Props) => {
160 let exampleProps: Record<string, unknown> = {}
161 if (typeof config.getExampleProps === 'function') {
162 exampleProps = {
163 ...config.getExampleProps(props)
164 }
165 }
166 return exampleProps
167 }
168
169 const addPage = (section: ExampleSection<Props>) => {
170 const page: ExamplesPage<Props> = {
171 examples: [],
172 index: section.pages.length
173 }
174 section.pages.push(page)
175 return page
176 }
177
178 const addExample = (sectionName: string, example: Example<Props>) => {
179 let section = sections.find(
180 (section) => section.sectionName === sectionName
181 )
182 if (!section) {
183 section = {
184 sectionName: sectionName,
185 propName: sectionProp!,
186 propValue: sectionName,
187 pages: []
188 }
189 sections.push(section)
190 }
191
192 let page = section.pages[section.pages.length - 1]
193
194 let { maxExamplesPerPage } = config
195
196 if (typeof maxExamplesPerPage === 'function') {
197 maxExamplesPerPage = maxExamplesPerPage(sectionName)
198 }
199
200 if (!page) {
201 page = addPage(section)
202 } else if (
203 maxExamplesPerPage &&
204 page.examples.length % maxExamplesPerPage === 0 &&
205 page.examples.length > 0
206 ) {
207 page = addPage(section)
208 }
209
210 page.examples.push(example)
211 }
212
213 // Serializes the given recursively, faster than JSON.stringify()
214 const fastSerialize = (props: Props) => {
215 const strArr: string[] = []
216 objToString(props, strArr)
217 return strArr.join('')
218 }
219
220 const objToString = (currObject: any, currString: string[]) => {
221 if (!currObject) {
222 return
223 }
224 if (React.isValidElement(currObject)) {
225 currString.push(JSON.stringify(currObject))
226 } else if (typeof currObject === 'object') {
227 for (const [key, value] of Object.entries(currObject)) {
228 currString.push(key)
229 objToString(value, currString)
230 }
231 } else {
232 currString.push(currObject)
233 }
234 }
235
236 const maybeAddExample = (props: Props): void => {
237 const componentProps = mergeComponentPropsFromConfig(props)
238 const ignore = typeof filter === 'function' ? filter(componentProps) : false
239 if (ignore) {
240 return
241 }
242 const propsString = fastSerialize(componentProps)
243 if (!PROPS_CACHE.includes(propsString)) {
244 const key = nanoid()
245 const exampleProps = getExampleProps(props)
246 exampleCount++
247 if (exampleCount < maxExamples) {
248 PROPS_CACHE.push(propsString)
249 let sectionName = 'Examples'
250 if (sectionProp && componentProps[sectionProp]) {
251 sectionName = componentProps[sectionProp] as unknown as string
252 }
253 addExample(sectionName, {
254 Component,
255 componentProps,
256 exampleProps,
257 key
258 })
259 }
260 }
261 }
262
263 if (isEmpty(config.propValues)) {
264 maybeAddExample({} as Props)
265 } else {
266 if (Array.isArray(excludeProps)) {
267 ;(Object.keys(config.propValues) as (keyof Props)[]).forEach(
268 (propName) => {
269 if (!excludeProps.includes(propName)) {
270 propValues[propName] = config.propValues![propName]
271 }
272 }
273 )
274 } else {
275 propValues = config.propValues
276 }
277 // eslint-disable-next-line no-console
278 console.info(
279 `Generating examples for ${Component.displayName} (${
280 Object.keys(propValues).length
281 } props):`,
282 propValues
283 )
284 // TODO reconcile the differences between these files
285 // generatePropCombinations should call getComponentProps and not do anything?
286 const combos = generatePropCombinations(propValues as any).filter(Boolean)
287 let index = 0
288 while (index < combos.length && exampleCount < maxExamples) {
289 const combo = combos[index]
290 if (combo) {
291 maybeAddExample(combo as Props)
292 index++
293 }
294 }
295 }
296
297 if (exampleCount >= maxExamples) {
298 console.error(
299 `Too many examples for ${Component.displayName}! Add a filter to the config.`
300 )
301 }
302
303 // eslint-disable-next-line no-console
304 console.info(
305 `Generated ${exampleCount} examples for ${Component.displayName}`
306 )
307
308 sections.forEach(({ pages }) => {
309 pages.forEach((page) => {
310 // eslint-disable-next-line no-param-reassign
311 page.parameters = getParameters(page)
312 })
313 })
314 return sections
315}
316
317function isEmpty(
318 obj: unknown
319): obj is null | undefined | Record<string, never> {
320 if (typeof obj !== 'object') return true
321 for (const key in obj) {
322 if (Object.hasOwnProperty.call(obj, key)) return false
323 }
324 return true
325}
326
327export default generateComponentExamples