feat: create specs ui (#18483)
Co-authored-by: Tim Griesser <tgriesser10@gmail.com>
@@ -22,4 +22,4 @@
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
'componentFolder': 'src',
|
||||
'supportFile': false,
|
||||
'component': {
|
||||
'testFiles': '**/*.spec.{js,ts,tsx,jsx}',
|
||||
'testFiles': '**/*.{spec,cy}.{js,ts,tsx,jsx}',
|
||||
'supportFile': 'cypress/component/support/index.ts',
|
||||
'pluginsFile': 'cypress/component/plugins/index.js',
|
||||
},
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
describe('NewSpec', () => {
|
||||
beforeEach(() => {
|
||||
cy.setupE2E('spec-generation')
|
||||
|
||||
// Fails locally (cypress:open) unless I refresh browsers
|
||||
cy.withCtx(async (ctx) => {
|
||||
await ctx.actions.app.refreshBrowsers()
|
||||
})
|
||||
|
||||
cy.initializeApp()
|
||||
|
||||
cy.withCtx((ctx, { testState }) => {
|
||||
testState.generatedSpecs = {
|
||||
story: 'src/Button.stories.cy.jsx',
|
||||
storyCopy: 'src/Button.stories-copy-1.cy.jsx',
|
||||
component: 'src/Button.cy.jsx',
|
||||
componentCopy: 'src/Button-copy-1.cy.jsx',
|
||||
integration: 'cypress/integration/HelloWorld.spec.js',
|
||||
integrationCopy: 'cypress/integration/HelloWorld-copy-1.spec.js',
|
||||
}
|
||||
|
||||
// Hack for `stop-only-all`
|
||||
testState.generatedSpecContent = {
|
||||
story: `import React from "react"
|
||||
import { mount } from "@cypress/react"
|
||||
import { composeStories } from "@storybook/testing-react"
|
||||
import * as stories from "./Button.stories"
|
||||
|
||||
const composedStories = composeStories(stories)
|
||||
|
||||
describe('Button', () => {
|
||||
${/** Hack for "stop-only-all" */'it'}.only('should render Primary', () => {
|
||||
const { Primary } = composedStories
|
||||
mount(<Primary />)
|
||||
})
|
||||
|
||||
it('should render Secondary', () => {
|
||||
const { Secondary } = composedStories
|
||||
mount(<Secondary />)
|
||||
})
|
||||
|
||||
it('should render Large', () => {
|
||||
const { Large } = composedStories
|
||||
mount(<Large />)
|
||||
})
|
||||
|
||||
it('should render Small', () => {
|
||||
const { Small } = composedStories
|
||||
mount(<Small />)
|
||||
})
|
||||
})`,
|
||||
component: `import { mount } from "@cypress/react"
|
||||
import Button from "./Button"
|
||||
|
||||
describe('<Button />', () => {
|
||||
it('renders', () => {
|
||||
// see: https://reactjs.org/docs/test-utils.html
|
||||
mount(<Button />)
|
||||
})
|
||||
})`,
|
||||
integration: `describe('HelloWorld.spec.js', () => {
|
||||
it('should visit', () => {
|
||||
cy.visit('/')
|
||||
})
|
||||
})`,
|
||||
}
|
||||
})
|
||||
|
||||
cy.withCtx((ctx, { testState }) => {
|
||||
const { fs, path, activeProject } = ctx
|
||||
const projectRoot = activeProject?.projectRoot as string
|
||||
|
||||
for (const file of Object.values(testState.generatedSpecs) as string[]) {
|
||||
fs.removeSync(path.join(projectRoot, file))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('generates a spec from story', () => {
|
||||
cy.visitApp('#/newspec')
|
||||
cy.wait(1000)
|
||||
cy.intercept('mutation-NewSpec_CodeGenSpec').as('codeGenSpec')
|
||||
|
||||
cy.findByText('Generate From Story').should('not.be.disabled').click()
|
||||
|
||||
cy.get('li').contains('Button.stories.jsx').as('codeGen').click()
|
||||
cy.wait('@codeGenSpec')
|
||||
|
||||
cy.withCtx((ctx, { testState }) => {
|
||||
const generatedSpec = ctx.activeProject?.generatedSpec
|
||||
|
||||
expect(generatedSpec?.spec.relative).eq(testState.generatedSpecs.story)
|
||||
const fileContent = ctx.fs.readFileSync(
|
||||
generatedSpec?.spec.absolute as string,
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
expect(fileContent).eq(testState.generatedSpecContent.story)
|
||||
})
|
||||
|
||||
// Test creating a copy
|
||||
cy.get('@codeGen').click()
|
||||
cy.wait('@codeGenSpec')
|
||||
|
||||
cy.withCtx((ctx, { testState }) => {
|
||||
const generatedSpec = ctx.activeProject?.generatedSpec
|
||||
|
||||
expect(generatedSpec?.spec.relative).eq(
|
||||
testState.generatedSpecs.storyCopy,
|
||||
)
|
||||
|
||||
ctx.fs.accessSync(
|
||||
generatedSpec?.spec.absolute as string,
|
||||
ctx.fs.constants.F_OK,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('generates a component from story', () => {
|
||||
cy.visitApp('#/newspec')
|
||||
cy.wait(1000)
|
||||
cy.intercept('mutation-NewSpec_CodeGenSpec').as('codeGenSpec')
|
||||
|
||||
cy.findByText('Generate From Component').click()
|
||||
|
||||
cy.get('li').contains('Button.jsx').as('codeGen').click()
|
||||
cy.wait('@codeGenSpec')
|
||||
|
||||
cy.withCtx((ctx, { testState }) => {
|
||||
const generatedSpec = ctx.activeProject?.generatedSpec
|
||||
|
||||
expect(generatedSpec?.spec.relative).eq(
|
||||
testState.generatedSpecs.component,
|
||||
)
|
||||
|
||||
const fileContent = ctx.fs.readFileSync(
|
||||
generatedSpec?.spec.absolute as string,
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
expect(fileContent).eq(testState.generatedSpecContent.component)
|
||||
})
|
||||
|
||||
// Test creating a copy
|
||||
cy.get('@codeGen').click()
|
||||
cy.wait('@codeGenSpec')
|
||||
|
||||
cy.withCtx((ctx, { testState }) => {
|
||||
const generatedSpec = ctx.activeProject?.generatedSpec
|
||||
|
||||
expect(generatedSpec?.spec.relative).eq(
|
||||
testState.generatedSpecs.componentCopy,
|
||||
)
|
||||
|
||||
ctx.fs.accessSync(
|
||||
generatedSpec?.spec.absolute as string,
|
||||
ctx.fs.constants.F_OK,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('generates an integration spec', () => {
|
||||
cy.visitApp('#/newspec')
|
||||
cy.wait(1000)
|
||||
cy.intercept('mutation-NewSpec_CodeGenSpec').as('codeGenSpec')
|
||||
|
||||
cy.findByText('Generate Integration').click()
|
||||
|
||||
cy.get('#fileName').type('HelloWorld.spec.js')
|
||||
cy.findByText('Generate Spec').as('generateSpec').click()
|
||||
cy.wait('@codeGenSpec')
|
||||
|
||||
cy.withCtx((ctx, { testState }) => {
|
||||
const generatedSpec = ctx.activeProject?.generatedSpec
|
||||
|
||||
expect(generatedSpec?.spec.relative).eq(
|
||||
testState.generatedSpecs.integration,
|
||||
)
|
||||
|
||||
const fileContent = ctx.fs.readFileSync(
|
||||
generatedSpec?.spec.absolute as string,
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
expect(fileContent).eq(testState.generatedSpecContent.integration)
|
||||
})
|
||||
|
||||
cy.get('@generateSpec').click()
|
||||
cy.wait('@codeGenSpec')
|
||||
|
||||
cy.withCtx((ctx, { testState }) => {
|
||||
const generatedSpec = ctx.activeProject?.generatedSpec
|
||||
|
||||
expect(generatedSpec?.spec.relative).eq(
|
||||
testState.generatedSpecs.integrationCopy,
|
||||
)
|
||||
|
||||
ctx.fs.accessSync(
|
||||
generatedSpec?.spec.absolute as string,
|
||||
ctx.fs.constants.F_OK,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,160 +1,15 @@
|
||||
// @ts-nocheck
|
||||
// @ts-ignore
|
||||
import * as JustMyLuck from 'just-my-luck'
|
||||
import faker from 'faker'
|
||||
import { template, keys, reduce, templateSettings } from 'lodash'
|
||||
import combineProperties from 'combine-properties'
|
||||
import type { AutSnapshot } from '../../src/runner/iframe-model'
|
||||
|
||||
templateSettings.interpolate = /{{([\s\S]+?)}}/g
|
||||
|
||||
let jml
|
||||
const setupSeeds = () => {
|
||||
const seed = 2
|
||||
|
||||
faker.seed(seed)
|
||||
jml = new JustMyLuck(JustMyLuck.MersenneTwister(seed))
|
||||
}
|
||||
|
||||
setupSeeds()
|
||||
|
||||
beforeEach(() => setupSeeds)
|
||||
|
||||
/**
|
||||
* Component Naming Fixtures
|
||||
*/
|
||||
export const modifiers = [
|
||||
'Async',
|
||||
'Dynamic',
|
||||
'Static',
|
||||
'Virtual',
|
||||
'Lazy',
|
||||
]
|
||||
|
||||
export const domainModels = [
|
||||
'Person',
|
||||
'Product',
|
||||
'Spec',
|
||||
'Settings',
|
||||
'Account',
|
||||
'Login',
|
||||
'Logout',
|
||||
'Launchpad',
|
||||
'Wizard',
|
||||
]
|
||||
|
||||
export const componentNames = [
|
||||
'List',
|
||||
'Table',
|
||||
'Header',
|
||||
'Footer',
|
||||
'Button',
|
||||
'Cell',
|
||||
'Row',
|
||||
'Skeleton',
|
||||
'Loader',
|
||||
'Layout',
|
||||
]
|
||||
|
||||
export const specPattern = ['.spec', '_spec']
|
||||
|
||||
export const fileExtension = ['.tsx', '.jsx', '.ts', '.js']
|
||||
|
||||
export const directories = {
|
||||
rootDedicated: template('tests'),
|
||||
rootSrc: template('src'),
|
||||
monorepo: template('packages/{{component}}/test'),
|
||||
jestRoot: template('__test__'),
|
||||
jestNestedLib: template('lib/{{component}}{{component2}}/__test__'),
|
||||
dedicatedNested: template('lib/{{component}}/test'),
|
||||
jestNested: template('src/{{component}}/__test__'),
|
||||
componentsNested: template('src/components/{{component}}'),
|
||||
componentsFlat: template('src/{{component}}'),
|
||||
viewsFlat: template('src/views'),
|
||||
frontendFlat: template('frontend'),
|
||||
frontendComponentsFlat: template('frontend/components'),
|
||||
}
|
||||
|
||||
const nameTemplates = {
|
||||
// Business Logic Components
|
||||
longDomain: template(`{{prefix}}{{modifier}}{{domain}}{{component}}`),
|
||||
longDomain2: template(`{{prefix}}{{domain}}{{component}}{{component2}}`),
|
||||
|
||||
// App Components
|
||||
page1: template(`{{domain}}Page`),
|
||||
layout: template(`{{domain}}Layout`),
|
||||
|
||||
presentationalShort: template(`Base{{component}}`),
|
||||
presentationalLong: template(`Base{{component}}{{component2}}`),
|
||||
medium1: template(`{{prefix}}{{modifier}}{{component}}`),
|
||||
medium2: template(`{{prefix}}{{component}}{{component2}}`),
|
||||
short: template(`{{prefix}}{{component}}`),
|
||||
}
|
||||
|
||||
const prefixes = ['I', 'V', 'Cy', null]
|
||||
|
||||
export const componentNameGenerator = (options: { template: any, omit: any, overrides: any } = { template: nameTemplates.medium1, omit: [], overrides: {} }) => {
|
||||
const withoutValues = reduce(options.omit, (acc, v) => {
|
||||
acc[v] = null
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const components = jml.pickCombination(componentNames, 2)
|
||||
const defaultOptions = {
|
||||
modifier: jml.pick(modifiers),
|
||||
domain: jml.pick(domainModels),
|
||||
prefix: jml.pick(prefixes),
|
||||
component: components[0],
|
||||
component2: components[1],
|
||||
}
|
||||
|
||||
return options.template({
|
||||
...defaultOptions,
|
||||
...withoutValues,
|
||||
...options.overrides,
|
||||
})
|
||||
}
|
||||
|
||||
const allRandomComponents = combineProperties({
|
||||
domain: domainModels,
|
||||
modifier: modifiers,
|
||||
prefix: prefixes,
|
||||
component: componentNames,
|
||||
component2: componentNames,
|
||||
fileExtension,
|
||||
specPattern,
|
||||
directory: keys(directories),
|
||||
})
|
||||
|
||||
export const randomComponents = (n = 200) => {
|
||||
return faker.random.arrayElements(allRandomComponents, n).map((d) => {
|
||||
const name = componentNameGenerator({
|
||||
overrides: d,
|
||||
template: faker.random.objectElement(nameTemplates),
|
||||
})
|
||||
|
||||
const gitFileState = jml.pick(['modified', 'unmodified', 'added', 'deleted'])
|
||||
|
||||
return {
|
||||
componentName: name,
|
||||
relativePath: directories[d.directory](d),
|
||||
specExtension: d.specPattern,
|
||||
fileExtension: d.fileExtension,
|
||||
name: `${name}${d.specPattern}${d.fileExtension}`,
|
||||
id: faker.datatype.uuid(),
|
||||
gitInfo: {
|
||||
comitter: gitFileState ? faker.internet.userName() : undefined,
|
||||
timeAgo: gitFileState ? faker.datatype.datetime() : undefined,
|
||||
fileState: gitFileState,
|
||||
},
|
||||
}
|
||||
}, n)
|
||||
}
|
||||
|
||||
export const autSnapshot: AutSnapshot = {
|
||||
id: 1,
|
||||
name: 'DOM Test Snapshot',
|
||||
$el: null,
|
||||
coords: [0, 0],
|
||||
scrollBy: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
highlightAttr: '',
|
||||
snapshots: [],
|
||||
htmlAttrs: {},
|
||||
viewportHeight: 500,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import FileMatch from './FileMatch.vue'
|
||||
import { ref } from 'vue'
|
||||
import { defaultMessages } from '@cy/i18n'
|
||||
import { each } from 'lodash'
|
||||
|
||||
/*---------- Selectors ----------*/
|
||||
// Inputs
|
||||
const anyFilenameInputSelector = `[placeholder="${defaultMessages.components.fileSearch.byFilenameInput}"]`
|
||||
const filenameInputSelector = `${anyFilenameInputSelector}:first`
|
||||
const extensionInputSelector = `[placeholder="${defaultMessages.components.fileSearch.byExtensionInput}"]`
|
||||
const fileMatchButtonSelector = '[data-testid=file-match-button]'
|
||||
|
||||
// File Match Indicator
|
||||
// X out of Y Matches when searching the file list
|
||||
const fileMatchIndicatorSelector = '[data-testid=file-match-indicator]'
|
||||
|
||||
const initialExtension = '*.stories.*'
|
||||
const initialPattern = ''
|
||||
|
||||
describe('<FileMatch />', { viewportWidth: 600, viewportHeight: 300 }, () => {
|
||||
describe('with some matches', () => {
|
||||
beforeEach(() => {
|
||||
const extensionPattern = ref(initialExtension)
|
||||
const pattern = ref(initialPattern)
|
||||
const matches = { total: 10, found: 9 }
|
||||
|
||||
const onUpdateExtensionPatternSpy = cy.spy().as('onUpdateExtensionPatternSpy')
|
||||
const onUpdatePatternSpy = cy.spy().as('onUpdatePatternSpy')
|
||||
|
||||
const methods = {
|
||||
'onUpdate:extensionPattern': (newValue) => {
|
||||
extensionPattern.value = newValue
|
||||
onUpdateExtensionPatternSpy(newValue)
|
||||
},
|
||||
'onUpdate:pattern': (newValue) => {
|
||||
pattern.value = newValue
|
||||
onUpdatePatternSpy(newValue)
|
||||
},
|
||||
}
|
||||
|
||||
cy.mount(() => (<div class="p-12 resize overflow-auto">
|
||||
<FileMatch
|
||||
matches={matches}
|
||||
extensionPattern={extensionPattern.value}
|
||||
pattern={pattern.value}
|
||||
{...methods} />
|
||||
</div>))
|
||||
})
|
||||
|
||||
describe('expanding/collapsing', () => {
|
||||
it('can be expanded and collapsed by the extension button', () => {
|
||||
cy.get(extensionInputSelector).should('not.exist')
|
||||
.get(fileMatchButtonSelector).click()
|
||||
.get(extensionInputSelector).should('be.visible')
|
||||
.get(fileMatchButtonSelector).click()
|
||||
.get(extensionInputSelector).should('not.exist')
|
||||
})
|
||||
|
||||
it('shows the extension textfield when expanded', () => {
|
||||
cy.get(extensionInputSelector).should('not.exist')
|
||||
.get(fileMatchButtonSelector).click()
|
||||
.get(extensionInputSelector)
|
||||
.should('be.visible').and('have.value', initialExtension)
|
||||
})
|
||||
|
||||
it('shows the file name textfield when collapsed', () => {
|
||||
cy.get(filenameInputSelector).should('be.visible')
|
||||
})
|
||||
|
||||
it('shows the file name textfield when expanded', () => {
|
||||
cy.get(fileMatchButtonSelector).click()
|
||||
.get(filenameInputSelector).should('be.visible')
|
||||
})
|
||||
|
||||
it('persists the file name search between collapsing and expanding', () => {
|
||||
const newText = 'New filename'
|
||||
|
||||
cy.get(filenameInputSelector).should('be.visible')
|
||||
.clear()
|
||||
.type(newText)
|
||||
.get(fileMatchButtonSelector).click()
|
||||
.get(anyFilenameInputSelector).should('have.length', 1)
|
||||
.get(filenameInputSelector).should('have.value', newText)
|
||||
.get(fileMatchButtonSelector).click()
|
||||
.get(filenameInputSelector).should('have.value', newText)
|
||||
})
|
||||
|
||||
it('persists the extension search between collapsing and expanding', () => {
|
||||
const newText = 'New extension'
|
||||
|
||||
cy.get(extensionInputSelector).should('not.exist')
|
||||
.get(fileMatchButtonSelector).click()
|
||||
.get(extensionInputSelector).should('be.visible')
|
||||
.clear()
|
||||
.type(newText)
|
||||
.get(fileMatchButtonSelector).click().click()
|
||||
.get(extensionInputSelector).should('be.visible').and('have.value', newText)
|
||||
})
|
||||
|
||||
it('displays the extension in the extension button when collapsed', () => {
|
||||
const newText = 'New extension'
|
||||
|
||||
cy.get(extensionInputSelector).should('not.exist')
|
||||
.get(fileMatchButtonSelector).click()
|
||||
.get(extensionInputSelector).should('be.visible')
|
||||
.clear()
|
||||
.type(newText)
|
||||
.get(fileMatchButtonSelector).click()
|
||||
.should('contain.text', newText)
|
||||
})
|
||||
|
||||
it('does not display in the extension button when expanded', () => {
|
||||
cy.get(extensionInputSelector).should('not.exist')
|
||||
.get(fileMatchButtonSelector).click()
|
||||
.should('have.text', '')
|
||||
.click()
|
||||
.should('have.text', initialExtension)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extensionPattern', () => {
|
||||
it('emits the update:extensionPattern event', () => {
|
||||
const newExtension = '*.tsx'
|
||||
|
||||
cy.get(fileMatchButtonSelector).click()
|
||||
.get(extensionInputSelector)
|
||||
.clear()
|
||||
.type(newExtension)
|
||||
.get('@onUpdateExtensionPatternSpy')
|
||||
.should('have.been.calledWith', newExtension)
|
||||
})
|
||||
|
||||
it('displays the initial extension pattern', () => {
|
||||
cy.get(fileMatchButtonSelector).should('have.text', initialExtension)
|
||||
.click()
|
||||
.get(extensionInputSelector).should('have.value', initialExtension)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pattern', () => {
|
||||
it('emits the update:pattern event', () => {
|
||||
const newFilePattern = 'BaseComponent'
|
||||
|
||||
cy.get(filenameInputSelector).type(newFilePattern)
|
||||
.get('@onUpdatePatternSpy')
|
||||
.should('have.been.calledWith', newFilePattern)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('indicator', () => {
|
||||
/*---------- Fixtures ----------*/
|
||||
// Matches
|
||||
const total = 10
|
||||
const matchesData: Record<string, [{ matches: {found: number, total: number}, pattern?: string, extensionPattern?: string }, string]> = {
|
||||
all: [
|
||||
{ matches: { found: 10, total } },
|
||||
'10 Matches',
|
||||
],
|
||||
'ignores numerator when file pattern isn\'t searched': [
|
||||
{ matches: { found: 9, total } },
|
||||
'10 Matches',
|
||||
],
|
||||
'shows numerator when file pattern is searched': [
|
||||
{ matches: { found: 9, total }, pattern: 'A Pattern', extensionPattern: '*.tsx' },
|
||||
'9 of 10 Matches',
|
||||
],
|
||||
one: [
|
||||
{ matches: { found: 1, total }, pattern: 'A Pattern', extensionPattern: '*.tsx' },
|
||||
'1 of 10 Matches',
|
||||
],
|
||||
'one without a pattern': [
|
||||
{ matches: { found: 1, total } },
|
||||
'1 Match',
|
||||
],
|
||||
'no matches': [
|
||||
{ matches: { found: 0, total: 0 } },
|
||||
'No Matches',
|
||||
],
|
||||
}
|
||||
|
||||
each(matchesData, ([theProps, expected], key) => {
|
||||
it(`displays ${key} matches`, () => {
|
||||
cy.mount(() => <FileMatch matches={theProps.matches} pattern={theProps.pattern || ''} extensionPattern={theProps.extensionPattern || ''} />)
|
||||
.get(fileMatchIndicatorSelector)
|
||||
.should('have.text', expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
<!--
|
||||
/**==============================================
|
||||
* * FileMatch.vue
|
||||
* Complex component to search and edit multiple
|
||||
* text filters at once
|
||||
* ==============================================
|
||||
* * Collapsed (default)
|
||||
*
|
||||
* ? The user is searching by filePattern only as is
|
||||
* ? able to see the extensionPattern
|
||||
*
|
||||
* ? [[v extensionPattern][filePattern][matches]]
|
||||
*
|
||||
*
|
||||
* ----------------------------------------------
|
||||
* * Expanded
|
||||
*
|
||||
* ? The user has two inputs and can edit filePattern
|
||||
* ? and extensionPattern independently.
|
||||
*
|
||||
* ? 1. The input is bound to extensionPattern.
|
||||
* ? 2. The bottom input is bound to the filePattern.
|
||||
*
|
||||
* ? [[v][extensionPattern][matches]]
|
||||
* ? [filePattern ]
|
||||
* ============================================**/
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="inline-flex items-center w-full rounded border-1 hocus-default focus-within-default h-40px truncate">
|
||||
<FileMatchButton
|
||||
:expanded="expanded"
|
||||
@click="toggleExpanded()"
|
||||
>
|
||||
<span v-if="!expanded">{{ localExtensionPattern }}</span>
|
||||
</FileMatchButton>
|
||||
<div class="inline-flex items-center flex-grow min-w-min group">
|
||||
<i-cy-magnifying-glass_x16
|
||||
v-if="!expanded"
|
||||
class="inline-block ml-12px mr-8px icon-light-gray-50 icon-dark-gray-500 group-focus-within:icon-light-indigo-50 group-focus-within:icon-dark-indigo-400"
|
||||
/>
|
||||
|
||||
<FileMatchInput
|
||||
v-if="expanded"
|
||||
v-model="localExtensionPattern"
|
||||
class="ml-12px"
|
||||
:placeholder="t('components.fileSearch.byExtensionInput')"
|
||||
/>
|
||||
<FileMatchInput
|
||||
v-else
|
||||
v-model="localPattern"
|
||||
aria-label="file-name-input"
|
||||
:placeholder="t('components.fileSearch.byFilenameInput')"
|
||||
/>
|
||||
</div>
|
||||
<slot name="matches">
|
||||
<FileMatchIndicator
|
||||
class="truncate"
|
||||
data-testid="file-match-indicator"
|
||||
>
|
||||
{{ indicatorText }}
|
||||
</FileMatchIndicator>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="inline-flex items-center w-full rounded mt-8px border-1 hocus-default focus-within-default h-40px"
|
||||
:class="{ 'hidden' : !expanded }"
|
||||
>
|
||||
<div class="inline-flex items-center flex-grow group">
|
||||
<i-cy-magnifying-glass_x16 class="inline-block ml-12px mr-8px icon-light-gray-50 icon-dark-gray-500 group-focus-within:icon-light-indigo-50 group-focus-within:icon-dark-indigo-400" />
|
||||
<FileMatchInput
|
||||
v-model="localPattern"
|
||||
:placeholder="t('components.fileSearch.byFilenameInput')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from '@cy/i18n'
|
||||
import FileMatchInput from './FileMatchInput.vue'
|
||||
import FileMatchButton from './FileMatchButton.vue'
|
||||
import FileMatchIndicator from './FileMatchIndicator.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useToggle, useVModels } from '@vueuse/core'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
type Matches = {
|
||||
total: number
|
||||
found: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
extensionPattern: string,
|
||||
pattern: string
|
||||
matches: Matches
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(eventName: 'update:extensionPattern', value: string): void
|
||||
(eventName: 'update:pattern', value: string): void
|
||||
}>()
|
||||
|
||||
const { extensionPattern: localExtensionPattern, pattern: localPattern } = useVModels(props, emits)
|
||||
|
||||
// 2 of 22 Matches
|
||||
// No Matches
|
||||
const indicatorText = computed(() => {
|
||||
const numerator = props.matches.found
|
||||
const denominator = props.matches.total
|
||||
|
||||
if (localPattern.value) {
|
||||
// When the user has attempted to search anything
|
||||
// "No Matches | 1 Match | { denominator } Matches"
|
||||
return t('components.fileSearch.matchesIndicator', { count: numerator, denominator, numerator })
|
||||
}
|
||||
|
||||
// When the user has attempted to search by file path
|
||||
// "No Matches | {numerator} of {denominator} Matches"
|
||||
return t('components.fileSearch.matchesIndicatorEmptyFileSearch', { count: numerator, denominator, numerator })
|
||||
})
|
||||
const [expanded, toggleExpanded] = useToggle(false)
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
// Anything with onClick SHOULD work, but isn't...
|
||||
// Defining emits for "Button.vue" removes the native handler on the
|
||||
// <button> element. vue-tsc just can't handle this yet.
|
||||
import FileMatchButton from './FileMatchButton.vue'
|
||||
import faker from 'faker'
|
||||
import { ref } from 'vue'
|
||||
const fileMatchButtonSelector = '[data-testid=file-match-button]'
|
||||
|
||||
describe('<FileMatchButton />', () => {
|
||||
it('renders a small extension', () => {
|
||||
cy.mount(() => (<div class="p-12">
|
||||
<FileMatchButton>
|
||||
*.jsx
|
||||
</FileMatchButton>
|
||||
</div>))
|
||||
})
|
||||
|
||||
it('renders a long bit of text', () => {
|
||||
cy.mount(() => (<div class="p-12">
|
||||
<FileMatchButton>
|
||||
{ faker.lorem.paragraph(1) }
|
||||
</FileMatchButton>
|
||||
</div>))
|
||||
})
|
||||
|
||||
it('takes in an optional expanded prop', () => {
|
||||
const expanded = ref(false)
|
||||
|
||||
cy.mount(() => (<div class="p-12">
|
||||
<FileMatchButton
|
||||
// @ts-ignore - vue @click isn't represented in JSX
|
||||
onClick={() => {
|
||||
expanded.value = !expanded.value
|
||||
}}
|
||||
expanded={expanded.value}
|
||||
>
|
||||
*.stories.*
|
||||
</FileMatchButton>
|
||||
</div>))
|
||||
.get(fileMatchButtonSelector)
|
||||
.click()
|
||||
.should('be.focused')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<button
|
||||
data-testid="file-match-button"
|
||||
class="inline-flex items-center h-full text-gray-700 transition duration-150 rounded-l outline-none group bg-gray-50 border-r-gray-100 border-r-1 hocus:bg-indigo-50 hocus:border-r-indigo-300 hocus:text-indigo-500 px-12px"
|
||||
>
|
||||
<i-cy-chevron-right-small_x16
|
||||
class="transition duration-150 transform min-w-16px min-h-16px icon-dark-gray-400 group-hocus:icon-dark-indigo-400"
|
||||
:class="{
|
||||
'rotate-90': expanded
|
||||
}"
|
||||
/>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
withDefaults(defineProps<{
|
||||
expanded: boolean
|
||||
}>(), { expanded: false })
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
import FileMatchIndicator from './FileMatchIndicator.vue'
|
||||
import faker from 'faker'
|
||||
|
||||
describe('<FileMatchIndicator />', () => {
|
||||
it('renders a reasonable length text', () => {
|
||||
cy.mount(() => (<div class="p-12">
|
||||
<FileMatchIndicator>
|
||||
No Matches
|
||||
</FileMatchIndicator>
|
||||
</div>))
|
||||
})
|
||||
|
||||
it('renders a long bit of text', () => {
|
||||
cy.mount(() => (<div class="p-12">
|
||||
<FileMatchIndicator>
|
||||
{ faker.lorem.paragraph(1) }
|
||||
</FileMatchIndicator>
|
||||
</div>))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center">
|
||||
<span class="text-center truncate rounded select-none bg-jade-100 text-jade-600 my-8px mr-8px px-8px">
|
||||
<slot /></span>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
import FileMatchInput from './FileMatchInput.vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
describe('<FileMatchInput />', () => {
|
||||
it('renders a reasonable length text and can be typed into', () => {
|
||||
const initialText = 'Initial Text Value'
|
||||
const newText = 'Hello'
|
||||
const inputText = ref(initialText)
|
||||
const onUpdateTextSpy = cy.spy().as('onUpdateTextSpy')
|
||||
const methods = {
|
||||
'onUpdate:modelValue': (newValue) => {
|
||||
inputText.value = newValue
|
||||
onUpdateTextSpy(newValue)
|
||||
},
|
||||
}
|
||||
|
||||
cy.mount(() => (<div class="p-12">
|
||||
<FileMatchInput modelValue={inputText.value} {...methods} />
|
||||
</div>))
|
||||
.get('input[type=search]').should('have.value', initialText)
|
||||
.clear().type(newText)
|
||||
.get('@onUpdateTextSpy').should('have.been.calledWith', newText)
|
||||
.invoke('getCalls').should('have.length.at.least', newText.length)
|
||||
.get('input[type=search]').should('have.value', newText)
|
||||
.clear()
|
||||
.get('@onUpdateTextSpy').should('have.been.calledWith', '')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<input
|
||||
v-model="localModelValue"
|
||||
class="flex-grow p-0 text-gray-700 placeholder-gray-400 border-transparent outline-none placeholder-shown:overflow-ellipsis placeholder-shown:truncate hocus:border-transparent mr-8px"
|
||||
type="search"
|
||||
>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useVModels } from '@vueuse/core'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const { modelValue: localModelValue } = useVModels(props, emits)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input[type="search"]::-webkit-search-decoration,
|
||||
input[type="search"]::-webkit-search-cancel-button,
|
||||
input[type="search"]::-webkit-search-results-button,
|
||||
input[type="search"]::-webkit-search-results-decoration { display: none !important; }
|
||||
|
||||
</style>
|
||||
@@ -26,7 +26,6 @@
|
||||
<!-- </keep-alive> -->
|
||||
</transition>
|
||||
</router-view>
|
||||
<ModalManager v-if="modalStore.activeModalId" />
|
||||
</section>
|
||||
</main>
|
||||
<nav
|
||||
@@ -43,11 +42,8 @@ import SidebarNavigation from '../navigation/SidebarNavigation.vue'
|
||||
import HeaderBar from '@cy/gql-components/HeaderBar.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { useMainStore } from '../store'
|
||||
|
||||
import ModalManager from '../modals/ModalManager.vue'
|
||||
import { useModalStore, useMainStore } from '../store'
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const mainStore = useMainStore()
|
||||
const currentRoute = useRoute()
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<StandardModal
|
||||
:model-value="true /* default to open */"
|
||||
@update:model-value="modalStore.close"
|
||||
>
|
||||
<component
|
||||
:is="modals[modalStore.activeModalId]"
|
||||
v-if="modalStore.activeModalId"
|
||||
:key="modalStore.activeModalId"
|
||||
/>
|
||||
</StandardModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import StandardModal from '@cy/components/StandardModal.vue'
|
||||
import { useModalStore } from '../store'
|
||||
import { modals } from './modals'
|
||||
|
||||
const modalStore = useModalStore()
|
||||
</script>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<div>A test modal</div>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { ModalTypes } from '../store'
|
||||
import type { Component } from 'vue'
|
||||
import TestModal from './TestModal.vue'
|
||||
|
||||
export const modals: Record<ModalTypes, Component | Promise<Component>> = {
|
||||
createSpec: TestModal,
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
w-24px
|
||||
h-24px"
|
||||
/>
|
||||
<i-bi-bookmark-star class="text-white w-18px h-18px" />
|
||||
</div>
|
||||
<nav
|
||||
class="flex-1 px-2 mt-5 space-y-1 bg-gray-1000"
|
||||
@@ -46,11 +45,9 @@ import SettingsIcon from '~icons/cy/settings_x24'
|
||||
import { useMainStore } from '../store'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Home', icon: SpecsIcon, href: '/' },
|
||||
{ name: 'Specs', icon: CodeIcon, href: '/specs' },
|
||||
{ name: 'Home', icon: CodeIcon, href: '/' },
|
||||
{ name: 'Runs', icon: RunsIcon, href: '/runs' },
|
||||
{ name: 'Settings', icon: SettingsIcon, href: '/settings' },
|
||||
{ name: 'New Spec', icon: SettingsIcon, href: '/newspec' },
|
||||
]
|
||||
|
||||
const mainStore = useMainStore()
|
||||
|
||||
@@ -1,33 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[active ? 'before:bg-jade-300' : 'before:bg-transparent']"
|
||||
class="w-full
|
||||
min-w-40px
|
||||
relative
|
||||
flex
|
||||
items-center
|
||||
rounded-md
|
||||
group
|
||||
focus-visible:outline-none
|
||||
hover:before:bg-indigo-300
|
||||
focus:before:bg-indigo-300
|
||||
before:content-open-square
|
||||
before:mr-4px
|
||||
before:rounded-r-md
|
||||
before:text-transparent
|
||||
before:h-40px
|
||||
before:w-4px"
|
||||
class="relative flex items-center w-full rounded-md min-w-40px group focus-visible:outline-none hover:before:bg-indigo-300 focus:before:bg-indigo-300 before:content-open-square before:mr-4px before:rounded-r-md before:text-transparent before:h-40px before:w-4px"
|
||||
>
|
||||
<span
|
||||
class="h-full
|
||||
flex
|
||||
items-center
|
||||
overflow-hidden
|
||||
p-8px
|
||||
gap-20px
|
||||
rounded-lg
|
||||
children:group-focus:text-indigo-300
|
||||
children:group-hover:text-indigo-300"
|
||||
class="flex items-center h-full overflow-hidden rounded-lg p-8px gap-20px children:group-focus:text-indigo-300 children:group-hover:text-indigo-300"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<template>
|
||||
<div v-if="query.data.value?.app">
|
||||
<SpecsList :gql="query.data.value.app" />
|
||||
<SpecsList
|
||||
v-if="query.data.value.app.activeProject?.specs"
|
||||
:gql="query.data.value.app"
|
||||
/>
|
||||
<CreateSpecPage
|
||||
v-else
|
||||
:gql="query.data.value.app"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
@@ -12,11 +19,13 @@
|
||||
import { gql, useQuery } from '@urql/vue'
|
||||
import SpecsList from '../specs/SpecsList.vue'
|
||||
import { SpecsPageContainerDocument } from '../generated/graphql'
|
||||
import CreateSpecPage from '../specs/CreateSpecPage.vue'
|
||||
|
||||
gql`
|
||||
query SpecsPageContainer {
|
||||
app {
|
||||
...Specs_SpecsList
|
||||
...CreateSpecPage
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<h2>New Spec</h2>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<Button
|
||||
:disabled="!newSpecQuery.data.value?.app.activeProject?.storybook"
|
||||
@click="codeGenTypeClicked('story')"
|
||||
>
|
||||
Generate From Story
|
||||
</Button>
|
||||
<Button @click="codeGenTypeClicked('component')">
|
||||
Generate From Component
|
||||
</Button>
|
||||
<Button @click="codeGenTypeClicked('integration')">
|
||||
Generate Integration
|
||||
</Button>
|
||||
</div>
|
||||
<template v-if="codeGenType">
|
||||
<template v-if="codeGenType !== 'integration'">
|
||||
<div class="p-16px">
|
||||
<Input
|
||||
id="glob-pattern"
|
||||
v-model="codeGenGlob"
|
||||
/>
|
||||
</div>
|
||||
<ul class="p-16px max-h-210px overflow-auto">
|
||||
<li
|
||||
v-for="candidate of codeGenCandidates"
|
||||
:key="candidate.relative"
|
||||
class="group"
|
||||
@click="candidateClick(candidate.absolute)"
|
||||
>
|
||||
<span class="text-indigo-600 font-medium">{{
|
||||
candidate.fileName
|
||||
}}</span>
|
||||
<span class="font-light text-gray-400">{{
|
||||
candidate.fileExtension
|
||||
}}</span>
|
||||
<span
|
||||
class="font-light text-gray-400 pl-16px hidden group-hover:inline"
|
||||
>{{ candidate.relativeFromProjectRoot }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template v-else-if="codeGenType === 'integration'">
|
||||
<div class="flex p-16px gap-4">
|
||||
<Input
|
||||
id="fileName"
|
||||
v-model="fileNameInput"
|
||||
class="flex-auto"
|
||||
/>
|
||||
<Button @Click="candidateClick(fileNameInput)">
|
||||
Generate Spec
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="candidateChosen && generatedSpec"
|
||||
class="p-16px flex flex-col items-center"
|
||||
>
|
||||
<Button @click="specClick">
|
||||
{{ generatedSpec.spec.relative }}
|
||||
</Button>
|
||||
<pre>{{ generatedSpec.content }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<route>
|
||||
{
|
||||
name: "New Spec Page",
|
||||
meta: {
|
||||
title: "New Spec"
|
||||
}
|
||||
}
|
||||
</route>
|
||||
<script lang="ts" setup>
|
||||
import Button from '@cy/components/Button.vue'
|
||||
import Input from '@cy/components/Input.vue'
|
||||
import { gql, useMutation, useQuery } from '@urql/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
NewSpec_NewSpecQueryDocument,
|
||||
NewSpec_SearchCodeGenCandidatesDocument,
|
||||
NewSpec_CodeGenGlobQueryDocument,
|
||||
NewSpec_CodeGenSpecDocument,
|
||||
NewSpec_SetCurrentSpecDocument,
|
||||
CodeGenType,
|
||||
} from '../generated/graphql'
|
||||
|
||||
gql`
|
||||
query NewSpec_NewSpecQuery {
|
||||
app {
|
||||
activeProject {
|
||||
id
|
||||
storybook {
|
||||
id
|
||||
}
|
||||
generatedSpec {
|
||||
id
|
||||
content
|
||||
spec {
|
||||
id
|
||||
name
|
||||
relative
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
gql`
|
||||
query NewSpec_CodeGenGlobQuery($type: CodeGenType!) {
|
||||
app {
|
||||
activeProject {
|
||||
id
|
||||
codeGenGlob: codeGenGlob(type: $type)
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
gql`
|
||||
fragment NewSpec_CodeGenCandidateNode on FilePartsEdge {
|
||||
node {
|
||||
id
|
||||
relative
|
||||
fileName
|
||||
baseName
|
||||
absolute
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
gql`
|
||||
query NewSpec_SearchCodeGenCandidates($glob: String!) {
|
||||
app {
|
||||
activeProject {
|
||||
id
|
||||
codeGenCandidates: codeGenCandidates(first: 25, glob: $glob) {
|
||||
edges {
|
||||
...NewSpec_CodeGenCandidateNode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
gql`
|
||||
mutation NewSpec_CodeGenSpec($codeGenCandidate: String!, $type: CodeGenType!) {
|
||||
codeGenSpec(codeGenCandidate: $codeGenCandidate, type: $type)
|
||||
}
|
||||
`
|
||||
|
||||
gql`
|
||||
mutation NewSpec_SetCurrentSpec($id: ID!) {
|
||||
setCurrentSpec(id: $id)
|
||||
}
|
||||
`
|
||||
|
||||
const newSpecQuery = useQuery({ query: NewSpec_NewSpecQueryDocument })
|
||||
|
||||
const codeGenType = ref<CodeGenType | null>(null)
|
||||
|
||||
// Urql allows reactive query variables (ref, computed) but has improper typing
|
||||
type ReactiveGraphQLVar = any
|
||||
|
||||
const codeGenGlobQuery = useQuery({
|
||||
query: NewSpec_CodeGenGlobQueryDocument,
|
||||
variables: { type: codeGenType as ReactiveGraphQLVar },
|
||||
pause: computed(() => !codeGenType.value),
|
||||
})
|
||||
// Query 'pause' with computed property was triggering an infinite loop so use ref and watch instead
|
||||
const codeGenGlob = ref('')
|
||||
|
||||
watch(codeGenGlobQuery.data, (value, prevVal) => {
|
||||
if (value?.app.activeProject?.codeGenGlob && value.app.activeProject.codeGenGlob !== prevVal?.app.activeProject?.codeGenGlob) {
|
||||
codeGenGlob.value = value.app.activeProject.codeGenGlob
|
||||
}
|
||||
})
|
||||
|
||||
const searchCodeGenCandidates = useQuery({
|
||||
query: NewSpec_SearchCodeGenCandidatesDocument,
|
||||
variables: { glob: codeGenGlob as ReactiveGraphQLVar },
|
||||
pause: computed(() => !codeGenGlob.value),
|
||||
})
|
||||
const codeGenCandidates = computed(() => {
|
||||
return (
|
||||
searchCodeGenCandidates.data.value?.app.activeProject?.codeGenCandidates?.edges.map(
|
||||
({ node: story }) => {
|
||||
return {
|
||||
...story,
|
||||
fileExtension: story.baseName.replace(story.fileName, ''),
|
||||
relativeFromProjectRoot: story.relative.replace(story.baseName, ''),
|
||||
}
|
||||
},
|
||||
) || []
|
||||
)
|
||||
})
|
||||
|
||||
const fileNameInput = ref('')
|
||||
|
||||
const mutation = useMutation(NewSpec_CodeGenSpecDocument)
|
||||
const candidateChosen = ref(false)
|
||||
const generatedSpec = computed(
|
||||
() => newSpecQuery.data.value?.app.activeProject?.generatedSpec,
|
||||
)
|
||||
|
||||
const setSpecMutation = useMutation(NewSpec_SetCurrentSpecDocument)
|
||||
const router = useRouter()
|
||||
|
||||
async function specClick () {
|
||||
const specId = newSpecQuery.data.value?.app.activeProject?.generatedSpec?.spec.id
|
||||
|
||||
if (!specId) {
|
||||
return
|
||||
}
|
||||
|
||||
await setSpecMutation.executeMutation({ id: specId })
|
||||
router.push('runner')
|
||||
}
|
||||
|
||||
function codeGenTypeClicked (type: CodeGenType) {
|
||||
codeGenType.value = type
|
||||
candidateChosen.value = false
|
||||
}
|
||||
|
||||
function candidateClick (codeGenCandidate: string) {
|
||||
candidateChosen.value = true
|
||||
mutation.executeMutation({
|
||||
codeGenCandidate,
|
||||
type: codeGenType.value as CodeGenType,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -48,7 +48,7 @@ import type { RunsConnectFragment } from '../generated/graphql'
|
||||
const { t } = useI18n()
|
||||
|
||||
gql`
|
||||
fragment RunsConnect on Query{
|
||||
fragment RunsConnect on Query {
|
||||
cloudViewer{
|
||||
id
|
||||
}
|
||||
|
||||
@@ -58,11 +58,12 @@ import type { RunsEmptyFragment } from '../generated/graphql'
|
||||
const { t } = useI18n()
|
||||
|
||||
gql`
|
||||
fragment RunsEmpty on Project{
|
||||
fragment RunsEmpty on Project {
|
||||
title
|
||||
projectId
|
||||
configFilePath
|
||||
cloudProject {
|
||||
id
|
||||
recordKeys {
|
||||
...RecordKey
|
||||
}
|
||||
@@ -83,12 +84,10 @@ const projectIdCode = computed(() => {
|
||||
const projectName = computed(() => props.gql.title)
|
||||
const configFilePath = computed(() => props.gql.configFilePath)
|
||||
const firstRecordKey = computed(() => {
|
||||
const allRecordKeys = props.gql.cloudProject?.recordKeys
|
||||
|
||||
return allRecordKeys?.[0] ?? '<record-key>'
|
||||
return props.gql.cloudProject?.recordKeys?.[0]
|
||||
})
|
||||
const recordCommand = computed(() => {
|
||||
return `cypress run --record --key ${firstRecordKey.value}`
|
||||
return `cypress run --record --key ${firstRecordKey.value?.key}`
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Anything with onClick SHOULD work, but isn't...
|
||||
// Defining emits for "Button.vue" removes the native handler on the
|
||||
// <button> element. vue-tsc just can't handle this yet.
|
||||
import CreateSpecCard from './CreateSpecCard.vue'
|
||||
import DocumentCode from '~icons/cy/document-code_x48'
|
||||
|
||||
const iconSelector = '[data-testid=create-spec-card-icon]'
|
||||
const specCardSelector = '[data-testid=create-spec-card]'
|
||||
const header = 'My header text here'
|
||||
const shortDescription = `We'll walk you through generating your first spec from a component.`
|
||||
|
||||
describe('<CreateSpecCard />', { viewportWidth: 400, viewportHeight: 400 }, () => {
|
||||
it('renders with long text', () => {
|
||||
const longHeader = 'Create from very long header content alsowhenwordshaveno-linebreaks'
|
||||
const longDescription = `This is some description text to explain how we import stuff using this button. Specifically, we're going to create specs from your user input. Also, this is a very long description to test how our CreateSpecCard resizes.`
|
||||
|
||||
cy.mount(() => (
|
||||
<div class="m-12">
|
||||
<CreateSpecCard icon={DocumentCode} header={longHeader} description={longDescription} />
|
||||
</div>
|
||||
)).get(specCardSelector)
|
||||
.should('contain.text', longHeader)
|
||||
.and('contain.text', longDescription)
|
||||
})
|
||||
|
||||
it('renders with normal length text', () => {
|
||||
cy.mount(() => (
|
||||
<div class="m-12">
|
||||
<CreateSpecCard icon={DocumentCode} header={header} description={shortDescription} />
|
||||
</div>
|
||||
)).get(specCardSelector)
|
||||
.should('contain.text', header)
|
||||
.and('contain.text', shortDescription)
|
||||
})
|
||||
|
||||
it('renders the icon passed in', () => {
|
||||
cy.mount(() => (
|
||||
<div class="m-12">
|
||||
<CreateSpecCard icon={DocumentCode} header={header} description={shortDescription} />
|
||||
</div>
|
||||
)).get(iconSelector)
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
// TODO: Brian says this can be fixed once the driver is hooked up properly
|
||||
xit('emits click events bound to it', () => {
|
||||
const onClickSpy = cy.spy().as('onClickSpy')
|
||||
|
||||
cy.mount(() => (<div class="m-12">
|
||||
<CreateSpecCard icon={DocumentCode} header={header} description={shortDescription}
|
||||
// @ts-ignore - vue @click isn't represented in JSX
|
||||
onClick={onClickSpy} />
|
||||
</div>))
|
||||
.get(specCardSelector)
|
||||
.focus()
|
||||
.click()
|
||||
.type('{enter}')
|
||||
.type(' ')
|
||||
// .get('@onClickSpy').should('have.been.calledThrice')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div
|
||||
class="block h-auto overflow-scroll text-center rounded outline-none
|
||||
cursor-pointer w-280px m-2px min-h-216px max-h-350px px-32px pt-32px
|
||||
pb-24px children:hyphens-manual border-1 hocus-default
|
||||
focus-within-default"
|
||||
:class="{
|
||||
'bg-gray-50 border-gray-100 pointer-events-none': disabled
|
||||
}"
|
||||
data-testid="create-spec-card"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
class="mx-auto my-0 w-48px h-48px"
|
||||
:class="disabled ?
|
||||
'icon-dark-gray-600 icon-light-gray-100 icon-dark-secondary-gray-600 icon-light-secondary-gray-300':
|
||||
'icon-dark-indigo-400 icon-light-indigo-100 icon-light-secondary-jade-200 icon-dark-secondary-jade-400'
|
||||
"
|
||||
data-testid="create-spec-card-icon"
|
||||
/>
|
||||
<h2
|
||||
class="leading-normal text-indigo-500 mt-10px text-18px mb-4px"
|
||||
:class="{
|
||||
'text-gray-700': disabled
|
||||
}"
|
||||
>
|
||||
<button class="focus:outline-none focus:ring-0">
|
||||
{{ header }}
|
||||
</button>
|
||||
</h2>
|
||||
<p
|
||||
class="text-gray-600 tracking-tight text-14px leading-[20px]"
|
||||
:class="{
|
||||
'': disabled
|
||||
}"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FunctionalComponent, SVGAttributes } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
icon: FunctionalComponent<SVGAttributes>,
|
||||
header: string,
|
||||
description: string,
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-32px children:mx-auto">
|
||||
<component
|
||||
:is="generator.card"
|
||||
v-for="generator in generators"
|
||||
:key="generator.id"
|
||||
:disabled="generator.disabled(props.gql.activeProject) || false"
|
||||
@click="$emit('select', generator.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { generatorList } from './generators'
|
||||
|
||||
// eslint-disable-next-line no-duplicate-imports
|
||||
import type { GeneratorId } from './generators'
|
||||
import { computed } from 'vue'
|
||||
import type { CreateSpecCardsFragment } from '../generated/graphql'
|
||||
import { gql } from '@urql/vue'
|
||||
|
||||
const props = defineProps<{
|
||||
gql: CreateSpecCardsFragment
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(eventName: 'select', id: GeneratorId): void
|
||||
}>()
|
||||
|
||||
const generators = computed(() => generatorList.filter((g) => g.matches(props.gql.activeTestingType)))
|
||||
|
||||
gql`
|
||||
fragment CreateSpecCards on App {
|
||||
activeTestingType
|
||||
activeProject {
|
||||
id
|
||||
storybook {
|
||||
storybookRoot
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
import CreateSpecModal from './CreateSpecModal.vue'
|
||||
import { ref } from 'vue'
|
||||
import { defaultMessages } from '@cy/i18n'
|
||||
|
||||
const modalCloseSelector = '[aria-label=Close]'
|
||||
const triggerButtonSelector = '[data-testid=trigger]'
|
||||
const modalSelector = '[data-testid=create-spec-modal]'
|
||||
|
||||
const messages = defaultMessages.createSpec.component.importFromComponent
|
||||
|
||||
describe('<CreateSpecModal />', () => {
|
||||
beforeEach(() => {
|
||||
const show = ref(true)
|
||||
|
||||
cy.mount(() => (<div>
|
||||
<CreateSpecModal
|
||||
gql={{
|
||||
activeProject: {
|
||||
id: 'id',
|
||||
codeGenGlob: '**.vue',
|
||||
storybook: null,
|
||||
},
|
||||
activeTestingType: 'component',
|
||||
}}
|
||||
show={show.value}
|
||||
onClose={() => show.value = false}
|
||||
/>
|
||||
</div>))
|
||||
})
|
||||
|
||||
it('renders a modal', () => {
|
||||
cy.get(modalSelector).should('be.visible')
|
||||
})
|
||||
|
||||
describe('dismissing', () => {
|
||||
it('is not dismissed when you press escape or click outside', () => {
|
||||
cy.get(modalSelector)
|
||||
.click(0, 0)
|
||||
.get(modalSelector)
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
it('is dismissed when the X button is clicked', () => {
|
||||
cy.get(modalSelector)
|
||||
.get(modalCloseSelector)
|
||||
.click()
|
||||
.get(modalSelector)
|
||||
.should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generator', () => {
|
||||
it('renders the generator', () => {
|
||||
cy.contains(messages.header).should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('playground', () => {
|
||||
it('can be opened and closed via the show prop', () => {
|
||||
const show = ref(false)
|
||||
|
||||
cy.mount(() => (<>
|
||||
<button data-testid="trigger" onClick={() => show.value = true}>Open Modal</button>
|
||||
<br/>
|
||||
<CreateSpecModal
|
||||
gql={{
|
||||
activeProject: {
|
||||
id: 'id',
|
||||
codeGenGlob: '**.vue',
|
||||
storybook: null,
|
||||
},
|
||||
activeTestingType: 'component',
|
||||
}}
|
||||
show={show.value}
|
||||
onClose={() => show.value = false}
|
||||
/>
|
||||
</>)).get(triggerButtonSelector)
|
||||
.click()
|
||||
.get(modalSelector)
|
||||
.should('be.visible')
|
||||
.get(modalCloseSelector)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<StandardModal
|
||||
class="transition duration-200 transition-all"
|
||||
:click-outside="false"
|
||||
variant="bare"
|
||||
:title="title"
|
||||
:model-value="show"
|
||||
data-testid="create-spec-modal"
|
||||
@update:model-value="$emit('close')"
|
||||
>
|
||||
<template #overlay="{classes}">
|
||||
<DialogOverlay
|
||||
:class="classes"
|
||||
class="bg-gray-900 opacity-[0.97]"
|
||||
/>
|
||||
</template>
|
||||
<div class="min-h-280px sm:min-w-640px flex flex-col">
|
||||
<component
|
||||
:is="generator.entry"
|
||||
v-if="generator"
|
||||
:key="generator.id"
|
||||
v-model:title="title"
|
||||
:code-gen-glob="props.gql.activeProject?.codeGenGlob"
|
||||
@restart="currentGeneratorId = undefined"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex-grow flex items-center self-center"
|
||||
>
|
||||
<CreateSpecCards
|
||||
:gql="props.gql"
|
||||
@select="currentGeneratorId = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StandardModal>
|
||||
</template>
|
||||
|
||||
<script lang ="ts" setup>
|
||||
import { generators, SpecGenerator, GeneratorId } from './generators'
|
||||
import { DialogOverlay } from '@headlessui/vue'
|
||||
import StandardModal from '@cy/components/StandardModal.vue'
|
||||
import StandardModalBody from '@cy/components/StandardModalBody.vue'
|
||||
import StandardModalFooter from '@cy/components/StandardModalFooter.vue'
|
||||
import CreateSpecCards from './CreateSpecCards.vue'
|
||||
import { ref, computed, Ref } from 'vue'
|
||||
import type { CreateSpecModalFragment } from '../generated/graphql'
|
||||
import { gql } from '@urql/vue'
|
||||
import { not, whenever } from '@vueuse/core'
|
||||
import { useI18n } from '@cy/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
initialGenerator?: GeneratorId,
|
||||
show: boolean,
|
||||
gql: CreateSpecModalFragment
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(eventName: 'close'): void
|
||||
}>()
|
||||
|
||||
gql`
|
||||
fragment CreateSpecModal on App {
|
||||
...CreateSpecCards
|
||||
activeProject {
|
||||
...ComponentGeneratorStepOne_codeGenGlob
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const currentGeneratorId: Ref<GeneratorId | undefined> = ref(props.initialGenerator)
|
||||
|
||||
const { t } = useI18n()
|
||||
const title = ref(t('createSpec.newSpecModalTitle'))
|
||||
|
||||
const generator = computed(() => {
|
||||
if (currentGeneratorId.value) return generators[currentGeneratorId.value]
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
whenever(not(generator), () => {
|
||||
title.value = t('createSpec.newSpecModalTitle')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
import Button from '@cy/components/Button.vue'
|
||||
import CreateSpecPage from './CreateSpecPage.vue'
|
||||
import { ref, Ref } from 'vue'
|
||||
import { defaultMessages } from '@cy/i18n'
|
||||
import type { TestingTypeEnum } from '../generated/graphql-test'
|
||||
|
||||
const pageTitleSelector = '[data-testid=create-spec-page-title]'
|
||||
const pageDescriptionSelector = '[data-testid=create-spec-page-description]'
|
||||
const noSpecsMessageSelector = '[data-testid=no-specs-message]'
|
||||
const viewSpecsSelector = '[data-testid=view-spec-pattern]'
|
||||
|
||||
const messages = defaultMessages.createSpec
|
||||
|
||||
describe('<CreateSpecPage />', () => {
|
||||
describe('mounting in component type', () => {
|
||||
beforeEach(() => {
|
||||
cy.mount(() => (<div class="p-12"><CreateSpecPage gql={{
|
||||
activeProject: {
|
||||
id: 'id',
|
||||
storybook: null,
|
||||
codeGenGlob: '**.vue',
|
||||
}, activeTestingType: 'component',
|
||||
}} /></div>))
|
||||
})
|
||||
|
||||
it('renders the "No Specs" footer', () => {
|
||||
cy.get(noSpecsMessageSelector)
|
||||
.should('be.visible')
|
||||
.and('contain.text', messages.noSpecsMessage)
|
||||
.get(viewSpecsSelector)
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
it('renders component mode', () => {
|
||||
expect(true).to.be.true
|
||||
})
|
||||
|
||||
it('renders the correct text for component testing', () => {
|
||||
cy.get(pageTitleSelector).should('contain.text', messages.page.title)
|
||||
.get(pageDescriptionSelector).should('contain.text', messages.page.component.description)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mounting in e2e mode', () => {
|
||||
beforeEach(() => {
|
||||
cy.mount(() => (<div class="p-12"><CreateSpecPage gql={{
|
||||
activeProject: {
|
||||
id: 'id',
|
||||
storybook: null,
|
||||
codeGenGlob: '**.vue',
|
||||
}, activeTestingType: 'e2e',
|
||||
}} /></div>))
|
||||
})
|
||||
|
||||
it('renders e2e mode', () => {
|
||||
expect(true).to.be.true
|
||||
})
|
||||
|
||||
it('renders the correct text', () => {
|
||||
cy.get(pageTitleSelector)
|
||||
.should('contain.text', messages.page.title)
|
||||
.get(pageDescriptionSelector).should('contain.text', messages.page.e2e.description)
|
||||
})
|
||||
})
|
||||
|
||||
it('playground', () => {
|
||||
const testingType: Ref<TestingTypeEnum> = ref('component')
|
||||
|
||||
cy.mount(() => (
|
||||
<div class="p-12 space-y-10 resize overflow-auto">
|
||||
{ /* Testing Utils */ }
|
||||
<Button variant="outline"
|
||||
size="md"
|
||||
// @ts-ignore
|
||||
onClick={() => testingType.value = testingType.value === 'component' ? 'e2e' : 'component'}>
|
||||
Toggle Testing Types
|
||||
</Button>
|
||||
|
||||
{ /* Subject Under Test */ }
|
||||
<CreateSpecPage gql={{
|
||||
activeProject: {
|
||||
id: 'id',
|
||||
storybook: null,
|
||||
codeGenGlob: '**.vue',
|
||||
},
|
||||
activeTestingType: testingType.value,
|
||||
}} />
|
||||
</div>))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<CreateSpecModal
|
||||
v-if="props.gql.activeTestingType"
|
||||
:key="generator"
|
||||
:initial-generator="generator"
|
||||
:show="showModal"
|
||||
:gql="props.gql"
|
||||
@close="closeModal"
|
||||
/>
|
||||
<div
|
||||
v-if="props.gql.activeTestingType"
|
||||
class="overflow-scroll text-center max-w-600px mx-auto py-40px"
|
||||
>
|
||||
<h1
|
||||
data-testid="create-spec-page-title"
|
||||
class="text-gray-900 text-32px mb-12px"
|
||||
>
|
||||
{{ t('createSpec.page.title') }}
|
||||
</h1>
|
||||
<p
|
||||
data-testid="create-spec-page-description"
|
||||
class="leading-normal text-gray-600 text-18px mb-32px"
|
||||
>
|
||||
{{ t(`createSpec.page.${props.gql.activeTestingType}.description`) }}
|
||||
</p>
|
||||
<CreateSpecCards
|
||||
data-testid="create-spec-page-cards"
|
||||
:gql="props.gql"
|
||||
@select="choose"
|
||||
/>
|
||||
|
||||
<div class="text-center border-t-1 pt-32px mt-32px">
|
||||
<p
|
||||
data-testid="no-specs-message"
|
||||
class="leading-normal text-gray-600 text-16px mb-16px"
|
||||
>
|
||||
{{ t('createSpec.noSpecsMessage') }}
|
||||
</p>
|
||||
<Button
|
||||
data-testid="view-spec-pattern"
|
||||
variant="outline"
|
||||
prefix-icon-class="icon-light-gray-50 icon-dark-gray-400"
|
||||
:prefix-icon="SettingsIcon"
|
||||
class="mx-auto duration-300 hocus:ring-gray-50 hocus:border-gray-200"
|
||||
@click.prevent=""
|
||||
>
|
||||
{{ t('createSpec.viewSpecPatternButton') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from '@cy/i18n'
|
||||
import SettingsIcon from '~icons/cy/settings_x16'
|
||||
import Button from '@cy/components/Button.vue'
|
||||
import { ref, computed, Ref } from 'vue'
|
||||
import CreateSpecModal from './CreateSpecModal.vue'
|
||||
import CreateSpecCards from './CreateSpecCards.vue'
|
||||
import { gql } from '@urql/vue'
|
||||
import type { CreateSpecPageFragment } from '../generated/graphql'
|
||||
const { t } = useI18n()
|
||||
|
||||
gql`
|
||||
fragment CreateSpecPage on App {
|
||||
activeTestingType
|
||||
...CreateSpecCards
|
||||
...CreateSpecModal
|
||||
}
|
||||
`
|
||||
|
||||
const props = defineProps<{
|
||||
gql: CreateSpecPageFragment
|
||||
}>()
|
||||
|
||||
const showModal = ref(false)
|
||||
|
||||
const generator = ref()
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
generator.value = null
|
||||
}
|
||||
|
||||
const choose = (id) => {
|
||||
showModal.value = true
|
||||
generator.value = id
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<div class="p-24px">
|
||||
<CreateSpecModal
|
||||
v-if="props.gql.activeTestingType"
|
||||
:show="showModal"
|
||||
:gql="props.gql"
|
||||
@close="showModal = false"
|
||||
/>
|
||||
<SpecsListHeader
|
||||
v-model="search"
|
||||
class="pb-32px"
|
||||
@newSpec="modalStore.open('createSpec')"
|
||||
@newSpec="showModal = true"
|
||||
/>
|
||||
<div class="grid items-center divide-y-1 children:h-40px">
|
||||
<div class="grid grid-cols-2 children:text-gray-800 children:font-medium">
|
||||
@@ -27,11 +33,10 @@ import SpecsListHeader from './SpecsListHeader.vue'
|
||||
import SpecsListRow from './SpecsListRow.vue'
|
||||
import { gql } from '@urql/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import CreateSpecModal from './CreateSpecModal.vue'
|
||||
import type { Specs_SpecsListFragment, SpecNode_SpecsListFragment } from '../generated/graphql'
|
||||
import { useI18n } from '@cy/i18n'
|
||||
import { useModalStore } from '../store'
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
gql`
|
||||
@@ -48,10 +53,11 @@ fragment SpecNode_SpecsList on SpecEdge {
|
||||
|
||||
gql`
|
||||
fragment Specs_SpecsList on App {
|
||||
...CreateSpecModal
|
||||
activeProject {
|
||||
id
|
||||
projectRoot
|
||||
specs: specs(first: 25) {
|
||||
specs: specs(first: 100) {
|
||||
edges {
|
||||
...SpecNode_SpecsList
|
||||
}
|
||||
@@ -64,6 +70,7 @@ const props = defineProps<{
|
||||
gql: Specs_SpecsListFragment
|
||||
}>()
|
||||
|
||||
const showModal = ref(false)
|
||||
const search = ref('')
|
||||
const specs = computed(() => props.gql.activeProject?.specs?.edges)
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ describe('<SpecsListHeader />', { keystrokeDelay: 0 }, () => {
|
||||
const onNewSpec = cy.spy().as('new-spec')
|
||||
const search = ref('')
|
||||
|
||||
cy.mount(<div class="max-w-800px p-12 resize overflow-auto"><SpecsListHeader
|
||||
cy.mount(() => (<div class="max-w-800px p-12 resize overflow-auto"><SpecsListHeader
|
||||
modelValue={search.value}
|
||||
onNewSpec={onNewSpec}
|
||||
/></div>)
|
||||
/></div>))
|
||||
.get(buttonSelector)
|
||||
.click()
|
||||
.get('@new-spec')
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
data-testid="new-spec-button"
|
||||
:prefix-icon="IconAdd"
|
||||
prefix-icon-class="justify-center text-lg text-center icon-light-transparent icon-dark-white"
|
||||
class="min-w-127px text-size-16px focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500"
|
||||
class="min-w-127px"
|
||||
size="lg"
|
||||
@click="$emit('newSpec')"
|
||||
>
|
||||
{{ t('specPage.newSpecButton') }}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<StandardModalBody
|
||||
variant="bare"
|
||||
class="w-640px h-444px overflow-scroll"
|
||||
>
|
||||
<slot />
|
||||
</StandardModalBody>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import StandardModalBody from '@cy/components/StandardModalBody.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,250 @@
|
||||
import FileChooser from './FileChooser.vue'
|
||||
|
||||
import { randomComponents } from '@packages/frontend-shared/cypress/support/mock-graphql/testStubSpecs'
|
||||
import { ref } from 'vue'
|
||||
import { defaultMessages } from '@cy/i18n'
|
||||
|
||||
/*---------- Fixtures ----------*/
|
||||
const numFiles = 20
|
||||
const allFiles = randomComponents(numFiles, 'FileParts')
|
||||
const extensionPattern = '*.jsx'
|
||||
const existentExtensionPattern = '*.tsx'
|
||||
const nonExistentFileName = 'non existent file'
|
||||
|
||||
/*---------- Selectors ----------*/
|
||||
// File List
|
||||
const fileRowSelector = '[data-testid=file-list-row]'
|
||||
|
||||
// Inputs
|
||||
const filenameInputSelector = `[placeholder="${defaultMessages.components.fileSearch.byFilenameInput}"]:first`
|
||||
const extensionInputSelector = `[placeholder="${defaultMessages.components.fileSearch.byExtensionInput}"]`
|
||||
const fileMatchButtonSelector = '[data-testid=file-match-button]'
|
||||
|
||||
// File Match Indicator
|
||||
// X out of Y Matches when searching the file list
|
||||
const fileMatchIndicatorSelector = '[data-testid=file-match-indicator]'
|
||||
|
||||
// No Results
|
||||
const noResultsSelector = '[data-testid=no-results]'
|
||||
const noResultsClearButtonSelector = '[data-testid=no-results-clear]'
|
||||
|
||||
// Loading
|
||||
const loadingSelector = '[data-testid=loading]'
|
||||
|
||||
describe('<FileChooser />', () => {
|
||||
it('renders files in a list', () => {
|
||||
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
|
||||
.get(fileRowSelector)
|
||||
.should('have.length', numFiles)
|
||||
})
|
||||
|
||||
it('can search by file name', () => {
|
||||
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
|
||||
.get(filenameInputSelector)
|
||||
.type('random string', { delay: 0 })
|
||||
})
|
||||
|
||||
it('filters the files by file name', () => {
|
||||
const query = 'base'
|
||||
const expectedMatches = allFiles.filter((f) => f.relative.toLowerCase().includes(query)).length
|
||||
|
||||
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
|
||||
.get(filenameInputSelector).type(query, { delay: 0 })
|
||||
.get(fileRowSelector)
|
||||
.should('have.length.at.least', expectedMatches)
|
||||
.and('have.length.below', allFiles.length)
|
||||
})
|
||||
|
||||
describe('matches', () => {
|
||||
it('displays the total number of file matches', () => {
|
||||
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
|
||||
.get(fileMatchIndicatorSelector).should('contain.text', `${allFiles.length } Matches`)
|
||||
})
|
||||
|
||||
it('handles pluralization', () => {
|
||||
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={[allFiles[0]]} />))
|
||||
.get(fileMatchIndicatorSelector).should('contain.text', `${1 } Match`)
|
||||
})
|
||||
|
||||
it('handles no matches', () => {
|
||||
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={[]} />))
|
||||
.get(fileMatchIndicatorSelector).should('contain.text', 'No Matches')
|
||||
})
|
||||
|
||||
it('updates the number of files found out of the total number available', () => {
|
||||
const query = 'base'
|
||||
|
||||
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
|
||||
.get(filenameInputSelector)
|
||||
|
||||
// Type some stuff in that will at least partially match components
|
||||
.type(query, { delay: 0 })
|
||||
.get(fileRowSelector)
|
||||
.then(($rows) => {
|
||||
// Figure out how many files were actually matched and make sure
|
||||
// that they're out of the total files passed in
|
||||
cy.get(fileMatchIndicatorSelector)
|
||||
.should('contain.text', `${$rows.length} of ${allFiles.length} Matches`)
|
||||
|
||||
// Get back to an empty state where all files are shown
|
||||
.get(filenameInputSelector).clear()
|
||||
.get(fileMatchIndicatorSelector).should('contain.text', `${allFiles.length } Matches`)
|
||||
|
||||
// Go to the no matches state
|
||||
.get(filenameInputSelector).type(nonExistentFileName, { delay: 0 })
|
||||
.get(fileMatchIndicatorSelector).should('contain.text', 'No Matches')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('no results', () => {
|
||||
it('does not show no results when there are files', () => {
|
||||
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
|
||||
.get(noResultsSelector).should('not.exist')
|
||||
})
|
||||
|
||||
it('shows no results when there are no files', () => {
|
||||
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={[]} />))
|
||||
.get(noResultsSelector).should('be.visible')
|
||||
})
|
||||
|
||||
describe('when searching the file pattern', () => {
|
||||
it('handles "no results" with the right text', () => {
|
||||
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
|
||||
.get(filenameInputSelector)
|
||||
.type(nonExistentFileName, { delay: 0 })
|
||||
.get(noResultsSelector)
|
||||
.should('contain.text', defaultMessages.noResults.defaultMessage)
|
||||
.and('contain.text', nonExistentFileName)
|
||||
})
|
||||
|
||||
it('clears the file search but leaves the extension search alone', () => {
|
||||
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
|
||||
.get(fileMatchButtonSelector)
|
||||
.should('have.text', extensionPattern)
|
||||
.click()
|
||||
|
||||
// Add some text to the extension to make it different than the
|
||||
// initial one
|
||||
.get(extensionInputSelector).clear().type(existentExtensionPattern, { delay: 0 })
|
||||
|
||||
// Add some text to the file name search to make it different than the
|
||||
// initial one
|
||||
.get(filenameInputSelector).type(nonExistentFileName, { delay: 0 })
|
||||
|
||||
// Clear
|
||||
.get(noResultsClearButtonSelector).click()
|
||||
|
||||
// Extension pattern is still the same
|
||||
.get(extensionInputSelector).should('have.value', existentExtensionPattern)
|
||||
|
||||
// And the file name input has been is cleared
|
||||
.get(filenameInputSelector).should('have.value', '')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when searching the extension', () => {
|
||||
it('handles "no results" with the right text', () => {
|
||||
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={[]} />))
|
||||
.get(noResultsSelector)
|
||||
.findByText(defaultMessages.components.fileSearch.noMatchesForExtension)
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
it('renders the extension inside of the no results view', () => {
|
||||
cy.mount(() => (<FileChooser
|
||||
extensionPattern={extensionPattern}
|
||||
files={[]} />))
|
||||
.get(noResultsSelector)
|
||||
.findByText(extensionPattern).should('be.visible')
|
||||
})
|
||||
|
||||
it('resets the extension to the initial extension', () => {
|
||||
cy.mount(() => (<FileChooser
|
||||
extensionPattern={extensionPattern}
|
||||
files={[]} />))
|
||||
.get(fileMatchButtonSelector)
|
||||
.should('have.text', extensionPattern)
|
||||
.click()
|
||||
|
||||
.get(extensionInputSelector).clear().type(existentExtensionPattern, { delay: 0 })
|
||||
.get(noResultsClearButtonSelector).click()
|
||||
.get(extensionInputSelector).should('have.value', extensionPattern)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading', () => {
|
||||
it('is not loading by default', () => {
|
||||
cy.mount(() => (<FileChooser
|
||||
extensionPattern={extensionPattern}
|
||||
files={[]} />))
|
||||
.get(loadingSelector).should('not.be.visible')
|
||||
})
|
||||
|
||||
it('toggles a reactive loading indicator', () => {
|
||||
// Use a button to toggle "loading" state externally
|
||||
const loading = ref(true)
|
||||
const buttonSelector = '[data-testid=toggle-button]'
|
||||
|
||||
cy.mount(() => (<div>
|
||||
<button data-testid="toggle-button" onClick={() => loading.value = !loading.value}>Toggle Loading</button>
|
||||
<FileChooser files={allFiles} loading={loading.value} extensionPattern={extensionPattern} /></div>))
|
||||
.get(loadingSelector).should('be.visible')
|
||||
.get(buttonSelector).click()
|
||||
.get(loadingSelector).should('not.be.visible')
|
||||
.get(buttonSelector).click()
|
||||
.get(loadingSelector).should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
it('debounces the file extension input event', () => {
|
||||
const onUpdateExtensionSpy = cy.spy().as('onUpdateExtensionSpy')
|
||||
const spies = {
|
||||
'onUpdate:extensionPattern': onUpdateExtensionSpy,
|
||||
}
|
||||
|
||||
const newExtension = existentExtensionPattern
|
||||
|
||||
cy.mount(() => (
|
||||
<FileChooser
|
||||
{...spies}
|
||||
extensionPattern={extensionPattern}
|
||||
files={allFiles}></FileChooser>))
|
||||
// Make sure the extension is in the button
|
||||
.get(fileMatchButtonSelector)
|
||||
.should('have.text', extensionPattern)
|
||||
.click()
|
||||
|
||||
// Clear the extension
|
||||
.get(extensionInputSelector)
|
||||
.clear()
|
||||
|
||||
// Make sure we emit the update event with "clear"
|
||||
.get('@onUpdateExtensionSpy').should('have.been.calledOnceWith', '')
|
||||
|
||||
// Type a new extension in
|
||||
.get(extensionInputSelector)
|
||||
.type(newExtension, { delay: 0 })
|
||||
|
||||
// Validate it's in there
|
||||
.get(extensionInputSelector)
|
||||
.should('have.value', newExtension)
|
||||
|
||||
// debounce should cause this to hit
|
||||
.get('@onUpdateExtensionSpy').should('not.have.been.calledWith', newExtension)
|
||||
|
||||
// once the debounce is resolved, this will hit
|
||||
.get('@onUpdateExtensionSpy').should('have.been.calledWith', newExtension)
|
||||
})
|
||||
|
||||
it('fires a selectFile event when a file is clicked on', () => {
|
||||
const onSelectFileSpy = cy.spy().as('onSelectFileSpy')
|
||||
|
||||
cy.mount(() => (
|
||||
<FileChooser
|
||||
onSelectFile={onSelectFileSpy}
|
||||
extensionPattern={extensionPattern}
|
||||
files={allFiles}></FileChooser>))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
<!--
|
||||
/**==============================================
|
||||
* * FileChooser.vue
|
||||
* Filter a list of files by a mix of glob pattern
|
||||
* and a search string that includes a file's relative
|
||||
* path.
|
||||
*
|
||||
* Features to note: debouncing + loading
|
||||
* ==============================================
|
||||
* * Debouncing
|
||||
*
|
||||
* ? To prevent calling update too frequently we debounce
|
||||
* ? both the "No Results" UI and the update event
|
||||
*
|
||||
* ============================================**/
|
||||
-->
|
||||
|
||||
<template>
|
||||
<CreateSpecModalBody
|
||||
variant="bare"
|
||||
class="relative bg-white px-24px flex
|
||||
flex-col"
|
||||
>
|
||||
<FileMatch
|
||||
ref="fileMatchRef"
|
||||
v-model:pattern="filePathSearch"
|
||||
v-model:extensionPattern="localExtensionPattern"
|
||||
class="sticky z-10 top-0px pt-24px pb-12px bg-white"
|
||||
:matches="matches"
|
||||
>
|
||||
<template
|
||||
v-if="loading"
|
||||
#matches
|
||||
>
|
||||
<i-cy-loading_x16 class="h-24px w-24px mr-10px animate-spin" />
|
||||
</template>
|
||||
</FileMatch>
|
||||
|
||||
<div
|
||||
v-show="loading"
|
||||
data-testid="loading"
|
||||
>
|
||||
<!-- TODO(ryan): Get mocks for a loading state here -->
|
||||
Loading
|
||||
</div>
|
||||
<FileList
|
||||
v-show="!loading"
|
||||
:style="{ paddingTop: `${fileMatchHeight + 36}px` }"
|
||||
class="absolute left-24px right-24px"
|
||||
:files="filteredFiles"
|
||||
:search="filePathSearch"
|
||||
@selectFile="selectFile"
|
||||
>
|
||||
<template #no-results>
|
||||
<NoResults
|
||||
empty-search
|
||||
:search="noResults.search"
|
||||
:message="noResults.message"
|
||||
@clear="noResults.clear"
|
||||
/>
|
||||
</template>
|
||||
</FileList>
|
||||
</CreateSpecModalBody>
|
||||
<div class="rounded-b w-full h-24px" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, Ref, ComputedRef } from 'vue'
|
||||
import { debouncedWatch, useDebounce, useElementSize } from '@vueuse/core'
|
||||
import { useI18n } from '@cy/i18n'
|
||||
|
||||
import Button from '@cy/components/Button.vue'
|
||||
import NoResults from '@cy/components/NoResults.vue'
|
||||
import CreateSpecModalBody from './CreateSpecModalBody.vue'
|
||||
import FileList from './FileList.vue'
|
||||
import FileMatch from '../../components/FileMatch.vue'
|
||||
import { gql } from '@urql/core'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
files: any[]
|
||||
extensionPattern: string,
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
|
||||
loading: false,
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
gql`
|
||||
fragment FileChooser on FileParts {
|
||||
relative
|
||||
id
|
||||
...FileListItem
|
||||
}
|
||||
`
|
||||
|
||||
const emits = defineEmits<{
|
||||
(eventName: 'selectFile', value: File)
|
||||
(eventName: 'update:extensionPattern', value: string)
|
||||
}>()
|
||||
|
||||
// eslint-disable-next-line
|
||||
const initialExtensionPattern = props.extensionPattern
|
||||
const localExtensionPattern = ref(props.extensionPattern)
|
||||
const filePathSearch = ref('')
|
||||
|
||||
const selectFile = (file) => {
|
||||
emits('selectFile', file)
|
||||
}
|
||||
|
||||
///*------- Styling -------*/
|
||||
|
||||
// For the FileList to be searchable without jumping to the top of the
|
||||
// FileMatcher's top when focused, we need to use some manual layout.
|
||||
// If we concede on position: sticky for the FileMatcher or on making the
|
||||
// FileList accessible, we could position the FileList relatively.
|
||||
const fileMatchRef = ref(null)
|
||||
const { height: fileMatchHeight } = useElementSize(fileMatchRef)
|
||||
|
||||
///*------- Debounce -------*/
|
||||
|
||||
const debounce = 200
|
||||
const debouncedExtensionPattern = useDebounce(localExtensionPattern, debounce)
|
||||
|
||||
debouncedWatch(localExtensionPattern, (value) => {
|
||||
emits('update:extensionPattern', value)
|
||||
}, { debounce })
|
||||
|
||||
///*------- Searching files -------*/
|
||||
|
||||
const filteredFiles = computed(() => {
|
||||
return props.files?.filter((file) => {
|
||||
return file.relative.toLowerCase().includes(filePathSearch.value.toLowerCase())
|
||||
})
|
||||
})
|
||||
|
||||
///*------- Matches Indicator -------*/
|
||||
|
||||
const matches = computed(() => {
|
||||
return {
|
||||
total: props.files.length,
|
||||
found: filteredFiles.value.length,
|
||||
}
|
||||
})
|
||||
|
||||
///*------- No Results Options -------*/
|
||||
|
||||
const noResults = computed(() => {
|
||||
return {
|
||||
search: filePathSearch.value || debouncedExtensionPattern.value,
|
||||
message: filePathSearch.value ? t('noResults.defaultMessage') : t('components.fileSearch.noMatchesForExtension'),
|
||||
clear: filePathSearch.value ?
|
||||
() => filePathSearch.value = '' :
|
||||
() => localExtensionPattern.value = initialExtensionPattern,
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
import FileList from './FileList.vue'
|
||||
import { randomComponents } from '@packages/frontend-shared/cypress/support/mock-graphql/testStubSpecs'
|
||||
import { ref, Ref } from 'vue'
|
||||
|
||||
const difficultFile = {
|
||||
baseName: '[...all].vue',
|
||||
fileExtension: '.vue',
|
||||
}
|
||||
|
||||
const noResultsSlot = () => <div data-testid="no-results">No Results</div>
|
||||
const noResultsSelector = '[data-testid=no-results]'
|
||||
const fileRowSelector = '[data-testid=file-list-row]'
|
||||
|
||||
const allFiles = randomComponents(10, 'FileParts')
|
||||
|
||||
allFiles[1] = { ...allFiles[1], ...difficultFile }
|
||||
describe('<FileList />', { viewportHeight: 500, viewportWidth: 400 }, () => {
|
||||
describe('with files', () => {
|
||||
const files = allFiles
|
||||
|
||||
beforeEach(() => {
|
||||
const selectFileSpy = cy.spy().as('selectFileSpy')
|
||||
|
||||
cy.mount(() => (<div class="resize overflow-auto min-w-300px m-2">
|
||||
<FileList onSelectFile={selectFileSpy} files={files} />
|
||||
</div>))
|
||||
})
|
||||
|
||||
it('renders all of the files passed in', () => {
|
||||
cy.get(fileRowSelector)
|
||||
.should('have.length', 10)
|
||||
})
|
||||
|
||||
it('emits a selectFile event when clicking on a row', () => {
|
||||
cy.get(fileRowSelector)
|
||||
.first()
|
||||
.click()
|
||||
.get('@selectFileSpy')
|
||||
.should('have.been.calledWith', files[0])
|
||||
})
|
||||
|
||||
it('correctly formats a difficult file', () => {
|
||||
cy.get('body').contains('[...all]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('without files', () => {
|
||||
it('shows the no results slot', () => {
|
||||
const files: Ref<typeof allFiles> = ref([])
|
||||
let idx = 0
|
||||
|
||||
cy.mount(() => (<div>
|
||||
<button data-testid="add-file"
|
||||
onClick={() => {
|
||||
files.value.push(allFiles[idx]); idx++
|
||||
}}>
|
||||
Add File
|
||||
</button>
|
||||
|
||||
<FileList
|
||||
v-slots={{ 'no-results': noResultsSlot }}
|
||||
files={files.value} />
|
||||
|
||||
</div>))
|
||||
.get(noResultsSelector).should('be.visible')
|
||||
.get('[data-testid=add-file]')
|
||||
.click()
|
||||
.get(noResultsSelector).should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div
|
||||
class="h-full outline-none"
|
||||
tabindex="0"
|
||||
>
|
||||
<ul v-if="files.length">
|
||||
<!-- TODO(jess): up arrow and down arrow navigation -->
|
||||
<li
|
||||
v-for="file in files"
|
||||
:key="file?.id"
|
||||
class="cursor-pointer group children:h-40px children:py-8px last:py-0 flex gap-8px
|
||||
items-center text-16px last:border-none border-b-1 border-b-gray-50 leading-normal"
|
||||
data-testid="file-list-row"
|
||||
@click="$emit('selectFile', file)"
|
||||
>
|
||||
<i-cy-document-blank_x16 class="icon-light-gray-50 icon-dark-gray-300 min-w-16px min-h-16px" />
|
||||
<div class="h-full inline-flex whitespace-nowrap items-center overflow-hidden">
|
||||
<span
|
||||
class="font-medium text-indigo-500
|
||||
group-hocus:text-indigo-500"
|
||||
>{{
|
||||
name(file) }}</span>
|
||||
<span class="font-light text-gray-400">{{ file.fileExtension }}</span>
|
||||
<span class="ml-20px font-light group-hocus:opacity-60 truncate opacity-0 duration-200 text-gray-600">{{ file.relative }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
v-else
|
||||
class="h-full flex items-center justify-center"
|
||||
>
|
||||
<slot name="no-results" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { gql } from '@urql/vue'
|
||||
import type { FileListItemFragment } from '../../generated/graphql'
|
||||
|
||||
gql`
|
||||
fragment FileListItem on FileParts {
|
||||
id
|
||||
relative
|
||||
fileName
|
||||
fileExtension
|
||||
baseName
|
||||
}
|
||||
`
|
||||
|
||||
const props = defineProps<{
|
||||
files: FileListItemFragment[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(eventName: 'selectFile', value: FileListItemFragment)
|
||||
}>()
|
||||
|
||||
// [...all].vue returns as [ when using the normal fileName
|
||||
const name = (file) => {
|
||||
return file.baseName.replace(file.fileExtension, '')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
import GeneratorSuccess from './GeneratorSuccess.vue'
|
||||
import { randomComponents } from '@packages/frontend-shared/cypress/support/mock-graphql/testStubSpecs'
|
||||
|
||||
const targetSelector = '[data-testid=file-row]'
|
||||
const spec = randomComponents(1, 'FileParts')[0]
|
||||
const content = `
|
||||
import ${spec.baseName} from './${spec.baseName}'
|
||||
import { mount } from '@cypress/react'
|
||||
|
||||
describe('<${spec.baseName} />', () => {
|
||||
it('renders', () => {
|
||||
// https://docs.cypress.io
|
||||
mount(<${spec.baseName} />)
|
||||
})
|
||||
})
|
||||
`.trim()
|
||||
|
||||
describe('<GeneratorSuccess />', () => {
|
||||
it('renders the relative file path', () => {
|
||||
cy.mount(() => (<GeneratorSuccess file={{ spec, content }} />))
|
||||
.get('body')
|
||||
.contains(spec.relative)
|
||||
})
|
||||
|
||||
it('can be expanded to show the content', () => {
|
||||
cy.mount(() => (<GeneratorSuccess file={{ spec, content }} />))
|
||||
.get(targetSelector)
|
||||
.click()
|
||||
.get('code .line')
|
||||
.should('have.length', content.split('\n').length)
|
||||
.wait(200) // just to show off the animation
|
||||
.get(targetSelector)
|
||||
.click()
|
||||
})
|
||||
|
||||
it('handles really long file names and really long content', () => {
|
||||
const relative = 'src/components/deep/nested/path/to/deep/nested/path/to/component/MyComponent/MyComponent.spec.tsx'
|
||||
const longContent = Object.keys(Array.from(Array(100))).map((c) => content).join('\n')
|
||||
|
||||
cy.mount(() => (<GeneratorSuccess file={{ spec: { ...spec, relative }, content: longContent }} />))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<Collapsible
|
||||
class="outline-none m-4px rounded overflow-hidden"
|
||||
>
|
||||
<template #target="{open}">
|
||||
<div
|
||||
class="gap-8px px-24px py-16px flex items-center cursor-pointer"
|
||||
data-testid="file-row"
|
||||
>
|
||||
<i-cy-status-passed-solid_x16 />
|
||||
<span class="text-jade-500 font-medium truncate">{{ file.spec.relative }}</span>
|
||||
<div class="justify-self-end flex-grow flex justify-end">
|
||||
<i-cy-chevron-down-small_x16
|
||||
:class="{ 'rotate-180': open }"
|
||||
class="transform transition duration-150 max-w-16px icon-dark-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="rounded border-1 mx-24px mb-24px">
|
||||
<ShikiHighlight
|
||||
:code="file.content"
|
||||
line-numbers
|
||||
lang="js"
|
||||
:copy-on-click="false"
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ShikiHighlight from '@cy/components/ShikiHighlight.vue'
|
||||
import Collapsible from '@cy/components/Collapsible.vue'
|
||||
import { gql } from '@urql/core'
|
||||
import type { GeneratorSuccessFragment } from '../../generated/graphql'
|
||||
|
||||
gql`
|
||||
fragment GeneratorSuccess on GeneratedSpec {
|
||||
content
|
||||
spec {
|
||||
fileName
|
||||
fileExtension
|
||||
baseName
|
||||
relative
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
defineProps<{
|
||||
file: GeneratorSuccessFragment
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { TestingTypeEnum } from '../../generated/graphql'
|
||||
|
||||
export const filters = {
|
||||
matchesCT: (testingType: TestingTypeEnum) => testingType === 'component',
|
||||
matchesE2E: (testingType: TestingTypeEnum) => testingType === 'e2e',
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { filters } from '../GeneratorsCommon'
|
||||
import ComponentGeneratorStepOne from './ComponentGeneratorStepOne.vue'
|
||||
import type { SpecGenerator } from '../types'
|
||||
import ComponentGeneratorCard from './ComponentGeneratorCard.vue'
|
||||
|
||||
export const ComponentGenerator: SpecGenerator = {
|
||||
card: ComponentGeneratorCard,
|
||||
entry: ComponentGeneratorStepOne,
|
||||
disabled: () => { },
|
||||
matches: filters.matchesCT,
|
||||
id: 'component-generator',
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<CreateSpecCard
|
||||
:disabled="disabled"
|
||||
:header="t('createSpec.component.importFromComponent.header')"
|
||||
:description="t('createSpec.component.importFromComponent.description')"
|
||||
:icon="DocumentCodeIcon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import CreateSpecCard from '../../CreateSpecCard.vue'
|
||||
import { useI18n } from '@cy/i18n'
|
||||
import DocumentCodeIcon from '~icons/cy/document-code_x48'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="flex-grow">
|
||||
<div
|
||||
v-if="mutation.fetching.value"
|
||||
class="inline-flex items-center w-full justify-center mt-48px"
|
||||
>
|
||||
<i-cy-loading_x16 class="animate-spin w-48px h-48px mr-12px" />
|
||||
<p class="text-lg">
|
||||
Loading
|
||||
</p>
|
||||
</div>
|
||||
<FileChooser
|
||||
v-else-if="!result"
|
||||
v-model:extensionPattern="extensionPattern"
|
||||
:files="allFiles"
|
||||
:loading="query.fetching.value"
|
||||
@selectFile="makeSpec"
|
||||
/>
|
||||
<GeneratorSuccess
|
||||
v-else
|
||||
:file="result"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-if="!result"
|
||||
class="rounded-b w-full h-24px"
|
||||
/>
|
||||
<StandardModalFooter
|
||||
v-else
|
||||
class="h-72px flex gap-16px"
|
||||
>
|
||||
<router-link
|
||||
class="outline-none"
|
||||
:to="{ path: 'runner', query: { file: result.spec.relative } }
|
||||
"
|
||||
>
|
||||
<Button
|
||||
:prefix-icon="TestResultsIcon"
|
||||
prefix-icon-class="w-16px h-16px icon-dark-white"
|
||||
>
|
||||
{{ t('createSpec.successPage.runSpecButton') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
:prefix-icon="PlusButtonIcon"
|
||||
prefix-icon-class="w-16px h-16px icon-dark-gray-500"
|
||||
variant="outline"
|
||||
@click="$emit('restart')"
|
||||
>
|
||||
{{ t('createSpec.successPage.createAnotherSpecButton') }}
|
||||
</Button>
|
||||
</StandardModalFooter>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModels, whenever } from '@vueuse/core'
|
||||
import { useI18n } from '@cy/i18n'
|
||||
import FileChooser from '../FileChooser.vue'
|
||||
import GeneratorSuccess from '../GeneratorSuccess.vue'
|
||||
import { computed, ref, ComputedRef } from 'vue'
|
||||
import { gql, useQuery, useMutation } from '@urql/vue'
|
||||
import { ComponentGeneratorStepOneDocument, ComponentGeneratorStepOne_GenerateSpecDocument } from '../../../generated/graphql'
|
||||
import StandardModalFooter from '@cy/components/StandardModalFooter.vue'
|
||||
import Button from '@cy/components/Button.vue'
|
||||
import PlusButtonIcon from '~icons/cy/add-large_x16.svg'
|
||||
import TestResultsIcon from '~icons/cy/test-results_x24.svg'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string,
|
||||
codeGenGlob: any
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'update:title', value: string): void,
|
||||
(event: 'update:description', value: string): void
|
||||
(event: 'restart'): void
|
||||
}>()
|
||||
|
||||
const { title } = useVModels(props, emits)
|
||||
|
||||
title.value = t('createSpec.component.importFromComponent.chooseAComponentHeader')
|
||||
|
||||
gql`
|
||||
fragment ComponentGeneratorStepOne_codeGenGlob on Project {
|
||||
codeGenGlob(type: component)
|
||||
}
|
||||
`
|
||||
|
||||
gql`
|
||||
query ComponentGeneratorStepOne($glob: String!) {
|
||||
app {
|
||||
activeProject {
|
||||
id
|
||||
codeGenCandidates(glob: $glob) {
|
||||
id
|
||||
name
|
||||
fileName
|
||||
fileExtension
|
||||
absolute
|
||||
relative
|
||||
baseName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
gql`
|
||||
mutation ComponentGeneratorStepOne_generateSpec($codeGenCandidate: String!, $type: CodeGenType!) {
|
||||
generateSpecFromSource(codeGenCandidate: $codeGenCandidate, type: $type) {
|
||||
...GeneratorSuccess
|
||||
}
|
||||
}`
|
||||
|
||||
const mutation = useMutation(ComponentGeneratorStepOne_GenerateSpecDocument)
|
||||
|
||||
const extensionPattern = ref(props.codeGenGlob)
|
||||
|
||||
const glob = computed(() => {
|
||||
return `**/${extensionPattern.value}`
|
||||
})
|
||||
|
||||
const query = useQuery({
|
||||
query: ComponentGeneratorStepOneDocument,
|
||||
|
||||
// @ts-ignore
|
||||
variables: { glob },
|
||||
})
|
||||
|
||||
const allFiles = computed((): any => {
|
||||
if (query.data.value?.app?.activeProject?.codeGenCandidates) {
|
||||
return query.data.value.app?.activeProject?.codeGenCandidates
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
const result = ref()
|
||||
|
||||
whenever(result, () => {
|
||||
title.value = t('createSpec.successPage.header')
|
||||
})
|
||||
|
||||
const makeSpec = async (file) => {
|
||||
const { data } = await mutation.executeMutation({
|
||||
codeGenCandidate: file.relative,
|
||||
type: 'component',
|
||||
})
|
||||
|
||||
result.value = data?.generateSpecFromSource
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ComponentGenerator'
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { SpecGenerator } from '../types'
|
||||
import { filters } from '../GeneratorsCommon'
|
||||
import EmptyGeneratorCard from './EmptyGeneratorCard.vue'
|
||||
|
||||
export const EmptyGenerator: SpecGenerator = {
|
||||
card: EmptyGeneratorCard,
|
||||
entry: EmptyGeneratorCard,
|
||||
matches: filters.matchesE2E,
|
||||
disabled: () => { },
|
||||
id: 'empty-generator',
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<CreateSpecCard
|
||||
:disabled="disabled"
|
||||
:header="t('createSpec.e2e.importEmptySpec.header')"
|
||||
:description="t('createSpec.e2e.importEmptySpec.description')"
|
||||
:icon="DocumentCodeIcon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import CreateSpecCard from '../../CreateSpecCard.vue'
|
||||
import { useI18n } from '@cy/i18n'
|
||||
import DocumentCodeIcon from '~icons/cy/document-code_x48'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export * from './EmptyGenerator'
|
||||
@@ -0,0 +1,27 @@
|
||||
import { keyBy } from 'lodash'
|
||||
import type { SpecGenerator, GeneratorId } from './types'
|
||||
import { ComponentGenerator } from './component'
|
||||
import { StoryGenerator } from './story'
|
||||
import { ScaffoldGenerator } from './scaffold'
|
||||
import { EmptyGenerator } from './empty'
|
||||
|
||||
export * from './types'
|
||||
|
||||
export * from './GeneratorsCommon'
|
||||
|
||||
export * from './component'
|
||||
|
||||
export * from './story'
|
||||
|
||||
export * from './scaffold'
|
||||
|
||||
export * from './empty'
|
||||
|
||||
export const generatorList: SpecGenerator[] = [
|
||||
ComponentGenerator,
|
||||
StoryGenerator,
|
||||
ScaffoldGenerator,
|
||||
EmptyGenerator,
|
||||
]
|
||||
|
||||
export const generators = keyBy(generatorList, 'id') as Record<GeneratorId, SpecGenerator>
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { SpecGenerator } from '../types'
|
||||
import { filters } from '../GeneratorsCommon'
|
||||
import ScaffoldGeneratorCard from './ScaffoldGeneratorCard.vue'
|
||||
|
||||
export const ScaffoldGenerator: SpecGenerator = {
|
||||
card: ScaffoldGeneratorCard,
|
||||
entry: ScaffoldGeneratorCard,
|
||||
matches: filters.matchesE2E,
|
||||
disabled: () => { },
|
||||
id: 'scaffold-generator',
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<CreateSpecCard
|
||||
:disabled="disabled"
|
||||
:header="t('createSpec.e2e.importFromScaffold.header')"
|
||||
:description="t('createSpec.e2e.importFromScaffold.description')"
|
||||
:icon="BoxOpenIcon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import CreateSpecCard from '../../CreateSpecCard.vue'
|
||||
import { useI18n } from '@cy/i18n'
|
||||
import BoxOpenIcon from '~icons/cy/box-open_x48'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ScaffoldGenerator'
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { SpecGenerator } from '../types'
|
||||
import { filters } from '../GeneratorsCommon'
|
||||
import StoryGeneratorStepOne from './StoryGeneratorStepOne.vue'
|
||||
import StoryGeneratorCard from './StoryGeneratorCard.vue'
|
||||
|
||||
export const StoryGenerator: SpecGenerator = {
|
||||
card: StoryGeneratorCard,
|
||||
entry: StoryGeneratorStepOne,
|
||||
matches: filters.matchesCT,
|
||||
disabled: (activeProject) => {
|
||||
if (activeProject) {
|
||||
return !activeProject.storybook
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
id: 'story-generator',
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<CreateSpecCard
|
||||
:disabled="disabled"
|
||||
:header="t('createSpec.component.importFromStory.header')"
|
||||
:description="disabled ?
|
||||
t('createSpec.component.importFromStory.notSetupDescription') :
|
||||
t('createSpec.component.importFromStory.description')"
|
||||
:icon="BookCodeIcon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import CreateSpecCard from '../../CreateSpecCard.vue'
|
||||
import { useI18n } from '@cy/i18n'
|
||||
import BookCodeIcon from '~icons/cy/book-code_x48'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="flex-grow">
|
||||
<div
|
||||
v-if="mutation.fetching.value"
|
||||
class="inline-flex items-center w-full justify-center mt-48px"
|
||||
>
|
||||
<i-cy-loading_x16 class="animate-spin w-48px h-48px mr-12px" />
|
||||
<p class="text-lg">
|
||||
Loading
|
||||
</p>
|
||||
</div>
|
||||
<FileChooser
|
||||
v-else-if="!result"
|
||||
v-model:extensionPattern="extensionPattern"
|
||||
:files="allFiles || []"
|
||||
:loading="query.fetching.value"
|
||||
@selectFile="makeSpec"
|
||||
/>
|
||||
<GeneratorSuccess
|
||||
v-else
|
||||
:file="result"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-if="!result"
|
||||
class="rounded-b w-full h-24px"
|
||||
/>
|
||||
<StandardModalFooter
|
||||
v-else
|
||||
class="h-72px flex gap-16px"
|
||||
>
|
||||
<router-link
|
||||
class="outline-none"
|
||||
:to="{ path: 'runner', query: { file: result.spec.relative } }
|
||||
"
|
||||
>
|
||||
<Button
|
||||
:prefix-icon="TestResultsIcon"
|
||||
prefix-icon-class="w-16px h-16px icon-dark-white"
|
||||
>
|
||||
{{ t('createSpec.successPage.runSpecButton') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
:prefix-icon="PlusButtonIcon"
|
||||
prefix-icon-class="w-16px h-16px icon-dark-gray-500"
|
||||
variant="outline"
|
||||
@click="$emit('restart')"
|
||||
>
|
||||
{{ t('createSpec.successPage.createAnotherSpecButton') }}
|
||||
</Button>
|
||||
</StandardModalFooter>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModels, whenever } from '@vueuse/core'
|
||||
import { useI18n } from '@cy/i18n'
|
||||
import FileChooser from '../FileChooser.vue'
|
||||
import GeneratorSuccess from '../GeneratorSuccess.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { gql, useQuery, useMutation } from '@urql/vue'
|
||||
import { StoryGeneratorStepOneDocument, StoryGeneratorStepOne_GenerateSpecDocument } from '../../../generated/graphql'
|
||||
import StandardModalFooter from '@cy/components/StandardModalFooter.vue'
|
||||
import Button from '@cy/components/Button.vue'
|
||||
import PlusButtonIcon from '~icons/cy/add-large_x16.svg'
|
||||
import TestResultsIcon from '~icons/cy/test-results_x24.svg'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string,
|
||||
codeGenGlob: any
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'update:title', value: string): void,
|
||||
(event: 'update:description', value: string): void
|
||||
(event: 'restart'): void
|
||||
}>()
|
||||
|
||||
const { title } = useVModels(props, emits)
|
||||
|
||||
title.value = t('createSpec.component.importFromStory.header')
|
||||
|
||||
gql`
|
||||
fragment StoryGeneratorStepOne_codeGenGlob on Project {
|
||||
codeGenGlob(type: story)
|
||||
}
|
||||
`
|
||||
|
||||
gql`
|
||||
query StoryGeneratorStepOne($glob: String!) {
|
||||
app {
|
||||
activeProject {
|
||||
id
|
||||
codeGenCandidates(glob: $glob) {
|
||||
id
|
||||
name
|
||||
fileName
|
||||
fileExtension
|
||||
absolute
|
||||
relative
|
||||
baseName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
gql`
|
||||
mutation StoryGeneratorStepOne_generateSpec($codeGenCandidate: String!, $type: CodeGenType!) {
|
||||
generateSpecFromSource(codeGenCandidate: $codeGenCandidate, type: $type) {
|
||||
...GeneratorSuccess
|
||||
}
|
||||
}`
|
||||
|
||||
const mutation = useMutation(StoryGeneratorStepOne_GenerateSpecDocument)
|
||||
|
||||
const extensionPattern = ref(props.codeGenGlob)
|
||||
|
||||
const glob = computed(() => {
|
||||
return `**/${extensionPattern.value}`
|
||||
})
|
||||
|
||||
const query = useQuery({
|
||||
query: StoryGeneratorStepOneDocument,
|
||||
|
||||
// @ts-ignore
|
||||
variables: { glob },
|
||||
})
|
||||
|
||||
const allFiles = computed(() => {
|
||||
if (query.data.value?.app?.activeProject?.codeGenCandidates) {
|
||||
return query.data.value.app?.activeProject?.codeGenCandidates
|
||||
}
|
||||
|
||||
return []
|
||||
}) as any
|
||||
|
||||
const result = ref()
|
||||
|
||||
whenever(result, () => {
|
||||
title.value = t('createSpec.successPage.header')
|
||||
})
|
||||
|
||||
const makeSpec = async (file) => {
|
||||
const { data } = await mutation.executeMutation({
|
||||
codeGenCandidate: file.relative,
|
||||
type: 'component',
|
||||
})
|
||||
|
||||
result.value = data?.generateSpecFromSource
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export * from './StoryGenerator'
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export type GeneratorId = 'component-generator' | 'empty-generator' | 'scaffold-generator' | 'story-generator'
|
||||
|
||||
export interface SpecGenerator {
|
||||
card: Component
|
||||
entry: Component
|
||||
matches: (testingType) => boolean
|
||||
disabled: (activeProject?) => boolean | void
|
||||
id: GeneratorId
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { createPinia as _createPinia } from 'pinia'
|
||||
// Pinia client-side storage
|
||||
export * from './main-store'
|
||||
|
||||
export * from './modals'
|
||||
|
||||
export * from './specs-store'
|
||||
|
||||
export * from './aut-store'
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export type ModalTypes = 'createSpec'
|
||||
|
||||
export interface ModalStore {
|
||||
activeModalId: ModalTypes | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Modals Store allows parts of the application to open and close modals in
|
||||
* unrelated parts of the application. This is useful for resolving
|
||||
* conflicts between timing-based and interaction-based triggers (like the
|
||||
* Growth Modals and the Create Spec modals)
|
||||
*/
|
||||
export const useModalStore = defineStore({
|
||||
id: 'modal',
|
||||
state: (): ModalStore => ({ activeModalId: null }),
|
||||
actions: {
|
||||
open (id: ModalTypes) {
|
||||
this.activeModalId = id
|
||||
},
|
||||
close () {
|
||||
this.activeModalId = null
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -65,7 +65,6 @@ export class ProjectActions {
|
||||
title,
|
||||
ctPluginsInitialized: false,
|
||||
e2ePluginsInitialized: false,
|
||||
generatedSpec: null,
|
||||
config: null,
|
||||
configChildProcess: null,
|
||||
})
|
||||
@@ -371,7 +370,7 @@ export class ProjectActions {
|
||||
specFileExtension,
|
||||
})
|
||||
|
||||
project.generatedSpec = {
|
||||
return {
|
||||
spec,
|
||||
content: newSpec.content,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BUNDLERS, FoundBrowser, FoundSpec, FullConfig, GeneratedSpec, Preferences } from '@packages/types'
|
||||
import { BUNDLERS, FoundBrowser, FoundSpec, FullConfig, Preferences } from '@packages/types'
|
||||
import type { NexusGenEnums, TestingTypeEnum } from '@packages/graphql/src/gen/nxs.gen'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import type { ChildProcess } from 'child_process'
|
||||
@@ -35,7 +35,6 @@ export interface ActiveProjectShape extends ProjectShape {
|
||||
config: Promise<FullConfig> | null
|
||||
configChildProcess: ConfigChildProcessShape | null
|
||||
preferences?: Preferences| null
|
||||
generatedSpec: GeneratedSpec | null
|
||||
}
|
||||
|
||||
export interface AppDataShape {
|
||||
|
||||
@@ -33,7 +33,7 @@ export class FileDataSource {
|
||||
}) as Promise<Result>
|
||||
}
|
||||
|
||||
normalizeFileToFileParts (options: CreateFileParts): SpecFile {
|
||||
normalizeFileToFileParts (options: CreateFileParts): SpecFile & { fileExtension: string } {
|
||||
const parsed = path.parse(options.absolute)
|
||||
|
||||
return {
|
||||
@@ -42,6 +42,7 @@ export class FileDataSource {
|
||||
relative: path.relative(options.projectRoot, options.absolute),
|
||||
baseName: parsed.base,
|
||||
fileName: parsed.base.split('.')[0] || '',
|
||||
fileExtension: parsed.ext,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,11 +55,11 @@ export class FileDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
async getFilesByGlob (glob: string | string[], globOptions?: GlobbyOptions) {
|
||||
async getFilesByGlob (cwd: string, glob: string | string[], globOptions?: GlobbyOptions) {
|
||||
const globs = (Array.isArray(glob) ? glob : [glob]).concat('!**/node_modules/**')
|
||||
|
||||
try {
|
||||
const files = await globby(globs, { onlyFiles: true, absolute: true, ...globOptions })
|
||||
const files = await globby(globs, { onlyFiles: true, absolute: true, cwd, ...globOptions })
|
||||
|
||||
return files
|
||||
} catch (e) {
|
||||
|
||||
@@ -5,8 +5,13 @@ import type { GraphQLSchema } from 'graphql'
|
||||
import type { DataContextShell } from '../DataContextShell'
|
||||
import type * as allOperations from '../gen/all-operations.gen'
|
||||
|
||||
// Filter out non-Query shapes
|
||||
type AllQueries<T> = {
|
||||
[K in keyof T]: K
|
||||
[K in keyof T]: T[K] extends { __resultType?: infer U }
|
||||
? U extends { __typename?: 'Query' }
|
||||
? K
|
||||
: never
|
||||
: never
|
||||
}[keyof T]
|
||||
|
||||
export class GraphQLDataSource {
|
||||
@@ -28,6 +33,10 @@ export class GraphQLDataSource {
|
||||
// Late require'd to avoid erroring if codegen hasn't run (for legacy Cypress workflow)
|
||||
const allQueries = (this._allQueries ??= require('../gen/all-operations.gen'))
|
||||
|
||||
if (!allQueries[document]) {
|
||||
throw new Error(`Trying to execute unknown operation ${document}, needs to be one of: [${Object.keys(allQueries).join(', ')}]`)
|
||||
}
|
||||
|
||||
return this._urqlClient.query(allQueries[document], variables).toPromise()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export class HtmlDataSource {
|
||||
|
||||
await Promise.all([
|
||||
graphql.executeQuery('AppQueryDocument', {}),
|
||||
graphql.executeQuery('NewSpec_NewSpecQueryDocument', {}),
|
||||
graphql.executeQuery('SettingsDocument', {}),
|
||||
graphql.executeQuery('SpecsPageContainerDocument', {}),
|
||||
graphql.executeQuery('HeaderBar_HeaderBarQueryDocument', {}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CodeGenType, SpecType } from '@packages/graphql/src/gen/nxs.gen'
|
||||
import { FrontendFramework, FRONTEND_FRAMEWORKS, ResolvedFromConfig, RESOLVED_FROM, SpecFile, STORYBOOK_GLOB } from '@packages/types'
|
||||
import { FrontendFramework, FRONTEND_FRAMEWORKS, ResolvedFromConfig, RESOLVED_FROM, SpecFileWithExtension, STORYBOOK_GLOB } from '@packages/types'
|
||||
import { scanFSForAvailableDependency } from 'create-cypress-tests'
|
||||
import path from 'path'
|
||||
|
||||
@@ -146,7 +146,7 @@ export class ProjectDataSource {
|
||||
return framework?.glob ?? looseComponentGlob
|
||||
}
|
||||
|
||||
async getCodeGenCandidates (glob: string): Promise<SpecFile[]> {
|
||||
async getCodeGenCandidates (glob: string): Promise<SpecFileWithExtension[]> {
|
||||
// Storybook can support multiple globs, so show default one while
|
||||
// still fetching all stories
|
||||
if (glob === STORYBOOK_GLOB) {
|
||||
@@ -161,7 +161,7 @@ export class ProjectDataSource {
|
||||
|
||||
const config = await this.ctx.project.getConfig(project.projectRoot)
|
||||
|
||||
const codeGenCandidates = await this.ctx.file.getFilesByGlob(glob)
|
||||
const codeGenCandidates = await this.ctx.file.getFilesByGlob(config.projectRoot || process.cwd(), glob)
|
||||
|
||||
return codeGenCandidates.map(
|
||||
(file) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SpecFile, StorybookInfo } from '@packages/types'
|
||||
import type { SpecFileWithExtension, StorybookInfo } from '@packages/types'
|
||||
import * as path from 'path'
|
||||
import type { DataContext } from '..'
|
||||
|
||||
@@ -20,7 +20,7 @@ export class StorybookDataSource {
|
||||
return this.storybookInfoLoader.load(this.ctx.activeProject?.projectRoot)
|
||||
}
|
||||
|
||||
async getStories (): Promise<SpecFile[]> {
|
||||
async getStories (): Promise<SpecFileWithExtension[]> {
|
||||
const project = this.ctx.activeProject
|
||||
|
||||
if (!project) {
|
||||
@@ -35,7 +35,7 @@ export class StorybookDataSource {
|
||||
|
||||
const config = await this.ctx.project.getConfig(project.projectRoot)
|
||||
const normalizedGlobs = storybook.storyGlobs.map((glob) => path.join(storybook.storybookRoot, glob))
|
||||
const files = await this.ctx.file.getFilesByGlob(normalizedGlobs)
|
||||
const files = await this.ctx.file.getFilesByGlob(project.projectRoot, normalizedGlobs)
|
||||
|
||||
// Don't currently support mdx
|
||||
return files.reduce((acc, file) => {
|
||||
@@ -50,7 +50,7 @@ export class StorybookDataSource {
|
||||
})
|
||||
|
||||
return [...acc, spec]
|
||||
}, [] as SpecFile[])
|
||||
}, [] as SpecFileWithExtension[])
|
||||
}
|
||||
|
||||
private storybookInfoLoader = this.ctx.loader<string, StorybookInfo | null>((projectRoots) => this.batchStorybookInfo(projectRoots))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -18,12 +18,14 @@ interface RuleConfig {
|
||||
|
||||
const makeRuleForClass = ({ name, theme, weight, color }: RuleConfig) => {
|
||||
const resolvedColor = color ? color : weight ? theme?.(`colors.${name}.${weight}`) : theme?.(`colors.${name}`)
|
||||
let [lightKey, darkKey] = [`.icon-light-${name}`, `.icon-dark-${name}`]
|
||||
let [lightKey, darkKey, secondaryLightKey, secondaryDarkKey] = [`.icon-light-${name}`, `.icon-dark-${name}`, `.icon-light-secondary-${name}`, `.icon-dark-secondary-${name}`]
|
||||
|
||||
// transparent, black, and white
|
||||
if (weight) {
|
||||
lightKey += `-${weight}`
|
||||
darkKey += `-${weight}`
|
||||
secondaryLightKey += `-${weight}`
|
||||
secondaryDarkKey += `-${weight}`
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -40,18 +42,49 @@ const makeRuleForClass = ({ name, theme, weight, color }: RuleConfig) => {
|
||||
'> *[fill][stroke].icon-light-fill': {
|
||||
fill: resolvedColor,
|
||||
},
|
||||
'> *[fill][stroke].icon-light-stroke': {
|
||||
stroke: resolvedColor,
|
||||
},
|
||||
},
|
||||
[darkKey]: {
|
||||
'> *[fill].icon-dark-fill': {
|
||||
[secondaryLightKey]: {
|
||||
'> *[fill].icon-light-secondary': {
|
||||
fill: resolvedColor,
|
||||
},
|
||||
'> *[stroke].icon-light-secondary': {
|
||||
stroke: resolvedColor,
|
||||
},
|
||||
'> *[fill][stroke].icon-light-secondary-fill': {
|
||||
fill: resolvedColor,
|
||||
},
|
||||
'> *[fill][stroke].icon-light-secondary-stroke': {
|
||||
stroke: resolvedColor,
|
||||
},
|
||||
},
|
||||
[darkKey]: {
|
||||
'> *[fill].icon-dark': {
|
||||
fill: resolvedColor,
|
||||
},
|
||||
'> *[stroke].icon-dark': {
|
||||
stroke: resolvedColor,
|
||||
},
|
||||
'> *[fill][stroke].icon-dark-stroke': {
|
||||
'> *[fill].icon-dark-fill': {
|
||||
fill: resolvedColor,
|
||||
},
|
||||
'> *[fill].icon-dark-stroke': {
|
||||
stroke: resolvedColor,
|
||||
},
|
||||
},
|
||||
[secondaryDarkKey]: {
|
||||
'> *[fill].icon-dark-secondary': {
|
||||
fill: resolvedColor,
|
||||
},
|
||||
'> *[stroke].icon-dark-secondary': {
|
||||
stroke: resolvedColor,
|
||||
},
|
||||
'> *[fill][stroke].icon-dark-secondary-fill': {
|
||||
fill: resolvedColor,
|
||||
},
|
||||
'> *[fill][stroke].icon-dark-secondary-stroke': {
|
||||
stroke: resolvedColor,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Our default hover/focus behavior for buttons and cards is an indigo
|
||||
* border that hovers in and out
|
||||
* Animations not working? Border looking a little off? Make sure that you
|
||||
* have border-1 set on the non-hocus state. If you *don't* want a gray
|
||||
* outline with that, do border-transparent for the non-hocus state.
|
||||
*/
|
||||
|
||||
const focusDefault = 'outline-none focus:border focus:border-indigo-300 focus:ring-2 focus:ring-indigo-100 focus:outline-transparent transition duration-150 disabled:hover:ring-0 disabled:hover:border-0'
|
||||
|
||||
// Usually what you want
|
||||
const hocusDefault = focusDefault.replace(/focus:/g, 'hocus:')
|
||||
|
||||
// If you want to control a parent card when an inner button is in focus
|
||||
const focusWithinDefault = focusDefault.replace(/focus:/g, 'focus-within:')
|
||||
|
||||
export const shortcuts = {
|
||||
'hocus-default': hocusDefault,
|
||||
'focus-within-default': focusWithinDefault,
|
||||
'focus-default': focusDefault,
|
||||
'hocus-link-default': 'focus:outline-transparent hocus:underline',
|
||||
}
|
||||
@@ -69,12 +69,12 @@ export const stubMutation: MaybeResolver<Mutation> = {
|
||||
setProjectPreferences (source, args, ctx) {
|
||||
return ctx.app
|
||||
},
|
||||
codeGenSpec (source, args, ctx) {
|
||||
generateSpecFromSource (source, args, ctx) {
|
||||
if (!ctx.app.activeProject) {
|
||||
throw Error('Cannot set currentSpec without active project')
|
||||
}
|
||||
|
||||
ctx.app.activeProject.generatedSpec = {
|
||||
return {
|
||||
__typename: 'GeneratedSpec',
|
||||
spec: {
|
||||
__typename: 'FileParts',
|
||||
@@ -84,12 +84,11 @@ export const stubMutation: MaybeResolver<Mutation> = {
|
||||
name: 'Basic',
|
||||
fileName: 'Basic.spec.tsx',
|
||||
baseName: 'Basic',
|
||||
fileExtension: 'tsx',
|
||||
},
|
||||
content: `it('should do stuff', () => {})`,
|
||||
id: 'U3BlYzovVXNlcnMvbGFjaGxhbi9jb2RlL3dvcmsvY3lwcmVzczUvcGFja2FnZXMvYXBwL3NyYy9CYXNpYy5zcGVjLnRzeA==',
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
reconfigureProject (src, args, ctx) {
|
||||
return true
|
||||
|
||||
@@ -28,14 +28,13 @@ export const createTestProject = (title: string): CodegenTypeMap['Project'] => {
|
||||
},
|
||||
__typename: 'SpecConnection' as const,
|
||||
edges: [
|
||||
...randomComponents(200).map((c) => {
|
||||
...randomComponents(200, 'Spec').map((c) => {
|
||||
return {
|
||||
__typename: 'SpecEdge' as const,
|
||||
cursor: 'eoifjew',
|
||||
node: {
|
||||
id: c.absolute,
|
||||
__typename: 'Spec' as const,
|
||||
...c,
|
||||
id: c.absolute,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as JustMyLuck from 'just-my-luck'
|
||||
import faker from 'faker'
|
||||
import { template, keys, reduce, templateSettings } from 'lodash'
|
||||
import { template, keys, reduce, templateSettings, TemplateExecutor } from 'lodash'
|
||||
import combineProperties from 'combine-properties'
|
||||
import type { FoundSpec } from '@packages/types'
|
||||
|
||||
templateSettings.interpolate = /{{([\s\S]+?)}}/g
|
||||
|
||||
@@ -88,11 +87,17 @@ const nameTemplates = {
|
||||
medium1: template(`{{prefix}}{{modifier}}{{component}}`),
|
||||
medium2: template(`{{prefix}}{{component}}{{component2}}`),
|
||||
short: template(`{{prefix}}{{component}}`),
|
||||
}
|
||||
} as const
|
||||
|
||||
const prefixes = ['I', 'V', 'Cy', null]
|
||||
|
||||
export const componentNameGenerator = (options: { template: any, omit?: any, overrides?: any } = { template: nameTemplates.medium1, omit: [], overrides: {} }) => {
|
||||
interface ComponentNameGeneratorOptions {
|
||||
template: TemplateExecutor
|
||||
omit?: string[]
|
||||
overrides?: object
|
||||
}
|
||||
|
||||
export const componentNameGenerator = (options: ComponentNameGeneratorOptions = { template: nameTemplates.medium1, omit: [], overrides: {} }) => {
|
||||
const withoutValues = reduce(options.omit, (acc, v) => {
|
||||
acc[v] = null
|
||||
|
||||
@@ -126,11 +131,11 @@ const allRandomComponents = combineProperties({
|
||||
directory: keys(directories),
|
||||
})
|
||||
|
||||
export const randomComponents = (n = 200): FoundSpec[] => {
|
||||
export const randomComponents = <T extends 'Spec' | 'FileParts'>(n = 200, baseTypename: T) => {
|
||||
return faker.random.arrayElements(allRandomComponents, n).map((d: ReturnType<typeof combineProperties>) => {
|
||||
const componentName = componentNameGenerator({
|
||||
overrides: d,
|
||||
template: faker.random.objectElement(nameTemplates),
|
||||
template: faker.random.objectElement<TemplateExecutor>(nameTemplates),
|
||||
})
|
||||
|
||||
const name = `${componentName}${d.specPattern}${d.fileExtension}`
|
||||
@@ -142,13 +147,12 @@ export const randomComponents = (n = 200): FoundSpec[] => {
|
||||
name: `${componentName}${d.specPattern}`,
|
||||
specFileExtension: `${d.specPattern}${d.fileExtension}`,
|
||||
fileExtension: d.fileExtension,
|
||||
specType: 'component',
|
||||
specType: 'component' as const,
|
||||
fileName: componentName,
|
||||
__typename: 'Spec',
|
||||
|
||||
__typename: baseTypename,
|
||||
id: faker.datatype.uuid(),
|
||||
gitInfo: {
|
||||
__typename: 'GitInfo',
|
||||
__typename: 'GitInfo' as const,
|
||||
id: faker.datatype.uuid(),
|
||||
author: faker.internet.userName(),
|
||||
lastModifiedTimestamp: new Date(faker.random.arrayElement([
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M40 5H10C8.89543 5 8 5.89543 8 7V35H10H39H40V5Z" fill="#D0D2E0" class="icon-light"/>
|
||||
<path d="M40 43C40 43 39.3905 43 39 43M8 35V41C8 42.1046 8.89543 43 10 43V43M8 35V7C8 5.89543 8.89543 5 10 5V5M8 35H10M10 5H40V35H39M10 5V35M10 35V43M10 35H39M10 43C10 43 27.6748 43 39 43M39 35C39 35 38 39 39 43M21 17L18 20L21 23M29 17L32 20L29 23M23.5 25.5L26.5 14.5" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 39H22V47L18 45.2857L14 47V39Z" fill="#69D3A7" class="icon-light-secondary"/>
|
||||
<path d="M14 39V38H13V39H14ZM22 39H23V38H22V39ZM22 47L21.6061
|
||||
47.9191C21.915 48.0516 22.2699 48.0199 22.5505 47.8348C22.8311 47.6498 23
|
||||
47.3361 23 47H22ZM14 47H13C13 47.3361 13.1689 47.6498 13.4495
|
||||
47.8348C13.7301 48.0199 14.085 48.0516 14.3939 47.9191L14 47ZM18
|
||||
45.2857L18.3939 44.3666C18.1424 44.2588 17.8576 44.2588 17.6061 44.3666L18
|
||||
45.2857ZM14 40H22V38H14V40ZM21 39V47H23V39H21ZM15 47V39H13V47H15ZM22.3939
|
||||
46.0809L18.3939 44.3666L17.6061 46.2049L21.6061 47.9191L22.3939
|
||||
46.0809ZM17.6061 44.3666L13.6061 46.0809L14.3939 47.9191L18.3939
|
||||
46.2049L17.6061 44.3666Z" class="icon-dark-secondary" fill="#00814D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M43 25.1622L28.5 33L24
|
||||
28.4872V47L43 37.2564V25.1622Z" fill="#D0D2E0" class="icon-light"/>
|
||||
<path d="M24 9L5 18.7436L24 28.4872L43 18.7436L24 9Z" fill="#D0D2E0" class="icon-light"/>
|
||||
<path d="M24 47L5 37.2564V25.1622M24 47L43 37.2564V25.1622M24 47V28.4872M24
|
||||
9L5 18.7436M24 9L43 18.7436M24 9L28 5L47 14.5L43 18.7436M24 9L20 5L1 14.5L5
|
||||
18.7436M5 18.7436L24 28.4872M5 18.7436L1 23L5 25.1622M43 18.7436L24
|
||||
28.4872M43 18.7436L47 23L43 25.1622M24 28.4872L19.5 33L5 25.1622M24
|
||||
28.4872L28.5 33L43 25.1622" stroke="#1B1E2E" class="icon-dark" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18 7L19.1314 9.86863L22 11L19.1314 12.1314L18 15L16.8686
|
||||
12.1314L14 11L16.8686 9.86863L18 7Z" fill="#1FA971" stroke="#00814D" class="icon-dark-secondary-stroke icon-light-secondary-fill" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M27 1L27.8485 3.15147L30 4L27.8485 4.84853L27 7L26.1515 4.84853L24
|
||||
4L26.1515 3.15147L27 1Z" fill="#1FA971" stroke="#00814D" class="icon-dark-secondary-stroke icon-light-secondary-fill" stroke-width="2"
|
||||
stroke-linejoin="round"/>
|
||||
<path d="M28 12L29.4142 15.5858L33 17L29.4142 18.4142L28 22L26.5858
|
||||
18.4142L23 17L26.5858 15.5858L28 12Z" fill="#1FA971" stroke="#00814D"
|
||||
class="icon-dark-secondary-stroke icon-light-secondary-fill" stroke-width="2" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 16.4116V7.58843C21 7.40548 20.95 7.22977 20.8599 7.07789L12
|
||||
12V21.8561C12.1673 21.8561 12.3346 21.8141 12.4856 21.7302L20.4856
|
||||
17.2858C20.8031 17.1094 21 16.7748 21 16.4116Z" fill="#D0D2E0" class="icon-light"/>
|
||||
<path d="M3.51436 17.2858L11.5144 21.7302C11.6654 21.8141 11.8327 21.8561
|
||||
12 21.8561V12L3.14014 7.07789C3.04997 7.22977 3 7.40548 3 7.58843V16.4116C3
|
||||
16.7748 3.19689 17.1094 3.51436 17.2858Z" fill="#D0D2E0" class="icon-light"/>
|
||||
<path d="M11.5144 2.26982L3.51436 6.71427C3.35682 6.80179 3.22897 6.92828
|
||||
3.14014 7.07789L12 12L20.8599 7.07789C20.771 6.92828 20.6432 6.80179
|
||||
20.4856 6.71427L12.4856 2.26982C12.1836 2.10203 11.8164 2.10203 11.5144
|
||||
2.26982Z" fill="#D0D2E0" class="icon-light"/>
|
||||
<path d="M12 12L20.8599 7.07789M12 12V21.8561M12 12L3.14014 7.07789M20.8599
|
||||
7.07789C20.95 7.22977 21 7.40548 21 7.58843V16.4116C21 16.7748 20.8031
|
||||
17.1094 20.4856 17.2858L12.4856 21.7302C12.3346 21.8141 12.1673 21.8561 12
|
||||
21.8561M20.8599 7.07789C20.771 6.92828 20.6432 6.80179 20.4856
|
||||
6.71427L12.4856 2.26982C12.1836 2.10203 11.8164 2.10203 11.5144
|
||||
2.26982L3.51436 6.71427C3.35682 6.80179 3.22897 6.92828 3.14014 7.07789M12
|
||||
21.8561C11.8327 21.8561 11.6654 21.8141 11.5144 21.7302L3.51436
|
||||
17.2858C3.19689 17.1094 3 16.7748 3 16.4116V7.58843C3 7.40548 3.04997
|
||||
7.22977 3.14014 7.07789" stroke="#1B1E2E" stroke-width="2"
|
||||
stroke-linecap="round" class="icon-dark" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 9L8 12L11 9M5 4L8 7L11 4" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 242 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 5L8 11L14 5" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 6L8 10L12 6" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 2.5L4 5.5L7 2.5" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 11L4 8L7 5M12 11L9 8L12 5" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.9999 13.9999L4.99994 7.99988L10.9999 1.99988" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 263 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 4L6 8L10 12" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 230 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.5 1L2.5 4L5.5 7" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 11L12 8L9 5M4 11L7 8L4 5" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 242 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.99994 13.9999L10.9999 7.99988L4.99994 1.99988" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 263 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 12L10 8L6 4" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.5 1L5.5 4L2.5 7" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 7L8 4L11 7M5 12L8 9L11 12" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 11L8 5L14 11" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 230 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.9999 9.99988L7.99991 5.99988L3.99991 9.99988" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 263 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 5.5L4 2.5L7 5.5" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1,18 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M32
|
||||
5.17964V13H39.8204C39.7221 12.784 39.5852 12.5852 39.4142 12.4142L32.5858
|
||||
5.58578C32.4148 5.41477 32.216 5.27792 32 5.17964Z" fill="#D0D2E0" class="icon-light"/>
|
||||
<path d="M20 21L17 24L20 27M28 21L31 24L28 27M22.5 29.5L25.5 18.5M32
|
||||
5.17964C31.7423 5.06237 31.4602 5 31.1716 5H9C8.44772 5 8 5.44772 8 6V42C8
|
||||
42.5523 8.44772 43 9 43H39C39.5523 43 40 42.5523 40 42V13.8284C40 13.5398
|
||||
39.9376 13.2577 39.8204 13M32 5.17964C32.216 5.27792 32.4148 5.41477
|
||||
32.5858 5.58578L39.4142 12.4142C39.5852 12.5852 39.7221 12.784 39.8204
|
||||
13M32 5.17964V13H39.8204" stroke="#1B1E2E" class="icon-dark"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M43 40C43 43.3137 40.3137 46 37 46C33.6863 46 31 43.3137 31 40C31
|
||||
36.6863 33.6863 34 37 34C40.3137 34 43 36.6863 43 40Z" class="icon-light-secondary" fill="#A3E7CB"/>
|
||||
<path d="M37 38V40M37 42V40M37 40H39H35M43 40C43 43.3137 40.3137 46 37
|
||||
46C33.6863 46 31 43.3137 31 40C31 36.6863 33.6863 34 37 34C40.3137 34 43
|
||||
36.6863 43 40Z" stroke="#00814D" class="icon-dark-secondary"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="7" fill="#1FA971" class="icon-dark"/>
|
||||
<path d="M10 6L8 10L6 8" stroke="white" stroke-width="2"
|
||||
stroke-linecap="round" class="icon-light" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 291 B |
@@ -2,7 +2,7 @@
|
||||
<button
|
||||
v-if="!href"
|
||||
style="width: fit-content"
|
||||
class="flex select-none items-center border rounded gap-8px outline-none"
|
||||
class="flex items-center leading-tight border rounded gap-8px outline-none"
|
||||
:class="classes"
|
||||
>
|
||||
<ButtonInternals>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
</div>
|
||||
<input
|
||||
v-model="localValue"
|
||||
:style="style"
|
||||
:type="type"
|
||||
:spellcheck="false"
|
||||
:class="_inputClasses"
|
||||
@@ -79,6 +80,7 @@ const props = withDefaults(defineProps<{
|
||||
suffixIcon?: FunctionalComponent<SVGAttributes, {}>
|
||||
suffixIconClasses?: string | string[] | Record<string, string>
|
||||
modelValue?: string
|
||||
style?: string
|
||||
}>(), {
|
||||
type: 'text',
|
||||
modelValue: '',
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="search"
|
||||
v-if="search || emptySearch"
|
||||
data-testid="no-results"
|
||||
class="text-center"
|
||||
>
|
||||
<NoResultsIllustration
|
||||
class="mx-auto mt-80px"
|
||||
class="mx-auto"
|
||||
alt
|
||||
/>
|
||||
<p class="text-gray-500 text-18px leading-normal">
|
||||
{{ message || t('noResults.defaultMessage') }}:
|
||||
<span class="block truncate text-purple-500">{{ search }}</span>
|
||||
{{ message || t('noResults.defaultMessage') }}
|
||||
<span
|
||||
v-if="search"
|
||||
class="block truncate text-purple-500"
|
||||
>{{ search }}</span>
|
||||
</p>
|
||||
<Button
|
||||
data-testid="no-results-clear"
|
||||
class="mx-auto mt-20px"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
@@ -20,7 +25,7 @@
|
||||
<template #prefix>
|
||||
<i-cy-delete_x12 class="w-12px icon-dark-gray-400" />
|
||||
</template>
|
||||
Clear Search
|
||||
{{ t('noResults.clearSearch') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,6 +38,7 @@ import NoResultsIllustration from '../assets/illustrations/no-results.svg'
|
||||
defineProps<{
|
||||
search?: string,
|
||||
message?: string
|
||||
emptySearch?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -54,14 +54,15 @@ shikiWrapperClasses computed property.
|
||||
@click="copyOnClick ? () => copyCode() : () => {}"
|
||||
v-html="highlightedCode"
|
||||
/>
|
||||
<CopyButton
|
||||
v-if="copyButton"
|
||||
<Button
|
||||
v-if="copyOnClick"
|
||||
variant="outline"
|
||||
tabindex="-1"
|
||||
class="absolute bottom-8px right-8px"
|
||||
:text="code"
|
||||
no-icon
|
||||
/>
|
||||
@click="copyCode"
|
||||
>
|
||||
{{ copied ? t('clipboard.copied') : t('clipboard.copy') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -98,10 +99,11 @@ export { highlighter, inheritAttrs }
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeMount, ref } from 'vue'
|
||||
import CopyButton from '@cy/components/CopyButton.vue'
|
||||
import Button from '@cy/components/Button.vue'
|
||||
// eslint-disable-next-line no-duplicate-imports
|
||||
import type { Ref } from 'vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useI18n } from '@cy/i18n'
|
||||
|
||||
const highlighterInitialized = ref(false)
|
||||
|
||||
@@ -128,6 +130,8 @@ const props = withDefaults(defineProps<{
|
||||
class: undefined,
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const resolvedLang = computed(() => {
|
||||
if (props.lang === 'javascript' || props.lang === 'js' || props.lang === 'jsx') return 'jsx'
|
||||
|
||||
@@ -165,7 +169,7 @@ avoid colliding with styles elsewhere in the document.
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
$offset: 1em;
|
||||
$offset: 1.1em;
|
||||
|
||||
.inline:deep(.shiki) {
|
||||
@apply py-1 px-2 bg-gray-50 text-gray-500 inline-block;
|
||||
@@ -181,13 +185,16 @@ $offset: 1em;
|
||||
}
|
||||
|
||||
&.line-numbers:deep(.shiki) {
|
||||
.line {
|
||||
}
|
||||
|
||||
code {
|
||||
counter-reset: step;
|
||||
counter-increment: step 0;
|
||||
|
||||
// Keep bg-gray-50 synced with the box-shadows.
|
||||
.line::before, .line:first-child::before {
|
||||
@apply bg-gray-50 text-gray-500 min-w-40px inline-block text-right px-8px mr-16px sticky;
|
||||
@apply bg-gray-50 text-gray-500 min-w-40px inline-block text-right px-8px mr-16px sticky group-hocus:bg-red-500;
|
||||
left: 0px !important;
|
||||
content: counter(step);
|
||||
counter-increment: step;
|
||||
@@ -196,12 +203,19 @@ $offset: 1em;
|
||||
// Adding padding to the top and bottom of these children adds unwanted
|
||||
// line-height to the line. This doesn't look good when you select the text.
|
||||
// To avoid this, we use box-shadows and offset the parent container.
|
||||
.line:first-child::before {
|
||||
box-shadow: 0 (-1 * $offset) theme('colors.gray.50') !important;
|
||||
:not(.line:only-child) {
|
||||
&:first-child:before {
|
||||
box-shadow: 0 (-1 * $offset) theme('colors.gray.50') !important;
|
||||
}
|
||||
|
||||
&:last-child::before {
|
||||
box-shadow: 0 $offset theme('colors.gray.50') !important;
|
||||
}
|
||||
}
|
||||
|
||||
.line:last-child::before {
|
||||
box-shadow: 0 $offset theme('colors.gray.50') !important;
|
||||
// If this rule was used for all of them, the gray would overlap between rows.
|
||||
.line:only-child::before {
|
||||
box-shadow: (-1 * $offset) 0 0 $offset theme('colors.gray.50') !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="localValue"
|
||||
:open="modelValue"
|
||||
class="fixed inset-0 z-10 overflow-y-auto"
|
||||
@close="clickOutside && setIsOpen(false)"
|
||||
>
|
||||
@@ -11,31 +11,19 @@
|
||||
>
|
||||
<DialogOverlay class="fixed inset-0 bg-gray-800 opacity-90" />
|
||||
</slot>
|
||||
|
||||
<div
|
||||
class="relative mx-auto bg-white rounded"
|
||||
:class="props.class || ''"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 flex items-center justify-between bg-white rounded-t border-b-1px border-b-gray-100 min-h-56px px-24px z-1"
|
||||
<StandardModalHeader
|
||||
:help-link="helpLink"
|
||||
:help-text="helpText"
|
||||
@close="setIsOpen(false)"
|
||||
>
|
||||
<DialogTitle class="text-gray-900 text-18px">
|
||||
<slot name="title" /> <span class="inline-block border-t border-t-gray-100 w-32px h-6px mx-8px" /> <a
|
||||
:href="helpLink"
|
||||
target="_blank"
|
||||
class="text-indigo-500 group outline-transparent text-16px"
|
||||
>
|
||||
<span class="group-hocus:underline">{{ helpText }}</span>
|
||||
<i-cy-circle-bg-question-mark_x16 class="relative inline-block icon-dark-indigo-500 icon-light-indigo-100 -top-2px ml-8px" /></a>
|
||||
</DialogTitle>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="border-transparent rounded-full p-5px border-1 hover:border-indigo-300 hocus-default"
|
||||
@click="setIsOpen(false)"
|
||||
>
|
||||
<i-cy-delete_x12 class="icon-dark-gray-400 w-12px h-12px" />
|
||||
</button>
|
||||
</div>
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</StandardModalHeader>
|
||||
|
||||
<DialogDescription
|
||||
v-if="$slots.description"
|
||||
@@ -43,14 +31,12 @@
|
||||
>
|
||||
<slot name="description" />
|
||||
</DialogDescription>
|
||||
<slot />
|
||||
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="border-t-1px px-24px py-16px bg-gray-50"
|
||||
>
|
||||
<StandardModalBody :variant="variant">
|
||||
<slot />
|
||||
</StandardModalBody>
|
||||
<StandardModalFooter v-if="$slots.footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</StandardModalFooter>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
@@ -62,13 +48,13 @@ export const inheritAttrs = false
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@cy/i18n'
|
||||
import { computed } from 'vue'
|
||||
import { useModelWrapper } from '@packages/frontend-shared/src/composables'
|
||||
import StandardModalHeader from './StandardModalHeader.vue'
|
||||
import StandardModalBody from './StandardModalBody.vue'
|
||||
import StandardModalFooter from './StandardModalFooter.vue'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogOverlay,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@headlessui/vue'
|
||||
|
||||
@@ -81,6 +67,8 @@ const props = withDefaults(defineProps<{
|
||||
helpLink?: string
|
||||
helpText?: string
|
||||
clickOutside?: boolean
|
||||
variant?: 'bare'
|
||||
title?: string,
|
||||
class?: string | string[] | Record<string, any>
|
||||
}>(), {
|
||||
clickOutside: true,
|
||||
@@ -88,14 +76,12 @@ const props = withDefaults(defineProps<{
|
||||
helpText: 'Need help?',
|
||||
helpLink: 'https://docs.cypress.io',
|
||||
class: undefined,
|
||||
variant: undefined,
|
||||
title: '',
|
||||
})
|
||||
|
||||
const localValue = useModelWrapper(props, emit, 'modelValue')
|
||||
|
||||
const setIsOpen = (val: boolean) => {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div :class="variant === 'bare' ? 'p-0' : 'p-24px' ">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
defineProps<{
|
||||
variant?: 'bare'
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="rounded-b border-t-1px px-24px py-16px bg-gray-50">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div
|
||||
class="sticky top-0 flex items-center justify-between bg-white rounded-t border-b-1px border-b-gray-100 min-h-56px px-24px z-1"
|
||||
>
|
||||
<DialogTitle class="text-gray-900 text-18px">
|
||||
<slot /> <span class="inline-block border-t border-t-gray-100 w-32px h-6px mx-8px" /> <a
|
||||
:href="helpLink"
|
||||
target="_blank"
|
||||
class="text-indigo-500 group outline-transparent text-16px"
|
||||
>
|
||||
<span class="group-hocus:underline">{{ helpText }}</span>
|
||||
<i-cy-circle-bg-question-mark_x16 class="relative inline-block icon-dark-indigo-500 icon-light-indigo-100 -top-2px ml-8px" /></a>
|
||||
</DialogTitle>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="border-transparent rounded-full p-5px border-1 hover:border-indigo-300 hocus-default"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i-cy-delete_x12 class="icon-dark-gray-400 w-12px h-12px m-4px" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DialogTitle } from '@headlessui/vue'
|
||||
|
||||
defineProps<{
|
||||
helpLink?: string,
|
||||
helpText?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(eventName: 'close'): void
|
||||
}>()
|
||||
</script>
|
||||