diff --git a/docs/dev-guide/ui-plugin-dev.md b/docs/dev-guide/ui-plugin-dev.md index 83ebd67856..e2f52bd935 100644 --- a/docs/dev-guide/ui-plugin-dev.md +++ b/docs/dev-guide/ui-plugin-dev.md @@ -107,9 +107,9 @@ api.describeConfig({ If you don't specify an icon, the plugin logo will be displayed if any (see [Logo](#logo)). -#### Config file +#### Config files -By default, a configuration UI might read and write to a configuration file, for example `.eslintrc.js`. +By default, a configuration UI might read and write to one or more configuration files, for example both `.eslintrc.js` and `vue.config.js`. You can provide what are the possible files to be detected in the user project: @@ -118,17 +118,22 @@ api.describeConfig({ /* ... */ // All possible files for this config files: { - json: ['.eslintrc', '.eslintrc.json'], - js: ['.eslintrc.js'], - // Will read from `package.json` - package: 'eslintConfig' + // eslintrc.js + eslint: { + js: ['.eslintrc.js'], + json: ['.eslintrc', '.eslintrc.json'], + // Will read from `package.json` + package: 'eslintConfig' + }, + // vue.config.js + vue: { + js: ['vue.config.js'] + } }, }) ``` -Supported types: `json`, `yaml`, `js`, `package`. - -**⚠️ Currently, only 1 file can be read and written to at a time.** +Supported types: `json`, `yaml`, `js`, `package`. The order is important: the first filename in the list will be used to create the config file if it doesn't exist. #### Display config prompts @@ -149,6 +154,124 @@ Those prompts will be displayed in the configuration details pane. See [Prompts](#prompts) for more info. +The `data` object contains the JSON result of each config file content. + +For example, let's say the user has the following `vue.config.js` in his project: + +```js +module.exports = { + lintOnSave: false +} +``` + +We declare the config file in our plugin like this: + +```js +api.describeConfig({ + /* ... */ + // All possible files for this config + files: { + // vue.config.js + vue: { + js: ['vue.config.js'] + } + }, +}) +``` + +Then the `data` object will be: + +```js +{ + // File + vue: { + // File data + lintOnSave: false + } +} +``` + +Multiple files example: if we add the following `eslintrc.js` file in the user project: + +```js +module.exports = { + root: true, + extends: [ + 'plugin:vue/essential', + '@vue/standard' + ] +} +``` + +And change the `files` option in our plugin to this: + +```js +api.describeConfig({ + /* ... */ + // All possible files for this config + files: { + // eslintrc.js + eslint: { + js: ['.eslintrc.js'], + json: ['.eslintrc', '.eslintrc.json'], + // Will read from `package.json` + package: 'eslintConfig' + }, + // vue.config.js + vue: { + js: ['vue.config.js'] + } + }, +}) +``` + +Then the `data` object will be: + +```js +{ + eslint: { + root: true, + extends: [ + 'plugin:vue/essential', + '@vue/standard' + ] + }, + vue: { + lintOnSave: false + } +} +``` + +#### Configuration tabs + +You can organize the prompts into several tabs: + +```js +api.describeConfig({ + /* ... */ + onRead: ({ data, cwd }) => ({ + tabs: [ + { + id: 'tab1', + label: 'My tab', + // Optional + icon: 'application_settings', + prompts: [ + // Prompt objects + ] + }, + { + id: 'tab2', + label: 'My other tab', + prompts: [ + // Prompt objects + ] + } + ] + }) +}) +``` + #### Save config changes Use the `onWrite` hook to write the data to the configuration file (or execute any nodejs code): @@ -156,7 +279,7 @@ Use the `onWrite` hook to write the data to the configuration file (or execute a ```js api.describeConfig({ /* ... */ - onWrite: ({ prompts, answers, data, file, cwd, api }) => { + onWrite: ({ prompts, answers, data, files, cwd, api }) => { // ... } }) @@ -166,8 +289,8 @@ Arguments: - `prompts`: current prompts runtime objects (see below) - `answers`: answers data from the user inputs -- `data`: read-only initial data read from the file -- `file`: descriptor of the found file (`{ type: 'json', path: '...' }`) +- `data`: read-only initial data read from the config files +- `files`: descriptors of the found files (`{ type: 'json', path: '...' }`) - `cwd`: current working directory - `api`: `onWrite API` (see below) @@ -190,6 +313,7 @@ Prompts runtime objects: // true if changed by user valueChanged: false, error: null, + tabId: null, // Original inquirer prompt object raw: data } @@ -197,8 +321,8 @@ Prompts runtime objects: `onWrite` API: -- `assignData(newData)`: use `Object.assign` to update the config data before writing. -- `setData(newData)`: each key of `newData` will be deeply set (or removed if `undefined` value) to the config data before writing. +- `assignData(fileId, newData)`: use `Object.assign` to update the config data before writing. +- `setData(fileId, newData)`: each key of `newData` will be deeply set (or removed if `undefined` value) to the config data before writing. - `async getAnswer(id, mapper)`: retrieve answer for a given prompt id and map it through `mapper` function if provided (for example `JSON.parse`). Example (from the ESLint plugin): @@ -213,7 +337,7 @@ api.describeConfig({ for (const prompt of prompts) { result[`rules.${prompt.id}`] = await api.getAnswer(prompt.id, JSON.parse) } - api.setData(result) + api.setData('eslint', result) } }) ``` diff --git a/packages/@vue/cli-plugin-eslint/ui.js b/packages/@vue/cli-plugin-eslint/ui.js index 1a89df3b04..ae19618a77 100644 --- a/packages/@vue/cli-plugin-eslint/ui.js +++ b/packages/@vue/cli-plugin-eslint/ui.js @@ -6,202 +6,206 @@ module.exports = api => { description: 'eslint.config.eslint.description', link: 'https://github.com/vuejs/eslint-plugin-vue', files: { - json: ['.eslintrc', '.eslintrc.json'], - js: ['.eslintrc.js'], - package: 'eslintConfig' + eslint: { + js: ['.eslintrc.js'], + json: ['.eslintrc', '.eslintrc.json'], + package: 'eslintConfig' + }, + vue: { + js: ['vue.config.js'] + } }, onRead: ({ data }) => ({ - prompts: [ + tabs: [ { - name: 'vue/attribute-hyphenation', - type: 'list', - message: 'Attribute hyphenation', - group: '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: [ + id: 'vue', + label: 'eslint.config.eslint.vue.label', + prompts: [ { - name: 'Off', - value: JSON.stringify('off') + name: 'vue/attribute-hyphenation', + type: 'list', + message: 'Attribute hyphenation', + group: '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: 'Never', - value: JSON.stringify(['error', 'never']) + name: 'vue/html-end-tags', + type: 'confirm', + message: 'Template end tags style', + group: '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: 'Always', - value: JSON.stringify(['error', 'always']) - } - ], - value: data.rules && JSON.stringify(data.rules['vue/attribute-hyphenation']) - }, - { - name: 'vue/html-end-tags', - type: 'confirm', - message: 'Template end tags style', - group: '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.rules && data.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: '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: 'vue/html-indent', + type: 'list', + message: 'Template indentation', + group: '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: 'Tabs', - value: JSON.stringify(['error', 'tab']) + name: 'vue/html-self-closing', + type: 'confirm', + message: 'Template tag self-closing style', + group: '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: '2 spaces', - value: JSON.stringify(['error', 2]) + name: 'vue/require-default-prop', + type: 'confirm', + message: 'Require default in required props', + group: '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: '4 spaces', - value: JSON.stringify(['error', 4]) + name: 'vue/require-prop-types', + type: 'confirm', + message: 'Require types for props', + group: '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: '8 spaces', - value: JSON.stringify(['error', 8]) - } - ], - value: data.rules && JSON.stringify(data.rules['vue/html-indent']) - }, - { - name: 'vue/html-self-closing', - type: 'confirm', - message: 'Template tag self-closing style', - group: '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.rules && data.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: '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.rules && data.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: '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.rules && data.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: '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.rules && data.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: '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: 'vue/attributes-order', + type: 'confirm', + message: 'Attribute order', + group: '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: 'Double quotes', - value: JSON.stringify(['error', 'double']) + name: 'vue/html-quotes', + type: 'list', + message: 'Attribute quote style', + group: '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: 'Single quotes', - value: JSON.stringify(['error', 'single']) + name: 'vue/order-in-components', + type: 'confirm', + message: 'Component options order', + group: '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') } - ], - value: data.rules && JSON.stringify(data.rules['vue/html-quotes']) + ] }, { - name: 'vue/order-in-components', - type: 'confirm', - message: 'Component options order', - group: '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.rules && data.rules['vue/order-in-components'] === 'error', - filter: input => JSON.stringify(input ? 'error' : 'off'), - transformer: input => input === JSON.stringify('error') + id: 'extra', + label: 'eslint.config.eslint.extra.label', + prompts: [ + { + name: 'lintOnSave', + type: 'confirm', + message: 'eslint.config.eslint.extra.lintOnSave.message', + description: '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 result = {} + const eslintData = {} + const vueData = {} for (const prompt of prompts) { - result[`rules.${prompt.id}`] = await api.getAnswer(prompt.id, JSON.parse) - } - api.setData(result) - } - }) - - api.describeConfig({ - id: 'eslintrc-config', - name: 'ESLint extra', - description: 'eslint.config.eslint-extra.description', - link: 'https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint#configuration', - files: { - js: ['vue.config.js'] - }, - onRead: ({ data }) => ({ - prompts: [ - { - name: 'lintOnSave', - type: 'confirm', - message: 'eslint.config.eslint-extra.lintOnSave.message', - description: '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.lintOnSave + // 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) } - ] - }), - onWrite: async ({ api, prompts }) => { - const result = {} - for (const prompt of prompts) { - result[prompt.id] = await api.getAnswer(prompt.id) } - api.setData(result) + api.setData('eslint', eslintData) + api.setData('vue', vueData) } }) diff --git a/packages/@vue/cli-plugin-pwa/ui.js b/packages/@vue/cli-plugin-pwa/ui.js index 3f644504a4..e9792af5ef 100644 --- a/packages/@vue/cli-plugin-pwa/ui.js +++ b/packages/@vue/cli-plugin-pwa/ui.js @@ -1,30 +1,3 @@ -const path = require('path') -const fs = require('fs') - -function readAppManifest (cwd) { - const manifestPath = path.join(cwd, 'public/manifest.json') - if (fs.existsSync(manifestPath)) { - try { - return JSON.parse(fs.readFileSync(manifestPath, { encoding: 'utf8' })) - } catch (e) { - console.log(`Can't read JSON in ${manifestPath}`) - } - } -} - -function updateAppManifest (cwd, handler) { - const manifestPath = path.join(cwd, 'public/manifest.json') - if (fs.existsSync(manifestPath)) { - try { - const manifest = JSON.parse(fs.readFileSync(manifestPath, { encoding: 'utf8' })) - handler(manifest) - fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), { encoding: 'utf8' }) - } catch (e) { - console.log(`Can't update JSON in ${manifestPath}`) - } - } -} - module.exports = api => { // Config file api.describeConfig({ @@ -33,10 +6,14 @@ module.exports = api => { description: 'pwa.config.pwa.description', link: 'https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-pwa#configuration', files: { - js: ['vue.config.js'] + vue: { + js: ['vue.config.js'] + }, + manifest: { + json: ['public/manifest.json'] + } }, onRead: ({ data, cwd }) => { - const manifest = readAppManifest(cwd) return { prompts: [ { @@ -46,7 +23,7 @@ module.exports = api => { description: 'pwa.config.pwa.workboxPluginMode.description', link: 'https://developers.google.com/web/tools/workbox/modules/workbox-webpack-plugin#which_plugin_to_use', default: 'GenerateSW', - value: data.pwa && data.pwa.workboxPluginMode, + value: data.vue && data.vue.pwa && data.vue.pwa.workboxPluginMode, choices: [ { name: 'GenerateSW', @@ -63,7 +40,7 @@ module.exports = api => { type: 'input', message: 'pwa.config.pwa.name.message', description: 'pwa.config.pwa.name.description', - value: data.pwa && data.pwa.name + value: data.vue && data.vue.pwa && data.vue.pwa.name }, { name: 'themeColor', @@ -71,7 +48,7 @@ module.exports = api => { message: 'pwa.config.pwa.themeColor.message', description: 'pwa.config.pwa.themeColor.description', default: '#4DBA87', - value: data.pwa && data.pwa.themeColor + value: data.vue && data.vue.pwa && data.vue.pwa.themeColor }, { name: 'backgroundColor', @@ -79,7 +56,7 @@ module.exports = api => { message: 'pwa.config.pwa.backgroundColor.message', description: 'pwa.config.pwa.backgroundColor.description', default: '#000000', - value: manifest && manifest.background_color, + value: data.manifest && data.manifest.background_color, skipSave: true }, { @@ -88,7 +65,7 @@ module.exports = api => { message: 'pwa.config.pwa.msTileColor.message', description: 'pwa.config.pwa.msTileColor.description', default: '#000000', - value: data.pwa && data.pwa.msTileColor + value: data.vue && data.vue.pwa && data.vue.pwa.msTileColor }, { name: 'appleMobileWebAppStatusBarStyle', @@ -96,7 +73,7 @@ module.exports = api => { message: 'pwa.config.pwa.appleMobileWebAppStatusBarStyle.message', description: 'pwa.config.pwa.appleMobileWebAppStatusBarStyle.description', default: 'default', - value: data.pwa && data.pwa.appleMobileWebAppStatusBarStyle + value: data.vue && data.vue.pwa && data.vue.pwa.appleMobileWebAppStatusBarStyle } ] } @@ -106,29 +83,29 @@ module.exports = api => { for (const prompt of prompts.filter(p => !p.raw.skipSave)) { result[`pwa.${prompt.id}`] = await api.getAnswer(prompt.id) } - api.setData(result) + api.setData('vue', result) // Update app manifest const name = result['pwa.name'] if (name) { - updateAppManifest(cwd, manifest => { - manifest.name = name - manifest.short_name = name + api.setData('manifest', { + name, + short_name: name }) } const themeColor = result['pwa.themeColor'] if (themeColor) { - updateAppManifest(cwd, manifest => { - manifest.theme_color = themeColor + api.setData('manifest', { + theme_color: themeColor }) } const backgroundColor = await api.getAnswer('backgroundColor') if (backgroundColor) { - updateAppManifest(cwd, manifest => { - manifest.background_color = backgroundColor + api.setData('manifest', { + background_color: backgroundColor }) } } diff --git a/packages/@vue/cli-ui/locales/en.json b/packages/@vue/cli-ui/locales/en.json index 6cc9a6f450..04f47f781a 100644 --- a/packages/@vue/cli-ui/locales/en.json +++ b/packages/@vue/cli-ui/locales/en.json @@ -312,7 +312,8 @@ "actions": { "cancel": "Cancel changes", "save": "Save changes", - "more-info": "More info" + "more-info": "More info", + "refresh": "Refresh" } }, "project-tasks": { @@ -419,13 +420,16 @@ "groups": { "strongly-recommended": "Strongly recommended", "recommended": "Recommended" - } - }, - "eslint-extra": { - "description": "Extra ESLint settings", - "lintOnSave": { - "message": "Lint on save", - "description": "Automatically lint source files when saved" + }, + "vue": { + "label": "Vue" + }, + "extra": { + "label": "Extra", + "lintOnSave": { + "message": "Lint on save", + "description": "Automatically lint source files when saved" + } } } }, diff --git a/packages/@vue/cli-ui/src/components/ConfigurationTab.vue b/packages/@vue/cli-ui/src/components/ConfigurationTab.vue new file mode 100644 index 0000000000..fa561a2474 --- /dev/null +++ b/packages/@vue/cli-ui/src/components/ConfigurationTab.vue @@ -0,0 +1,50 @@ + + + + + + + diff --git a/packages/@vue/cli-ui/src/graphql-api/api/configuration.js b/packages/@vue/cli-ui/src/graphql-api/api/configuration.js index c5304ee228..d72f84a975 100644 --- a/packages/@vue/cli-ui/src/graphql-api/api/configuration.js +++ b/packages/@vue/cli-ui/src/graphql-api/api/configuration.js @@ -6,12 +6,12 @@ const schema = createSchema(joi => ({ description: joi.string(), link: joi.string().uri(), icon: joi.string(), - files: joi.object({ + files: joi.object().pattern(/^/, joi.object({ json: joi.array().items(joi.string()), js: joi.array().items(joi.string()), yaml: joi.array().items(joi.string()), package: joi.string() - }), + })), onRead: joi.func().required(), onWrite: joi.func().required() })) diff --git a/packages/@vue/cli-ui/src/graphql-api/connectors/configurations.js b/packages/@vue/cli-ui/src/graphql-api/connectors/configurations.js index 7225ddef9e..b3a07148ec 100644 --- a/packages/@vue/cli-ui/src/graphql-api/connectors/configurations.js +++ b/packages/@vue/cli-ui/src/graphql-api/connectors/configurations.js @@ -27,21 +27,17 @@ function findOne (id, context) { ) } -function findFile (config, context) { - if (!config.files) { - return null - } - - if (config.files.package) { +function findFile (fileDescriptor, context) { + if (fileDescriptor.package) { const pkg = folders.readPackage(cwd.get(), context) - const data = pkg[config.files.package] + const data = pkg[fileDescriptor.package] if (data) { return { type: 'package', path: path.join(cwd.get(), 'package.json') } } } for (const type of fileTypes) { - const files = config.files[type] + const files = fileDescriptor[type] if (files) { for (const file of files) { const resolvedFile = path.resolve(cwd.get(), file) @@ -53,16 +49,12 @@ function findFile (config, context) { } } -function getDefaultFile (config, context) { - if (!config.files) { - return null - } - - const keys = Object.keys(config.files) +function getDefaultFile (fileDescriptor, context) { + const keys = Object.keys(fileDescriptor) if (keys.length) { for (const key of keys) { if (key !== 'package') { - const file = config.files[key][0] + const file = fileDescriptor[key][0] return { type: key, path: path.resolve(cwd.get(), file) @@ -72,65 +64,88 @@ function getDefaultFile (config, context) { } } -function readData (config, context) { - const file = findFile(config, context) - config.file = file +function readFile (config, fileDescriptor, context) { + const file = findFile(fileDescriptor, context) + let fileData = {} if (file) { if (file.type === 'package') { const pkg = folders.readPackage(cwd.get(), context) - return pkg[config.files.package] + fileData = pkg[config.files.package] } else if (file.type === 'js') { - return loadModule(file.path, cwd.get(), true) + fileData = loadModule(file.path, cwd.get(), true) } else { const rawContent = fs.readFileSync(file.path, { encoding: 'utf8' }) if (file.type === 'json') { - return JSON.parse(rawContent) + fileData = JSON.parse(rawContent) } else if (file.type === 'yaml') { - return yaml.safeLoad(rawContent) + fileData = yaml.safeLoad(rawContent) } } } - return {} + return { + file, + fileData + } } -function writeData ({ config, data, changedFields }, context) { - let file = findFile(config, context) +function readData (config, context) { + const data = {} + config.foundFiles = {} + if (!config.files) return data + for (const fileId in config.files) { + const fileDescriptor = config.files[fileId] + const { file, fileData } = readFile(config, fileDescriptor, context) + config.foundFiles[fileId] = file + data[fileId] = fileData + } + return data +} + +function writeFile (config, fileId, data, changedFields, context) { + const fileDescriptor = config.files[fileId] + let file = findFile(fileDescriptor, context) if (!file) { - file = getDefaultFile(config, context) + file = getDefaultFile(fileDescriptor, context) } - if (file) { - log('Config write', config.id, data, changedFields, file.path) - fs.ensureFileSync(file.path) - let rawContent - if (file.type === 'package') { - const pkg = folders.readPackage(cwd.get(), context) - pkg[config.files.package] = data - rawContent = JSON.stringify(pkg, null, 2) - } else { - if (file.type === 'json') { - rawContent = JSON.stringify(data, null, 2) - } else if (file.type === 'yaml') { - rawContent = yaml.safeDump(data) - } else if (file.type === 'js') { - let source = fs.readFileSync(file.path, { encoding: 'utf8' }) - if (!source.trim()) { - rawContent = `module.exports = ${stringifyJS(data, null, 2)}` - } else { - const changedData = changedFields.reduce((obj, field) => { - obj[field] = data[field] - return obj - }, {}) - rawContent = extendJSConfig(changedData, source) - } + if (!file) return + + log('Config write', config.id, data, changedFields, file.path) + fs.ensureFileSync(file.path) + let rawContent + if (file.type === 'package') { + const pkg = folders.readPackage(cwd.get(), context) + pkg[config.files.package] = data + rawContent = JSON.stringify(pkg, null, 2) + } else { + if (file.type === 'json') { + rawContent = JSON.stringify(data, null, 2) + } else if (file.type === 'yaml') { + rawContent = yaml.safeDump(data) + } else if (file.type === 'js') { + let source = fs.readFileSync(file.path, { encoding: 'utf8' }) + if (!source.trim()) { + rawContent = `module.exports = ${stringifyJS(data, null, 2)}` + } else { + const changedData = changedFields.reduce((obj, field) => { + obj[field] = data[field] + return obj + }, {}) + rawContent = extendJSConfig(changedData, source) } } - fs.writeFileSync(file.path, rawContent, { encoding: 'utf8' }) + } + fs.writeFileSync(file.path, rawContent, { encoding: 'utf8' }) +} + +function writeData ({ config, data, changedFields }, context) { + for (const fileId in data) { + writeFile(config, fileId, data[fileId], changedFields[fileId], context) } } -async function getPrompts (id, context) { +async function getPromptTabs (id, context) { const config = findOne(id, context) if (config) { const data = readData(config, context) @@ -139,17 +154,35 @@ async function getPrompts (id, context) { config, data } + + // API const configData = await config.onRead({ cwd: cwd.get(), data }) + + let tabs = configData.tabs + if (!tabs) { + tabs = [ + { + id: '__default', + label: 'Default', + prompts: configData.prompts + } + ] + } await prompts.reset() - configData.prompts.forEach(prompts.add) + for (const tab of tabs) { + tab.prompts = tab.prompts.map(data => prompts.add({ + ...data, + tabId: tab.id + })) + } if (configData.answers) { await prompts.setAnswers(configData.answers) } await prompts.start() - return prompts.list() + return tabs } return [] } @@ -160,32 +193,35 @@ async function save (id, context) { if (current.config === config) { const answers = prompts.getAnswers() let data = clone(current.data) - const changedFields = [] + const changedFields = {} + const getChangedFields = fileId => changedFields[fileId] || (changedFields[fileId] = []) + + // API await config.onWrite({ prompts: prompts.list(), answers, data: current.data, - file: config.file, + files: config.foundFiles, cwd: cwd.get(), api: { - assignData: newData => { - changedFields.push(...Object.keys(newData)) - Object.assign(data, newData) + assignData: (fileId, newData) => { + getChangedFields(fileId).push(...Object.keys(newData)) + Object.assign(data[fileId], newData) }, - setData: newData => { + setData: (fileId, newData) => { Object.keys(newData).forEach(key => { let field = key const dotIndex = key.indexOf('.') if (dotIndex !== -1) { field = key.substr(0, dotIndex) } - changedFields.push(field) + getChangedFields(fileId).push(field) const value = newData[key] if (typeof value === 'undefined') { - remove(data, key) + remove(data[fileId], key) } else { - set(data, key, value) + set(data[fileId], key, value) } }) }, @@ -204,6 +240,7 @@ async function save (id, context) { } } }) + writeData({ config, data, changedFields }, context) current = {} } @@ -222,7 +259,7 @@ function cancel (id, context) { module.exports = { list, findOne, - getPrompts, + getPromptTabs, save, cancel } diff --git a/packages/@vue/cli-ui/src/graphql-api/connectors/prompts.js b/packages/@vue/cli-ui/src/graphql-api/connectors/prompts.js index 2af2522cc3..adbddd4d4d 100644 --- a/packages/@vue/cli-ui/src/graphql-api/connectors/prompts.js +++ b/packages/@vue/cli-ui/src/graphql-api/connectors/prompts.js @@ -107,6 +107,7 @@ function generatePrompt (data) { value: null, valueChanged: false, error: null, + tabId: data.tabId || null, raw: data } } @@ -171,7 +172,9 @@ function list () { } function add (data) { - prompts.push(generatePrompt(data)) + const prompt = generatePrompt(data) + prompts.push(prompt) + return prompt } async function start () { diff --git a/packages/@vue/cli-ui/src/graphql-api/schema/configuration.js b/packages/@vue/cli-ui/src/graphql-api/schema/configuration.js index 16d376de01..aed463d430 100644 --- a/packages/@vue/cli-ui/src/graphql-api/schema/configuration.js +++ b/packages/@vue/cli-ui/src/graphql-api/schema/configuration.js @@ -20,14 +20,21 @@ type Configuration implements DescribedEntity { description: String link: String icon: String - prompts: [Prompt] plugin: Plugin + tabs: [ConfigurationTab]! +} + +type ConfigurationTab { + id: ID! + label: String! + icon: String + prompts: [Prompt] } ` exports.resolvers = { Configuration: { - prompts: (configuration, args, context) => configurations.getPrompts(configuration.id, context), + tabs: (configuration, args, context) => configurations.getPromptTabs(configuration.id, context), plugin: (configuration, args, context) => plugins.findOne(configuration.pluginId, context) }, diff --git a/packages/@vue/cli-ui/src/graphql-api/schema/prompt.js b/packages/@vue/cli-ui/src/graphql-api/schema/prompt.js index abb0ab9bbf..589011e061 100644 --- a/packages/@vue/cli-ui/src/graphql-api/schema/prompt.js +++ b/packages/@vue/cli-ui/src/graphql-api/schema/prompt.js @@ -21,6 +21,7 @@ type Prompt implements DescribedEntity { value: String valueChanged: Boolean error: PromptError + tabId: String } input PromptInput { diff --git a/packages/@vue/cli-ui/src/graphql/configuration.gql b/packages/@vue/cli-ui/src/graphql/configuration.gql index a158fa7602..015f9a7de5 100644 --- a/packages/@vue/cli-ui/src/graphql/configuration.gql +++ b/packages/@vue/cli-ui/src/graphql/configuration.gql @@ -5,8 +5,13 @@ query configuration ($id: ID!) { configuration(id: $id) { ...configuration link - prompts { - ...prompt + tabs { + id + label + icon + prompts { + ...prompt + } } } } diff --git a/packages/@vue/cli-ui/src/graphql/promptFragment.gql b/packages/@vue/cli-ui/src/graphql/promptFragment.gql index 9ebf51dda5..4360e254e3 100644 --- a/packages/@vue/cli-ui/src/graphql/promptFragment.gql +++ b/packages/@vue/cli-ui/src/graphql/promptFragment.gql @@ -19,4 +19,5 @@ fragment prompt on Prompt { error { ...promptError } + tabId } diff --git a/packages/@vue/cli-ui/src/mixins/Prompts.js b/packages/@vue/cli-ui/src/mixins/Prompts.js index 248d2127c6..9cd9673a12 100644 --- a/packages/@vue/cli-ui/src/mixins/Prompts.js +++ b/packages/@vue/cli-ui/src/mixins/Prompts.js @@ -2,7 +2,9 @@ import PROMPT_ANSWER from '../graphql/promptAnswer.gql' export default function ({ field, - query + query, + variables = null, + updateQuery = null }) { // @vue/component return { @@ -32,6 +34,15 @@ export default function ({ } }, + watch: { + hasPromptsChanged: { + handler (value) { + this.$emit('has-changes', value) + }, + immediate: true + } + }, + methods: { async answerPrompt ({ prompt, value }) { await this.$apollo.mutate({ @@ -43,13 +54,17 @@ export default function ({ } }, update: (store, { data: { promptAnswer } }) => { - let variables = this.$apollo.queries[field].options.variables || undefined - if (typeof variables === 'function') { - variables = variables.call(this) + let vars = variables || this.$apollo.queries[field].options.variables || undefined + if (typeof vars === 'function') { + vars = vars.call(this) + } + const data = store.readQuery({ query, variables: vars }) + if (updateQuery) { + updateQuery.call(this, data, promptAnswer) + } else { + data[field].prompts = promptAnswer } - const data = store.readQuery({ query, variables }) - data[field].prompts = promptAnswer - store.writeQuery({ query, variables, data }) + store.writeQuery({ query, variables: vars, data }) } }) } diff --git a/packages/@vue/cli-ui/src/views/ProjectConfigurationDetails.vue b/packages/@vue/cli-ui/src/views/ProjectConfigurationDetails.vue index 31b6046511..851a58d11c 100644 --- a/packages/@vue/cli-ui/src/views/ProjectConfigurationDetails.vue +++ b/packages/@vue/cli-ui/src/views/ProjectConfigurationDetails.vue @@ -1,11 +1,32 @@ - - - + + + + + + + + + tabsHaveChanges[tab.id] = value" + /> + + + +