feat(ui): PluginApi: configurations

This commit is contained in:
Guillaume Chau
2018-03-25 21:54:43 +02:00
parent e07abbbfa6
commit 05e0dd06fc
27 changed files with 553 additions and 70 deletions

View File

@@ -1,12 +1,14 @@
module.exports = api => {
// Config file
api.describeConfig({
id: 'eslintrc',
name: 'ESLint configuration',
description: 'Error checking & Code quality',
link: 'https://eslint.org',
icon: '.eslintrc.json',
files: {
json: ['eslintrc', 'eslintrc.json'],
js: ['eslintrc.js'],
json: ['.eslintrc', '.eslintrc.json'],
js: ['.eslintrc.js'],
package: 'eslintConfig'
},
onRead: ({ data }) => {
@@ -21,7 +23,7 @@ module.exports = api => {
choices: [
{
name: 'Off',
value: 'off'
value: JSON.stringify('off')
},
{
name: 'Never',
@@ -40,14 +42,14 @@ module.exports = api => {
value: JSON.stringify(['error', 'only-multiline'])
}
],
value: JSON.stringify(data.rules['comma-dangle'] || ['error', 'never'])
value: JSON.stringify(data.rules && data.rules['comma-dangle'] || ['error', 'never'])
}
]
}
},
onWrite: ({ file, answers }) => {
file.assignData({
'rules.comma-dangle': answers.rules.commaDangle
file.setData({
'rules.comma-dangle': JSON.parse(answers.rules.commaDangle)
})
}
})

View File

@@ -4,4 +4,4 @@
"plugin:vue/essential",
"@vue/standard"
]
}
}

View File

@@ -1,6 +1,8 @@
{
"name": "@vue/cli-ui",
"version": "3.0.0-beta.6",
"author": "Guillaume Chau",
"license": "MIT",
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
@@ -19,8 +21,10 @@
"apollo-link-state": "^0.4.0",
"apollo-link-ws": "^1.0.0",
"apollo-utilities": "^1.0.9",
"clone": "^1.0.4",
"file-icons-js": "^1.0.3",
"graphql": "^0.13.0",
"js-yaml": "^3.11.0",
"lowdb": "^1.0.0",
"lru-cache": "^4.1.2",
"mkdirp": "^0.5.1",

View File

@@ -0,0 +1,64 @@
<template>
<div
class="configuration-item list-item"
:class="{
selected
}"
>
<div class="content">
<ItemLogo
:file-icon="iconClass"
/>
<ListItemInfo
:name="configuration.name"
:description="configuration.description"
:selected="selected"
/>
</div>
</div>
</template>
<script>
import icons from 'file-icons-js'
export default {
props: {
configuration: {
type: Object,
required: true
},
selected: {
type: Boolean,
default: false
}
},
computed: {
iconClass () {
return icons.getClassWithColor(this.configuration.icon || this.configuration.id) || 'gear-icon medium-blue'
}
},
}
</script>
<style lang="stylus" scoped>
@import "~@/style/imports"
.configuration-item
padding $padding-item
.content
h-box()
box-center()
.list-item-info
flex 100% 1 1
width 0
>>> .description
white-space nowrap
overflow hidden
text-overflow ellipsis
</style>

View File

@@ -21,6 +21,11 @@
@load="loaded = true"
@error="error = true"
>
<div
v-else-if="fileIcon"
class="dynamic-file-icon"
:class="fileIcon"
/>
<VueIcon
v-else
:icon="icon"
@@ -42,6 +47,11 @@ export default {
default: 'widgets'
},
fileIcon: {
type: String,
default: null
},
selected: {
type: Boolean,
default: false
@@ -92,6 +102,10 @@ export default {
>>> svg
fill rgba($color-text-light, .3)
.dynamic-file-icon
&::before
font-size 24px
&.vuejs
.wrapper
background lighten($vue-ui-color-primary, 70%)

View File

@@ -29,7 +29,7 @@ const BUILTIN_ROUTES = [
tooltip: 'plugins'
},
{
name: 'project-configuration',
name: 'project-configurations',
icon: 'settings_applications',
tooltip: 'configuration'
},

View File

@@ -0,0 +1,154 @@
const fs = require('fs')
const path = require('path')
const yaml = require('js-yaml')
const clone = require('clone')
// Connectors
const cwd = require('./cwd')
const plugins = require('./plugins')
const folders = require('./folders')
const prompts = require('./prompts')
// Utils
const { set } = require('../../util/object')
const fileTypes = ['js', 'json', 'yaml']
let current = {}
function list (context) {
return plugins.getApi().configurations
}
function findOne (id, context) {
return list(context).find(
c => c.id === id
)
}
function findFile (config, context) {
if (config.files.package) {
const pkg = folders.readPackage(cwd.get(), context)
const data = pkg[config.files.package]
if (data) {
return { type: 'package', path: path.join(cwd.get(), 'package.json') }
}
}
for (const type of fileTypes) {
const files = config.files[type]
if (files) {
for (const file of files) {
const resolvedFile = path.resolve(cwd.get(), file)
if (fs.existsSync(resolvedFile)) {
return { type, path: resolvedFile }
}
}
}
}
}
function readData (config, context) {
const file = findFile(config, context)
if (file) {
if (file.type === 'package') {
const pkg = folders.readPackage(cwd.get(), context)
return pkg[config.files.package]
} else {
const rawContent = fs.readFileSync(file.path, { encoding: 'utf8' })
if (file.type === 'json') {
return JSON.parse(rawContent)
} else if (file.type === 'yaml') {
return yaml.safeLoad(rawContent)
} else if (file.type === 'js') {
// TODO
}
}
}
}
function writeData ({ config, data }, context) {
const file = findFile(config, context)
if (file) {
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') {
// TODO
}
}
fs.writeFileSync(file.path, rawContent, { encoding: 'utf8' })
}
}
function getPrompts (id, context) {
const config = findOne(id, context)
if (config) {
if (current.config !== config) {
const data = readData(config, context)
current = {
config,
data
}
const configData = config.onRead({
data
})
prompts.reset()
configData.prompts.forEach(prompts.add)
if (configData.answers) {
prompts.setAnswers(configData.answers)
}
prompts.start()
}
return prompts.list()
}
return []
}
function save (id, context) {
const config = findOne(id, context)
if (config) {
if (current.config === config) {
const answers = prompts.getAnswers()
let data = clone(current.data)
config.onWrite({
answers,
data,
file: {
...config.file,
assignData: newData => {
Object.assign(data, newData)
},
setData: newData => {
Object.keys(newData).forEach(key => {
set(data, key, newData[key])
})
}
}
})
writeData({ config, data }, context)
current = {}
}
}
return config
}
function cancel (id, context) {
const config = findOne(id, context)
if (config) {
current = {}
}
return config
}
module.exports = {
list,
findOne,
getPrompts,
save,
cancel
}

View File

@@ -80,7 +80,6 @@ function runPluginApi (id, context) {
const module = loadModule(`${id}/ui`, cwd.get(), true)
if (module) {
module(pluginApi)
console.log(`PluginApi called for ${id}`)
}
}

View File

@@ -1,3 +1,5 @@
const ObjectUtil = require('../../util/object')
let answers = {}
let prompts = []
@@ -103,53 +105,15 @@ function getChoices (prompt) {
}
function setAnswer (id, value) {
const fields = id.split('.')
let obj = answers
const l = fields.length
for (let i = 0; i < l - 1; i++) {
const key = fields[i]
if (!obj[key]) {
obj[key] = {}
}
obj = obj[key]
}
obj[fields[l - 1]] = value
ObjectUtil.set(answers, id, value)
}
function getAnswer (id) {
const fields = id.split('.')
let obj = answers
const l = fields.length
for (let i = 0; i < l - 1; i++) {
const key = fields[i]
if (!obj[key]) {
return undefined
}
obj = obj[key]
}
return obj[fields[l - 1]]
ObjectUtil.get(answers, id)
}
function removeAnswer (id) {
const fields = id.split('.')
let obj = answers
const l = fields.length
const objs = []
for (let i = 0; i < l - 1; i++) {
const key = fields[i]
if (!obj[key]) {
return
}
objs.splice(0, 0, { obj, key, value: obj[key] })
obj = obj[key]
}
delete obj[fields[l - 1]]
// Clear empty objects
for (const { obj, key, value } of objs) {
if (!Object.keys(value).length) {
delete obj[key]
}
}
ObjectUtil.remove(answers, id)
}
function generatePrompt (data) {

View File

@@ -113,11 +113,11 @@ function getPrompts (id, context) {
prompts.reset()
task.prompts.forEach(prompts.add)
const data = getSavedData(id, context)
if (data) {
prompts.setAnswers(data.answers)
}
prompts.start()
}
return prompts.list()

View File

@@ -11,6 +11,7 @@ const progress = require('./connectors/progress')
const logs = require('./connectors/logs')
const plugins = require('./connectors/plugins')
const tasks = require('./connectors/tasks')
const configurations = require('./connectors/configurations')
// Prevent code from exiting server process
exit.exitProcess = false
@@ -38,6 +39,10 @@ module.exports = {
prompts: (task, args, context) => tasks.getPrompts(task.id, context)
},
Configuration: {
prompts: (configuration, args, context) => configurations.getPrompts(configuration.id, context)
},
Query: {
cwd: () => cwd.get(),
consoleLogs: (root, args, context) => logs.list(context),
@@ -51,7 +56,9 @@ module.exports = {
pluginInstallation: (root, args, context) => plugins.getInstallation(context),
plugin: (root, { id }, context) => plugins.findOne(id, context),
tasks: (root, args, context) => tasks.list(context),
task: (root, { id }, context) => tasks.findOne(id, context)
task: (root, { id }, context) => tasks.findOne(id, context),
configurations: (root, args, context) => configurations.list(context),
configuration: (root, { id }, context) => configurations.findOne(id, context)
},
Mutation: {
@@ -77,7 +84,9 @@ module.exports = {
pluginUpdate: (root, { id }, context) => plugins.update(id, context),
taskRun: (root, { id }, context) => tasks.run(id, context),
taskStop: (root, { id }, context) => tasks.stop(id, context),
taskLogsClear: (root, { id }, context) => tasks.clearLogs(id, context)
taskLogsClear: (root, { id }, context) => tasks.clearLogs(id, context),
configurationSave: (root, { id }, context) => configurations.save(id, context),
configurationCancel: (root, { id }, context) => configurations.cancel(id, context)
},
Subscription: {

View File

@@ -239,6 +239,8 @@ type Mutation {
taskRun (id: ID!): Task
taskStop (id: ID!): Task
taskLogsClear (id: ID!): Task
configurationSave (id: ID!): Configuration
configurationCancel (id: ID!): Configuration
}
type Subscription {

View File

@@ -0,0 +1,12 @@
#import "./configurationFragment.gql"
#import "./promptFragment.gql"
query configuration ($id: ID!) {
configuration(id: $id) {
...configuration
link
prompts {
...prompt
}
}
}

View File

@@ -0,0 +1,7 @@
#import "./configurationFragment.gql"
mutation configurationCancel ($id: ID!) {
configurationCancel (id: $id) {
...configuration
}
}

View File

@@ -0,0 +1,6 @@
fragment configuration on Configuration {
id
name
description
icon
}

View File

@@ -0,0 +1,7 @@
#import "./configurationFragment.gql"
mutation configurationSave ($id: ID!) {
configurationSave (id: $id) {
...configuration
}
}

View File

@@ -0,0 +1,7 @@
#import "./configurationFragment.gql"
query configurations {
configurations {
...configuration
}
}

View File

@@ -236,9 +236,15 @@
}
}
},
"project-configuration": {
"project-configurations": {
"title": "Project configuration"
},
"project-configuration-details": {
"actions": {
"cancel": "Cancel changes",
"save": "Save changes"
}
},
"project-tasks": {
"title": "Project tasks"
},

View File

@@ -236,7 +236,7 @@
}
}
},
"project-configuration": {
"project-configurations": {
"title": "Configuration du projet"
},
"project-tasks": {

View File

@@ -211,7 +211,7 @@
}
}
},
"project-configuration": {
"project-configurations": {
"title": "プロジェクト設定"
},
"project-tasks": {

View File

@@ -16,6 +16,12 @@ export default function ({
).length === 0
},
hasPromptsChanged () {
return !!this.visiblePrompts.find(
prompt => prompt.valueChanged
)
},
visiblePrompts () {
if (!this[field]) {
return []

View File

@@ -5,7 +5,8 @@ import { apolloClient } from './vue-apollo'
import ProjectHome from './views/ProjectHome.vue'
import ProjectPlugins from './views/ProjectPlugins.vue'
import ProjectPluginsAdd from './views/ProjectPluginsAdd.vue'
import ProjectConfiguration from './views/ProjectConfiguration.vue'
import ProjectConfigurations from './views/ProjectConfigurations.vue'
import ProjectConfigurationDetails from './views/ProjectConfigurationDetails.vue'
import ProjectTasks from './views/ProjectTasks.vue'
import ProjectTaskDetails from './views/ProjectTaskDetails.vue'
import ProjectSelect from './views/ProjectSelect.vue'
@@ -43,8 +44,16 @@ const router = new Router({
},
{
path: 'configuration',
name: 'project-configuration',
component: ProjectConfiguration
name: 'project-configurations',
component: ProjectConfigurations,
children: [
{
path: ':id',
name: 'project-configuration-details',
component: ProjectConfigurationDetails,
props: true
}
]
},
{
path: 'tasks',

View File

@@ -0,0 +1,49 @@
exports.set = function (target, path, value) {
const fields = path.split('.')
let obj = target
const l = fields.length
for (let i = 0; i < l - 1; i++) {
const key = fields[i]
if (!obj[key]) {
obj[key] = {}
}
obj = obj[key]
}
obj[fields[l - 1]] = value
}
exports.get = function (target, path) {
const fields = path.split('.')
let obj = target
const l = fields.length
for (let i = 0; i < l - 1; i++) {
const key = fields[i]
if (!obj[key]) {
return undefined
}
obj = obj[key]
}
return obj[fields[l - 1]]
}
exports.remove = function (target, path) {
const fields = path.split('.')
let obj = target
const l = fields.length
const objs = []
for (let i = 0; i < l - 1; i++) {
const key = fields[i]
if (!obj[key]) {
return
}
objs.splice(0, 0, { obj, key, value: obj[key] })
obj = obj[key]
}
delete obj[fields[l - 1]]
// Clear empty objects
for (const { obj, key, value } of objs) {
if (!Object.keys(value).length) {
delete obj[key]
}
}
}

View File

@@ -1,10 +0,0 @@
<template>
<div class="project-configuration page">
<ContentView
:title="$t('views.project-configuration.title')"
class="limit-width"
>
WIP
</ContentView>
</div>
</template>

View File

@@ -0,0 +1,109 @@
<template>
<div class="project-configuration-details">
<div class="content">
<PromptsList
:prompts="visiblePrompts"
@answer="answerPrompt"
/>
</div>
<div class="actions-bar space-between">
<VueButton
:disabled="!hasPromptsChanged"
icon-left="cancel"
class="big"
:label="$t('views.project-configuration-details.actions.cancel')"
@click="cancel()"
/>
<VueButton
:disabled="!hasPromptsChanged"
icon-left="save"
class="primary big"
:label="$t('views.project-configuration-details.actions.save')"
@click="save()"
/>
</div>
</div>
</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
})
],
props: {
id: {
type: String,
required: true
}
},
data () {
return {
configuration: null
}
},
apollo: {
configuration: {
query: CONFIGURATION,
variables () {
return {
id: this.id
}
},
fetchPolicy: 'cache-and-network'
}
},
methods: {
async cancel () {
await this.$apollo.mutate({
mutation: CONFIGURATION_CANCEL,
variables: {
id: this.id
}
})
this.$apollo.queries.configuration.refetch()
},
async save () {
await this.$apollo.mutate({
mutation: CONFIGURATION_SAVE,
variables: {
id: this.id
}
})
this.$apollo.queries.configuration.refetch()
}
},
}
</script>
<style lang="stylus" scoped>
@import "~@/style/imports"
.project-configuration-details
v-box()
align-items stretch
height 100%
.content
flex 100% 1 1
height 0
overflow-x hidden
overflow-y auto
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div class="project-configurations page">
<ContentView
:title="$t('views.project-configurations.title')"
class="limit-width"
>
<ApolloQuery
:query="require('../graphql/configurations.gql')"
fetch-policy="cache-and-network"
class="fill-height"
>
<template slot-scope="{ result: { data, loading } }">
<VueLoadingIndicator
v-if="loading"
class="overlay"
/>
<NavContent
v-else-if="data"
:items="generateItems(data.configurations)"
class="configurations"
>
<ConfigurationItem
slot-scope="{ item, selected }"
:configuration="item.configuration"
:selected="selected"
/>
</NavContent>
</template>
</ApolloQuery>
</ContentView>
</div>
</template>
<script>
import RestoreRoute from '../mixins/RestoreRoute'
export default {
mixins: [
RestoreRoute()
],
methods: {
generateItems (configurations) {
return configurations.map(
configuration => ({
route: {
name: 'project-configuration-details',
params: {
id: configuration.id
}
},
configuration
})
)
}
}
}
</script>

View File

@@ -2162,6 +2162,10 @@ clone@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.3.tgz#298d7e2231660f40c003c2ed3140decf3f53085f"
clone@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
cmd-shim@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-2.0.2.tgz#6fcbda99483a8fd15d7d30a196ca69d688a2efdb"
@@ -6128,7 +6132,7 @@ js-tokens@^3.0.0, js-tokens@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
js-yaml@^3.10.0, js-yaml@^3.4.3, js-yaml@^3.5.2, js-yaml@^3.7.0, js-yaml@^3.8.1, js-yaml@^3.9.0, js-yaml@^3.9.1:
js-yaml@^3.10.0, js-yaml@^3.11.0, js-yaml@^3.4.3, js-yaml@^3.5.2, js-yaml@^3.7.0, js-yaml@^3.8.1, js-yaml@^3.9.0, js-yaml@^3.9.1:
version "3.11.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.11.0.tgz#597c1a8bd57152f26d622ce4117851a51f5ebaef"
dependencies: