From 0b2572d19eb9f5274414dc20cec1afcf433da5f9 Mon Sep 17 00:00:00 2001
From: Erwan Rouchet <rouchet@teklia.com>
Date: Mon, 28 Nov 2022 12:56:06 +0000
Subject: [PATCH] Model and ModelVersion views

---
 src/api/model.js                              |   2 +
 src/components/Model/Model.vue                |  58 +++++++
 src/components/Model/Selection.vue            |   2 +-
 src/components/Model/index.ts                 |   1 +
 .../Workers/WorkerRuns/WorkerRunDetails.vue   |   8 +-
 src/router/index.js                           |  18 ++-
 src/store/model.js                            |   5 +
 src/views/{ModelList.vue => Model/List.vue}   |  35 +----
 src/views/Model/Model.vue                     | 113 ++++++++++++++
 src/views/Model/Version.vue                   | 145 ++++++++++++++++++
 src/views/Model/index.ts                      |   1 +
 tests/unit/store/model.spec.js                |  46 ++++++
 12 files changed, 401 insertions(+), 33 deletions(-)
 create mode 100644 src/components/Model/Model.vue
 create mode 100644 src/components/Model/index.ts
 rename src/views/{ModelList.vue => Model/List.vue} (80%)
 create mode 100644 src/views/Model/Model.vue
 create mode 100644 src/views/Model/Version.vue
 create mode 100644 src/views/Model/index.ts

diff --git a/src/api/model.js b/src/api/model.js
index 6d3982f0c..e3fd8fb6c 100644
--- a/src/api/model.js
+++ b/src/api/model.js
@@ -4,6 +4,8 @@ import { unique } from '.'
 // List available models
 export const listModels = unique(async params => (await axios.get('/models/', { params })).data)
 
+export const retrieveModel = unique(async id => (await axios.get(`/model/${id}/`)).data)
+
 // List available model version
 export const listModelVersions = unique(async ({ id, ...params }) => (await axios.get(`/model/${id}/versions/`, { params })).data)
 
diff --git a/src/components/Model/Model.vue b/src/components/Model/Model.vue
new file mode 100644
index 000000000..71b07518b
--- /dev/null
+++ b/src/components/Model/Model.vue
@@ -0,0 +1,58 @@
+<template>
+  <div class="title is-4">Versions</div>
+  <div class="control">
+    <VersionList
+      :model-id="modelId"
+      :worker-run-id="workerRunId"
+      :process-id="processId"
+    />
+  </div>
+  <template v-if="!processId">
+    <hr />
+    <h2 class="title is-4">Members</h2>
+    <ListMembers
+      content-type="model"
+      :content-id="modelId"
+      v-model:page-number="membersPageNumber"
+    />
+  </template>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { UUID_REGEX } from '@/config'
+import ListMembers from '@/components/Memberships/ListMembers.vue'
+import VersionList from './Versions/List.vue'
+
+export default defineComponent({
+  props: {
+    modelId: {
+      type: String,
+      required: true,
+      validator: value => typeof value === 'string' && UUID_REGEX.test(value)
+    },
+    processId: {
+      type: String,
+      default: '',
+      validator: value => typeof value === 'string' && (value === '' || UUID_REGEX.test(value))
+    },
+    workerRunId: {
+      type: String,
+      default: '',
+      validator: value => typeof value === 'string' && (value === '' || UUID_REGEX.test(value))
+    }
+  },
+  components: {
+    ListMembers,
+    VersionList
+  },
+  data: () => ({
+    membersPageNumber: 1
+  }),
+  watch: {
+    modelId () {
+      this.membersPageNumber = 1
+    }
+  }
+})
+</script>
diff --git a/src/components/Model/Selection.vue b/src/components/Model/Selection.vue
index 97cfa8a9e..635fa96e5 100644
--- a/src/components/Model/Selection.vue
+++ b/src/components/Model/Selection.vue
@@ -30,7 +30,7 @@
 <script>
 import { mapState, mapActions } from 'vuex'
 import Modal from '@/components/Modal.vue'
-import ModelList from '@/views/ModelList'
+import ModelList from '@/views/Model/List'
 
 export default {
   components: {
diff --git a/src/components/Model/index.ts b/src/components/Model/index.ts
new file mode 100644
index 000000000..02ad20bf5
--- /dev/null
+++ b/src/components/Model/index.ts
@@ -0,0 +1 @@
+export { default } from './Model.vue'
diff --git a/src/components/Process/Workers/WorkerRuns/WorkerRunDetails.vue b/src/components/Process/Workers/WorkerRuns/WorkerRunDetails.vue
index 7c8432ca4..3a2f1aafd 100644
--- a/src/components/Process/Workers/WorkerRuns/WorkerRunDetails.vue
+++ b/src/components/Process/Workers/WorkerRuns/WorkerRunDetails.vue
@@ -124,18 +124,18 @@
       <span class="button" v-on:click="close">Close</span>
       <div class="field is-grouped ml-auto">
         <router-link
-          v-if="workerRun?.worker_version.worker.id"
+          v-if="workerRun?.model_version?.id"
           class="button"
-          :to="{ name: 'worker-manage', params: { workerId: workerRun?.worker_version.worker.id } }"
+          :to="{ name: 'model-version', params: { versionId: workerRun?.model_version.id } }"
         >
-          View worker
+          View model version
         </router-link>
         <router-link
           v-if="workerRun?.worker_version.id"
           class="button"
           :to="{ name: 'worker-version', params: { versionId: workerRun?.worker_version.id } }"
         >
-          View version
+          View worker version
         </router-link>
         <router-link
           v-if="workerRun?.process.id"
diff --git a/src/router/index.js b/src/router/index.js
index c674a418e..00f4cca3a 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -31,7 +31,9 @@ const ReposList = () => import(/* webpackChunkName: "users" */ '@/views/Repos/Li
 const ReposCreate = () => import(/* webpackChunkName: "users" */ '@/views/Repos/Create')
 const ReposRights = () => import(/* webpackChunkName: "users" */ '@/views/Repos/Rights')
 const ProcessList = () => import(/* webpackChunkName: "users" */ '@/views/Process/List')
-const ModelsList = () => import(/* webpackChunkName: "users" */ '@/views/ModelList')
+const ModelsList = () => import(/* webpackChunkName: "users" */ '@/views/Model/List')
+const Model = () => import(/* webpackChunkName: "users" */ '@/views/Model')
+const ModelVersion = () => import(/* webpackChunkName: "users" */ '@/views/Model/Version')
 const ProcessStatus = () => import(/* webpackChunkName: "users" */ '@/views/Process/Status')
 const ProcessFilter = () => import(/* webpackChunkName: "users" */ '@/views/Process/Filter')
 const ProcessConfigure = () => import(/* webpackChunkName: "users" */ '@/views/Process/Configure')
@@ -326,6 +328,20 @@ const routes = [
     meta: { requiresLogin: true, requiresVerified: true },
     props: true
   },
+  {
+    path: `/model/:modelId(${UUID})`,
+    name: 'model',
+    component: Model,
+    meta: { requiresLogin: true, requiresVerified: true },
+    props: true
+  },
+  {
+    path: `/model-version/:versionId(${UUID})`,
+    name: 'model-version',
+    component: ModelVersion,
+    meta: { requiresLogin: true, requiresVerified: true },
+    props: true
+  },
   {
     path: '/errors/unreachable',
     name: 'no-backend',
diff --git a/src/store/model.js b/src/store/model.js
index 4fd1f7115..9f1327c12 100644
--- a/src/store/model.js
+++ b/src/store/model.js
@@ -51,6 +51,11 @@ export const actions = {
     return resp
   },
 
+  async retrieveModel ({ commit }, modelId) {
+    const resp = await api.retrieveModel(modelId)
+    commit('setModels', [resp])
+  },
+
   async getModelVersion ({ commit }, modelVersionId) {
     const resp = await api.retrieveModelVersion(modelVersionId)
     commit('setModelVersions', [resp])
diff --git a/src/views/ModelList.vue b/src/views/Model/List.vue
similarity index 80%
rename from src/views/ModelList.vue
rename to src/views/Model/List.vue
index fe4181e1b..733937c77 100644
--- a/src/views/ModelList.vue
+++ b/src/views/Model/List.vue
@@ -52,25 +52,12 @@
         <div v-if="!selectedModel" class="notification is-info">
           Please select a <strong>model</strong> on the left panel.
         </div>
-        <template v-else>
-          <div class="title is-4">Versions</div>
-          <div class="control">
-            <VersionList
-              :model-id="selectedModel"
-              :worker-run-id="workerRunId"
-              :process-id="processId"
-            />
-          </div>
-          <template v-if="!processId">
-            <hr />
-            <h2 class="title is-4">Members</h2>
-            <ListMembers
-              content-type="model"
-              :content-id="selectedModel"
-              v-model:page-number="membersPageNumber"
-            />
-          </template>
-        </template>
+        <Model
+          v-else
+          :model-id="selectedModel"
+          :process-id="processId"
+          :worker-run-id="workerRunId"
+        />
       </div>
     </div>
   </main>
@@ -78,16 +65,14 @@
 
 <script>
 import { mapActions, mapMutations } from 'vuex'
-import VersionList from '@/components/Model/Versions/List'
+import Model from '@/components/Model'
 import Paginator from '@/components/Paginator.vue'
 import { errorParser } from '@/helpers'
-import ListMembers from '@/components/Memberships/ListMembers.vue'
 
 export default {
   components: {
     Paginator,
-    VersionList,
-    ListMembers
+    Model
   },
   props: {
     processId: {
@@ -107,7 +92,6 @@ export default {
   data: () => ({
     loading: false,
     modelsPage: null,
-    membersPageNumber: 1,
     // ID of the selected model
     selectedModel: null,
     page: 1,
@@ -139,9 +123,6 @@ export default {
     page: {
       immediate: true,
       handler: 'updateModelsPage'
-    },
-    selectedModel () {
-      this.membersPageNumber = 1
     }
   }
 }
diff --git a/src/views/Model/Model.vue b/src/views/Model/Model.vue
new file mode 100644
index 000000000..cb05fc7f8
--- /dev/null
+++ b/src/views/Model/Model.vue
@@ -0,0 +1,113 @@
+<template>
+  <main class="container is-fluid">
+    <template v-if="model">
+      <router-link class="button is-pulled-right" :to="{ name: 'models-list' }">
+        <i class="icon-arrow-left"></i>
+        Models
+      </router-link>
+      <div class="subtitle is-5">Model</div>
+      <div class="title">{{ model.name }}</div>
+
+      <div class="columns">
+        <div class="column">
+          <div class="field">
+            <label class="label">Created</label>
+            <div class="control">
+              <p>{{ creationDate }}</p>
+            </div>
+          </div>
+        </div>
+        <div class="column">
+          <div class="field">
+            <label class="label">Last updated</label>
+            <div class="control">
+              <p>{{ updateDate }}</p>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="field">
+        <label class="label">Description</label>
+        <div class="control">
+          <p class="has-line-breaks">
+            {{ model.description || '—' }}
+          </p>
+        </div>
+      </div>
+
+      <hr />
+      <Model :model-id="modelId" />
+    </template>
+    <div v-else-if="error" class="notification is-danger">{{ error }}</div>
+    <div v-else class="loader is-size-2 mx-auto"></div>
+  </main>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { mapState, mapMutations, mapActions } from 'vuex'
+import { UUID_REGEX } from '@/config'
+import { ago, errorParser } from '@/helpers'
+import { UUID } from '@/types'
+import Model from '@/components/Model'
+
+export default defineComponent({
+  props: {
+    modelId: {
+      type: String,
+      required: true,
+      validator: value => typeof value === 'string' && UUID_REGEX.test(value)
+    }
+  },
+  components: {
+    Model
+  },
+  data: () => ({
+    loading: false,
+    error: null as string | null
+  }),
+  computed: {
+    ...mapState('model', ['models']),
+    model () {
+      return this.models[this.modelId]
+    },
+    creationDate () {
+      if (!this.model) return
+      return ago(new Date(this.model.created))
+    },
+    updateDate () {
+      if (!this.model) return
+      return ago(new Date(this.model.updated))
+    }
+  },
+  methods: {
+    ...mapMutations('notifications', ['notify']),
+    ...mapActions('model', ['retrieveModel']),
+    async loadModel (modelId: UUID) {
+      this.loading = true
+      try {
+        await this.retrieveModel(modelId)
+      } catch (err) {
+        this.error = errorParser(err)
+      } finally {
+        this.loading = false
+      }
+    }
+  },
+  watch: {
+    modelId: {
+      immediate: true,
+      handler (newValue: UUID) {
+        if (!this.models[newValue]) this.loadModel(newValue)
+      }
+    }
+  }
+})
+</script>
+
+<style scoped>
+.has-line-breaks {
+  white-space: pre-line
+}
+</style>
diff --git a/src/views/Model/Version.vue b/src/views/Model/Version.vue
new file mode 100644
index 000000000..a46b5a071
--- /dev/null
+++ b/src/views/Model/Version.vue
@@ -0,0 +1,145 @@
+<template>
+  <main class="container is-fluid">
+    <template v-if="model">
+      <h1 class="title is-3">
+        <router-link :to="{ name: 'model', params: { modelId: model.id } }">
+          {{ model.name }}
+        </router-link>
+      </h1>
+      <p class="subtitle is-5">Version <ItemId :item-id="version.id" /></p>
+      <hr />
+
+      <div class="field">
+        <label class="label">State</label>
+        <div class="control">
+          <div class="tag is-capitalized" :class="stateClass">{{ version.state }}</div>
+        </div>
+      </div>
+
+      <div class="field">
+        <label class="label">Description</label>
+        <div class="control">
+          <p class="has-line-breaks">
+            {{ version.description || '—' }}
+          </p>
+        </div>
+      </div>
+
+      <div class="field">
+        <label class="label">Tag</label>
+        <div class="control">
+          <p>{{ version.tag || '—' }}</p>
+        </div>
+      </div>
+
+      <div class="field">
+        <label class="label">Parent</label>
+        <div class="control">
+          <router-link
+            v-if="version.parent"
+            :to="{ name: 'model-version', params: { versionId: version.parent } }"
+          >
+            <samp>{{ truncateShort(version.parent) }}</samp>
+          </router-link>
+          <template v-else>—</template>
+        </div>
+      </div>
+
+      <div class="field">
+        <label class="label">Size</label>
+        <div class="control">
+          <p :title="version.size">{{ size }}</p>
+        </div>
+      </div>
+
+      <div class="field">
+        <label class="label">Configuration</label>
+        <div class="control">
+          <pre v-if="model.configuration">{{ model.configuration }}</pre>
+          <template v-else>—</template>
+        </div>
+      </div>
+    </template>
+    <div class="notification is-danger" v-else-if="error">{{ error }}</div>
+    <div class="loader is-size-2 mx-auto" v-else></div>
+  </main>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { mapActions, mapState } from 'vuex'
+import { MODEL_VERSION_STATE_COLORS, UUID_REGEX } from '@/config'
+import { errorParser, formatBytes } from '@/helpers'
+import { truncateMixin } from '@/mixins'
+import ItemId from '@/components/ItemId.vue'
+
+export default defineComponent({
+  mixins: [
+    truncateMixin
+  ],
+  props: {
+    versionId: {
+      type: String,
+      required: true,
+      validator: id => typeof id === 'string' && UUID_REGEX.test(id)
+    }
+  },
+  components: {
+    ItemId
+  },
+  data: () => ({
+    error: null as string | null
+  }),
+  computed: {
+    ...mapState('model', ['models', 'modelVersions']),
+    model () {
+      if (!this.version) return null
+      return this.models[this.version.model_id]
+    },
+    version () {
+      return this.modelVersions[this.versionId]
+    },
+    size () {
+      if (!this.version) return null
+      return formatBytes(this.version.size)
+    },
+    stateClass () {
+      if (!this.version) return null
+      // @ts-expect-error Requires migrating the models store to Pinia to get a ModelVersion type
+      return MODEL_VERSION_STATE_COLORS[this.version.state]
+    }
+  },
+  methods: {
+    ...mapActions('model', ['getModelVersion', 'retrieveModel'])
+  },
+  watch: {
+    versionId: {
+      immediate: true,
+      async handler (newValue) {
+        try {
+          await this.getModelVersion(newValue)
+        } catch (err) {
+          this.error = errorParser(err)
+        }
+      }
+    },
+    version: {
+      immediate: true,
+      async handler (newValue) {
+        if (!newValue?.model_id) return
+        try {
+          await this.retrieveModel(newValue.model_id)
+        } catch (err) {
+          this.error = errorParser(err)
+        }
+      }
+    }
+  }
+})
+</script>
+
+<style scoped>
+.has-line-breaks {
+  white-space: pre-line
+}
+</style>
diff --git a/src/views/Model/index.ts b/src/views/Model/index.ts
new file mode 100644
index 000000000..02ad20bf5
--- /dev/null
+++ b/src/views/Model/index.ts
@@ -0,0 +1 @@
+export { default } from './Model.vue'
diff --git a/tests/unit/store/model.spec.js b/tests/unit/store/model.spec.js
index 4887839fd..be0dcc8e9 100644
--- a/tests/unit/store/model.spec.js
+++ b/tests/unit/store/model.spec.js
@@ -94,6 +94,29 @@ describe('model', () => {
       })
     })
 
+    describe('retrieveModel', () => {
+      it('retrieves a model', async () => {
+        const model = { id: 'model1' }
+        mock.onGet('/model/model1/').reply(200, model)
+
+        await store.dispatch('model/retrieveModel', 'model1')
+
+        assert.deepStrictEqual(store.history, [
+          {
+            action: 'model/retrieveModel',
+            payload: 'model1'
+          },
+          {
+            mutation: 'model/setModels',
+            payload: [model]
+          }
+        ])
+        assert.deepStrictEqual(store.state.model.models, {
+          model1: model
+        })
+      })
+    })
+
     describe('listModelVersions', () => {
       it('lists stored model versions', async () => {
         const reply = {
@@ -118,6 +141,29 @@ describe('model', () => {
       })
     })
 
+    describe('getModelVersion', () => {
+      it('retrieves a model version', async () => {
+        const version = { id: 'model_version1' }
+        mock.onGet('/modelversion/model_version1/').reply(200, version)
+
+        await store.dispatch('model/getModelVersion', 'model_version1')
+
+        assert.deepStrictEqual(store.history, [
+          {
+            action: 'model/getModelVersion',
+            payload: 'model_version1'
+          },
+          {
+            mutation: 'model/setModelVersions',
+            payload: [version]
+          }
+        ])
+        assert.deepStrictEqual(store.state.model.modelVersions, {
+          model_version1: version
+        })
+      })
+    })
+
     describe('deleteModelVersions', () => {
       it('deletes a model version', async () => {
         store.state.model.modelVersions = { model_version_0: { id: 'model_version_0' } }
-- 
GitLab