feat: rework eslint configuration tab to display all rules (#2008)

* feat(cli-plugin-eslint): Add configuration tab

* chore(vue-cli-plugin-eslint): Add missing translations, extract UI descriptors

* fix(vue-cli-plugin-eslint): Import rules from CWD

* feat(vue-cli-plugin-eslint): Add uncategorized category, add tests

* test(vue-cli-plugin-eslint): Add missing tests

* fix(vue-cli-plugin-eslint): Escape html from rules' descriptions

* chore(vue-cli): Add --ci flag in tests

* chore(vue-cli-plugin-eslint): Remove snapshot
This commit is contained in:
Michał Sajnóg
2018-08-01 11:40:29 +02:00
committed by Guillaume Chau
parent 495c25a019
commit 7953d8300f
6 changed files with 425 additions and 270 deletions
@@ -0,0 +1,155 @@
const configDescriptor = require('../ui/configDescriptor')
const { getEslintConfigName, getDefaultValue, getEslintPrompts } = configDescriptor
describe('getEslintConfigName', () => {
describe('for "extend" of string type', () => {
it('returns null if it doesn\'t extend vue plugin config', () => {
expect(getEslintConfigName({
extends: 'eslint:recommended'
})).toBe(null)
})
it('returns vue config used', () => {
expect(getEslintConfigName({
extends: 'plugin:vue/recommended'
})).toBe('plugin:vue/recommended')
})
})
describe('for "extend" of array type', () => {
it('returns null if it doesn\'t extend vue plugin config', () => {
expect(getEslintConfigName({
extends: ['eslint:recommended', 'standard']
})).toBe(null)
})
it('returns vue config used', () => {
expect(getEslintConfigName({
extends: ['eslint:recommended', 'plugin:vue/recommended']
})).toBe('plugin:vue/recommended')
})
})
})
describe('getDefaultValue', () => {
const getResult = (config, ruleCategory) => {
const rule = {
meta: {
docs: {
category: ruleCategory
}
}
}
const data = {
eslint: {
extends: config
}
}
return getDefaultValue(rule, data)
}
it('returns "ERROR" value if the rule belongs to the selected configuration', () => {
expect(getResult('plugin:vue/base', 'base')).toBe('error')
expect(getResult('plugin:vue/essential', 'base')).toBe('error')
expect(getResult('plugin:vue/essential', 'essential')).toBe('error')
expect(getResult('plugin:vue/strongly-recommended', 'base')).toBe('error')
expect(getResult('plugin:vue/strongly-recommended', 'essential')).toBe('error')
expect(getResult('plugin:vue/strongly-recommended', 'strongly-recommended')).toBe('error')
expect(getResult('plugin:vue/recommended', 'base')).toBe('error')
expect(getResult('plugin:vue/recommended', 'essential')).toBe('error')
expect(getResult('plugin:vue/recommended', 'strongly-recommended')).toBe('error')
expect(getResult('plugin:vue/recommended', 'recommended')).toBe('error')
})
it('returns "OFF" value if the rule doesn\'t belong to the selected configuration', () => {
expect(getResult('plugin:vue/base', 'essential')).toBe('off')
expect(getResult('plugin:vue/base', 'strongly-recommended')).toBe('off')
expect(getResult('plugin:vue/base', 'recommended')).toBe('off')
expect(getResult('plugin:vue/essential', 'strongly-recommended')).toBe('off')
expect(getResult('plugin:vue/essential', 'recommended')).toBe('off')
expect(getResult('plugin:vue/strongly-recommended', 'recommended')).toBe('off')
expect(getResult('plugin:vue/base', undefined)).toBe('off')
expect(getResult('plugin:vue/essential', undefined)).toBe('off')
expect(getResult('plugin:vue/strongly-recommended', undefined)).toBe('off')
expect(getResult('plugin:vue/recommended', undefined)).toBe('off')
})
})
describe('getEslintPrompts', () => {
// project configuration
const data = {
eslint: {
extends: 'plugin:vue/recommended',
rules: {
'vue/lorem': ['error', ['asd']], // custom setting
'vue/ipsum': 'warning'
}
}
}
// all rules
const rules = {
'lorem': {
meta: {
docs: {
category: undefined,
description: 'Lorem description',
url: 'http://test.com/lorem'
}
}
},
'ipsum': {
meta: {
docs: {
category: 'recommended',
description: 'Ipsum description',
url: 'http://test.com/ipsum'
}
}
},
'dolor': {
meta: {
docs: {
category: 'base',
description: 'Dolor description',
url: 'http://test.com/dolor'
}
}
}
}
const prompts = getEslintPrompts(data, rules)
it('creates an array with three settings', () => {
expect(prompts).toHaveLength(3)
})
it('creates an array which order matches eslint categories', () => {
expect(prompts[0].name).toBe('vue/dolor')
expect(prompts[1].name).toBe('vue/ipsum')
expect(prompts[2].name).toBe('vue/lorem')
})
it('doesn\'t set value on prompt item, if the rule wasn\'t set in project\'s eslint config', () => {
expect(prompts[0].value).toBe(undefined)
})
it('sets value on prompt item, if the rule was set in project\'s eslint config', () => {
expect(prompts[1].value).toBe('"warning"')
expect(prompts[2].value).toBe('["error",["asd"]]')
})
it('generates an extra choice for rules that have a custom setting', () => {
expect(prompts[0].choices).toHaveLength(3)
expect(prompts[1].choices).toHaveLength(3)
expect(prompts[2].choices).toHaveLength(4)
})
it('sets a default value to "ERROR" for rule that belong to the choosen config', () => {
expect(prompts[0].default).toBe('"error"')
expect(prompts[1].default).toBe('"error"')
expect(prompts[2].default).toBe('"off"')
})
})
-264
View File
@@ -1,264 +0,0 @@
module.exports = api => {
const CONFIG = 'org.vue.eslintrc'
// Config file
api.describeConfig({
id: CONFIG,
name: 'ESLint configuration',
description: 'org.vue.eslint.config.eslint.description',
link: 'https://github.com/vuejs/eslint-plugin-vue',
files: {
eslint: {
js: ['.eslintrc.js'],
json: ['.eslintrc', '.eslintrc.json'],
yaml: ['.eslintrc.yaml', '.eslintrc.yml'],
package: 'eslintConfig'
},
vue: {
js: ['vue.config.js']
}
},
onRead: ({ data }) => ({
tabs: [
{
id: 'vue',
label: 'org.vue.eslint.config.eslint.vue.label',
prompts: [
{
name: 'vue/attribute-hyphenation',
type: 'list',
message: 'Attribute hyphenation',
group: 'org.vue.eslint.config.eslint.groups.strongly-recommended',
description: 'Enforce attribute naming style in template (`my-prop` or `myProp`)',
link: 'https://github.com/vuejs/eslint-plugin-vue/blob/master/docs/rules/attribute-hyphenation.md',
default: JSON.stringify('off'),
choices: [
{
name: 'Off',
value: JSON.stringify('off')
},
{
name: 'Never',
value: JSON.stringify(['error', 'never'])
},
{
name: 'Always',
value: JSON.stringify(['error', 'always'])
}
],
value: data.eslint && data.eslint.rules && JSON.stringify(data.eslint.rules['vue/attribute-hyphenation'])
},
{
name: 'vue/html-end-tags',
type: 'confirm',
message: 'Template end tags style',
group: 'org.vue.eslint.config.eslint.groups.strongly-recommended',
description: 'End tag on Void elements, end tags and self-closing opening tags',
link: 'https://github.com/vuejs/eslint-plugin-vue/blob/master/docs/rules/html-end-tags.md',
default: false,
value: data.eslint && data.eslint.rules && data.eslint.rules['vue/html-end-tags'] === 'error',
filter: input => JSON.stringify(input ? 'error' : 'off'),
transformer: input => input === JSON.stringify('error')
},
{
name: 'vue/html-indent',
type: 'list',
message: 'Template indentation',
group: 'org.vue.eslint.config.eslint.groups.strongly-recommended',
description: 'Enforce indentation in template',
link: 'https://github.com/vuejs/eslint-plugin-vue/blob/master/docs/rules/html-indent.md',
default: JSON.stringify('off'),
choices: [
{
name: 'Off',
value: JSON.stringify('off')
},
{
name: 'Tabs',
value: JSON.stringify(['error', 'tab'])
},
{
name: '2 spaces',
value: JSON.stringify(['error', 2])
},
{
name: '4 spaces',
value: JSON.stringify(['error', 4])
},
{
name: '8 spaces',
value: JSON.stringify(['error', 8])
}
],
value: data.eslint && data.eslint.rules && JSON.stringify(data.eslint.rules['vue/html-indent'])
},
{
name: 'vue/html-self-closing',
type: 'confirm',
message: 'Template tag self-closing style',
group: 'org.vue.eslint.config.eslint.groups.strongly-recommended',
description: 'Self-close any component or non-Void element tags',
link: 'https://github.com/vuejs/eslint-plugin-vue/blob/master/docs/rules/html-self-closing.md',
default: false,
value: data.eslint && data.eslint.rules && data.eslint.rules['vue/html-self-closing'] === 'error',
filter: input => JSON.stringify(input ? 'error' : 'off'),
transformer: input => input === JSON.stringify('error')
},
{
name: 'vue/require-default-prop',
type: 'confirm',
message: 'Require default in required props',
group: 'org.vue.eslint.config.eslint.groups.strongly-recommended',
description: 'This rule requires default value to be set for each props that are not marked as `required`',
link: 'https://github.com/vuejs/eslint-plugin-vue/blob/master/docs/rules/require-default-prop.md',
default: false,
value: data.eslint && data.eslint.rules && data.eslint.rules['vue/require-default-prop'] === 'error',
filter: input => JSON.stringify(input ? 'error' : 'off'),
transformer: input => input === JSON.stringify('error')
},
{
name: 'vue/require-prop-types',
type: 'confirm',
message: 'Require types for props',
group: 'org.vue.eslint.config.eslint.groups.strongly-recommended',
description: 'In committed code, prop definitions should always be as detailed as possible, specifying at least type(s)',
link: 'https://github.com/vuejs/eslint-plugin-vue/blob/master/docs/rules/require-prop-types.md',
default: false,
value: data.eslint && data.eslint.rules && data.eslint.rules['vue/require-prop-types'] === 'error',
filter: input => JSON.stringify(input ? 'error' : 'off'),
transformer: input => input === JSON.stringify('error')
},
{
name: 'vue/attributes-order',
type: 'confirm',
message: 'Attribute order',
group: 'org.vue.eslint.config.eslint.groups.recommended',
description: 'This rule aims to enforce ordering of component attributes (the default order is specified in the Vue style guide)',
link: 'https://github.com/vuejs/eslint-plugin-vue/blob/master/docs/rules/attributes-order.md',
default: false,
value: data.eslint && data.eslint.rules && data.eslint.rules['vue/attributes-order'] === 'error',
filter: input => JSON.stringify(input ? 'error' : 'off'),
transformer: input => input === JSON.stringify('error')
},
{
name: 'vue/html-quotes',
type: 'list',
message: 'Attribute quote style',
group: 'org.vue.eslint.config.eslint.groups.recommended',
description: 'Enforce style of the attribute quotes in templates',
link: 'https://github.com/vuejs/eslint-plugin-vue/blob/master/docs/rules/html-quotes.md',
default: JSON.stringify('off'),
choices: [
{
name: 'Off',
value: JSON.stringify('off')
},
{
name: 'Double quotes',
value: JSON.stringify(['error', 'double'])
},
{
name: 'Single quotes',
value: JSON.stringify(['error', 'single'])
}
],
value: data.eslint && data.eslint.rules && JSON.stringify(data.eslint.rules['vue/html-quotes'])
},
{
name: 'vue/order-in-components',
type: 'confirm',
message: 'Component options order',
group: 'org.vue.eslint.config.eslint.groups.recommended',
description: 'This rule aims to enforce ordering of component options (the default order is specified in the Vue style guide)',
link: 'https://github.com/vuejs/eslint-plugin-vue/blob/master/docs/rules/order-in-components.md',
default: false,
value: data.eslint && data.eslint.rules && data.eslint.rules['vue/order-in-components'] === 'error',
filter: input => JSON.stringify(input ? 'error' : 'off'),
transformer: input => input === JSON.stringify('error')
}
]
},
{
id: 'extra',
label: 'org.vue.eslint.config.eslint.extra.label',
prompts: [
{
name: 'lintOnSave',
type: 'confirm',
message: 'org.vue.eslint.config.eslint.extra.lintOnSave.message',
description: 'org.vue.eslint.config.eslint.extra.lintOnSave.description',
link: 'https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint#configuration',
default: true,
value: data.vue && data.vue.lintOnSave
}
]
}
]
}),
onWrite: async ({ api, prompts }) => {
const eslintData = {}
const vueData = {}
for (const prompt of prompts) {
// eslintrc
if (prompt.id.indexOf('vue/') === 0) {
eslintData[`rules.${prompt.id}`] = await api.getAnswer(prompt.id, JSON.parse)
} else {
// vue.config.js
vueData[prompt.id] = await api.getAnswer(prompt.id)
}
}
api.setData('eslint', eslintData)
api.setData('vue', vueData)
}
})
// Tasks
api.describeTask({
match: /vue-cli-service lint/,
description: 'org.vue.eslint.tasks.lint.description',
link: 'https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint#injected-commands',
prompts: [
{
name: 'noFix',
type: 'confirm',
default: false,
description: 'org.vue.eslint.tasks.lint.noFix'
}
],
onBeforeRun: ({ answers, args }) => {
if (answers.noFix) args.push('--no-fix')
}
})
const OPEN_ESLINTRC = 'org.vue.eslint.open-eslintrc'
api.onViewOpen(({ view }) => {
if (view.id !== 'vue-project-configurations') {
removeSuggestions()
}
})
api.onConfigRead(({ config }) => {
if (config.id === CONFIG) {
api.addSuggestion({
id: OPEN_ESLINTRC,
type: 'action',
label: 'org.vue.eslint.suggestions.open-eslintrc.label',
handler () {
const file = config.foundFiles.eslint.path
const launch = require('launch-editor')
launch(file)
return {
keep: true
}
}
})
} else {
removeSuggestions()
}
})
function removeSuggestions () {
[OPEN_ESLINTRC].forEach(id => api.removeSuggestion(id))
}
}
@@ -0,0 +1,190 @@
const CONFIG = 'org.vue.eslintrc'
const CATEGORIES = [
'base',
'essential',
'strongly-recommended',
'recommended',
'uncategorized'
]
const DEFAULT_CATEGORY = 'essential'
const RULE_SETTING_OFF = 'off'
const RULE_SETTING_ERROR = 'error'
const RULE_SETTING_WARNING = 'warning'
const RULE_SETTINGS = [RULE_SETTING_OFF, RULE_SETTING_ERROR, RULE_SETTING_WARNING]
const defaultChoices = [
{
name: 'org.vue.eslint.config.eslint.setting.off',
value: JSON.stringify(RULE_SETTING_OFF)
},
{
name: 'org.vue.eslint.config.eslint.setting.error',
value: JSON.stringify(RULE_SETTING_ERROR)
},
{
name: 'org.vue.eslint.config.eslint.setting.warning',
value: JSON.stringify(RULE_SETTING_WARNING)
}
]
function escapeHTML (text) {
return text.replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function getEslintConfigName (eslint) {
let config = eslint.extends
if (eslint.extends instanceof Array) {
config = eslint.extends.find(configName => configName.startsWith('plugin:vue/'))
}
return config && config.startsWith('plugin:vue/') ? config : null
}
// Sets default value regarding selected global config
function getDefaultValue (rule, data) {
const { category: ruleCategory } = rule.meta.docs
const currentCategory = getEslintConfigName(data.eslint)
if (!currentCategory || ruleCategory === undefined) return RULE_SETTING_OFF
return CATEGORIES.indexOf(ruleCategory) <= CATEGORIES.indexOf(currentCategory.split('/')[1])
? RULE_SETTING_ERROR
: RULE_SETTING_OFF
}
function getEslintPrompts (data, rules) {
const allRules = Object.keys(rules)
.map(ruleKey => ({
...rules[ruleKey],
name: `vue/${ruleKey}`
}))
return CATEGORIES
.map(category =>
allRules.filter(rule =>
rule.meta.docs.category === category || (
category === 'uncategorized' &&
rule.meta.docs.category === undefined
)
)
)
.reduce((acc, rulesArr) => [...acc, ...rulesArr], [])
.map(rule => {
const value = data.eslint &&
data.eslint.rules &&
data.eslint.rules[rule.name]
return {
name: rule.name,
type: 'list',
message: rule.name,
group: `org.vue.eslint.config.eslint.groups.${rule.meta.docs.category || 'uncategorized'}`,
description: escapeHTML(rule.meta.docs.description),
link: rule.meta.docs.url,
default: JSON.stringify(getDefaultValue(rule, data)),
value: JSON.stringify(value),
choices: !value || RULE_SETTINGS.indexOf(value) > -1
? defaultChoices
: [...defaultChoices, {
name: 'org.vue.eslint.config.eslint.setting.custom',
value: JSON.stringify(value)
}]
}
})
}
function onRead ({ data, cwd }) {
const rules = require(`${cwd}/node_modules/eslint-plugin-vue`).rules
return {
tabs: [
{
id: 'general',
label: 'org.vue.eslint.config.eslint.general.label',
prompts: [
{
name: 'lintOnSave',
type: 'confirm',
message: 'org.vue.eslint.config.eslint.general.lintOnSave.message',
description: 'org.vue.eslint.config.eslint.general.lintOnSave.description',
link: 'https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint#configuration',
default: true,
value: data.vue && data.vue.lintOnSave
},
{
name: 'config',
type: 'list',
message: 'org.vue.eslint.config.eslint.general.config.message',
description: 'org.vue.eslint.config.eslint.general.config.description',
link: 'https://github.com/vuejs/eslint-plugin-vue',
default: `plugin:vue/${DEFAULT_CATEGORY}`,
choices: CATEGORIES.filter(category => category !== 'uncategorized').map(category => ({
name: `org.vue.eslint.config.eslint.groups.${category}`,
value: `plugin:vue/${category}`
})),
value: getEslintConfigName(data.eslint)
}
]
},
{
id: 'rules',
label: 'org.vue.eslint.config.eslint.rules.label',
prompts: getEslintPrompts(data, rules)
}
]
}
}
async function onWrite ({ data, api, prompts }) {
const eslintData = { ...data.eslint }
const vueData = {}
for (const prompt of prompts) {
// eslintrc
if (prompt.id === 'config') {
if (eslintData.extends instanceof Array) {
const vueEslintConfig = eslintData.extends.find(config => config.indexOf('plugin:vue/') === 0)
const index = eslintData.extends.indexOf(vueEslintConfig)
eslintData.extends[index] = JSON.parse(prompt.value)
} else {
eslintData.extends = JSON.parse(prompt.value)
}
} else if (prompt.id.indexOf('vue/') === 0) {
eslintData[`rules.${prompt.id}`] = await api.getAnswer(prompt.id, JSON.parse)
} else {
// vue.config.js
vueData[prompt.id] = await api.getAnswer(prompt.id)
}
}
api.setData('eslint', eslintData)
api.setData('vue', vueData)
}
const config = {
id: CONFIG,
name: 'ESLint configuration',
description: 'org.vue.eslint.config.eslint.description',
link: 'https://github.com/vuejs/eslint-plugin-vue',
files: {
eslint: {
js: ['.eslintrc.js'],
json: ['.eslintrc', '.eslintrc.json'],
yaml: ['.eslintrc.yaml', '.eslintrc.yml'],
package: 'eslintConfig'
},
vue: {
js: ['vue.config.js']
}
},
onRead,
onWrite
}
module.exports = {
config,
getEslintConfigName,
getDefaultValue,
getEslintPrompts
}
@@ -0,0 +1,40 @@
const configDescriptor = require('./configDescriptor')
const taskDescriptor = require('./taskDescriptor')
const CONFIG = 'org.vue.eslintrc'
const OPEN_ESLINTRC = 'org.vue.eslint.open-eslintrc'
module.exports = api => {
api.describeConfig(configDescriptor.config)
api.describeTask(taskDescriptor.task)
api.onViewOpen(({ view }) => {
if (view.id !== 'vue-project-configurations') {
removeSuggestions()
}
})
api.onConfigRead(({ config }) => {
if (config.id === CONFIG) {
api.addSuggestion({
id: OPEN_ESLINTRC,
type: 'action',
label: 'org.vue.eslint.suggestions.open-eslintrc.label',
handler () {
const file = config.foundFiles.eslint.path
const launch = require('launch-editor')
launch(file)
return {
keep: true
}
}
})
} else {
removeSuggestions()
}
})
function removeSuggestions () {
[OPEN_ESLINTRC].forEach(id => api.removeSuggestion(id))
}
}
@@ -0,0 +1,20 @@
const task = {
match: /vue-cli-service lint/,
description: 'org.vue.eslint.tasks.lint.description',
link: 'https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint#injected-commands',
prompts: [
{
name: 'noFix',
type: 'confirm',
default: false,
description: 'org.vue.eslint.tasks.lint.noFix'
}
],
onBeforeRun: ({ answers, args }) => {
if (answers.noFix) args.push('--no-fix')
}
}
module.exports = {
task
}
+20 -6
View File
@@ -580,18 +580,32 @@
"eslint": {
"description": "Error checking & Code quality",
"groups": {
"base": "Base",
"essential": "Essential",
"strongly-recommended": "Strongly recommended",
"recommended": "Recommended"
"recommended": "Recommended",
"use-with-caution": "Use with caution",
"uncategorized": "Uncategorized"
},
"vue": {
"label": "Vue"
"setting": {
"off": "Off",
"error": "Error",
"warning": "Warning",
"custom": "Custom"
},
"extra": {
"label": "Extra",
"general": {
"label": "General",
"lintOnSave": {
"message": "Lint on save",
"description": "Automatically lint source files when saved"
},
"config": {
"message": "Select config",
"description": "Select pre-defined configuration"
}
},
"rules": {
"label": "Rules"
}
}
},
@@ -686,4 +700,4 @@
}
}
}
}
}