From 689e5058757fe456ef5f897a67a9aa1a2150a238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20S=C3=A1ros?= Date: Thu, 5 Oct 2023 17:28:56 +0200 Subject: [PATCH] WIP(ui-billboard,ui-breadcrumb,ui-scripts,ui-test-utils): wip --- package-lock.json | 90 ++++- .../__new-tests__/Billboard.test.tsx | 11 + packages/ui-breadcrumb/package.json | 5 +- .../__new-tests__/Breadcrumb.test.tsx | 57 +++ .../Breadcrumb/__tests__/Breadcrumb.test.tsx | 53 +-- packages/ui-breadcrumb/tsconfig.build.json | 1 + .../ui-scripts/lib/test/generateA11yTests.tsx | 61 ++++ .../lib/test/generateComponentExamples.tsx | 325 ++++++++++++++++++ .../lib/test/generatePropCombinations.ts | 86 +++++ .../src/utils/generateComponentExamples.ts | 5 +- 10 files changed, 664 insertions(+), 30 deletions(-) create mode 100644 packages/ui-breadcrumb/src/Breadcrumb/__new-tests__/Breadcrumb.test.tsx create mode 100644 packages/ui-scripts/lib/test/generateA11yTests.tsx create mode 100644 packages/ui-scripts/lib/test/generateComponentExamples.tsx create mode 100644 packages/ui-scripts/lib/test/generatePropCombinations.ts diff --git a/package-lock.json b/package-lock.json index 2500db4a6d..f71d025d8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53861,15 +53861,72 @@ "prop-types": "^15.8.1" }, "devDependencies": { + "@instructure/ui-axe-check": "^8.45.0", "@instructure/ui-babel-preset": "8.45.0", "@instructure/ui-test-locator": "8.45.0", "@instructure/ui-test-utils": "8.45.0", - "@instructure/ui-themes": "8.45.0" + "@instructure/ui-themes": "8.45.0", + "@testing-library/jest-dom": "^6.1.3", + "@testing-library/react": "^14.0.0" }, "peerDependencies": { "react": ">=16.8 <=18" } }, + "packages/ui-breadcrumb/node_modules/@testing-library/jest-dom": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.1.3.tgz", + "integrity": "sha512-YzpjRHoCBWPzpPNtg6gnhasqtE/5O4qz8WCwDEaxtfnPO6gkaLrnuXusrGSPyhIGPezr1HM7ZH0CFaUTY9PJEQ==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.3.0", + "@babel/runtime": "^7.9.2", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@jest/globals": ">= 28", + "@types/jest": ">= 28", + "jest": ">= 28", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "@jest/globals": { + "optional": true + }, + "@types/jest": { + "optional": true + }, + "jest": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "packages/ui-breadcrumb/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, "packages/ui-buttons": { "name": "@instructure/ui-buttons", "version": "8.45.0", @@ -59583,6 +59640,7 @@ "@babel/runtime": "^7.22.15", "@instructure/emotion": "8.45.0", "@instructure/shared-types": "8.45.0", + "@instructure/ui-axe-check": "^8.45.0", "@instructure/ui-babel-preset": "8.45.0", "@instructure/ui-icons": "8.45.0", "@instructure/ui-link": "8.45.0", @@ -59595,7 +59653,37 @@ "@instructure/ui-truncate-text": "8.45.0", "@instructure/ui-utils": "8.45.0", "@instructure/ui-view": "8.45.0", + "@testing-library/jest-dom": "^6.1.3", + "@testing-library/react": "^14.0.0", "prop-types": "^15.8.1" + }, + "dependencies": { + "@testing-library/jest-dom": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.1.3.tgz", + "integrity": "sha512-YzpjRHoCBWPzpPNtg6gnhasqtE/5O4qz8WCwDEaxtfnPO6gkaLrnuXusrGSPyhIGPezr1HM7ZH0CFaUTY9PJEQ==", + "dev": true, + "requires": { + "@adobe/css-tools": "^4.3.0", + "@babel/runtime": "^7.9.2", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + } + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } } }, "@instructure/ui-buttons": { diff --git a/packages/ui-billboard/src/Billboard/__new-tests__/Billboard.test.tsx b/packages/ui-billboard/src/Billboard/__new-tests__/Billboard.test.tsx index 9a34415845..ba64f98429 100644 --- a/packages/ui-billboard/src/Billboard/__new-tests__/Billboard.test.tsx +++ b/packages/ui-billboard/src/Billboard/__new-tests__/Billboard.test.tsx @@ -28,8 +28,10 @@ import userEvent from '@testing-library/user-event' import '@testing-library/jest-dom/extend-expect' import { Billboard } from '../index' +import BillboardExamples from '../__examples__/Billboard.examples' import { IconUserLine } from '@instructure/ui-icons' import { runAxeCheck } from '@instructure/ui-axe-check' +import { generateA11yTests } from '../../../../ui-scripts/lib/test/generateA11yTests' const TEST_HEADING = 'test-heading' const TEST_MESSAGE = 'test-message' @@ -56,6 +58,15 @@ describe('', () => { expect(axeCheck).toBe(true) }) + const generatedExamples = generateA11yTests(Billboard, BillboardExamples) + for (const example of generatedExamples) { + it(example.description, async () => { + const { container } = render(example.content) + const axeCheck = await runAxeCheck(container) + expect(axeCheck).toBe(true) + }) + } + it('should render a heading with the correct tag', () => { render() const heading = screen.getByText(TEST_HEADING) diff --git a/packages/ui-breadcrumb/package.json b/packages/ui-breadcrumb/package.json index d180bfa81b..906470e786 100644 --- a/packages/ui-breadcrumb/package.json +++ b/packages/ui-breadcrumb/package.json @@ -23,10 +23,13 @@ }, "license": "MIT", "devDependencies": { + "@instructure/ui-axe-check": "^8.45.0", "@instructure/ui-babel-preset": "8.45.0", "@instructure/ui-test-locator": "8.45.0", "@instructure/ui-test-utils": "8.45.0", - "@instructure/ui-themes": "8.45.0" + "@instructure/ui-themes": "8.45.0", + "@testing-library/jest-dom": "^6.1.3", + "@testing-library/react": "^14.0.0" }, "dependencies": { "@babel/runtime": "^7.22.15", diff --git a/packages/ui-breadcrumb/src/Breadcrumb/__new-tests__/Breadcrumb.test.tsx b/packages/ui-breadcrumb/src/Breadcrumb/__new-tests__/Breadcrumb.test.tsx new file mode 100644 index 0000000000..37088c5728 --- /dev/null +++ b/packages/ui-breadcrumb/src/Breadcrumb/__new-tests__/Breadcrumb.test.tsx @@ -0,0 +1,57 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { render } from '@testing-library/react' + +// import { runAxeCheck } from '@instructure/ui-axe-check' +import { runAxeCheck } from '@instructure/ui-axe-check' +import { Breadcrumb } from '../index' +import BreadcrumbExamples from '../__examples__/Breadcrumb.examples' +import { generateA11yTests } from '../../../../ui-scripts/lib/test/generateA11yTests' + +const originalResizeObserver = global.ResizeObserver + +describe('', () => { + beforeAll(() => { + // Mock for ResizeObserver browser API + global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn() + })) + }) + + const generatedComponents = generateA11yTests(Breadcrumb, BreadcrumbExamples) + for (const component of generatedComponents) { + it((component as any).description, async () => { + const { container } = render((component as any).content) + const axeCheck = await runAxeCheck(container) + expect(axeCheck).toBe(true) + }) + } + + afterAll(() => { + global.ResizeObserver = originalResizeObserver + }) +}) diff --git a/packages/ui-breadcrumb/src/Breadcrumb/__tests__/Breadcrumb.test.tsx b/packages/ui-breadcrumb/src/Breadcrumb/__tests__/Breadcrumb.test.tsx index 418cce0548..f9a7aea5fa 100644 --- a/packages/ui-breadcrumb/src/Breadcrumb/__tests__/Breadcrumb.test.tsx +++ b/packages/ui-breadcrumb/src/Breadcrumb/__tests__/Breadcrumb.test.tsx @@ -22,41 +22,42 @@ * SOFTWARE. */ -import React from 'react' +// import React from 'react' -import { expect, mount, generateA11yTests } from '@instructure/ui-test-utils' +// import { expect, mount, generateA11yTests } from '@instructure/ui-test-utils' +import { generateA11yTests } from '@instructure/ui-test-utils' import { Breadcrumb } from '../index' -import { BreadcrumbLocator } from '../BreadcrumbLocator' +// import { BreadcrumbLocator } from '../BreadcrumbLocator' import BreadcrumbExamples from '../__examples__/Breadcrumb.examples' describe('', async () => { - it('should render the label as an aria-label attribute', async () => { - await mount( - - Account - - ) - const breadcrumb = await BreadcrumbLocator.find() - const label = await breadcrumb.find(':label(Settings)') - - expect(label.getAttribute('aria-label')).to.equal('Settings') - }) + // it('should render the label as an aria-label attribute', async () => { + // await mount( + // + // Account + // + // ) + // const breadcrumb = await BreadcrumbLocator.find() + // const label = await breadcrumb.find(':label(Settings)') + // + // expect(label.getAttribute('aria-label')).to.equal('Settings') + // }) describe('with generated examples', async () => { generateA11yTests(Breadcrumb, BreadcrumbExamples) }) - it('should render an icon as a separator', async () => { - await mount( - - Account - Settings - - ) - const breadcrumb = await BreadcrumbLocator.find() - const icon = await breadcrumb.find('svg') - - expect(icon.getAttribute('aria-hidden')).to.equal('true') - }) + // it('should render an icon as a separator', async () => { + // await mount( + // + // Account + // Settings + // + // ) + // const breadcrumb = await BreadcrumbLocator.find() + // const icon = await breadcrumb.find('svg') + // + // expect(icon.getAttribute('aria-hidden')).to.equal('true') + // }) }) diff --git a/packages/ui-breadcrumb/tsconfig.build.json b/packages/ui-breadcrumb/tsconfig.build.json index dee617ff5f..d6053fb811 100644 --- a/packages/ui-breadcrumb/tsconfig.build.json +++ b/packages/ui-breadcrumb/tsconfig.build.json @@ -10,6 +10,7 @@ { "path": "../ui-babel-preset/tsconfig.build.json" }, { "path": "../ui-test-locator/tsconfig.build.json" }, { "path": "../ui-test-utils/tsconfig.build.json" }, + { "path": "../ui-axe-check/tsconfig.build.json" }, { "path": "../ui-themes/tsconfig.build.json" }, { "path": "../emotion/tsconfig.build.json" }, { "path": "../shared-types/tsconfig.build.json" }, diff --git a/packages/ui-scripts/lib/test/generateA11yTests.tsx b/packages/ui-scripts/lib/test/generateA11yTests.tsx new file mode 100644 index 0000000000..e8c526f92e --- /dev/null +++ b/packages/ui-scripts/lib/test/generateA11yTests.tsx @@ -0,0 +1,61 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import React from 'react' +import { + Example, + StoryConfig, + generateComponentExamples +} from './generateComponentExamples' + +type ReturnComponentType = { + content: React.JSX.Element + description: string +} + +const renderExample = ({ Component, componentProps, key }: Example) => ( + +) + +export function generateA11yTests>( + Component: React.ComponentType, + componentExample: StoryConfig +): ReturnComponentType[] { + const sections = generateComponentExamples(Component, componentExample) + const returnComponents: ReturnComponentType[] = [] + sections.forEach(({ pages }, sectionIndex) => { + pages.forEach(({ examples }, pageIndex) => { + examples.forEach((example, exampleIndex) => { + const Example = renderExample.bind(null, example) + const description = `${Component.displayName} example ${ + sectionIndex * sections.length + pageIndex + 1 + }/${exampleIndex + 1}` + returnComponents.push({ + content: , + description + }) + }) + }) + }) + return returnComponents +} diff --git a/packages/ui-scripts/lib/test/generateComponentExamples.tsx b/packages/ui-scripts/lib/test/generateComponentExamples.tsx new file mode 100644 index 0000000000..d274999472 --- /dev/null +++ b/packages/ui-scripts/lib/test/generateComponentExamples.tsx @@ -0,0 +1,325 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { generatePropCombinations } from './generatePropCombinations' +import React, { ComponentType, ReactNode, useId } from 'react' + +export type StoryConfig = { + /** + * Used to divide the resulting examples into sections. It should correspond + * to an enumerated prop in the Component + */ + sectionProp?: keyof Props + /** + * Specifies the max number of examples that can exist in a single page + * within a section + */ + maxExamplesPerPage?: number | ((sectionName: string) => number) + /** + * Specifies the total max number of examples. Default: 500 + */ + maxExamples?: number + /** + * An object with keys that correspond to the component props. Each key has a + * corresponding value array. This array contains possible values for that prop. + */ + propValues?: Partial> + /** + * Prop keys to exclude from propValues. Useful when generating propValues with code. + */ + excludeProps?: (keyof Props)[] + /** + * The values returned by this function are passed to the component. + * A function called with the prop combination for the current example. It + * returns an object of props that will be passed into the `renderExample` + * function as componentProps. + */ + getComponentProps?: (props: Props & Record) => Partial + /** + * The values returned by this function are passed to a `View` that wraps the + * example. + * A function called with the prop combination for the current example. It + * returns an object of props that will be passed into the `renderExample` + * function as exampleProps. + */ + getExampleProps?: (props: Props & Record) => Record + /** + * A function called with the examples and index for the current page of + * examples. It returns an object of parameters/metadata for that page of + * examples (e.g. to be passed in to a visual regression tool like chromatic). + */ + getParameters?: (params: ExamplesPage) => { + [key: string]: any + delay?: number + disable?: boolean + } + filter?: (props: Props) => boolean +} + +type ExampleSection = { + sectionName: string + propName: keyof Props + propValue: string + pages: ExamplesPage[] +} + +export type ExamplesPage = { + examples: Example[] + index: number + renderExample?: (exampleProps: Example) => ReactNode + parameters?: Record +} + +export type Example = { + Component: ComponentType + componentProps: Partial + exampleProps: Record // actually Partial + key: string +} + +/** + * Generates examples for the given component based on the given configuration. + * @param Component A React component + * @param config A configuration object (stored in xy.examples.jsx files in InstUI) + * @returns Array of examples broken into sections and pages if configured to do so. + * @module generateComponentExamples + * @private + * + */ +export function generateComponentExamples>( + Component: ComponentType, + config: StoryConfig +) { + const { sectionProp, excludeProps, filter } = config + + const PROPS_CACHE: string[] = [] + const sections: ExampleSection[] = [] + const maxExamples = config.maxExamples ? config.maxExamples : 500 + let exampleCount = 0 + let propValues: Partial> = {} + + const getParameters = (page: ExamplesPage) => { + const examples = page.examples + const index = page.index + let parameters = {} + if (typeof config.getParameters === 'function') { + parameters = { + ...config.getParameters({ examples, index }) + } + } + return parameters + } + + /** + * Merges the auto-generated props with ones in the examples files specified + * by the `getComponentProps()` method; props from the example files have + * priority + */ + const mergeComponentPropsFromConfig = (props: Props) => { + let componentProps = props + // TODO this code is so complicated because getComponentProps(props) can return + // different values based on its props parameter. + // If it would always return the same thing then we could reduce the + // number of combinations generated by generatePropCombinations() by + // getComponentProps() reducing some to 1 value, it would also remove the + // need of PROPS_CACHE and duplicate checks. + // InstUI is not using the 'props' param of getComponentProps(), but others are + if (typeof config.getComponentProps === 'function') { + componentProps = { + ...componentProps, + ...config.getComponentProps(props) + } + } + return componentProps + } + + const getExampleProps = (props: Props) => { + let exampleProps: Record = {} + if (typeof config.getExampleProps === 'function') { + exampleProps = { + ...config.getExampleProps(props) + } + } + return exampleProps + } + + const addPage = (section: ExampleSection) => { + const page: ExamplesPage = { + examples: [], + index: section.pages.length + } + section.pages.push(page) + return page + } + + const addExample = (sectionName: string, example: Example) => { + let section = sections.find( + (section) => section.sectionName === sectionName + ) + if (!section) { + section = { + sectionName: sectionName, + propName: sectionProp!, + propValue: sectionName, + pages: [] + } + sections.push(section) + } + + let page = section.pages[section.pages.length - 1] + + let { maxExamplesPerPage } = config + + if (typeof maxExamplesPerPage === 'function') { + maxExamplesPerPage = maxExamplesPerPage(sectionName) + } + + if (!page) { + page = addPage(section) + } else if ( + maxExamplesPerPage && + page.examples.length % maxExamplesPerPage === 0 && + page.examples.length > 0 + ) { + page = addPage(section) + } + + page.examples.push(example) + } + + // Serializes the given recursively, faster than JSON.stringify() + const fastSerialize = (props: Props) => { + const strArr: string[] = [] + objToString(props, strArr) + return strArr.join('') + } + + const objToString = (currObject: any, currString: string[]) => { + if (!currObject) { + return + } + if (React.isValidElement(currObject)) { + currString.push(JSON.stringify(currObject)) + } else if (typeof currObject === 'object') { + for (const [key, value] of Object.entries(currObject)) { + currString.push(key) + objToString(value, currString) + } + } else { + currString.push(currObject) + } + } + + const maybeAddExample = (props: Props): void => { + const componentProps = mergeComponentPropsFromConfig(props) + const ignore = typeof filter === 'function' ? filter(componentProps) : false + if (ignore) { + return + } + const propsString = fastSerialize(componentProps) + if (!PROPS_CACHE.includes(propsString)) { + const key = useId() + const exampleProps = getExampleProps(props) + exampleCount++ + if (exampleCount < maxExamples) { + PROPS_CACHE.push(propsString) + let sectionName = 'Examples' + if (sectionProp && componentProps[sectionProp]) { + sectionName = componentProps[sectionProp] as unknown as string + } + addExample(sectionName, { + Component, + componentProps, + exampleProps, + key + }) + } + } + } + + if (isEmpty(config.propValues)) { + maybeAddExample({} as Props) + } else { + if (Array.isArray(excludeProps)) { + ;(Object.keys(config.propValues) as (keyof Props)[]).forEach( + (propName) => { + if (!excludeProps.includes(propName)) { + propValues[propName] = config.propValues![propName] + } + } + ) + } else { + propValues = config.propValues + } + // eslint-disable-next-line no-console + console.info( + `Generating examples for ${Component.displayName} (${ + Object.keys(propValues).length + } props):`, + propValues + ) + // TODO reconcile the differences between these files + // generatePropCombinations should call getComponentProps and not do anything? + const combos = generatePropCombinations(propValues as any).filter(Boolean) + let index = 0 + while (index < combos.length && exampleCount < maxExamples) { + const combo = combos[index] + if (combo) { + maybeAddExample(combo as Props) + index++ + } + } + } + + if (exampleCount >= maxExamples) { + console.error( + `Too many examples for ${Component.displayName}! Add a filter to the config.` + ) + } + + // eslint-disable-next-line no-console + console.info( + `Generated ${exampleCount} examples for ${Component.displayName}` + ) + + sections.forEach(({ pages }) => { + pages.forEach((page) => { + // eslint-disable-next-line no-param-reassign + page.parameters = getParameters(page) + }) + }) + return sections +} + +function isEmpty( + obj: unknown +): obj is null | undefined | Record { + if (typeof obj !== 'object') return true + for (const key in obj) { + if (Object.hasOwnProperty.call(obj, key)) return false + } + return true +} + +export default generateComponentExamples diff --git a/packages/ui-scripts/lib/test/generatePropCombinations.ts b/packages/ui-scripts/lib/test/generatePropCombinations.ts new file mode 100644 index 0000000000..ace5062368 --- /dev/null +++ b/packages/ui-scripts/lib/test/generatePropCombinations.ts @@ -0,0 +1,86 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +type Props = T extends Record ? Record : T +type ArrayElement = A extends readonly (infer T)[] ? T : never + +/** + * Given possible values for each prop, returns all combinations of those prop values. + * To generate the prop names and values from the component source see the `parsePropValues` utility + * + * @param {Object} propValues an object with the shape {propName: arrayOfPossibleValues} + * @returns {Array} an array of all prop combinations [{propAName: propAValue, propBName: propBValue}] + * + * @module generatePropCombinations + * @private + */ +export function generatePropCombinations>( + propValues: Props +) { + type PropValueType = ArrayElement[keyof Props]> + const propNames = Object.keys(propValues) + const combos: Array, PropValueType>> = [] + + if (!propNames.length) return combos + + const numProps = propNames.length + for (let i = 0; i < numProps; i++) { + const propName = propNames[i] + const valuesForProp = propValues[propName as keyof Props] + + if (!Array.isArray(valuesForProp) || !valuesForProp.length) { + throw new Error( + `[ui-examples-loader] Please provide a non-empty array of possible values for + prop ${propName}. in "propValues"` + ) + } + + const numValues = valuesForProp.length + const numCombos = combos.length + + for (let j = 0; j < numValues; j++) { + const propValue = valuesForProp[j] + if (numCombos > 0) { + for (let k = 0; k < numCombos; k++) { + const combo = combos[k] + + // Check against the keys of the object here. `combo[propName]` could + // evaluate to a boolean value which will mess up this logic. + if (!Object.keys(combo).includes(propName)) { + // eslint-disable-next-line no-param-reassign + combo[propName as keyof Props] = propValue + } else { + combos.push({ ...combo, [propName]: propValue }) + } + } + } else { + //@ts-expect-error TODO: fix this + combos.push({ [propName]: propValue as PropValueType }) + } + } + } + return combos +} + +export default generatePropCombinations diff --git a/packages/ui-test-utils/src/utils/generateComponentExamples.ts b/packages/ui-test-utils/src/utils/generateComponentExamples.ts index 05afaef5e6..b891f69a4a 100644 --- a/packages/ui-test-utils/src/utils/generateComponentExamples.ts +++ b/packages/ui-test-utils/src/utils/generateComponentExamples.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { nanoid } from 'nanoid' +// import { nanoid } from 'nanoid' import { generatePropCombinations } from './generatePropCombinations' import React, { ComponentType, ReactNode } from 'react' @@ -241,7 +241,8 @@ export function generateComponentExamples>( } const propsString = fastSerialize(componentProps) if (!PROPS_CACHE.includes(propsString)) { - const key = nanoid() + // const key = nanoid() + const key = '' + Math.round(Math.random() * 10000) const exampleProps = getExampleProps(props) exampleCount++ if (exampleCount < maxExamples) {