1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 | import { nanoid } from 'nanoid'
|
26 |
|
27 | import { generatePropCombinations } from './generatePropCombinations'
|
28 | import React, { ComponentType, ReactNode } from 'react'
|
29 |
|
30 | export type StoryConfig<Props> = {
|
31 | |
32 |
|
33 |
|
34 |
|
35 | sectionProp?: keyof Props
|
36 | |
37 |
|
38 |
|
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 |
|
63 |
|
64 |
|
65 |
|
66 |
|
67 |
|
68 | getExampleProps?: (props: Props & Record<string, any>) => Record<string, any>
|
69 | |
70 |
|
71 |
|
72 |
|
73 |
|
74 | getParameters?: (params: ExamplesPage<Props>) => {
|
75 | [key: string]: any
|
76 | delay?: number
|
77 | disable?: boolean
|
78 | }
|
79 | filter?: (props: Props) => boolean
|
80 | }
|
81 |
|
82 | type ExampleSection<Props> = {
|
83 | sectionName: string
|
84 | propName: keyof Props
|
85 | propValue: string
|
86 | pages: ExamplesPage<Props>[]
|
87 | }
|
88 |
|
89 | export type ExamplesPage<Props> = {
|
90 | examples: Example<Props>[]
|
91 | index: number
|
92 | renderExample?: (exampleProps: Example<Props>) => ReactNode
|
93 | parameters?: Record<string, unknown>
|
94 | }
|
95 |
|
96 | export type Example<Props> = {
|
97 | Component: ComponentType
|
98 | componentProps: Partial<Props>
|
99 | exampleProps: Record<string, any>
|
100 | key: string
|
101 | }
|
102 |
|
103 |
|
104 |
|
105 |
|
106 |
|
107 |
|
108 |
|
109 |
|
110 |
|
111 |
|
112 | export 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 |
|
138 |
|
139 |
|
140 |
|
141 | const mergeComponentPropsFromConfig = (props: Props) => {
|
142 | let componentProps = props
|
143 |
|
144 |
|
145 |
|
146 |
|
147 |
|
148 |
|
149 |
|
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 |
|
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 |
|
278 | console.info(
|
279 | `Generating examples for ${Component.displayName} (${
|
280 | Object.keys(propValues).length
|
281 | } props):`,
|
282 | propValues
|
283 | )
|
284 |
|
285 |
|
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 |
|
304 | console.info(
|
305 | `Generated ${exampleCount} examples for ${Component.displayName}`
|
306 | )
|
307 |
|
308 | sections.forEach(({ pages }) => {
|
309 | pages.forEach((page) => {
|
310 |
|
311 | page.parameters = getParameters(page)
|
312 | })
|
313 | })
|
314 | return sections
|
315 | }
|
316 |
|
317 | function 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 |
|
327 | export default generateComponentExamples
|