Open spec file from desktop gui and refactor ui-components to typescript (#7747)

This commit is contained in:
Zach Panzarino
2020-06-24 13:51:50 -04:00
committed by GitHub
parent 9705dd3cb1
commit 759d87f167
42 changed files with 847 additions and 295 deletions
@@ -2,56 +2,69 @@
"integration": [
{
"name": "app_spec.coffee",
"absolute": "/user/project/cypress/integration/app_spec.coffee",
"relative": "cypress/integration/app_spec.coffee"
},
{
"name": "accounts/account_new_spec.coffee",
"absolute": "/user/project/cypress/integration/accounts/account_new_spec.coffee",
"relative": "cypress/integration/accounts/account_new_spec.coffee"
},
{
"name": "accounts/accounts_list_spec.coffee",
"absolute": "/user/project/cypress/integration/accounts/accounts_list_spec.coffee",
"relative": "cypress/integration/accounts/accounts_list_spec.coffee"
},
{
"name": "admin_users/admin_users_list_spec.coffee",
"absolute": "/user/project/cypress/integration/admin_users/admin_users_list_spec.coffee",
"relative": "cypress/integration/admin_users/admin_users_list_spec.coffee"
},
{
"name": "admin_users/admin.user/foo_list_spec.coffee",
"absolute": "/user/project/cypress/integration/admin_users/admin.user/foo_list_spec.coffee",
"relative": "cypress/integration/admin_users/admin.user/foo_list_spec.coffee"
}
],
"unit": [
{
"name": "admin_users/admin/users/bar_list_spec.coffee",
"absolute": "/user/project/cypress/unit/admin_users/admin/users/bar_list_spec.coffee",
"relative": "cypress/unit/admin_users/admin/users/bar_list_spec.coffee"
},
{
"name": "admin_users/admin/users/all/admin/one_list_spec.coffee",
"absolute": "/user/project/cypress/unit/admin_users/admin/users/all/admin/one_list_spec.coffee",
"relative": "cypress/unit/admin_users/admin/users/all/admin/one_list_spec.coffee"
},
{
"name": "admin_users/admin/users/all/admin/two_list_spec.coffee",
"absolute": "/user/project/cypress/unit/admin_users/admin/users/all/admin/two_list_spec.coffee",
"relative": "cypress/unit/admin_users/admin/users/all/admin/two_list_spec.coffee"
},
{
"name": "admin_users/admin/users/all/admin/three_list_spec.coffee",
"absolute": "/user/project/cypress/unit/admin_users/admin/users/all/admin/three_list_spec.coffee",
"relative": "cypress/unit/admin_users/admin/users/all/admin/three_list_spec.coffee"
},
{
"name": "admin_users/admin/users/all/admin/four_list_spec.coffee",
"absolute": "/user/project/cypress/unit/admin_users/admin/users/all/admin/four_list_spec.coffee",
"relative": "cypress/unit/admin_users/admin/users/all/admin/four_list_spec.coffee"
},
{
"name": "admin_users/admin/users/all/admin/five_list_spec.coffee",
"absolute": "/user/project/cypress/unit/admin_users/admin/users/all/admin/five_list_spec.coffee",
"relative": "cypress/unit/admin_users/admin/users/all/admin/five_list_spec.coffee"
},
{
"name": "admin_users/admin/users/all/admin/six_list_spec.coffee",
"absolute": "/user/project/cypress/unit/admin_users/admin/users/all/admin/six_list_spec.coffee",
"relative": "cypress/unit/admin_users/admin/users/all/admin/six_list_spec.coffee"
},
{
"name": "admin_users/admin/users/all/admin/last_list_spec.coffee",
"absolute": "/user/project/cypress/unit/admin_users/admin/users/all/admin/last_list_spec.coffee",
"relative": "cypress/unit/admin_users/admin/users/all/admin/last_list_spec.coffee"
}
]
@@ -16,12 +16,15 @@ describe('Specs List', function () {
cy.stub(this.ipc, 'getOptions').resolves({ projectRoot: '/foo/bar' })
cy.stub(this.ipc, 'getCurrentUser').resolves(this.user)
cy.stub(this.ipc, 'getSpecs').yields(null, this.specs)
cy.stub(this.ipc, 'getUserEditor').resolves({})
cy.stub(this.ipc, 'closeBrowser').resolves(null)
cy.stub(this.ipc, 'launchBrowser')
cy.stub(this.ipc, 'openFinder')
cy.stub(this.ipc, 'openFile')
cy.stub(this.ipc, 'externalOpen')
cy.stub(this.ipc, 'onboardingClosed')
cy.stub(this.ipc, 'onSpecChanged')
cy.stub(this.ipc, 'setUserEditor')
this.openProject = this.util.deferred()
cy.stub(this.ipc, 'openProject').returns(this.openProject.promise)
@@ -173,8 +176,8 @@ describe('Specs List', function () {
})
it('lists test specs', function () {
cy.get('.file a').last().should('contain', 'last_list_spec.coffee')
cy.get('.file a').last().should('not.contain', 'admin_users')
cy.get('.file .file-name-wrapper').last().should('contain', 'last_list_spec.coffee')
cy.get('.file .file-name-wrapper').last().should('not.contain', 'admin_users')
})
})
})
@@ -515,7 +518,7 @@ describe('Specs List', function () {
this.ipc.getSpecs.yields(null, this.specs)
this.openProject.resolve(this.config)
cy.contains('.file a', 'app_spec.coffee').as('firstSpec')
cy.contains('.file .file-name-wrapper', 'app_spec.coffee').as('firstSpec')
})
it('closes then launches browser on click of file', () => {
@@ -533,9 +536,11 @@ describe('Specs List', function () {
})
it('adds \'active\' class on click', () => {
cy.get('@firstSpec')
cy.get('@firstSpec').parent()
.should('not.have.class', 'active')
.click()
cy.get('@firstSpec').click()
.parent()
.should('have.class', 'active')
})
@@ -544,7 +549,7 @@ describe('Specs List', function () {
this.ipc.getSpecs.yield(null, this.specs)
})
cy.get('@firstSpec').should('have.class', 'active')
cy.get('@firstSpec').parent().should('have.class', 'active')
})
})
@@ -557,7 +562,7 @@ describe('Specs List', function () {
context('choose shallow spec', function () {
beforeEach(() => {
cy.get('.file a').contains('a', 'app_spec.coffee').as('firstSpec').click()
cy.get('.file .file-name-wrapper').contains('a', 'app_spec.coffee').as('firstSpec').click()
})
it('updates spec icon', function () {
@@ -566,13 +571,13 @@ describe('Specs List', function () {
})
it('sets spec as active', () => {
cy.get('@firstSpec').should('have.class', 'active')
cy.get('@firstSpec').parent().should('have.class', 'active')
})
})
context('choose deeper nested spec', function () {
beforeEach(() => {
cy.get('.file a').contains('a', 'last_list_spec.coffee').as('deepSpec').click()
cy.get('.file .file-name-wrapper').contains('a', 'last_list_spec.coffee').as('deepSpec').click()
})
it('updates spec icon', () => {
@@ -580,7 +585,7 @@ describe('Specs List', function () {
})
it('sets spec as active', () => {
cy.get('@deepSpec').should('have.class', 'active')
cy.get('@deepSpec').parent().should('have.class', 'active')
})
})
})
@@ -603,8 +608,8 @@ describe('Specs List', function () {
})
it('updates active spec', function () {
cy.get('@firstSpec').should('not.have.class', 'active')
cy.get('@secondSpec').should('have.class', 'active')
cy.get('@firstSpec').parent().should('not.have.class', 'active')
cy.get('@secondSpec').parent().should('have.class', 'active')
})
})
})
@@ -623,15 +628,155 @@ describe('Specs List', function () {
this.ipc.onSpecChanged.yield(null, 'integration/app_spec.coffee')
})
cy.get('@firstSpec').should('have.class', 'active')
cy.get('@firstSpec').parent().should('have.class', 'active')
.then(function () {
this.ipc.onSpecChanged.yield(null, 'integration/accounts/account_new_spec.coffee')
})
cy.get('@firstSpec').should('not.have.class', 'active')
cy.get('@firstSpec').parent().should('not.have.class', 'active')
cy.contains('a', 'account_new_spec.coffee')
.parent()
.should('have.class', 'active')
})
})
describe('open in IDE', function () {
beforeEach(function () {
this.ipc.getSpecs.yields(null, this.specs)
this.openProject.resolve(this.config)
cy.get('.file').contains('a', 'app_spec.coffee').parent().as('spec')
cy.get('@spec').contains('Open in IDE').as('button')
})
it('does not display button without hover', function () {
cy.contains('Open in IDE').should('not.be.visible')
})
it('displays when spec is hovered over', function () {
cy.get('@button').invoke('show').should('be.visible')
})
describe('opens files', function () {
beforeEach(function () {
this.availableEditors = [
{ id: 'computer', name: 'On Computer', isOther: false, openerId: 'computer' },
{ id: 'atom', name: 'Atom', isOther: false, openerId: 'atom' },
{ id: 'vim', name: 'Vim', isOther: false, openerId: 'vim' },
{ id: 'sublime', name: 'Sublime Text', isOther: false, openerId: 'sublime' },
{ id: 'vscode', name: 'Visual Studio Code', isOther: false, openerId: 'vscode' },
{ id: 'other', name: 'Other', isOther: true, openerId: '' },
]
cy.get('@button').invoke('show')
})
context('when user has not already set opener and opens file', function () {
beforeEach(function () {
this.ipc.getUserEditor.resolves({
availableEditors: this.availableEditors,
preferredOpener: this.availableEditors[4],
})
})
it('opens in preferred opener', function () {
cy.get('@button').click().then(() => {
expect(this.ipc.openFile).to.be.calledWith({
where: this.availableEditors[4],
file: '/user/project/cypress/integration/app_spec.coffee',
line: 0,
column: 0,
})
})
})
})
context('when user has not already set opener and opens file', function () {
beforeEach(function () {
this.ipc.getUserEditor.resolves({
availableEditors: this.availableEditors,
})
cy.get('@button').click()
})
it('opens modal with available editors', function () {
this.availableEditors.forEach(({ name }) => {
cy.contains(name)
})
cy.contains('Set preference and open file')
})
it('closes modal when cancel is clicked', function () {
cy.contains('Cancel').click()
cy.contains('Set preference and open file').should('not.be.visible')
})
describe('when editor is not selected', function () {
it('disables submit button', function () {
cy.contains('Set preference and open file')
.should('have.class', 'is-disabled')
.click()
.then(function () {
expect(this.ipc.setUserEditor).not.to.be.called
expect(this.ipc.openFile).not.to.be.called
})
})
it('shows validation message when hovering over submit button', function () {
cy.get('.editor-picker-modal .submit').trigger('mouseover')
cy.get('.cy-tooltip').should('have.text', 'Please select a preference')
})
})
describe('when Other is selected but path is not entered', function () {
beforeEach(function () {
cy.contains('Other').click()
})
it('disables submit button', function () {
cy.contains('Set preference and open file')
.should('have.class', 'is-disabled')
.click()
.then(function () {
expect(this.ipc.setUserEditor).not.to.be.called
expect(this.ipc.openFile).not.to.be.called
})
})
it('shows validation message when hovering over submit button', function () {
cy.get('.editor-picker-modal .submit').trigger('mouseover')
cy.get('.cy-tooltip').should('have.text', 'Please enter the path for the "Other" editor')
})
})
describe('when editor is set', function () {
beforeEach(function () {
cy.contains('Visual Studio Code').click()
cy.contains('Set preference and open file').click()
})
it('closes modal', function () {
cy.contains('Set preference and open file').should('not.be.visible')
})
it('sets user editor', function () {
expect(this.ipc.setUserEditor).to.be.calledWith(this.availableEditors[4])
})
it('opens file in selected editor', function () {
expect(this.ipc.openFile).to.be.calledWith({
where: this.availableEditors[4],
file: '/user/project/cypress/integration/app_spec.coffee',
line: 0,
column: 0,
})
})
})
})
})
})
})
+1
View File
@@ -50,6 +50,7 @@ register('launch:browser', false)
register('log:out')
register('on:focus:tests', false)
register('on:menu:clicked', false)
register('open:file')
register('open:finder')
register('open:project', false)
register('on:config:changed', false)
+2 -1
View File
@@ -2,6 +2,7 @@
@import 'styles/mixins';
@import 'styles/vendor';
@import 'styles/components/*';
@import '../../ui-components/src/file-opener/file-opener';
@import '!(styles)*/**/*';
@import '../../../node_modules/@reach/dialog/styles.css';
@import '../../ui-components/src/dropdown';
@import '../../ui-components/src/editor-picker';
@@ -0,0 +1,53 @@
import { observer } from 'mobx-react'
import PropTypes from 'prop-types'
import React from 'react'
import ipc from '../lib/ipc'
import { FileOpener as Opener } from '@packages/ui-components'
const openFile = (where, { absoluteFile: file, line, column }) => {
ipc.openFile({
where,
file,
line,
column,
})
}
const getUserEditor = (callback) => {
ipc.getUserEditor().then(callback)
}
const FileOpener = observer((props) => {
const fileDetails = {
column: 0,
line: 0,
...props.fileDetails,
}
return (
<Opener
fileDetails={fileDetails}
openFile={openFile}
getUserEditor={getUserEditor}
setUserEditor={ipc.setUserEditor}
className={props.className}
>
<span><i className="fas fa-external-link-alt fa-sm" /> Open in IDE</span>
</Opener>
)
})
const fileDetails = PropTypes.shape({
absoluteFile: PropTypes.string.isRequired,
originalFile: PropTypes.string.isRequired,
relativeFile: PropTypes.string.isRequired,
})
FileOpener.propTypes = {
fileDetails: fileDetails.isRequired,
className: PropTypes.string,
}
export default FileOpener
+18 -15
View File
@@ -5,6 +5,7 @@ import { observer } from 'mobx-react'
import Loader from 'react-loader'
import Tooltip from '@cypress/react-tooltip'
import FileOpener from './file-opener'
import ipc from '../lib/ipc'
import projectsApi from '../projects/projects-api'
import specsStore, { allSpecsSpec } from './specs-store'
@@ -28,7 +29,7 @@ class SpecsList extends Component {
'show-clear-filter': !!specsStore.filter,
})}>
<label htmlFor='filter'>
<i className='fas fa-search'></i>
<i className='fas fa-search' />
</label>
<input
id='filter'
@@ -49,7 +50,7 @@ class SpecsList extends Component {
<a onClick={this._selectSpec.bind(this, allSpecsSpec)}
title="Run all integration specs together"
className={cs('all-tests', { active: specsStore.isChosen(allSpecsSpec) })}>
<i className={`fa-fw ${this._allSpecsIcon(specsStore.isChosen(allSpecsSpec))}`}></i>{' '}
<i className={`fa-fw ${this._allSpecsIcon(specsStore.isChosen(allSpecsSpec))}`} />{' '}
{allSpecsSpec.displayName}
</a>
</header>
@@ -140,8 +141,8 @@ class SpecsList extends Component {
<li key={spec.path} className={`folder level-${nestingLevel} ${isExpanded ? 'folder-expanded' : 'folder-collapsed'}`}>
<div>
<div className="folder-name" onClick={this._selectSpecFolder.bind(this, spec)}>
<i className={`folder-collapse-icon fas fa-fw ${isExpanded ? 'fa-caret-down' : 'fa-caret-right'}`}></i>
{nestingLevel !== 0 ? <i className={`far fa-fw ${isExpanded ? 'fa-folder-open' : 'fa-folder'}`}></i> : null}
<i className={`folder-collapse-icon fas fa-fw ${isExpanded ? 'fa-caret-down' : 'fa-caret-right'}`} />
{nestingLevel !== 0 ? <i className={`far fa-fw ${isExpanded ? 'fa-folder-open' : 'fa-folder'}`} /> : null}
{
nestingLevel === 0 ?
<>
@@ -172,19 +173,21 @@ class SpecsList extends Component {
}
_specContent (spec, nestingLevel) {
const fileDetails = {
absoluteFile: spec.absolute,
originalFile: spec.relative,
relativeFile: spec.relative,
}
return (
<li key={spec.path} className={`file level-${nestingLevel}`}>
<a href='#' onClick={this._selectSpec.bind(this, spec)} className={cs({ active: specsStore.isChosen(spec) })}>
<div>
<div className="file-name">
<i className={`fa-fw ${this._specIcon(specsStore.isChosen(spec))}`}></i>
{spec.displayName}
</div>
</div>
<div>
<div></div>
<li key={spec.path} className={cs(`file level-${nestingLevel}`, { active: specsStore.isChosen(spec) })}>
<a href='#' onClick={this._selectSpec.bind(this, spec)} className="file-name-wrapper">
<div className="file-name">
<i className={`fa-fw ${this._specIcon(specsStore.isChosen(spec))}`} />
{spec.displayName}
</div>
</a>
<FileOpener fileDetails={fileDetails} className="file-open-in-ide" />
</li>
)
}
@@ -200,7 +203,7 @@ class SpecsList extends Component {
</code>
</h5>
<a className='helper-docs-link' onClick={this._openHelp}>
<i className='fas fa-question-circle'></i>{' '}
<i className='fas fa-question-circle' />{' '}
Need help?
</a>
</div>
+47 -20
View File
@@ -169,40 +169,57 @@ $max-nesting-level: 14;
}
}
.file > a {
font-weight: 400;
.file {
border-bottom: 1px dotted #eeeeee;
padding: 4px 0;
font-family: $font-sans;
color: #637eb9;
font-size: 15px;
display: flex;
&.active {
pointer-events: none;
background-color: #F5FBF7;
color: #4c4e63;
text-decoration: none;
&:hover {
pointer-events: none;
background-color: #F5FBF7;
color: #4c4e63;
> .file-name-wrapper {
color: #4c4e63 !important;
text-decoration: none;
}
}
&:hover, &:focus {
background-color: #f8f8f8;
color: #38589c;
cursor: pointer;
text-decoration: none;
}
}
.list-as-table>.file, .list-as-table>.file>a {
float: left;
width: 100%;
> .file-open-in-ide {
display: flex;
}
}
> .file-name-wrapper {
color: #637eb9;
flex-grow: 1;
font-family: $font-sans;
font-size: 15px;
font-weight: 400;
padding: 4px 0;
&:hover, &:focus {
color: #38589c;
text-decoration: none;
}
}
> .file-open-in-ide {
align-items: center;
color: #727474;
display: none;
font-family: $font-sans;
font-size: 13px;
font-weight: 400;
padding: 4px 10px;
&:hover {
background-color: rgba(186, 186, 186, 0.2);
text-decoration: none;
}
}
}
.list-as-table>.file {
@@ -217,3 +234,13 @@ $max-nesting-level: 14;
}
}
}
// overwrite file opener styles
[data-reach-dialog-content] {
font-size: 1em;
padding-top: 1em;
h1 {
font-size: 1.75em;
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
import _ from 'lodash'
import { computed, observable } from 'mobx'
import { FileDetails } from '../opener/file-model'
import { FileDetails } from '@packages/ui-components'
interface ParsedStackMessageLine {
message: string
@@ -3,7 +3,7 @@ import { observer } from 'mobx-react'
import Prism from 'prismjs'
import { CodeFrame } from './err-model'
import FileOpener from '../opener/file-opener'
import FileOpener from '../lib/file-opener'
interface Props {
codeFrame: CodeFrame
+1 -1
View File
@@ -2,7 +2,7 @@ import _ from 'lodash'
import { observer } from 'mobx-react'
import React, { ReactElement } from 'react'
import FileOpener from '../opener/file-opener'
import FileOpener from '../lib/file-opener'
import Err from './err-model'
const cypressLineRegex = /(cypress:\/\/|cypress_runner\.js)/
+51
View File
@@ -0,0 +1,51 @@
import { observer } from 'mobx-react'
import React from 'react'
// @ts-ignore
import Tooltip from '@cypress/react-tooltip'
import events from './events'
import { GetUserEditorResult, Editor, FileDetails, FileOpener as Opener } from '@packages/ui-components'
interface Props {
fileDetails: FileDetails,
className?: string
}
const openFile = (where: Editor, { absoluteFile: file, line, column }: FileDetails) => {
events.emit('open:file', {
where,
file,
line,
column,
})
}
const getUserEditor = (callback: (result: GetUserEditorResult) => any) => {
events.emit('get:user:editor', callback)
}
const setUserEditor = (editor: Editor) => {
events.emit('set:user:editor', editor)
}
const FileOpener = observer((props: Props) => {
const { originalFile, line, column } = props.fileDetails
return (
<Tooltip title={'Open in IDE'} wrapperClassName={props.className} className='cy-tooltip'>
<span>
<Opener
openFile={openFile}
getUserEditor={getUserEditor}
setUserEditor={setUserEditor}
fileDetails={props.fileDetails}
>
{originalFile}{!!line && `:${line}`}{!!column && `:${column}`}
</Opener>
</span>
</Tooltip>
)
})
export default FileOpener
+1 -2
View File
@@ -3,7 +3,6 @@
@import 'lib/variables';
@import 'lib/base';
@import 'lib/tooltip';
@import 'lib/modal';
@import '../../../node_modules/@reach/dialog/styles.css';
@import '../../ui-components/src/editor-picker';
@import '../../ui-components/src/file-opener/file-opener';
@import './!(lib)*/**/*';
+1 -2
View File
@@ -4,7 +4,6 @@
@import 'lib/fonts';
@import 'lib/base';
@import 'lib/tooltip';
@import 'lib/modal';
@import '../../../node_modules/@reach/dialog/styles.css';
@import '../../ui-components/src/editor-picker';
@import '../../ui-components/src/file-opener/file-opener';
@import '!(lib)*/**/*';
@@ -1,7 +0,0 @@
export interface FileDetails {
absoluteFile: string
column: number
line: number
originalFile: string
relativeFile: string
}
-23
View File
@@ -1,23 +0,0 @@
.editor-picker-modal {
max-width: 40em;
.editor-picker {
margin-bottom: 1em;
}
.controls {
> span:first-child {
order: 1;
}
button.is-disabled,
button.is-disabled:hover,
button.is-disabled:focus {
background: $pass !important;
cursor: default !important;
opacity: 0.5;
}
padding: 1em 1em 1em;
}
}
@@ -1,6 +1,6 @@
import React, { Component, ReactElement } from 'react'
import FileOpener from '../opener/file-opener'
import FileOpener from '../lib/file-opener'
const renderRunnableHeader = (children:ReactElement) => <div className="runnable-header">{children}</div>
@@ -254,8 +254,8 @@
width: 100%;
z-index: 1;
span > a:before,
span > span:before {
span > span > a:before,
span > span > span:before {
@extend .#{$fa-css-prefix};
@extend .#{$fa-css-prefix}-file;
color: #bdbdbd;
+4
View File
@@ -21,6 +21,7 @@ const chromePolicyCheck = require('../util/chrome_policy_check')
const browsers = require('../browsers')
const konfig = require('../konfig')
const editors = require('../util/editors')
const fileOpener = require('../util/file-opener')
const nullifyUnserializableValues = (obj) => {
// nullify values that cannot be cloned
@@ -167,6 +168,9 @@ const handleEvent = function (options, bus, event, id, type, arg) {
case 'window:close':
return Windows.getByWebContents(event.sender).destroy()
case 'open:file':
return fileOpener.openFile(arg)
case 'open:finder':
return open.opn(arg)
.then(send)
@@ -0,0 +1,179 @@
import React from 'react'
import { render } from 'react-dom'
import { FileOpener } from '../../'
const _ = Cypress._
const fileDetails = {
absoluteFile: '/absolute/path/to/file_spec.js',
column: 0,
line: 0,
originalFile: 'path/to/file_spec.js',
relativeFile: 'path/to/file_spec.js',
}
const preferredOpener = {
id: 'vscode',
name: 'VS Code',
openerId: 'vscode',
isOther: false,
}
const availableEditors = [
{ id: 'computer', name: 'On Computer', openerId: 'computer', isOther: false, description: 'Opens on computer etc etc' },
{ id: 'atom', name: 'Atom', openerId: 'atom', isOther: false },
{ id: 'sublime', name: 'Sublime Text', openerId: 'sublime', isOther: false },
{ id: 'vscode', name: 'VS Code', openerId: 'vscode', isOther: false },
{ id: 'other', name: 'Other', openerId: '', isOther: true, description: 'Enter the full path etc etc' },
]
describe('<FileOpener />', () => {
let defaultProps
beforeEach(() => {
defaultProps = {
fileDetails,
openFile: () => {},
getUserEditor: (callback) => {
callback({
preferredOpener,
availableEditors,
})
},
setUserEditor: () => {},
className: 'file-opener',
}
cy.visit('dist/index.html')
cy.viewport(600, 600)
})
it('renders link text', () => {
cy.render(render, <FileOpener {...defaultProps}>Open in IDE</FileOpener>)
cy.get('.file-opener').should('have.text', 'Open in IDE')
})
context('when user has already set opener and opens file', () => {
it('opens in preferred opener', () => {
const openFile = cy.stub()
cy.render(render, <FileOpener {...defaultProps} openFile={openFile}>Open in IDE</FileOpener>)
cy.get('.file-opener').click().then(() => {
expect(openFile).to.be.calledWith(preferredOpener, fileDetails)
})
})
})
context('when user has not already set opener and opens file', () => {
let defaultPropsModal
beforeEach(() => {
defaultPropsModal = {
...defaultProps,
getUserEditor: (callback) => {
callback({
availableEditors,
})
},
}
})
it('opens modal with available editors', () => {
cy.render(render, <FileOpener {...defaultPropsModal}>Open in IDE</FileOpener>)
cy.get('.file-opener').click()
_.each(availableEditors, ({ name }) => {
cy.contains(name)
})
cy.contains('Set preference and open file')
})
it('closes modal when cancel is clicked', () => {
cy.render(render, <FileOpener {...defaultPropsModal}>Open in IDE</FileOpener>)
cy.get('.file-opener').click()
cy.contains('Sublime Text').click()
cy.contains('Cancel').click()
cy.contains('Set preference and open file').should('not.be.visible')
})
it('initially has no editors chosen', () => {
cy.render(render, <FileOpener {...defaultPropsModal}>Open in IDE</FileOpener>)
cy.get('.file-opener').click()
cy.get('input[type="radio"]').should('not.be.checked')
cy.get('.submit').should('have.class', 'is-disabled')
})
it('should not open without editor selected', () => {
const setEditor = cy.stub()
const openFile = cy.stub()
cy.render(render, <FileOpener {...defaultPropsModal} setEditor={setEditor} openFile={openFile}>Open in IDE</FileOpener>)
cy.get('.file-opener').click()
cy.get('.submit')
.should('have.class', 'is-disabled')
.click()
.then(() => {
expect(setEditor).not.to.be.called
expect(openFile).not.to.be.called
})
})
it('disables submit when Other is selected but path not entered', () => {
const setEditor = cy.stub()
const openFile = cy.stub()
cy.render(render, <FileOpener {...defaultPropsModal} setEditor={setEditor} openFile={openFile}>Open in IDE</FileOpener>)
cy.get('.file-opener').click()
cy.contains('Other').click()
cy.get('.submit')
.should('have.class', 'is-disabled')
.click()
.then(() => {
expect(setEditor).not.to.be.called
expect(openFile).not.to.be.called
})
})
it('sets user editor when selected', () => {
const setEditor = cy.stub()
cy.render(render, <FileOpener {...defaultPropsModal} setUserEditor={setEditor}>Open in IDE</FileOpener>)
cy.get('.file-opener').click()
cy.contains('Sublime Text').click()
cy.get('.submit').click().then(() => {
expect(setEditor).to.be.calledWith(availableEditors[2])
})
})
it('opens in correct editor when selected', () => {
const openFile = cy.stub()
cy.render(render, <FileOpener {...defaultPropsModal} openFile={openFile}>Open in IDE</FileOpener>)
cy.get('.file-opener').click()
cy.contains('Sublime Text').click()
cy.get('.submit').click().then(() => {
expect(openFile).to.be.calledWith(availableEditors[2], fileDetails)
})
})
it('closes modal after selection', () => {
cy.render(render, <FileOpener {...defaultPropsModal}>Open in IDE</FileOpener>)
cy.get('.file-opener').click()
cy.contains('Sublime Text').click()
cy.get('.submit').click()
cy.contains('Set preference and open file').should('not.be.visible')
})
})
})
@@ -2,12 +2,12 @@ const wp = require('@cypress/webpack-preprocessor')
const webpackOptions = {
mode: 'none',
resolve: {
extensions: ['.js', '.jsx', '.png'],
extensions: ['.ts', '.js', '.jsx', '.tsx', '.png'],
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
test: /\.(ts|js|jsx|tsx)$/,
exclude: /node_modules/,
use: {
loader: require.resolve('babel-loader'),
@@ -19,6 +19,7 @@ const webpackOptions = {
presets: [
require.resolve('@babel/preset-env'),
require.resolve('@babel/preset-react'),
require.resolve('@babel/preset-typescript'),
],
babelrc: false,
},
@@ -3,5 +3,6 @@
@import "../../node_modules/@fortawesome/fontawesome-free/scss/brands.scss";
@import "../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss";
@import '../../../../node_modules/@reach/dialog/styles.css';
@import '../../src/dropdown';
@import '../../src/editor-picker';
@import '../../src/file-opener/file-opener';
+8
View File
@@ -0,0 +1,8 @@
/// <reference path="../../cli/types/cy-blob-util.d.ts" />
/// <reference path="../../cli/types/cy-bluebird.d.ts" />
/// <reference path="../../cli/types/cy-moment.d.ts" />
/// <reference path="../../cli/types/cy-minimatch.d.ts" />
/// <reference path="../../cli/types/cypress.d.ts" />
/// <reference path="../../cli/types/cypress-global-vars.d.ts" />
/// <reference path="../../cli/types/cypress-type-helpers.d.ts" />
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "@packages/ui-components",
"version": "0.0.0",
"private": true,
"main": "src/index.jsx",
"main": "src/index.tsx",
"scripts": {
"build-for-tests": "webpack",
"check-deps": "node ../../scripts/check-deps.js --verbose",
@@ -20,6 +20,7 @@
"@babel/preset-env": "7.9.0",
"@babel/preset-react": "7.9.4",
"@fortawesome/fontawesome-free": "5.12.1",
"@reach/dialog": "0.6.1",
"@reach/visually-hidden": "0.6.2",
"babel-loader": "8.1.0",
"browser-logos": "github:alrra/browser-logos",
+21 -1
View File
@@ -2,5 +2,25 @@
"extends": [
"plugin:@cypress/dev/react",
"plugin:@cypress/dev/tests"
]
],
"parser": "@typescript-eslint/parser",
"settings": {
"react": {
"version": "16.12"
}
},
"rules": {
"arrow-body-style": "off",
"no-unused-vars": "off",
"react/jsx-filename-extension": [
"warn",
{
"extensions": [
".js",
".jsx",
".tsx"
]
}
]
}
}
@@ -1,7 +1,11 @@
import _ from 'lodash'
import React from 'react'
const families = {
interface FamilyOptions {
[key: string]: RegExp
}
const families: FamilyOptions = {
chrome: /^chrome/i,
chromium: /^chromium/i,
edge: /^edge/i,
@@ -9,7 +13,11 @@ const families = {
firefox: /^firefox/i,
}
const logoPaths = {
interface LogoOptions {
[key: string]: string
}
const logoPaths: LogoOptions = {
canary: require('browser-logos/src/chrome-canary/chrome-canary_32x32.png'),
chrome: require('browser-logos/src/chrome/chrome_32x32.png'),
chromium: require('browser-logos/src/chromium/chromium_32x32.png'),
@@ -23,24 +31,30 @@ const logoPaths = {
firefoxNightly: require('browser-logos/src/firefox-nightly/firefox-nightly_32x32.png'),
}
const familyFallback = (browserKey) => {
const familyFallback = (browserKey: string) => {
return _.reduce(families, (found, regex, family) => {
if (found) return found
if (found !== '') return found
if (regex.test(browserKey)) return family
}, null)
return ''
}, '')
}
const logoPath = (browserName) => {
const logoPath = (browserName: string) => {
const browserKey = _.camelCase(browserName)
return logoPaths[browserKey] || logoPaths[familyFallback(browserKey)]
}
interface Props {
browserName: string
}
// browserName should be the browser's display name
const BrowserIcon = ({ browserName }) => {
const BrowserIcon = ({ browserName }: Props) => {
if (logoPath(browserName)) {
return <img className='browser-icon' src={logoPath(browserName)} />
return <img className='browser-icon' src={logoPath(browserName)} alt={browserName} />
}
return <i className='browser-icon fas fa-fw fa-globe' />
@@ -1,31 +1,36 @@
import cs from 'classnames'
import _ from 'lodash'
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import React, { Component, ReactNode } from 'react'
import { findDOMNode } from 'react-dom'
class Dropdown extends Component {
interface Indexable {
[key: string]: any
}
interface Props {
className?: string
chosen: Indexable
others: Indexable[]
onSelect: (item: Indexable) => any
renderItem: (item: Indexable) => ReactNode
keyProperty: string
disabled?: boolean
document: Document
}
class Dropdown extends Component<Props> {
static defaultProps = {
className: '',
document,
}
static propTypes = {
className: PropTypes.string,
chosen: PropTypes.object.isRequired,
others: PropTypes.arrayOf(PropTypes.object).isRequired,
onSelect: PropTypes.func.isRequired,
renderItem: PropTypes.func.isRequired,
// property for unique value on each item that can be used as its key
keyProperty: PropTypes.string.isRequired,
disabled: PropTypes.bool,
}
state = { open: false }
outsideClickHandler: (e: Event) => void = () => {}
componentDidMount () {
this.outsideClickHandler = (e) => {
if (!findDOMNode(this).contains(e.target)) {
this.outsideClickHandler = (e: Event) => {
if (!findDOMNode(this)?.contains(e.target as Node)) {
this.setState({ open: false })
}
}
@@ -76,7 +81,7 @@ class Dropdown extends Component {
return (
<span className='dropdown-toggle'>
<span className='dropdown-caret'></span>
<span className='dropdown-caret' />
<span className='sr-only'>Toggle Dropdown</span>
</span>
)
@@ -94,7 +99,7 @@ class Dropdown extends Component {
{_.map(this.props.others, (item) => (
<li
key={item[this.props.keyProperty]}
tabIndex='0'
tabIndex={0}
onClick={() => this._onSelect(item)}
>{this.props.renderItem(item)}</li>
))}
@@ -102,7 +107,7 @@ class Dropdown extends Component {
)
}
_onSelect (item) {
_onSelect (item: object) {
this.setState({ open: false })
this.props.onSelect(item)
}
@@ -1,62 +0,0 @@
import _ from 'lodash'
import PropTypes from 'prop-types'
import { observer, PropTypes as MobxPropTypes } from 'mobx-react'
import React from 'react'
import { Select, SelectItem } from './select'
const EditorPicker = observer(({ chosen = {}, editors, onSelect, onUpdateOtherPath }) => {
const editorOptions = _.reject(editors, { isOther: true })
const otherOption = _.find(editors, { isOther: true })
const onChange = (id) => {
const editor = _.find(editors, { id })
onSelect(editor)
}
const updateOtherPath = (event) => {
onUpdateOtherPath(_.trim(event.target.value || ''))
}
const otherInput = (
<input
type='text'
className='other-input'
value={otherOption.openerId || ''}
onFocus={_.partial(onChange, 'other')}
onChange={updateOtherPath}
/>
)
return (
<Select value={chosen.id} className='editor-picker' name='editor-picker' onChange={onChange}>
{_.map(editorOptions, (editor) => (
<SelectItem key={editor.id} value={editor.id}>
{editor.name}
</SelectItem>
))}
<SelectItem value={otherOption.id}>
{otherOption.name}: {otherInput}
{chosen.isOther && <span className='description'>Enter the full path to your editor's executable</span>}
</SelectItem>
</Select>
)
})
const editorType = PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
openerId: PropTypes.string.isRequired,
isOther: PropTypes.bool.isRequired,
description: PropTypes.string,
})
EditorPicker.propTypes = {
chosenEditor: editorType,
editors: MobxPropTypes.observableArrayOf(editorType).isRequired,
onSelect: PropTypes.func.isRequired,
onUpdateOtherPath: PropTypes.func.isRequired,
}
export default EditorPicker
@@ -1,36 +0,0 @@
@import './select/select';
.editor-picker {
label {
align-items: start;
display: flex;
flex-wrap: wrap;
}
.other-input {
border: solid 1px #7e7e7e;
border-radius: 3px;
margin-left: 0.3em;
padding: 0.2em 0.4em;
}
.is-selected .other-input {
flex-grow: 2;
}
.description {
color: #7e7e7e;
font-size: 0.9em;
}
i.description {
margin-left: 0.4em;
margin-top: 0.1em;
}
span.description {
display: block;
padding-left: 5.2em;
width: 100%;
}
}
@@ -8,15 +8,9 @@ import Tooltip from '@cypress/react-tooltip'
import cs from 'classnames'
import React from 'react'
import VisuallyHidden from '@reach/visually-hidden'
// @ts-ignore
import { EditorPicker } from '@packages/ui-components'
export interface Editor {
id: string
name: string
openerId: string
isOther: boolean
}
import EditorPicker from './editor-picker'
import { Editor } from './file-model'
interface Props {
chosenEditor: Editor
@@ -0,0 +1,56 @@
import _ from 'lodash'
import { observer } from 'mobx-react'
import React, { ChangeEvent } from 'react'
import { Editor } from './file-model'
import { Select, SelectItem } from '../select'
interface Props {
chosen?: Partial<Editor>
editors: Editor[]
onSelect: (editor: Editor) => any
onUpdateOtherPath: (path: string) => any
}
const EditorPicker = observer(({ chosen = {}, editors, onSelect, onUpdateOtherPath }: Props) => {
const editorOptions = _.reject(editors, { isOther: true })
const otherOption = _.find(editors, { isOther: true })
const onChange = (id: string) => {
const editor = _.find(editors, { id })
editor && onSelect(editor)
}
const updateOtherPath = (event: ChangeEvent<HTMLInputElement>) => {
onUpdateOtherPath(_.trim(event.target.value || ''))
}
const otherInput = (
<input
type='text'
className='other-input'
value={otherOption?.openerId || ''}
onFocus={_.partial(onChange, 'other')}
onChange={updateOtherPath}
/>
)
return (
<Select value={chosen.id || ''} className='editor-picker' name='editor-picker' onChange={onChange}>
{_.map(editorOptions, (editor) => (
<SelectItem key={editor.id} value={editor.id}>
{editor.name}
</SelectItem>
))}
{otherOption && (
<SelectItem value={otherOption.id}>
{otherOption.name}: {otherInput}
{chosen.isOther && <span className='description'>Enter the full path to your editor's executable</span>}
</SelectItem>
)}
</Select>
)
})
export default EditorPicker
@@ -0,0 +1,20 @@
export interface FileDetails {
absoluteFile: string
column: number
line: number
originalFile: string
relativeFile: string
}
export interface Editor {
id: string
name: string
openerId: string
isOther: boolean
description?: string
}
export interface GetUserEditorResult {
preferredOpener?: Editor
availableEditors?: Editor[]
}
@@ -1,5 +1,70 @@
@import '../select/select';
$pass: #08c18d !default;
$font-sans: "Helvetica Neue", Helvetica, Arial, sans-serif !default;
.editor-picker {
label {
align-items: start;
display: flex;
flex-wrap: wrap;
}
.other-input {
border: solid 1px #7e7e7e;
border-radius: 3px;
margin-left: 0.3em;
padding: 0.2em 0.4em;
}
.is-selected .other-input {
flex-grow: 2;
}
.description {
color: #7e7e7e;
font-size: 0.9em;
}
i.description {
margin-left: 0.4em;
margin-top: 0.1em;
}
span.description {
display: block;
padding-left: 5.2em;
width: 100%;
}
}
.editor-picker-modal {
max-width: 40em;
.editor-picker {
margin-bottom: 1em;
}
.controls {
> span:first-child {
order: 1;
}
button.is-disabled,
button.is-disabled:hover,
button.is-disabled:focus {
background: $pass !important;
cursor: default !important;
opacity: 0.5;
}
padding: 1em 1em 1em;
}
}
[data-reach-dialog-overlay] {
display: flex;
padding: 2em 0;
z-index: 1;
}
@@ -7,7 +72,6 @@
align-items: center;
background: #f8f8f8;
border-radius: 10px;
font-family: $open-sans;
font-size: 0.9em;
justify-content: center;
margin: auto;
@@ -15,6 +79,12 @@
padding: 2em 0 0;
position: relative;
@if variable-exists(open-sans) {
font-family: $open-sans;
} @else {
font-family: $font-sans;
}
h1 {
font-size: 1.5em;
padding-bottom: 0.5em;
@@ -76,3 +146,4 @@
top: 0.5em;
}
}
@@ -1,34 +1,21 @@
import _ from 'lodash'
import { action } from 'mobx'
import { observer, useLocalStore } from 'mobx-react'
import React, { MouseEvent } from 'react'
// @ts-ignore
import Tooltip from '@cypress/react-tooltip'
import React, { MouseEvent, ReactNode } from 'react'
import EditorPickerModal, { Editor } from './editor-picker-modal'
import { FileDetails } from './file-model'
import events from '../lib/events'
interface GetUserEditorResult {
preferredOpener?: Editor
availableEditors?: Editor[]
}
import EditorPickerModal from './editor-picker-modal'
import { GetUserEditorResult, Editor, FileDetails } from './file-model'
interface Props {
fileDetails: FileDetails,
children: ReactNode
fileDetails: FileDetails
openFile: (where: Editor, absoluteFile: FileDetails) => any
getUserEditor: (callback: (result: GetUserEditorResult) => void) => any
setUserEditor: (editor: Editor) => any
className?: string
}
const openFile = (where: Editor, { absoluteFile: file, line, column }: FileDetails) => {
events.emit('open:file', {
where,
file,
line,
column,
})
}
const FileOpener = observer(({ fileDetails, className }: Props) => {
const FileOpener = observer(({ children, fileDetails, openFile, getUserEditor, setUserEditor, className }: Props) => {
const state = useLocalStore(() => ({
editors: [] as Editor[],
chosenEditor: {} as Editor,
@@ -57,7 +44,7 @@ const FileOpener = observer(({ fileDetails, className }: Props) => {
// TODO: instead of the back-n-forth, send 'open:file' or similar, and if the
// user editor isn't set, it should send back the available editors
events.emit('get:user:editor', (result: GetUserEditorResult) => {
getUserEditor((result: GetUserEditorResult) => {
state.setIsLoadingEditor(false)
if (result.preferredOpener) {
@@ -70,28 +57,24 @@ const FileOpener = observer(({ fileDetails, className }: Props) => {
}
const setEditor = (editor: Editor) => {
events.emit('set:user:editor', editor)
setUserEditor(editor)
state.setIsModalOpen(false)
state.setChosenEditor({} as Editor)
openFile(editor, fileDetails)
}
const { originalFile, line, column } = fileDetails
return (
<Tooltip title={'Open in IDE'} wrapperClassName={className} className='cy-tooltip'>
<a onClick={attemptOpenFile} href='#'>
{originalFile}{!!line && `:${line}`}{!!column && `:${column}`}
<EditorPickerModal
chosenEditor={state.chosenEditor}
editors={state.editors}
isOpen={state.isModalOpen}
onSetEditor={setEditor}
onSetChosenEditor={state.setChosenEditor}
onClose={_.partial(state.setIsModalOpen, false)}
/>
</a>
</Tooltip>
<a className={className} onClick={attemptOpenFile} href='#'>
{children}
<EditorPickerModal
chosenEditor={state.chosenEditor}
editors={state.editors}
isOpen={state.isModalOpen}
onSetEditor={setEditor}
onSetChosenEditor={state.setChosenEditor}
onClose={_.partial(state.setIsModalOpen, false)}
/>
</a>
)
})
-7
View File
@@ -1,7 +0,0 @@
export { default as BrowserIcon } from './browser-icon'
export { default as Dropdown } from './dropdown'
export { default as EditorPicker } from './editor-picker'
export * from './select'
+13
View File
@@ -0,0 +1,13 @@
export { default as BrowserIcon } from './browser-icon'
export { default as Dropdown } from './dropdown'
export { default as EditorPicker } from './file-opener/editor-picker'
export { default as EditorPickerModal } from './file-opener/editor-picker-modal'
export { default as FileOpener } from './file-opener/file-opener'
export * from './file-opener/file-model'
export * from './select'
@@ -1,3 +0,0 @@
import { createContext } from 'react'
export default createContext()
@@ -0,0 +1,16 @@
import _ from 'lodash'
import { createContext, KeyboardEvent } from 'react'
interface ContextValue {
handleChange: (value: string) => any
handleKeyDown: (event: KeyboardEvent) => any
isSelected: (value: string) => boolean
name: string
}
export default createContext<ContextValue>({
handleChange: _.noop,
handleKeyDown: _.noop,
isSelected: () => false,
name: '',
})
@@ -1,9 +1,9 @@
import cs from 'classnames'
import React, { Children, useCallback, useMemo } from 'react'
import React, { Children, KeyboardEvent, ReactElement, ReactNode, useCallback, useMemo } from 'react'
import _ from 'lodash'
import Context from './context'
const generateGroupName = (name) => {
const generateGroupName = (name?: string) => {
if (name) {
return name
}
@@ -11,12 +11,12 @@ const generateGroupName = (name) => {
return _.uniqueId('Select-')
}
const toValues = (children) => {
const withSelectItem = _.filter(children, (child) => {
const toValues = (children: ReactNode[]) => {
const withSelectItem = _.filter(children, (child: ReactElement) => {
return child.props.selectItem
})
return _.map(withSelectItem, (child) => child.props.value)
return _.map<any, string>(withSelectItem, (child: ReactElement) => child.props.value)
}
const left = 13
@@ -24,10 +24,18 @@ const up = 38
const right = 39
const down = 40
const Select = ({ children, className, name, onChange, value }) => {
interface Props {
children: ReactNode
className?: string
name?: string
onChange?: (value: string) => any
value: string
}
const Select = ({ children, className, name, onChange = _.noop, value }: Props) => {
const allValues = useMemo(() => toValues(Children.toArray(children)), [children])
const handleKeyDown = useCallback(({ keyCode }) => {
const handleKeyDown = useCallback(({ keyCode }: KeyboardEvent) => {
if (![left, up, right, down].includes(keyCode)) {
return
}
@@ -67,8 +75,4 @@ const Select = ({ children, className, name, onChange, value }) => {
)
}
Select.defaultProps = {
onChange: _.noop,
}
export default Select
@@ -1,17 +1,23 @@
import cs from 'classnames'
import React, { useRef } from 'react'
import React, { KeyboardEvent, ReactNode, useRef } from 'react'
import { partial, uniqueId } from 'lodash'
import VisuallyHidden from '@reach/visually-hidden'
import useSelect from './use-select'
const SelectItem = ({ value, children, selectItem, ...rest }) => {
interface Props {
value: string
children?: ReactNode
selectItem?: boolean
}
const SelectItem = ({ value, children, selectItem, ...rest }: Props) => {
const { name, handleChange, handleKeyDown, isSelected } = useSelect()
const liRef = useRef()
const inputRef = useRef()
const liRef = useRef(null)
const inputRef = useRef(null)
const id = uniqueId('select-item-')
const onKeyDown = (e) => {
const onKeyDown = (e: KeyboardEvent) => {
// ensure it's not an element in children that's being keyed down
if (e.target && e.target !== liRef.current && e.target !== inputRef.current) {
return
@@ -40,6 +46,9 @@ const SelectItem = ({ value, children, selectItem, ...rest }) => {
name={name}
type='radio'
value={value}
// style required to prevent an error from being thrown
// when used inside of a dialog (ex. editor picker modal)
style={{ margin: 0 }}
/>
</VisuallyHidden>
{children}
-1
View File
@@ -15,7 +15,6 @@ const config: webpack.Configuration = {
},
}
// @ts-ignore
config.plugins = [
// @ts-ignore
...config.plugins,