From e5f10e318335abae11b3628ea7cf0d4ea0f5e1f8 Mon Sep 17 00:00:00 2001 From: Valentin Rigal <rigal@teklia.com> Date: Fri, 7 Apr 2023 09:33:54 +0000 Subject: [PATCH] Model creation form --- src/api/model.js | 3 + src/components/Model/ModelPicker.vue | 61 +++++++++++++-- src/router/index.js | 8 ++ src/store/model.js | 6 ++ src/views/Model/Create.vue | 106 +++++++++++++++++++++++++++ src/views/Model/List.vue | 27 +++++-- src/views/TrainingProcessCreate.vue | 4 +- tests/unit/store/model.spec.js | 26 +++++++ 8 files changed, 226 insertions(+), 15 deletions(-) create mode 100644 src/views/Model/Create.vue diff --git a/src/api/model.js b/src/api/model.js index e3fd8fb6c..3936872e8 100644 --- a/src/api/model.js +++ b/src/api/model.js @@ -1,6 +1,9 @@ import axios from 'axios' import { unique } from '.' +// Create a new model +export const createModel = unique(async params => (await axios.post('/models/', params)).data) + // List available models export const listModels = unique(async params => (await axios.get('/models/', { params })).data) diff --git a/src/components/Model/ModelPicker.vue b/src/components/Model/ModelPicker.vue index 3f021e871..387598b24 100644 --- a/src/components/Model/ModelPicker.vue +++ b/src/components/Model/ModelPicker.vue @@ -9,9 +9,18 @@ /> </div> <Modal is-large v-model="opened" :title="placeholder"> + <button + type="button" + class="button is-info is-pulled-right" + v-on:click="creationModal = true" + > + Create a model + </button> + <div class="title is-4">Available Models</div> + <form v-if="modelsPage" - class="field is-pulled-right" + class="field" v-on:submit.prevent="filter" > <div class="control"> @@ -23,7 +32,7 @@ /> </div> </form> - <div class="title is-4">Available Models</div> + <div class="control"> <Paginator :response="modelsPage" @@ -35,17 +44,26 @@ > <table class="table is-fullwidth is-hoverable"> <thead> - <tr><th>Name</th></tr> + <tr> + <th>Name</th> + <th>Description</th> + <th></th> + </tr> </thead> <tbody> <tr v-for="model in results" :key="model.id"> + <td :title="model.name"> + {{ truncateShort(model.name) }} + </td> + <td :title="model.description"> + {{ truncateShort(model.description) }} + </td> <td> - {{ model.name }} <button v-if="model.id !== modelValue?.id" type="button" class="button is-pulled-right" - v-on:click="$emit('update:modelValue', model)" + v-on:click="selectModel(model)" > Select </button> @@ -63,7 +81,17 @@ </table> </Paginator> </div> - </modal> + </Modal> + + <Modal + v-model="creationModal" + title="Create a model for training" + is-large + > + <CreateModel v-on:create-model="selectModel" /> + <!-- Prevent displaying modal's footer as the view above handles the creation form --> + <template v-slot:footer><div></div></template> + </Modal> </div> </template> @@ -72,14 +100,20 @@ import { mapActions, mapMutations } from 'vuex' import Paginator from '@/components/Paginator.vue' import { errorParser } from '@/helpers' import Modal from '@/components/Modal' +import CreateModel from '@/views/Model/Create.vue' +import { truncateMixin } from '@/mixins' /* * A component allowing to pick a specific model */ export default { + mixins: [ + truncateMixin + ], components: { Paginator, - Modal + Modal, + CreateModel }, emits: ['update:modelValue'], props: { @@ -97,7 +131,8 @@ export default { loading: false, page: 1, modelsPage: null, - nameFilter: '' + nameFilter: '', + creationModal: false }), methods: { ...mapMutations('notifications', ['notify']), @@ -118,12 +153,22 @@ export default { // Update models list. Reset page if required if (this.page === 1) return this.updateModelsPage() this.page = 1 + }, + selectModel (model) { + this.$emit('update:modelValue', model) + // Automatically close modals upon selection or creation + this.opened = false + this.creationModal = false } }, watch: { page: { immediate: true, handler: 'updateModelsPage' + }, + opened: { + // Reload in case the list has changed e.g. after a model is created + handler: 'updateModelsPage' } } } diff --git a/src/router/index.js b/src/router/index.js index a78b4c7f2..b7aa5fded 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -33,6 +33,7 @@ const ReposRights = () => import(/* webpackChunkName: "users" */ '@/views/Repos/ const ProcessList = () => import(/* webpackChunkName: "users" */ '@/views/Process/List') const ModelsList = () => import(/* webpackChunkName: "users" */ '@/views/Model/List') const Model = () => import(/* webpackChunkName: "users" */ '@/views/Model') +const ModelCreate = () => import(/* webpackChunkName: "users" */ '@/views/Model/Create') const ModelVersion = () => import(/* webpackChunkName: "users" */ '@/views/Model/Version') const ProcessDetails = () => import(/* webpackChunkName: "users" */ '@/views/Process/Details') const ProcessFilter = () => import(/* webpackChunkName: "users" */ '@/views/Process/Filter') @@ -331,6 +332,13 @@ const routes = [ meta: { requiresLogin: true, requiresVerified: true }, props: true }, + { + path: '/models/create', + name: 'model-create', + component: ModelCreate, + meta: { requiresLogin: true, requiresVerified: true }, + props: false + }, { path: `/model/:modelId(${UUID})`, name: 'model', diff --git a/src/store/model.js b/src/store/model.js index 9f1327c12..7cd10abb7 100644 --- a/src/store/model.js +++ b/src/store/model.js @@ -39,6 +39,12 @@ export const mutations = { } export const actions = { + async createModel ({ commit }, params) { + const resp = await api.createModel(params) + commit('setModels', [resp]) + return resp + }, + async listModels ({ commit }, params) { const resp = await api.listModels(params) commit('setModels', resp.results) diff --git a/src/views/Model/Create.vue b/src/views/Model/Create.vue new file mode 100644 index 000000000..7c70e60d1 --- /dev/null +++ b/src/views/Model/Create.vue @@ -0,0 +1,106 @@ +<template> + <main class="container is-fluid"> + <router-link v-if="mainView" class="button is-pulled-right" :to="{ name: 'models-list' }"> + <i class="icon-arrow-left"></i> + Models + </router-link> + <h1 class="title">New model</h1> + <h2 class="subtitle">Add a Machine-Learning model</h2> + + <form v-on:submit.prevent="create"> + <div class="field"> + <label class="label">Model name *</label> + <div class="control"> + <input + class="input is-fullwidth" + :disabled="loading || null" + type="text" + v-model="fields.name" + /> + </div> + <p v-if="errors.name" class="help is-danger">{{ errors.name }}</p> + </div> + + <div class="field"> + <label class="label">Model description</label> + <div class="control"> + <input + class="input is-fullwidth" + :disabled="loading || null" + type="text" + v-model="fields.description" + /> + </div> + <p v-if="errors.description" class="help is-danger">{{ errors.description }}</p> + </div> + + <div class="columns is-pulled-right mt-2"> + <div class="column is-narrow"> + <button + type="submit" + class="button is-primary is-fullwidth" + v-on:click="create" + :disabled="!allowCreate || null" + :title="allowCreate ? 'Create a new model' : 'A name is required to create a new model'" + > + <i v-if="loading" class="loader"></i> + <span v-else class="icon-plus">Create model</span> + </button> + </div> + </div> + </form> + </main> +</template> + +<script> +import { mapMutations } from 'vuex' +import { corporaMixin } from '@/mixins.js' +import { errorParser } from '@/helpers' + +export default { + emits: [ + 'create-model' + ], + mixins: [ + corporaMixin + ], + data: () => ({ + loading: false, + fields: { + name: '', + description: '' + }, + errors: {} + }), + computed: { + mainView () { + // The component can be mounted as a single view + return this.$route.name === 'model-create' + }, + allowCreate () { + return this.fields.name && !this.loading + } + }, + methods: { + ...mapMutations('notifications', ['notify']), + async create () { + if (!this.allowCreate) return + this.loading = true + this.errors = {} + try { + const payload = { name: this.fields.name } + if (this.fields.description.trim()) payload.description = this.fields.description + const resp = await this.$store.dispatch('model/createModel', payload) + this.notify({ type: 'success', text: `Model "${this.fields.name}" created successfully.` }) + this.$emit('create-model', resp) + this.fields.name = this.fields.description = '' + } catch (err) { + if (err?.response?.data) this.errors = err.response.data + this.notify({ type: 'error', text: errorParser(err) }) + } finally { + this.loading = false + } + } + } +} +</script> diff --git a/src/views/Model/List.vue b/src/views/Model/List.vue index 733937c77..edf33ad9b 100644 --- a/src/views/Model/List.vue +++ b/src/views/Model/List.vue @@ -2,10 +2,15 @@ <main class="container is-fluid"> <div class="columns"> <div class="field column is-one-third"> + <router-link class="button is-primary is-pulled-right" :to="{ name: 'model-create' }"> + Create a model + </router-link> + + <div class="title is-4">Available Models</div> <!-- Selection of a worker is required to list versions --> <form v-if="modelsPage" - class="field is-pulled-right" + class="field" v-on:submit.prevent="filter" > <div class="control"> @@ -17,7 +22,7 @@ /> </div> </form> - <div class="title is-4">Available Models</div> + <div class="control"> <Paginator :response="modelsPage" @@ -29,7 +34,11 @@ > <table class="table is-fullwidth is-hoverable"> <thead> - <tr><th>Name</th></tr> + <tr> + <th>Name</th> + <th>Description</th> + <th></th> + </tr> </thead> <tbody> <tr @@ -39,9 +48,13 @@ class="is-clickable" :class="{ 'is-selected': selectedModel === model.id }" > - <td> - {{ model.name }} + <td :title="model.name"> + {{ truncateShort(model.name) }} + </td> + <td :title="model.description"> + {{ truncateShort(model.description) }} </td> + <td></td> </tr> </tbody> </table> @@ -68,8 +81,12 @@ import { mapActions, mapMutations } from 'vuex' import Model from '@/components/Model' import Paginator from '@/components/Paginator.vue' import { errorParser } from '@/helpers' +import { truncateMixin } from '@/mixins' export default { + mixins: [ + truncateMixin + ], components: { Paginator, Model diff --git a/src/views/TrainingProcessCreate.vue b/src/views/TrainingProcessCreate.vue index 0fce5608c..1560ebc10 100644 --- a/src/views/TrainingProcessCreate.vue +++ b/src/views/TrainingProcessCreate.vue @@ -324,7 +324,7 @@ export default { }, data: () => ({ workerVersionModal: false, - modelModal: false, + modelCreationModal: false, modelVersionModal: false, loading: false, fieldErrors: {}, @@ -408,7 +408,7 @@ export default { this.model = model // Remove an optional model version this.modelVersion = null - this.modelModal = false + this.modelCreationModal = false }, selectModelVersion (modelVersion) { this.modelVersion = modelVersion diff --git a/tests/unit/store/model.spec.js b/tests/unit/store/model.spec.js index be0dcc8e9..e5140b20c 100644 --- a/tests/unit/store/model.spec.js +++ b/tests/unit/store/model.spec.js @@ -69,6 +69,32 @@ describe('model', () => { mock.restore() }) + describe('createModel', () => { + it('creates a model', async () => { + store.state.model.models = { model_0: { id: 'model_0' } } + const attrs = { name: 'test', description: 'A' } + mock.onPost('/models/').reply(201, { id: 'model_1', ...attrs }) + + await store.dispatch('model/createModel', attrs) + + assert.deepStrictEqual(store.history, [ + { action: 'model/createModel', payload: attrs }, + { mutation: 'model/setModels', payload: [{ id: 'model_1', ...attrs }] } + ]) + assert.deepStrictEqual(mock.history.all.map(req => pick(req, ['method', 'url', 'data'])), [ + { + method: 'post', + url: '/models/', + data: attrs + } + ]) + assert.deepStrictEqual(store.state.model.models, { + model_0: { id: 'model_0' }, + model_1: { id: 'model_1', ...attrs } + }) + }) + }) + describe('listModels', () => { it('lists stored models with filters', async () => { const reply = { -- GitLab