diff --git a/packages/@vue/cli-plugin-eslint/__tests__/ui.spec.js b/packages/@vue/cli-plugin-eslint/__tests__/ui.spec.js new file mode 100644 index 000000000..222f968f8 --- /dev/null +++ b/packages/@vue/cli-plugin-eslint/__tests__/ui.spec.js @@ -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"') + }) +}) diff --git a/packages/@vue/cli-plugin-eslint/ui.js b/packages/@vue/cli-plugin-eslint/ui.js deleted file mode 100644 index 2ab284172..000000000 --- a/packages/@vue/cli-plugin-eslint/ui.js +++ /dev/null @@ -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)) - } -} diff --git a/packages/@vue/cli-plugin-eslint/ui/configDescriptor.js b/packages/@vue/cli-plugin-eslint/ui/configDescriptor.js new file mode 100644 index 000000000..28e75c483 --- /dev/null +++ b/packages/@vue/cli-plugin-eslint/ui/configDescriptor.js @@ -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, '>') +} + +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 +} diff --git a/packages/@vue/cli-plugin-eslint/ui/index.js b/packages/@vue/cli-plugin-eslint/ui/index.js new file mode 100644 index 000000000..9f66208d8 --- /dev/null +++ b/packages/@vue/cli-plugin-eslint/ui/index.js @@ -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)) + } +} diff --git a/packages/@vue/cli-plugin-eslint/ui/taskDescriptor.js b/packages/@vue/cli-plugin-eslint/ui/taskDescriptor.js new file mode 100644 index 000000000..4147a03ca --- /dev/null +++ b/packages/@vue/cli-plugin-eslint/ui/taskDescriptor.js @@ -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 +} diff --git a/packages/@vue/cli-ui/locales/en.json b/packages/@vue/cli-ui/locales/en.json index ec46ccb72..b2ffe10bb 100644 --- a/packages/@vue/cli-ui/locales/en.json +++ b/packages/@vue/cli-ui/locales/en.json @@ -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 @@ } } } -} \ No newline at end of file +}