diff --git a/npm/design-system/.mocharc.json b/npm/design-system/.mocharc.json new file mode 100644 index 000000000000..a119a8d739e9 --- /dev/null +++ b/npm/design-system/.mocharc.json @@ -0,0 +1,8 @@ +{ + "watch-ignore": [ + "./test/_test-output", + "node_modules" + ], + "require": "ts-node/register", + "exit": true +} diff --git a/npm/design-system/cypress.json b/npm/design-system/cypress.json index 24ce00cbb6be..0c1d7609a069 100644 --- a/npm/design-system/cypress.json +++ b/npm/design-system/cypress.json @@ -14,6 +14,5 @@ "componentFolder": "src", "experimentalComponentTesting": true, "experimentalFetchPolyfill": true, - "fixturesFolder": false, - "supportFile": false + "fixturesFolder": false } diff --git a/npm/design-system/cypress/support/index.js b/npm/design-system/cypress/support/index.js new file mode 100644 index 000000000000..72b4e3f25723 --- /dev/null +++ b/npm/design-system/cypress/support/index.js @@ -0,0 +1,2 @@ +import 'regenerator-runtime/runtime' +import 'cypress-real-events/support' diff --git a/npm/design-system/package.json b/npm/design-system/package.json index 50f13f68e534..f2adc14c426b 100644 --- a/npm/design-system/package.json +++ b/npm/design-system/package.json @@ -21,6 +21,8 @@ "@fortawesome/free-brands-svg-icons": "5.15.2", "@fortawesome/free-solid-svg-icons": "5.15.2", "@fortawesome/react-fontawesome": "0.1.14", + "@iconify/icons-vscode-icons": "1.1.1", + "@iconify/react": "2.0.0-rc.8", "classnames": "2.2.6", "debug": "4.3.2" }, @@ -41,6 +43,8 @@ "babel-loader": "8.0.6", "css-loader": "2.1.1", "cypress": "0.0.0-development", + "cypress-real-events": "1.1.0", + "mocha": "^7.0.1", "react": "16.8.6", "react-dom": "16.8.6", "rollup": "^2.38.5", diff --git a/npm/design-system/src/components/FileExplorer/FileExplorer.module.scss b/npm/design-system/src/components/FileExplorer/FileExplorer.module.scss new file mode 100644 index 000000000000..d5e9c03b529e --- /dev/null +++ b/npm/design-system/src/components/FileExplorer/FileExplorer.module.scss @@ -0,0 +1,48 @@ +@use '../../index.scss' as *; + +.nav { + user-select: none; + white-space: nowrap; +} + +.ul { + margin-block-start: 0; + margin-block-end: 0; + margin-inline-start: 0; + margin-inline-end: 0; + padding-inline-start: 0; + &:before { + display: none; + } +} + +.li.li { + padding-left: 20px; +} + + +.ul, .li { + position: relative; + list-style: none; + font-size: $text-s; + line-height: 1.6; +} + +.a { + position: relative; + color: unset; + text-decoration: none; + display: inline-block; + width: 100%; + &:hover { + cursor: pointer; + } +} + +.ul .ul { + margin-inline-start: $text-xs; +} + +.isSelected, .isSelected:hover { + text-decoration: underline; +} \ No newline at end of file diff --git a/npm/design-system/src/components/FileExplorer/FileExplorer.spec.tsx b/npm/design-system/src/components/FileExplorer/FileExplorer.spec.tsx new file mode 100644 index 000000000000..f327126aa6dd --- /dev/null +++ b/npm/design-system/src/components/FileExplorer/FileExplorer.spec.tsx @@ -0,0 +1,130 @@ +import { mount } from '@cypress/react' +import React from 'react' +import { FileExplorer, FileComponentProps, FolderComponentProps } from './FileExplorer' +import { FileNode, makeFileHierarchy, TreeNode } from './helpers/makeFileHierarchy' + +import styles from './FileExplorer.module.scss' + +const specs: Cypress.Cypress['spec'][] = [ + { + relative: 'foo/bar/foo.spec.js', + absolute: 'Users/code/foo/bar/foo.spec.js', + name: 'foo/bar/foo.spec.js', + }, + { + relative: 'bar/foo.spec.tsx', + absolute: 'bar/foo.spec.tsx', + name: 'bar/foo.spec.tsx', + }, + { + relative: 'merp/map.spec.ts', + absolute: 'merp/map.spec.ts', + name: 'merp/map.spec.ts', + }, +] + +interface FileExplorerTestProps { + clickFileStub: typeof cy.stub + clickFolderStub: typeof cy.stub +} + +function createFileExplorer (testProps: FileExplorerTestProps): React.FC { + return () => { + const [selectedFile, setSelectedFile] = React.useState() + + const onFileClick = (file: FileNode) => { + setSelectedFile(file.absolute) + } + + const files = makeFileHierarchy(specs.map((spec) => spec.relative)) + + const FileComponent: React.FC = (props) => { + return ( +
{ + testProps.clickFileStub(props.item) + props.onClick(props.item) + }}> + {props.item.name} +
+ ) + } + + const FolderComponent: React.FC = (props) => { + return ( +
{ + testProps.clickFolderStub() + props.onClick() + }}> + {props.item.name} +
+ ) + } + + return ( + + ) + } +} + +describe('FileExplorer', () => { + it('basic usage', () => { + const files: TreeNode[] = [ + { + type: 'folder', + name: 'foo', + absolute: 'foo', + files: [ + { + type: 'file', + name: 'bar.js', + absolute: 'foo/bar.js', + }, + ], + }, + ] + + const FileComponent: React.FC = (props) =>
{props.item.name}
+ const FolderComponent: React.FC = (props) =>
{props.item.name}
+ + mount( + {}} + />, + ) + }) + + it('clicks file and folders', () => { + const clickFolderStub = cy.stub() + const clickFileStub = cy.stub() + + const Wrapper = createFileExplorer({ + clickFolderStub, + clickFileStub, + }) + + mount() + + cy.get('div').contains('bar').click().then(() => { + expect(clickFolderStub).to.have.been.calledWith() + }) + + cy.get('div').contains('map.spec.ts').click().then(() => { + expect(clickFileStub).to.have.been.calledWith({ + type: 'file', + absolute: 'merp/map.spec.ts', + name: 'map.spec.ts', + }) + }) + }) +}) diff --git a/npm/design-system/src/components/FileExplorer/FileExplorer.tsx b/npm/design-system/src/components/FileExplorer/FileExplorer.tsx new file mode 100644 index 000000000000..225ed1137b1c --- /dev/null +++ b/npm/design-system/src/components/FileExplorer/FileExplorer.tsx @@ -0,0 +1,241 @@ +import React from 'react' +import cs from 'classnames' +import { FileNode, FolderNode, TreeNode } from './helpers/makeFileHierarchy' + +export { FileNode, FolderNode, TreeNode } + +export interface FolderComponentProps { + item: FolderNode + depth: number + isOpen: boolean + onClick: () => void +} + +export interface FileComponentProps { + item: FileNode + depth: number + onClick: (file: FileNode) => void +} + +export interface FileExplorerProps extends React.HTMLAttributes { + files: TreeNode[] + fileComponent: React.FC + folderComponent: React.FC + selectedFile?: string + searchInput?: JSX.Element + onFileClick: (file: FileNode) => void + + // Styles. They should be a *.module.scss. + // TODO: Can we type these? Do we want to couple to CSS modules? + cssModule?: { + nav: any + ul: any + li: any + a: any + isSelected: any + } +} + +export interface FileTreeProps extends FileExplorerProps { + depth: number + openFolders: Record + style?: React.CSSProperties + setSelectedFile: (absolute: string) => void +} + +export const FileExplorer: React.FC = (props) => { + /** + * Whether a folder is open or not is a **UI** concern. + * From a file system point of view, there is no such concept as "open" or "closed", + * only from a user's point of view. + * For this reason we save the open state as part of the UI component. The easiest + * way to do this is a key/value pair, mapping the absolute path of a directory to a boolean + * + * { + * 'foo': true, + * 'foo/bar': true + * 'foo/bar/qux': false + * } + * + * Every directory is set to open by default. When you add a new directory + * or file via your file system (eg mkdir foo/bar && touch foo/bar/hello.js) it will be added + * without losing the current state of open/closed directories. + */ + const [openFolders, setOpenFolders] = React.useState>({}) + + React.useLayoutEffect(() => { + function walk (nodes: TreeNode[]) { + for (const node of nodes) { + if (node.type === 'folder') { + // only update with newly created folders. + // we want to maintain the current state (open/closed) of existing folders. + if (!(node.absolute in openFolders)) { + setOpenFolders({ ...openFolders, [node.absolute]: true }) + } + + walk(node.files) + } + } + } + + walk(props.files) + }, [props.files, openFolders]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + const files: TreeNode[] = [] + + function flatten (nodes: TreeNode[]) { + for (const node of nodes) { + if (node.type === 'folder') { + // only update with newly created folders. + // we want to maintain the current state (open/closed) of existing folders. + if (openFolders[node.absolute]) { + files.push(node) + flatten(node.files) + } else { + files.push(node) + } + } else { + files.push(node) + } + } + } + + flatten(props.files) + + const selectSpecByIndex = (index: number) => { + const file = typeof index !== 'number' || index < 0 + ? files[0] + : files[index] + + const specElement = document.querySelector(`[data-item="${file.absolute}"]`) as HTMLDivElement + + if (specElement) { + specElement.focus() + } + } + + const selectedSpecIndex = files.findIndex((file) => { + return file.absolute === (document.activeElement as HTMLElement).dataset.item + }) + + if (e.key === 'Enter') { + const selected = files[selectedSpecIndex] + + if (selected.type === 'file') { + // Run the spec. + props.onFileClick(selected) + } + + // Toggle the folder open/closed. + return setSelectedFile(selected.absolute) + } + + if (e.key === 'ArrowUp') { + return selectSpecByIndex(selectedSpecIndex - 1) + } + + if (e.key === 'ArrowDown') { + return selectSpecByIndex(selectedSpecIndex + 1) + } + } + + const setSelectedFile = (absolute: string) => { + setOpenFolders({ ...openFolders, [absolute]: !openFolders[absolute] }) + } + + return ( + + ) +} + +export const FileTree: React.FC = (props) => { + // Negative margins let the tag take full width (a11y) + // while the
  • tag with text content can be positioned relatively + // This gives us HTML + cssModule-only highlight and click handling + const fileTree = (item: TreeNode) => { + if (item.type !== 'folder') { + return + } + + return ( + + ) + } + + const renderFolder = (item: FolderNode) => { + return ( + props.setSelectedFile(item.absolute)} + /> + ) + } + + const renderFile = (item: FileNode) => { + return ( + + ) + } + + return ( + <> + {props.searchInput} + + + ) +} diff --git a/npm/design-system/src/components/FileExplorer/helpers/makeFileHierarchy.spec.ts b/npm/design-system/src/components/FileExplorer/helpers/makeFileHierarchy.spec.ts new file mode 100644 index 000000000000..b784f17716b4 --- /dev/null +++ b/npm/design-system/src/components/FileExplorer/helpers/makeFileHierarchy.spec.ts @@ -0,0 +1,129 @@ +import { + getAllFolders, + makeFileHierarchy, + TreeNode, +} from './makeFileHierarchy' +import { expect } from 'chai' + +describe('makeFileHierarchy', () => { + it('getAllFolders', () => { + const files: string[] = [ + 'forOfStatement.js', + 'foo/y/bar.js', + 'foo/bar', + 'a/b/c', + ] + const actual = getAllFolders(files) + + expect(actual).to.have.members(['foo', 'foo/y', 'foo/bar', 'a', 'a/b', 'a/b/c']) + }) + + it('simple case', () => { + const files: string[] = ['x/y/z.js'] + const actual = makeFileHierarchy(files) + + const expected: TreeNode[] = [ + { + name: 'x', + absolute: 'x', + type: 'folder', + files: [ + { + name: 'y', + absolute: 'x/y', + type: 'folder', + files: [ + { + name: 'z.js', + type: 'file', + absolute: 'x/y/z.js', + }, + ], + }, + ], + }, + ] + + expect(actual).to.eql(expected) + }) + + it('handles case of no files or folders', () => { + const actual = makeFileHierarchy([]) + + expect(actual).to.have.members([]) + }) + + it('works for a complex case', () => { + const files: string[] = [ + 'forOfStatement.js', + 'x', + 'x/y/z', + 'a/b/c/test1.js', + 'a/b/c/d/test2.js', + ] + const actual = makeFileHierarchy(files) + + const expected: TreeNode[] = [ + { + name: 'x', + files: [ + { + name: 'y', + files: [ + { + name: 'z', + files: [], + type: 'folder', + absolute: 'x/y/z', + }, + ], + type: 'folder', + absolute: 'x/y', + }, + ], + type: 'folder', + absolute: 'x', + }, + { + name: 'a', + files: [ + { + name: 'b', + files: [ + { + name: 'c', + files: [ + { + name: 'd', + files: [ + { + type: 'file', + name: 'test2.js', + absolute: 'a/b/c/d/test2.js', + }, + ], + type: 'folder', + absolute: 'a/b/c/d', + }, + { + type: 'file', + name: 'test1.js', + absolute: 'a/b/c/test1.js', + }, + ], + type: 'folder', + absolute: 'a/b/c', + }, + ], + type: 'folder', + absolute: 'a/b', + }, + ], + type: 'folder', + absolute: 'a', + }, + ] + + expect(actual).to.eql(expected) + }) +}) diff --git a/npm/design-system/src/components/FileExplorer/helpers/makeFileHierarchy.ts b/npm/design-system/src/components/FileExplorer/helpers/makeFileHierarchy.ts new file mode 100644 index 000000000000..42705e4c962d --- /dev/null +++ b/npm/design-system/src/components/FileExplorer/helpers/makeFileHierarchy.ts @@ -0,0 +1,177 @@ +interface BaseNode { + name: string + type: 'file' | 'folder' + absolute: string +} + +export interface FileNode extends BaseNode { + type: 'file' +} + +export interface FolderNode extends BaseNode { + type: 'folder' + files: TreeNode[] +} + +export type TreeNode = FileNode | FolderNode + +export function getAllFolders (files: string[]): string[] { + /** + * Returns an array of all nested directories given an array of files and folders. + * + * const files = [ + * 'foo.js', + * 'foo/y/bar.js', + * 'foo/bar', + * 'a/b/c', + * ] + * + * getAllFolderNodes(files) //=> ['foo', 'foo/y', 'foo/bar', 'a', 'a/b', 'a/b/c']) + */ + const dirs = new Set() + + for (const file of files) { + const path = file.split('/') + + if (path.length) { + // Does it contain a file? Assumption: files have an extension. + const hasFileNode = path[path.length - 1].includes('.') + + // Remove file if present. + const dirOnly = hasFileNode ? path.splice(0, path.length - 1) : path + + // Add directory to set. + for (let i = 0; i < dirOnly.length; i++) { + const dir = dirOnly.slice(0, i + 1) + + dirs.add(dir.join('/')) + } + } + } + + return Array.from(dirs) +} + +export function getAllFileNodes (files: string[]): Record { + /** + * Returns a key/value map of directories to contained files. + * + * { + * foo: ['bar.js'] + * 'foo/bar': ['qux.js'] + * } + */ + const allFileNodes: Record = { + '/': [], + } + + for (const file of files) { + const split = file.split('/') + const isFileNode = split[split.length - 1].includes('.') + const isRoot = split.length === 1 + const isFileNodeInFolderNode = split.length > 1 && split[split.length - 1].includes('.') + + if (isFileNodeInFolderNode) { + const [file, ...path] = split.reverse() + const dir = path.reverse().join('/') + + if (!allFileNodes[dir]) { + allFileNodes[dir] = [file] + } else { + allFileNodes[dir] = allFileNodes[dir].concat(file) + } + } + + if (isFileNode && isRoot) { + allFileNodes['/'] = allFileNodes['/'].concat(file) + } + } + + return allFileNodes +} + +function charCount (str: string, letter: string) { + let count = 0 + + for (let position = 0; position < str.length; position++) { + if (str.charAt(position) === letter) { + count += 1 + } + } + + return count +} + +/** + * Given a list of files and folders, returns an nested array structure + * representing a file system with use metadata like type, name and absolute. + * + * const files: string[] = ['x/y/z.js'] + * const actual = makeFileNodeHierarchy(files) + * [ + * { + * name: 'x', + * absolute: 'x', + * type: 'folder', + * files: [ + * { + * name: 'y', + * absolute: 'x/y', + * type: 'folder', + * files: [ + * { + * name: 'z.js', + * type: 'file', + * absolute: 'x/y/z.js' + * } + * ], + * } + * ] + * } + * ] + */ +export function makeFileHierarchy (files: string[]): TreeNode[] { + const allFolderNodes = getAllFolders(files) + const allFileNodes = getAllFileNodes(files) + + const foldersByLength = allFolderNodes.reduce>((acc, curr) => { + const count = charCount(curr, '/') + + if (!acc[count]) { + return { ...acc, [count]: [curr] } + } + + return { ...acc, [count]: [...acc[count], curr] } + }, {}) + + function walk (dirs: string[], depth = 0): TreeNode[] { + if (!dirs) { + return [] + } + + return dirs.map((dir) => { + const nestedDirs = foldersByLength[depth + 1] + ? walk(foldersByLength[depth + 1].filter((x) => x.startsWith(dir)), depth + 1) + : [] + + const containedFileNodes = (allFileNodes[dir] || []).map((file) => { + return { + type: 'file', + name: file, + absolute: `${dir}/${file}`, + } + }) + + const dirname = dir.split('/').reverse()[0] + + return { + name: dirname, + files: [...nestedDirs, ...containedFileNodes], + type: 'folder', + absolute: dir, + } + }) + } + + return walk(foldersByLength[0]) +} diff --git a/npm/design-system/src/components/SpecList/SpecList.module.scss b/npm/design-system/src/components/SpecList/SpecList.module.scss new file mode 100644 index 000000000000..e2f17d8fd635 --- /dev/null +++ b/npm/design-system/src/components/SpecList/SpecList.module.scss @@ -0,0 +1,73 @@ +@use '../../index.scss' as *; + +.nav { + user-select: none; + white-space: nowrap; +} + +.ul { + margin-block-start: 0; + margin-block-end: 0; + margin-inline-start: 0; + margin-inline-end: 0; + padding-inline-start: 0; + &:before { + display: none; + } +} + +.li.li { + padding-left: 20px; +} + + +.ul, .li { + position: relative; + list-style: none; + font-size: $text-s; + padding: 0; + line-height: 1.6; +} + +.a { + position: relative; + color: unset; + text-decoration: none; + display: inline-block; + width: 100%; + &:hover { + background: opacify($color: $metal-05, $amount: 0.2); + } +} + +.ul .ul::before { + content: ""; + position: absolute; + z-index: 0; + width: 3px; + bottom: 0; + top: 0; + left: calc(1 * (#{$text-xs})); + background-size: 3px 3px; + background-image: radial-gradient(#999999 30%, transparent 0); + background-position: center; + display: block; +} + +.ul .ul { + margin-inline-start: $text-xs; +} + +.folderIcon, .brandIcon { + margin-right: 0.5em; +} + +.isClosed { + + .ul, + .li { + display: none; + } +} + +.isSelected, .isSelected:hover { + background: $chill-10; +} \ No newline at end of file diff --git a/npm/design-system/src/components/SpecList/SpecList.spec.tsx b/npm/design-system/src/components/SpecList/SpecList.spec.tsx new file mode 100644 index 000000000000..eebd2b5d989d --- /dev/null +++ b/npm/design-system/src/components/SpecList/SpecList.spec.tsx @@ -0,0 +1,104 @@ +/// +import { mount } from '@cypress/react' +import React from 'react' +import { FileNode } from '../FileExplorer/helpers/makeFileHierarchy' +import { SpecList } from './SpecList' + +const specs: Cypress.Cypress['spec'][] = [ + { + relative: 'foo/bar/foo.spec.js', + absolute: 'Users/code/foo/bar/foo.spec.js', + name: 'foo/bar/foo.spec.js', + }, + { + relative: 'qux/foo.spec.tsx', + absolute: 'qux/foo.spec.tsx', + name: 'qux/foo.spec.tsx', + }, + { + relative: 'merp/foo.spec.ts', + absolute: 'merp/foo.spec.ts', + name: 'merp/foo.spec.ts', + }, +] + +describe('SpecList', () => { + const createSpecList = (selectStub: typeof cy.stub): React.FC => { + return () => { + const [selectedFile, setSelectedFile] = React.useState() + + const onFileClick = (file: FileNode) => { + selectStub(file) + setSelectedFile(file.absolute) + } + + return ( + + ) + } + } + + it('renders and selects a file', () => { + const selectStub = cy.stub() + const Subject = createSpecList(selectStub) + + mount() + + cy.get('div').contains('foo.spec.tsx').click().then(() => { + expect(selectStub).to.have.been.calledWith({ + type: 'file', + absolute: 'qux/foo.spec.tsx', + name: 'foo.spec.tsx', + }) + }) + }) + + it('closes a folder', () => { + const Subject = createSpecList(cy.stub()) + + mount() + + cy.get('div').contains('foo.spec.tsx').should('exist') + + // qux folder contains foo.spec.tsx. If we close it, it should not exist anymore. + cy.get('div').contains('qux').click().then(() => { + cy.get('div').contains('foo.spec.tsx').should('not.exist') + }) + }) + + it('navigates with arrow keys', () => { + const selectStub = cy.stub() + const Subject = createSpecList(selectStub) + + mount() + + // close the "foo" directory + cy.get('div').contains('foo').click() + + // navigate to "qux" + cy.realPress('ArrowDown') + cy.get('div').contains('foo.spec.tsx').should('exist') + + // collapse "qux", hiding "foo.spec.tsx" + cy.realPress('{enter}') + cy.get('div').contains('foo.spec.tsx').should('not.exist') + + // uncollapse "qux", revealing "foo.spec.tsx" + cy.realPress('{enter}') + cy.get('div').contains('foo.spec.tsx').should('exist') + + // navigate to "foo.spec.tsx" + cy.realPress('ArrowDown') + cy.realPress('{enter}').then(() => { + expect(selectStub).to.have.been.calledWith({ + type: 'file', + absolute: 'qux/foo.spec.tsx', + name: 'foo.spec.tsx', + }) + }) + }) +}) diff --git a/npm/design-system/src/components/SpecList/SpecList.tsx b/npm/design-system/src/components/SpecList/SpecList.tsx new file mode 100644 index 000000000000..99c3536ff5ab --- /dev/null +++ b/npm/design-system/src/components/SpecList/SpecList.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { + FileComponentProps, + FolderComponentProps, + FileExplorer, + FileExplorerProps, +} from '../FileExplorer/FileExplorer' +import { makeFileHierarchy } from '../FileExplorer/helpers/makeFileHierarchy' + +import { InlineIcon } from '@iconify/react' +import javascriptIcon from '@iconify/icons-vscode-icons/file-type-js-official' +import typescriptIcon from '@iconify/icons-vscode-icons/file-type-typescript-official' +import reactJs from '@iconify/icons-vscode-icons/file-type-reactjs' +import reactTs from '@iconify/icons-vscode-icons/file-type-reactts' +import folderClosed from '@iconify/icons-vscode-icons/default-folder' +import folderOpen from '@iconify/icons-vscode-icons/default-folder-opened' +import styles from './SpecList.module.scss' + +const icons: Record = { + js: { icon: javascriptIcon }, + ts: { icon: typescriptIcon }, + tsx: { icon: reactTs }, + jsx: { icon: reactJs }, + folderOpen: { icon: folderOpen }, + folderClosed: { icon: folderClosed }, +} + +const getExt = (path: string) => { + const extensionMatches = path.match(/(?:\.([^.]+))?$/) + + return extensionMatches ? extensionMatches[1] : '' +} + +const FileComponent: React.FC = (props) => { + const ext = getExt(props.item.name) + const inlineIconProps = ext && icons[ext] + + return ( +
    props.onClick(props.item)} + > + + {props.item.name} +
    + ) +} + +const FolderComponent: React.FC = (props) => { + const inlineIconProps = props.isOpen ? icons.folderOpen : icons.folderClosed + + return ( +
    + + {props.item.name} +
    + ) +} + +interface SpecListProps extends Omit< + FileExplorerProps, 'files' | 'fileComponent' | 'folderComponent' | 'cssModule' +> { + specs: Cypress.Cypress['spec'][] +} + +export const SpecList: React.FC = (props) => { + const files = React.useMemo(() => makeFileHierarchy(props.specs.map((spec) => spec.relative)), [props.specs]) + + return ( + <> + + + ) +} diff --git a/npm/design-system/src/index.ts b/npm/design-system/src/index.ts index 63dd402ee8e6..73d3ac314158 100644 --- a/npm/design-system/src/index.ts +++ b/npm/design-system/src/index.ts @@ -2,6 +2,10 @@ export * from './components/Button' export * from './components/CypressLogo/CypressLogo' +export * from './components/FileExplorer/FileExplorer' + +export * from './components/SpecList/SpecList' + export * from './components/SearchInput/SearchInput' export * from './components/Nav' diff --git a/npm/design-system/tsconfig.json b/npm/design-system/tsconfig.json index b9e1588263ce..8a6fd4784e0b 100644 --- a/npm/design-system/tsconfig.json +++ b/npm/design-system/tsconfig.json @@ -5,7 +5,7 @@ "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, "skipLibCheck": true, "lib": [ - "es2015", + "es2016", "dom" ] /* Specify library files to be included in the compilation: */, "declaration": true, /* Generates corresponding '.d.ts' file. */ diff --git a/packages/runner-ct/cypress/component/SpecList/SpecList.spec.tsx b/packages/runner-ct/cypress/component/SpecList/SpecList.spec.tsx deleted file mode 100644 index 232d7f30615e..000000000000 --- a/packages/runner-ct/cypress/component/SpecList/SpecList.spec.tsx +++ /dev/null @@ -1,203 +0,0 @@ -/// -import React from 'react' -import { mount } from '@cypress/react' -import { SpecList } from '../../../src/SpecList' -import { SpecFile } from '../../../src/SpecList/make-spec-hierarchy' -import { SpecFileItem } from '../../../src/SpecList/SpecFileItem' - -const createSpec = (name: string): Cypress.Cypress['spec'] => ({ - absolute: `/root/cypress/component/${name}`, - relative: `component/${name}`, - name, -}) - -const spec: SpecFile = { - ...createSpec('foo-bar.js'), - name: 'foo-bar.js', - shortName: 'foo-bar.js', - type: 'file', -} - -const specs: Cypress.Cypress['spec'][] = [ - createSpec('index.spec.js'), - createSpec('shared/bar.js'), - createSpec('shared/runner.js'), - createSpec('shared/spec-list.js'), - createSpec('component/shared/utils/transform.js'), -] - -describe('SpecList', () => { - it('selected and non selected spec', () => { - const selectStub = cy.stub() - const unselectedSpec = { ...spec, shortName: 'unselected.spec.js' } - - mount( -
      - - -
    , - ) - - cy.get('[aria-checked=true]') - .contains('selected.spec.js') - - cy.get('[aria-checked=false]') - .contains('unselected.spec.js') - .click() - .then(() => { - expect(selectStub).to.have.been.calledWith(unselectedSpec) - }) - }) - - it('renders an empty list', () => { - mount( - {}} - />, - ) - }) - - it('opens and closes folders', () => { - mount( - , - ); - - ['bar.js', 'runner.js', 'spec-list.js'].forEach((spec) => { - cy.get('[role=radio]').contains(spec).should('exist') - }) - - cy.get('[role=radio]').contains('transform.js') - cy.get('a').contains('shared').click(); - - ['bar.js', 'runner.js', 'spec-list.js'].forEach((spec) => { - cy.get('[role=radio]').contains(spec).should('not.exist') - }) - }) - - it('filters the specs', () => { - mount( - , - ) - - cy.get('[placeholder="Find spec..."]').type('transform.js') - cy.get('[role=radio]').contains('transform.js').should('exist'); - - ['bar.js', 'runner.js', 'spec-list.js'].forEach((spec) => { - cy.get('[role=radio]').contains(spec).should('not.exist') - }) - }) - - it('selects a spec to run', () => { - const onSelectSpecStub = cy.stub() - - mount( - , - ) - - cy.get('[role=radio]') - .contains('transform.js') - .click() - .then(() => { - expect(onSelectSpecStub).to.have.been.calledOnce - }) - }) - - it('selects a spec to run only with keyboard', () => { - const onSelectSpecStub = cy.stub() - - mount( - , - ) - - cy.get('input[placeholder="Find spec..."]').focus() - - cy.realPress('ArrowDown') - cy.realPress('ArrowDown') - cy.realPress('ArrowUp') - cy.realPress('ArrowDown') - cy.realPress('{enter}').then(() => { - expect(onSelectSpecStub).to.have.been.calledOnce - expect(onSelectSpecStub.args[0][0].name).to.eq('shared/bar.js') - }) - }) - - context('a11y navigation', () => { - beforeEach(() => { - mount( - {}} - />, - ) - - cy.get('input[placeholder="Find spec..."]').click() - }) - - it('ArrowUp initially focuses the first field', () => { - cy.realPress('ArrowUp') - - cy.get('[role=radio]') - .contains('index.spec.js') - .parent() - .should('be.focused') - }) - - it('ArrowUp moves focus to previous spec', () => { - cy.contains('runner.js').parent().focus().should('be.focused') - cy.realPress('ArrowUp') - - cy.get('[role=radio]') - .contains('bar.js') - .parent() - .should('be.focused') - }) - - it('ArrowDown', () => { - cy.contains('runner.js').parent().focus().should('be.focused') - cy.realPress('ArrowDown') - - cy.get('[role=radio]') - .contains('spec-list.js') - .parent() - .should('be.focused') - }) - - it('Allows to navigate between files when spec list is searched', () => { - cy.get('input').type('bar') - cy.realPress('ArrowDown') - - cy - .get('[role=radio]') - .contains('bar.js') - .parent() - .should('be.focused') - }) - }) -}) diff --git a/packages/runner-ct/cypress/component/SpecList/make-spec-hierarchy.spec.ts b/packages/runner-ct/cypress/component/SpecList/make-spec-hierarchy.spec.ts deleted file mode 100644 index ba9549fec6c0..000000000000 --- a/packages/runner-ct/cypress/component/SpecList/make-spec-hierarchy.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { expect } from 'chai' -import { makeSpecHierarchy, SpecFolderOrSpecFile } from '../../../src/SpecList/make-spec-hierarchy' - -const baseVals: Cypress.Cypress['spec'] = { - absolute: '/', - relative: '/', - name: '', -} - -const files = [ - { ...baseVals, name: 'forOfStatement.js' }, - { ...baseVals, name: 'foo/y/bar.js' }, -] - -describe('makeSpecHierarchy', () => { - it('works for a complex case', () => { - const actual = makeSpecHierarchy(files) - - const expected: SpecFolderOrSpecFile[] = [ - { - ...baseVals, - name: 'forOfStatement.js', - shortName: 'forOfStatement.js', - type: 'file', - }, - { - shortName: 'foo', - type: 'folder', - specs: [ - { - shortName: 'y', - type: 'folder', - specs: [ - { - ...baseVals, - shortName: 'bar.js', - name: 'foo/y/bar.js', - type: 'file', - }, - ], - }, - ], - }, - ] - - expect(actual).to.eqls(expected) - }) -}) diff --git a/packages/runner-ct/src/SpecList/SpecFileItem.tsx b/packages/runner-ct/src/SpecList/SpecFileItem.tsx deleted file mode 100644 index c66122a25a65..000000000000 --- a/packages/runner-ct/src/SpecList/SpecFileItem.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react' -import { SpecFile } from './make-spec-hierarchy' -import cs from 'classnames' -import './spec-file.scss' - -export type OnSelectSpec = (spec: SpecFile) => void - -interface SpecFileProps { - spec: SpecFile - selected: boolean - onSelectSpec: OnSelectSpec -} - -export const SpecFileItem: React.FC = (props: SpecFileProps) => { - return ( -
  • { - e.preventDefault() - props.onSelectSpec(props.spec) - }} - > -
    { - if (e.key === ' ' || e.key === 'Enter') { - props.onSelectSpec(props.spec) - } - }} - > - - - - - {props.spec.shortName} - -
    -
  • ) -} diff --git a/packages/runner-ct/src/SpecList/SpecGroupItem.tsx b/packages/runner-ct/src/SpecList/SpecGroupItem.tsx deleted file mode 100644 index 55313cdf90be..000000000000 --- a/packages/runner-ct/src/SpecList/SpecGroupItem.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import cs from 'classnames' -import React, { useState } from 'react' - -import { CollapseGroup } from '../icons/CollapseGroup' -import { ExpandGroup } from '../icons/ExpandGroup' -import { SpecFolder } from './make-spec-hierarchy' -import { SpecItem } from './SpecItem' -import { OnSelectSpec } from './SpecFileItem' -import './spec-group.scss' - -interface SpecGroupProps { - group: SpecFolder - selectedSpecs: string[] - onSelectSpec: OnSelectSpec -} - -export const SpecGroupItem: React.FC = (props) => { - const [open, setIsOpen] = useState(true) - - return ( -
  • - setIsOpen(!open)} - className='spec-list__group-name' - title={props.group.shortName} - > - - {open ? : } - - {props.group.shortName} - - -
      - { - props.group.specs.reduce((acc, item) => { - if (!open) { - return [] - } - - return acc.concat( - , - ) - }, []) - } -
    -
  • - ) -} diff --git a/packages/runner-ct/src/SpecList/SpecItem.tsx b/packages/runner-ct/src/SpecList/SpecItem.tsx deleted file mode 100644 index 2fce75d66133..000000000000 --- a/packages/runner-ct/src/SpecList/SpecItem.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react' -import { SpecGroupItem } from './SpecGroupItem' -import { OnSelectSpec, SpecFileItem } from './SpecFileItem' -import { SpecFolderOrSpecFile } from '../../src/SpecList/make-spec-hierarchy' - -interface SpecItemProps { - item: SpecFolderOrSpecFile - selectedSpecs: string[] - onSelectSpec: OnSelectSpec -} - -export const SpecItem: React.FC = (props) => { - if (props.item.type === 'file') { - return ( - - ) - } - - return ( - - ) -} diff --git a/packages/runner-ct/src/SpecList/SpecList.module.scss b/packages/runner-ct/src/SpecList/SpecList.module.scss deleted file mode 100644 index 6ee8a12837e6..000000000000 --- a/packages/runner-ct/src/SpecList/SpecList.module.scss +++ /dev/null @@ -1,13 +0,0 @@ -@use '@cypress/design-system/src/index.scss' as *; - -.specListContainer { - height: 100vh; - width: 100%; - overflow-y: auto; - z-index: 3; - box-shadow: $shadow-lg; -} - -.specsList { - padding: 8px; -} diff --git a/packages/runner-ct/src/SpecList/SpecList.tsx b/packages/runner-ct/src/SpecList/SpecList.tsx deleted file mode 100644 index c7a56703b049..000000000000 --- a/packages/runner-ct/src/SpecList/SpecList.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useState } from 'react' -import { observer } from 'mobx-react' -import { SpecItem } from './SpecItem' -import { OnSelectSpec } from './SpecFileItem' -import { SearchSpec } from './components/SearchSpec' -import { makeSpecHierarchy } from './make-spec-hierarchy' -import styles from './SpecList.module.scss' -import cs from 'classnames' - -interface SpecsListProps { - selectedSpecs: string[] - specs: Cypress.Cypress['spec'][] - onSelectSpec: OnSelectSpec - className?: string - inputRef?: React.Ref -} - -export const SpecList: React.FC = observer((props) => { - const [search, setSearch] = useState('') - const filteredSpecs = props.specs.filter((spec) => spec.name.toLowerCase().includes(search)) - const hierarchy = makeSpecHierarchy(filteredSpecs) - - const handleKeyDown = (e: React.KeyboardEvent) => { - const selectSpecByIndex = (index: number) => { - const spec = typeof index !== 'number' || index < 0 - ? filteredSpecs[0] - : filteredSpecs[index] - - const specElement = document.querySelector(`[data-spec="${spec.relative}"]`) as HTMLDivElement - - if (specElement) { - specElement.focus() - } - } - - const selectedSpecIndex = filteredSpecs.findIndex((spec) => - spec.relative === (document.activeElement as HTMLElement)?.dataset?.spec) - - if (e.key === 'ArrowUp') { - return selectSpecByIndex(selectedSpecIndex - 1) - } - - if (e.key === 'ArrowDown') { - return selectSpecByIndex(selectedSpecIndex + 1) - } - } - - return ( -
    - -
      - { - hierarchy.map((item) => ( - - )) - } -
    - -
    - ) -}) diff --git a/packages/runner-ct/src/SpecList/index.ts b/packages/runner-ct/src/SpecList/index.ts deleted file mode 100644 index 8966e6a3e4cc..000000000000 --- a/packages/runner-ct/src/SpecList/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './SpecList' diff --git a/packages/runner-ct/src/SpecList/make-spec-hierarchy.ts b/packages/runner-ct/src/SpecList/make-spec-hierarchy.ts deleted file mode 100644 index 04111300634c..000000000000 --- a/packages/runner-ct/src/SpecList/make-spec-hierarchy.ts +++ /dev/null @@ -1,66 +0,0 @@ -export type SpecFolderOrSpecFile = SpecFile | SpecFolder - -type CypressSpec = Cypress.Cypress['spec'] - -export interface SpecFile extends CypressSpec { - type: 'file' - shortName: string -} - -export interface SpecFolder { - type: 'folder' - shortName: string - specs: SpecFolderOrSpecFile[] -} - -/** - * Split specs into group by their - * first level of folder hierarchy - * - * @param {Array<{name: string}>} specs - */ -// export function makeSpecHierarchy (specs: { name: string }[]) { -export function makeSpecHierarchy (specs: Cypress.Cypress['spec'][]) { - // to save the existing folder paths - const kvpGroups: { [fullPath: string]: SpecFolder } = {} - - return specs.reduce((groups, spec) => { - const pathArray = spec.name.split('/') - let currentSpecArray: SpecFolderOrSpecFile[] = groups - let currentPath = '' - - do { - const pathPart = pathArray.shift() || '' - - currentPath += `/${pathPart}` - // if we have a file set is as part of the current group - if (!pathArray.length) { - currentSpecArray.push({ - ...spec, - type: 'file', - shortName: pathPart, - }) - } else if (pathPart) { - //if it is a folder find if the next folder exists - let currentGroup: SpecFolder | undefined = kvpGroups[currentPath] - - if (!currentGroup) { - //if it does not exist we create it - currentGroup = { - type: 'folder', - shortName: pathPart, - specs: [], - } - - kvpGroups[currentPath] = currentGroup - // and add it to the current set of objects - currentSpecArray.push(currentGroup) - } - - currentSpecArray = currentGroup.specs - } - } while (pathArray.length) - - return groups - }, []) -} diff --git a/packages/runner-ct/src/SpecList/spec-file.scss b/packages/runner-ct/src/SpecList/spec-file.scss deleted file mode 100644 index 5dcd3a46fb3d..000000000000 --- a/packages/runner-ct/src/SpecList/spec-file.scss +++ /dev/null @@ -1,48 +0,0 @@ -@import "../runner-ct-variables.scss"; - -.spec-list__file { - list-style: none; - margin: 4px 0; - outline-offset: 4px; -} - -.spec-list__spec-file__radio { - margin-bottom: 8px; - cursor: pointer; - white-space: nowrap; - overflow-x: hidden; - text-overflow: ellipsis; -} - -.spec-list__spec-file__radio__input { - margin-left: 4px; // better focus displaying; - - [role="radio"] { - opacity: 0; - width: 0; - height: 0; - } - - .spec-list__radio-control { - box-sizing: border-box; - display: inline-block; - margin-right: 8px; - width: 10px; - height: 10px; - border-radius: 50%; - border: 1px solid $ct-accent-blue; - } - - .spec-list__radio-control--unselected { - border: 1px solid $ct-accent-blue; - } - - .spec-list__radio-control--selected { - background: $ct-accent-blue; - } -} - -.spec-list__file--radio { - margin: 0; - padding: 0; -} diff --git a/packages/runner-ct/src/SpecList/spec-group.scss b/packages/runner-ct/src/SpecList/spec-group.scss deleted file mode 100644 index 2fc991124145..000000000000 --- a/packages/runner-ct/src/SpecList/spec-group.scss +++ /dev/null @@ -1,41 +0,0 @@ -@mixin dotted-border { - &:before { - content: ''; - position: absolute; - z-index: 1; - width: 3px; - bottom: 0; - top: 0; - left: -16px; - background-size: 3px 3px; - background-image: radial-gradient(#999999 30%, transparent 0); - background-position: center; - } -} - -.spec-list__group-icon { - margin: 0 5px 0 0; - cursor: pointer; - position: relative; - -} - -.spec-list__group-name { - display: block; - margin: 8px 0; - white-space: nowrap; - overflow-x: hidden; - text-overflow: ellipsis; - cursor: default; -} - -li.spec-list__group { - list-style: none; -} - -ul.spec-list__group--specs { - margin: 0 0 0 20px; - padding: 0; - position: relative; - @include dotted-border; -} \ No newline at end of file diff --git a/packages/runner-ct/src/app/RunnerCt.tsx b/packages/runner-ct/src/app/RunnerCt.tsx index 6ca5bf67c138..53f8104c2883 100644 --- a/packages/runner-ct/src/app/RunnerCt.tsx +++ b/packages/runner-ct/src/app/RunnerCt.tsx @@ -2,12 +2,8 @@ import cs from 'classnames' import { observer } from 'mobx-react' import * as React from 'react' import { useScreenshotHandler } from './useScreenshotHandler' -import { library } from '@fortawesome/fontawesome-svg-core' -import { fab } from '@fortawesome/free-brands-svg-icons' -import { fas } from '@fortawesome/free-solid-svg-icons' -import { far } from '@fortawesome/free-regular-svg-icons' import { ReporterContainer } from './ReporterContainer' -import { NavItem } from '@cypress/design-system' +import { NavItem, SpecList, FileNode } from '@cypress/design-system' import SplitPane from 'react-split-pane' import State from '../lib/state' @@ -15,7 +11,7 @@ import Header from '../header/header' import Iframes from '../iframe/iframes' import Message from '../message/message' import EventManager from '../lib/event-manager' -import { SpecList } from '../SpecList' +import { SearchSpec } from '../SpecList/components/SearchSpec' import { useGlobalHotKey } from '../lib/useHotKey' import { debounce } from '../lib/debounce' import { LeftNavMenu } from './LeftNavMenu' @@ -24,10 +20,6 @@ import { Plugins } from './Plugins' import { KeyboardHelper } from './KeyboardHelper' import './RunnerCt.scss' -library.add(fas) -library.add(fab) -library.add(far) - interface AppProps { state: State eventManager: typeof EventManager @@ -60,11 +52,12 @@ const App: React.FC = observer( const { state, eventManager, config } = props const [activeIndex, setActiveIndex] = React.useState(0) + const [search, setSearch] = React.useState('') const headerRef = React.useRef(null) - const runSpec = (spec: Cypress.Cypress['spec']) => { + const runSpec = (file: FileNode) => { setActiveIndex(0) - state.setSingleSpec(spec) + state.setSingleSpec(props.state.specs.find((spec) => spec.absolute.includes(file.absolute))) } function monitorWindowResize () { @@ -245,6 +238,8 @@ const App: React.FC = observer( } : {} + const filteredSpecs = props.state.specs.filter((spec) => spec.relative.toLowerCase().includes(search.toLowerCase())) + return ( = observer( > + } /> =1.13.0, mocha@^8.1.1, mocha@^8.1.3: yargs-parser "13.1.2" yargs-unparser "2.0.0" -mocha@^7.1.0: +mocha@^7.0.1, mocha@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.2.0.tgz#01cc227b00d875ab1eed03a75106689cfed5a604" integrity sha512-O9CIypScywTVpNaRrCAgoUnJgozpIofjKUYmJhiCIJMiuYnLI6otcb1/kpW9/n/tJODHGZ7i8aLQoDVsMtOKQQ==