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