feat(ui): config improvements (#1487)

BREAKING CHANGES:

- The configuration API has changed.
- The `files` options now accept an object of different config files:

```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']
    }
  },
})
```

- The `onWrite` api has changed: `setData` and `assignData` have now `fileId` as the first argument:

```js
api.describeConfig({
  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)
  }
})
```

Other changes

- Config tabs (optional):

```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
        ]
      }
    ]
  })
})
```
This commit is contained in:
Guillaume Chau
2018-06-10 14:01:45 +02:00
committed by GitHub
parent e258f5a3c9
commit dbef5e9fed
15 changed files with 634 additions and 344 deletions
+180 -176
View File
@@ -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'])
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: '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: '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: '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: '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: '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: '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/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: [
id: 'extra',
label: 'eslint.config.eslint.extra.label',
prompts: [
{
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])
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
}
],
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: 'Double quotes',
value: JSON.stringify(['error', 'double'])
},
{
name: 'Single quotes',
value: JSON.stringify(['error', 'single'])
}
],
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')
]
}
]
}),
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)
}
})
+20 -43
View File
@@ -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
})
}
}
+12 -8
View File
@@ -313,7 +313,8 @@
"actions": {
"cancel": "Cancel changes",
"save": "Save changes",
"more-info": "More info"
"more-info": "More info",
"refresh": "Refresh"
}
},
"project-tasks": {
@@ -420,13 +421,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"
}
}
}
},
@@ -0,0 +1,50 @@
<template>
<div class="configuration-tab">
<PromptsList
:prompts="visiblePrompts"
@answer="answerPrompt"
/>
</div>
</template>
<script>
import Prompts from '../mixins/Prompts'
import CONFIGURATION from '../graphql/configuration.gql'
export default {
mixins: [
Prompts({
field: 'tab',
query: CONFIGURATION,
variables () {
return {
id: this.configuration.id
}
},
updateQuery (data, prompts) {
const result = {}
for (const prompt of prompts) {
const list = result[prompt.tabId] || (result[prompt.tabId] = [])
list.push(prompt)
}
for (const tabId in result) {
data.configuration.tabs[tabId].prompts = result[tabId]
}
}
})
],
props: {
configuration: {
type: Object,
required: true
},
tab: {
type: Object,
required: true
}
}
}
</script>
@@ -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()
}))
@@ -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 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(fileDescriptor, context)
}
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' })
}
function writeData ({ config, data, changedFields }, context) {
let file = findFile(config, context)
if (!file) {
file = getDefaultFile(config, 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)
}
}
}
fs.writeFileSync(file.path, rawContent, { encoding: 'utf8' })
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
}
@@ -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 () {
@@ -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)
},
@@ -21,6 +21,7 @@ type Prompt implements DescribedEntity {
value: String
valueChanged: Boolean
error: PromptError
tabId: String
}
input PromptInput {
@@ -5,8 +5,13 @@ query configuration ($id: ID!) {
configuration(id: $id) {
...configuration
link
prompts {
...prompt
tabs {
id
label
icon
prompts {
...prompt
}
}
}
}
@@ -19,4 +19,5 @@ fragment prompt on Prompt {
error {
...promptError
}
tabId
}
+22 -7
View File
@@ -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 })
data[field].prompts = promptAnswer
store.writeQuery({ query, variables, data })
const data = store.readQuery({ query, variables: vars })
if (updateQuery) {
updateQuery.call(this, data, promptAnswer)
} else {
data[field].prompts = promptAnswer
}
store.writeQuery({ query, variables: vars, data })
}
})
}
@@ -1,11 +1,32 @@
<template>
<div class="project-configuration-details">
<div class="content">
<PromptsList
:prompts="visiblePrompts"
@answer="answerPrompt"
/>
</div>
<template v-if="configuration">
<div v-if="configuration.tabs.length > 1" class="tabs">
<VueGroup
v-model="currentTab"
class="tabs-selector"
>
<VueGroupButton
v-for="tab of configuration.tabs"
:key="tab.id"
:value="tab.id"
:icon-left="tab.icon"
:label="$t(tab.label)"
/>
</VueGroup>
</div>
<div class="content">
<ConfigurationTab
v-for="tab of configuration.tabs"
v-show="tab.id === currentTab"
:key="tab.id"
:configuration="configuration"
:tab="tab"
@has-changes="value => tabsHaveChanges[tab.id] = value"
/>
</div>
</template>
<div class="actions-bar space-between">
<VueButton
@@ -26,7 +47,15 @@
/>
<VueButton
:disabled="!hasPromptsChanged"
v-if="configuration && !hasPromptsChanged"
icon-left="refresh"
class="big primary"
:label="$t('views.project-configuration-details.actions.refresh')"
@click="refetch()"
/>
<VueButton
v-else
icon-left="save"
class="primary big"
:label="$t('views.project-configuration-details.actions.save')"
@@ -37,20 +66,11 @@
</template>
<script>
import Prompts from '../mixins/Prompts'
import CONFIGURATION from '../graphql/configuration.gql'
import CONFIGURATION_SAVE from '../graphql/configurationSave.gql'
import CONFIGURATION_CANCEL from '../graphql/configurationCancel.gql'
export default {
mixins: [
Prompts({
field: 'configuration',
query: CONFIGURATION
})
],
metaInfo () {
return {
title: this.configuration && `${this.configuration.name} - ${this.$t('views.project-configurations.title')}`
@@ -66,7 +86,9 @@ export default {
data () {
return {
configuration: null
configuration: null,
currentTab: '__default',
tabsHaveChanges: {}
}
},
@@ -77,11 +99,44 @@ export default {
return {
id: this.id
}
},
async result ({ data, loading }) {
if (!this.$_init && !loading && data && data.configuration) {
this.$_init = true
this.tabsHaveChanges = data.configuration.tabs.reduce((obj, tab) => {
obj[tab.id] = false
return obj
}, {})
await this.$nextTick()
this.currentTab = data.configuration.tabs[0].id
}
}
}
},
computed: {
hasPromptsChanged () {
for (const key in this.tabsHaveChanges) {
if (this.tabsHaveChanges[key]) return true
}
return false
}
},
watch: {
id: 'init'
},
created () {
this.init()
},
methods: {
init (tab) {
this.currentTab = '__default'
this.$_init = false
},
async cancel () {
await this.$apollo.mutate({
mutation: CONFIGURATION_CANCEL,
@@ -90,7 +145,7 @@ export default {
}
})
this.$apollo.queries.configuration.refetch()
this.refetch()
},
async save () {
@@ -101,6 +156,10 @@ export default {
}
})
this.refetch()
},
refetch () {
this.$apollo.queries.configuration.refetch()
}
}
@@ -120,4 +179,7 @@ export default {
height 0
overflow-x hidden
overflow-y auto
.tabs
margin $padding-item 0
</style>
@@ -1,6 +1,6 @@
describe('Configurations', () => {
it('Displays configurations', () => {
cy.visit('/configuration')
cy.get('.configuration-item').should('have.length', 3)
cy.get('.configuration-item').should('have.length', 2)
})
})