Skip to content
Snippets Groups Projects
Commit 6124ef79 authored by Erwan Rouchet's avatar Erwan Rouchet
Browse files

Merge branch 'existing-worker-configuration' into 'master'

configuration selection modal

Closes #860

See merge request !1129
parents a42df017 a481ddcf
No related branches found
No related tags found
1 merge request!1129configuration selection modal
import { cloneDeep } from 'lodash'
import assert from 'assert'
import axios from 'axios'
import Vuex from 'vuex'
import { mount, createLocalVue } from '@vue/test-utils'
import store from '~/test/store'
import { workerConfigurationsSample } from '~/test/samples'
import { FakeAxios } from '~/test/testhelpers'
import ConfigurationsList from '~/vue/Process/Workers/Configurations/List.vue'
const localVue = createLocalVue()
localVue.use(Vuex)
describe('Process/Workers/Configurations/List.vue', () => {
let mock
before('Setting up Axios mock', () => {
mock = new FakeAxios(axios)
})
afterEach(() => {
mock.reset()
store.reset()
})
after('Removing mocks', () => {
mock.restore()
})
it('handles worker run update errors', async () => {
mock.onPatch('/imports/workers/workerRun1/', { configuration_id: 'configid1' }).reply(400, { detail: 'Something went wrong.' })
// see the "nodes" computed property in vue/Process/Configure.vue
const workerRunNode = {
parents: [],
slug: 'workerRun1',
name: 'reco',
type: 'reco',
configurationId: null,
workerId: 'worker_1'
}
/*
* if workerConfigurations isn't set in the store beforehand, the API call
* that lists and sets them is made but the configurations are not displayed
*/
store.state.process = {
workerConfigurations: {
worker_1: {
configid1: cloneDeep(workerConfigurationsSample)[0],
configid2: cloneDeep(workerConfigurationsSample)[1]
}
}
}
const wrapper = mount(ConfigurationsList, {
store,
localVue,
propsData: {
workerRun: workerRunNode,
dataImportId: 'dataimportid'
}
})
const openButton = wrapper.find('div').get('span')
await openButton.trigger('click')
const selectConfig = wrapper.get('ul').findAll('li').at(1).get('a')
await selectConfig.trigger('click')
const saveButton = wrapper.get('button[class="button is-primary"]')
await saveButton.trigger('click')
await store.actionsCompleted()
assert.deepStrictEqual(store.history, [
{
action: 'process/updateWorkerRun',
payload: {
dataImportId: 'dataimportid',
workerRunId: 'workerRun1',
payload: {
configuration_id: 'configid1'
}
}
},
{
mutation: 'notifications/notify',
payload: {
type: 'error',
text: 'Something went wrong.'
}
}
])
})
})
<template>
<form>
<div class="field">
<label class="label">Name</label>
<input
class="input"
type="text"
v-model="workerConfig.name"
:disabled="loading"
/>
</div>
<div class="field">
<label class="label">JSON payload</label>
<div class="control">
<textarea
class="textarea is-family-monospace"
:class="{ 'is-danger': Boolean(configError) }"
v-model="workerConfig.configuration"
:disabled="loading"
placeholder="{}"
></textarea>
<p class="help is-danger" v-if="configError">{{ configError }}</p>
<p class="help" v-else>You can define here any JSON object that will be made available to the worker when it runs.</p>
</div>
</div>
<button
type="button"
class="button is-success"
:disabled="!allowCreate"
v-on:click="createConfiguration"
:title="allowCreate ? 'Create a new configuration' : disabledTitle"
>
Create
</button>
</form>
</template>
<script>
import { isPlainObject, isEmpty } from 'lodash'
import { errorParser } from '~/js/helpers'
import { mapMutations, mapState } from 'vuex'
export default {
props: {
workerRun: {
type: Object,
required: true
}
},
data: () => ({
loading: false,
workerConfig: { name: '', configuration: '' },
configurationsError: null
}),
computed: {
...mapState('process', ['workerConfigurations']),
workerId () {
return this.workerRun.workerId
},
configInput () {
return this.workerConfig.configuration
},
configError () {
if (!this.configInput.trim()) return null
try {
const config = JSON.parse(this.workerConfig.configuration.trim())
if (!isPlainObject(config) | !config || isEmpty(config)) return 'Please enter a valid JSON configuration'
} catch (e) {
if (e instanceof SyntaxError) return e.message
throw e
}
return null
},
allowCreate () {
return !this.loading && !this.configError && this.workerConfig.name.trim() && this.configInput && this.configInput.trim()
},
disabledTitle () {
const name = this.workerConfig.name.trim()
if (!name && !this.configInput.trim()) return 'Please fill out the creation form'
else if (this.configError | !this.configInput.trim()) return 'Please enter a valid JSON configuration'
else if (!name) return 'Please name your configuration'
else return ''
}
},
methods: {
...mapMutations('notifications', ['notify']),
async createConfiguration () {
if (this.loading || this.configError || !this.workerConfig.name) return
this.loading = true
try {
const configuration = await this.$store.dispatch('process/createConfiguration', {
workerId: this.workerRun.workerId,
configuration: {
...this.workerConfig,
configuration: JSON.parse(this.configInput.trim())
}
})
// Go back to configurations list only if the creation succeeded
this.createConfig = false
this.workerConfig = { name: '', configuration: '' }
// Select the newly created configuration
this.$emit('createdConfiguration', configuration.id)
this.notify({ type: 'info', text: 'Configuration created. Save to add it to this worker run.' })
} catch (err) {
this.$store.commit('notifications/notify', { type: 'error', text: errorParser(err) }, { root: true })
this.loading = false
}
}
}
}
</script>
<template>
<div>
<span class="button" v-on:click="configModal = true">
Configure
</span>
<Modal
:title="'Configure ' + (workerRun.name || workerRun.slug)"
is-large
v-model="configModal"
>
<div v-if="!loading" class="columns contains-menu">
<div class="column is-narrow has-overflow-auto">
<aside class="menu">
<ul class="menu-list">
<li>
<a
class="no-config"
:class="{ 'is-active': !selectedConfiguration }"
v-on:click="unsetConfiguration"
>
No configuration
</a>
</li>
<li v-if="configurationsError">{{ configurationsError }}</li>
<li v-else v-for="configuration in workerConfigurations[workerId]" :key="configuration.id">
<a
v-on:click="setConfiguration(configuration.id)"
:class="{ 'is-active': selectedConfiguration === configuration.id }"
>
{{ configuration.name }}
</a>
</li>
<li>
<a v-on:click="configCreate = true">
<i class="icon-add-square"></i>
New configuration
</a>
</li>
</ul>
</aside>
</div>
<div class="column">
<pre v-if="selectedConfiguration && !configCreate">{{ configContent | pretty }}</pre>
<div v-else-if="configCreate">
<CreateForm
v-on:createdConfiguration="createdConfiguration"
:worker-run="workerRun"
/>
</div>
<div v-else class="notification is-info">
Select a <strong>worker configuration</strong> on the left panel to see its details.
</div>
</div>
</div>
<template v-slot:footer>
<button class="button is-primary" v-on:click="saveConfiguration">Save</button>
</template>
</Modal>
</div>
</template>
<script>
import CreateForm from './Form'
import Modal from '~/vue/Modal'
import { errorParser } from '~/js/helpers'
import { mapActions, mapState } from 'vuex'
export default {
components: {
Modal,
CreateForm
},
props: {
/*
* This workerRun prop actually comes from the "nodes" computed property in
* vue/Process/Configure.vue which maps worker runs to objects with
* readily accessible workerId, name and type attributes
*/
workerRun: {
type: Object,
required: true
},
configurationId: {
type: String,
default: null
},
dataImportId: {
type: String,
required: true
}
},
data: () => ({
/*
* Clicking the 'create configuration' button should always open the creation form
* and set configCreate to true, and clicking on 'no configuration' or on some other
* configuration in the list should close the form and set configCreate to false
*/
configCreate: false,
configModal: false,
loading: false,
configurationsError: null,
// selectedConfiguration stores the current selected configuration before it is confirmed by the user (Save)
selectedConfiguration: null
}),
computed: {
...mapState('process', ['workerConfigurations']),
workerId () {
return this.workerRun.workerId
},
configContent () {
if (!this.selectedConfiguration) return
if (!this.workerConfigurations[this.workerId] ?? !this.workerConfigurations[this.workerId][this.selectedConfiguration]) return
return this.workerConfigurations[this.workerId][this.selectedConfiguration].configuration
}
},
mounted () {
this.selectedConfiguration = this.configurationId || null
},
methods: {
...mapActions('process', ['listConfigurations', 'updateWorkerRun']),
async retrieveConfigurations () {
if (!this.workerId || this.workerConfigurations[this.workerId]) return
this.configurationsError = null
this.loading = true
try {
await this.listConfigurations({ workerId: this.workerId })
} catch (err) {
this.configurationsError = errorParser(err)
} finally {
this.loading = false
}
},
setConfiguration (configId) {
if (this.selectedConfiguration !== configId) this.selectedConfiguration = configId
if (this.configCreate) this.configCreate = false
},
unsetConfiguration () {
if (this.configCreate) this.configCreate = false
if (!this.selectedConfiguration) return
this.selectedConfiguration = null
},
createdConfiguration (newConfiguration) {
this.selectedConfiguration = newConfiguration
this.configCreate = false
},
async saveConfiguration () {
if (this.loading) return
if (this.selectedConfiguration === this.configurationId) {
this.configModal = false
return
}
this.loading = true
const payload = { configuration_id: this.selectedConfiguration }
try {
await this.$store.dispatch('process/updateWorkerRun', {
dataImportId: this.dataImportId,
workerRunId: this.workerRun.slug,
payload
})
if (this.selectedConfiguration) {
this.$store.commit('notifications/notify', {
type: 'success',
text: `Configuration ${this.workerConfigurations[this.workerId][this.selectedConfiguration].name} added to worker run.`
})
} else {
this.$store.commit('notifications/notify', { type: 'success', text: 'Configuration removed from worker run.' })
}
this.configModal = false
} catch (err) {
this.$store.commit('notifications/notify', { type: 'error', text: errorParser(err) }, { root: true })
throw err
} finally {
this.loading = false
}
}
},
filters: {
pretty: function (value) {
return JSON.stringify(value, null, 2)
}
},
watch: {
workerId: {
immediate: true,
handler () {
this.retrieveConfigurations()
}
},
configurationId: {
handler (newValue) {
this.selectedConfiguration = newValue
}
}
}
}
</script>
<style scoped>
.has-overflow-auto {
height: inherit;
overflow: auto
}
.contains-menu {
height: 100%
}
.no-config {
font-style: italic;
border-bottom: 2px solid lightgrey;
}
</style>
......@@ -11,26 +11,13 @@
>
</a>
<WorkerTag class="has-text-dark no-text-transform is-size-6" :worker-tag="workerRun" />
<span v-if="configurationName" class="tag no-text-transform is-success">{{ configurationName | truncateShort }}</span>
</div>
<div class="control">
<div class="select">
<select v-model="configurationId" v-on:change="updateConfiguration" :disabled="!workerConfigurations[workerRun.workerId]">
<option value="">
No configuration
</option>
<option
v-for="(config, configId) in workerConfigurations[workerRun.workerId]"
:key="configId"
:value="configId"
>
{{ config.name }}
</option>
</select>
</div>
</div>
<span class="button" v-on:click="configModal = true">
Create new configuration
</span>
<ConfigurationsList
:configuration-id="configurationId"
:worker-run="workerRun"
:data-import-id="dataImportId"
/>
<span
v-on:click="toggle"
class="control button"
......@@ -71,53 +58,24 @@
></a>
</a>
</div>
<Modal v-model="configModal" :title="'Configure ' + (workerRun.name || workerRun.slug)">
<label class="label">Name</label>
<input
class="input"
type="text"
v-model="workerConfig.name"
:disabled="loading"
/>
<div class="field">
<label class="label">JSON payload</label>
<p>You can define here any JSON object that will be made available to the worker when it runs.</p>
<div class="control">
<textarea
class="textarea is-family-monospace"
:class="{ 'is-danger': Boolean(configError) }"
v-model="workerConfig.configuration"
:disabled="loading"
></textarea>
<p class="help is-danger" v-if="configError">{{ configError }}</p>
</div>
</div>
<template v-slot:footer>
<button
type="button"
class="button is-success"
:disabled="loading || configError || !workerConfig.name"
v-on:click="createConfiguration"
>
Create
</button>
</template>
</Modal>
</div>
</template>
<script>
import { isPlainObject } from 'lodash'
import { mapState } from 'vuex'
import { errorParser } from '~/js/helpers'
import WorkerTag from './WorkerTag'
import Modal from '~/vue/Modal'
import ConfigurationsList from './Configurations/List'
import { truncateMixin } from '~/js/mixins'
import { mapState } from 'vuex'
export default {
components: {
WorkerTag,
Modal
ConfigurationsList
},
mixins: [
truncateMixin
],
props: {
workerRun: {
type: Object,
......@@ -136,13 +94,10 @@ export default {
data: () => ({
toggled: false,
loading: false,
configModal: false,
workerConfig: { name: '', configuration: '{}' },
configurationId: ''
}),
created () {
this.initConfiguration()
window.addEventListener('click', (e) => {
// Toggle Parents dropdown off when user clicks outside component
if (!this.$el.contains(e.target)) this.toggled = false
......@@ -150,6 +105,13 @@ export default {
},
computed: {
...mapState('process', ['workerConfigurations']),
workerId () {
return this.workerRun.workerId
},
configurationName () {
const currentWorkerConfigurations = this.workerConfigurations[this.workerId] ?? {}
return currentWorkerConfigurations[this.configurationId]?.name
},
depsIcon () {
return {
'icon-arrow-right': this.toggled,
......@@ -172,19 +134,6 @@ export default {
return this.workerRunsNodes.filter(run =>
this.workerRun.parents.some(parent => parent.slug === run.slug)
)
},
configInput () {
return this.workerConfig.configuration
},
configError () {
if (!this.configInput.trim()) return null
try {
if (!isPlainObject(JSON.parse(this.configInput.trim()))) return 'Payload should be a JSON object'
return null
} catch (e) {
if (e instanceof SyntaxError) return e.message
throw e
}
}
},
methods: {
......@@ -213,33 +162,6 @@ export default {
parents: this.workerRun.parents.map(parent => parent.slug).filter(id => id !== parentToRemove.slug)
})
},
async createConfiguration () {
if (this.loading || this.configError || !this.workerConfig.name) return
this.loading = true
try {
const configuration = await this.$store.dispatch('process/createConfiguration', {
workerId: this.workerRun.workerId,
configuration: {
...this.workerConfig,
configuration: JSON.parse(this.configInput.trim())
}
})
this.loading = false
// Close the modal only if the creation succeeded
this.configModal = false
this.workerConfig = { name: '', configuration: '{}' }
// Update the configuration with the newly created one
this.configurationId = configuration.id
this.updateConfiguration()
} catch (err) {
this.$store.commit('notifications/notify', { type: 'error', text: errorParser(err) }, { root: true })
this.loading = false
}
},
async updateConfiguration () {
// Restore the previous value if the update failed
if (!await this.updateWorkerRun({ configuration_id: this.configurationId })) this.configurationId = this.workerRun.configurationId || ''
},
async updateWorkerRun (payload) {
if (this.loading) return
this.loading = true
......@@ -259,8 +181,6 @@ export default {
},
initConfiguration () {
this.configurationId = this.workerRun.configurationId || ''
const workerId = this.workerRun.workerId
if (!(workerId in this.workerConfigurations)) this.$store.dispatch('process/listConfigurations', { workerId })
}
},
watch: {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment