diff --git a/src/components/Model/EditForm.vue b/src/components/Model/EditForm.vue index f266b3a23d2bc350dde4ac66f1a064ee7bb0c986..e11181ff4669f9ff573ce7487404a95e7dee71e5 100644 --- a/src/components/Model/EditForm.vue +++ b/src/components/Model/EditForm.vue @@ -1,65 +1,76 @@ <template> - <slot name="action"> - <button - class="button" - v-on:click="openModal = allowEdition" - v-bind="$attrs" - :disabled="!allowEdition || null" - :title="allowTitle" - > - <i class="icon-edit"></i> - </button> - </slot> + <button + v-if="mode === 'edit'" + class="button" + v-on:click="openModal = allowOpen" + v-bind="$attrs" + :disabled="!allowOpen || undefined" + :title="openButtonTitle" + > + <i class="icon-edit"></i> + </button> + <button + v-else-if="mode === 'create'" + class="button is-primary" + v-on:click="openModal = allowOpen" + v-bind="$attrs" + :disabled="!allowOpen || undefined" + :title="openButtonTitle" + > + Create + </button> - <Modal v-model="openModal" :title="modalTitle" is-large> - <form class="form" v-on:submit.prevent="update"> - <div class="field"> - <label class="label">Name</label> - <div class="control"> - <input - class="input" - :class="{ 'is-danger': fieldErrors.name }" - type="text" - maxlength="100" - v-model.trim="payload.name" - :disabled="loading || null" - /> - <template v-if="fieldErrors.name"> - <p class="help is-danger" v-for="err in fieldErrors.name" :key="err">{{ err }}</p> - </template> + <Teleport to="body"> + <Modal v-model="openModal" :title="modalTitle" is-large> + <form class="form" v-on:submit.prevent="saveAction"> + <div class="field"> + <label class="label">Name</label> + <div class="control"> + <input + class="input" + :class="{ 'is-danger': fieldErrors.name }" + type="text" + maxlength="100" + v-model.trim="payload.name" + :disabled="loading || undefined" + /> + <template v-if="fieldErrors.name"> + <p class="help is-danger" v-for="err in fieldErrors.name" :key="err">{{ err }}</p> + </template> + </div> </div> - </div> - <div class="field"> - <label class="label">Description</label> - <div class="control"> - <textarea - class="textarea" - :class="{ 'is-danger': fieldErrors.description }" - type="text" - v-model.trim="payload.description" - :disabled="loading || null" - ></textarea> - <template v-if="fieldErrors.description"> - <p class="help is-danger" v-for="err in fieldErrors.description" :key="err">{{ err }}</p> - </template> + <div class="field"> + <label class="label">Description</label> + <div class="control"> + <textarea + class="textarea" + :class="{ 'is-danger': fieldErrors.description }" + type="text" + v-model.trim="payload.description" + :disabled="loading || undefined" + ></textarea> + <template v-if="fieldErrors.description"> + <p class="help is-danger" v-for="err in fieldErrors.description" :key="err">{{ err }}</p> + </template> + </div> </div> - </div> - </form> + </form> - <template v-slot:footer="{ close }"> - <button class="button" v-on:click="close">Cancel</button> - <button - type="submit" - v-on:click="update" - class="button is-primary ml-auto" - :class="{ 'is-loading': loading }" - :disabled="loading || null" - > - Update - </button> - </template> - </Modal> + <template v-slot:footer="{ close }"> + <button class="button" v-on:click="close">Cancel</button> + <button + type="submit" + v-on:click="saveAction" + class="button is-primary ml-auto" + :class="{ 'is-loading': loading }" + :disabled="loading || !payload.name.length || undefined" + > + {{ saveButtonName }} + </button> + </template> + </Modal> + </Teleport> </template> <script lang="ts"> @@ -67,7 +78,7 @@ import { mapActions } from 'pinia' import { errorParser, ensureArray } from '@/helpers' import Modal from '@/components/Modal.vue' import { useNotificationStore, useModelStore } from '@/stores' -import { UpdateModelPayload } from '@/api' +import { CreateModelPayload } from '@/api' import { PropType, defineComponent } from 'vue' import { isAxiosError } from 'axios' import { Model } from '@/types/model' @@ -76,10 +87,17 @@ export default defineComponent({ components: { Modal }, + emits: { + reload: (value: boolean) => typeof value === 'boolean' + }, props: { - model: { - type: Object as PropType<Model>, + mode: { + type: String, required: true + }, + modelInstance: { + type: Object as PropType<Model>, + default: null } }, data: () => ({ @@ -87,33 +105,48 @@ export default defineComponent({ payload: { name: '', description: '' - } as UpdateModelPayload, + } as CreateModelPayload, loading: false, - fieldErrors: {} as Partial<Record<keyof UpdateModelPayload, string[]>> + fieldErrors: {} as Partial<Record<keyof CreateModelPayload, string[]>> }), computed: { - allowEdition () { - return this.model.rights.includes('write') + allowOpen () { + if (this.mode === 'edit' && this.modelInstance) return this.modelInstance.rights.includes('write') + return true }, - allowTitle () { - return this.allowEdition - ? 'Edit model' - : 'A contributor access level is required to edit a model' + openButtonTitle () { + if (this.mode === 'edit') { + return this.allowOpen + ? 'Edit model' + : 'A contributor access level is required to edit a model' + } + return 'Create a new model' }, modalTitle () { - return `Edit model "${this.model.name}"` + if (this.mode === 'edit' && this.modelInstance) return `Edit model "${this.modelInstance.name}"` + return 'Create a new model' + }, + saveButtonName () { + if (this.mode === 'edit') return 'Update' + return 'Create' } }, methods: { - ...mapActions(useModelStore, ['updateModel']), + ...mapActions(useModelStore, ['updateModel', 'createModel', 'listModels']), ...mapActions(useNotificationStore, ['notify']), - async update () { - if (this.loading) return + async saveAction () { + if (this.loading || (this.mode === 'edit' && !this.modelInstance) || !this.payload.name.length) return this.loading = true this.fieldErrors = {} try { - await this.updateModel(this.model.id, this.payload) - this.notify({ type: 'info', text: 'Model updated successfully.' }) + if (this.mode === 'edit' && this.modelInstance) { + await this.updateModel(this.modelInstance.id, this.payload) + this.notify({ type: 'info', text: 'Model updated successfully.' }) + } else { + await this.createModel(this.payload) + this.$emit('reload', true) + this.notify({ type: 'success', text: 'Model successfully created.' }) + } this.openModal = false } catch (err) { this.notify({ type: 'error', text: errorParser(err) }) @@ -128,11 +161,12 @@ export default defineComponent({ } }, watch: { - model: { + modelInstance: { immediate: true, handler () { - this.payload.name = this.model.name - this.payload.description = this.model.description + if (!this.modelInstance) return + this.payload.name = this.modelInstance.name + this.payload.description = this.modelInstance.description } } } diff --git a/src/router/index.js b/src/router/index.js index cf43d27968cfaabd897307149e8a7132ebc007b5..ae9eaa52ab5b995ac934bb450788c7a2522aadba 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -29,7 +29,6 @@ const ImportFromBucket = () => import(/* webpackChunkName: "users" */ '@/views/I 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 ProcessDatasets = () => import(/* webpackChunkName: "users" */ '@/views/Process/Datasets') @@ -308,13 +307,6 @@ 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/views/Model/Create.vue b/src/views/Model/Create.vue deleted file mode 100644 index f921f6463f6d76249e42e37fedf2c9541f16a665..0000000000000000000000000000000000000000 --- a/src/views/Model/Create.vue +++ /dev/null @@ -1,114 +0,0 @@ -<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 || undefined" - type="text" - v-model.trim="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 || undefined" - type="text" - v-model.trim="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 || undefined" - :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 lang="ts"> -import { isAxiosError } from 'axios' -import { defineComponent } from 'vue' -import { mapActions } from 'pinia' - -import { CreateModelPayload } from '@/api' -import { corporaMixin } from '@/mixins' -import { errorParser } from '@/helpers' -import { useModelStore, useNotificationStore } from '@/stores' -import { Model } from '@/types/model' - -export default defineComponent({ - emits: { - 'create-model' (value: Model) { - return value.id !== undefined - } - }, - mixins: [ - corporaMixin - ], - data: () => ({ - loading: false, - fields: { - name: '', - description: '' - } as CreateModelPayload, - errors: {} as Record<string, unknown> - }), - computed: { - mainView () { - // The component can be mounted as a single view - return this.$route.name === 'model-create' - }, - allowCreate () { - return Boolean(this.fields.name) && !this.loading - } - }, - methods: { - ...mapActions(useNotificationStore, ['notify']), - async create () { - if (!this.allowCreate) return - this.loading = true - this.errors = {} - try { - const payload: CreateModelPayload = { name: this.fields.name } - if (this.fields.description?.trim()) payload.description = this.fields.description - const model = await useModelStore().createModel(payload) - this.notify({ type: 'success', text: `Model "${this.fields.name}" created successfully.` }) - this.$emit('create-model', model) - this.fields.name = this.fields.description = '' - } catch (err) { - if (isAxiosError(err) && 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 00679daca793b3f908d18944ff1764ff75c874e4..78cf9ca958c6d45f5bc3150b43e8830d62f5ce94 100644 --- a/src/views/Model/List.vue +++ b/src/views/Model/List.vue @@ -1,14 +1,12 @@ <template> <main class="container is-fluid"> <div class="columns"> - <div class="column is-one-third"> - <div class="has-text-right"> - <router-link - class="button mb-2" - :to="{ name: 'model-create' }" - > - Create a model - </router-link> + <div class="field column is-one-third"> + <div class="has-text-right mb-2"> + <CreateForm + mode="create" + v-on:reload="updateModelsPage" + /> </div> <div v-if="compatibleWorkerId" class="field has-text-right"> @@ -93,7 +91,7 @@ <div v-if="!selectedModel" class="notification is-info"> Please select a <strong>model</strong> on the left panel. </div> - <Model + <ModelComponent v-else :model-id="selectedModel" :process-id="processId" @@ -118,6 +116,7 @@ import { Model } from '@/types/model' import Paginator from '@/components/Paginator.vue' import ModelComponent from '@/components/Model/Model.vue' +import CreateForm from '@/components/Model/EditForm.vue' import { PageNumberPagination } from '@/types' export default defineComponent({ @@ -126,7 +125,8 @@ export default defineComponent({ ], components: { Paginator, - Model: ModelComponent + ModelComponent, + CreateForm }, props: { /** diff --git a/src/views/Model/Model.vue b/src/views/Model/Model.vue index 63753af6018a99e20fec3d1e63eb278ed4ab6a68..d0197a230f41bf04c11e7e76b6b69398597537b0 100644 --- a/src/views/Model/Model.vue +++ b/src/views/Model/Model.vue @@ -7,7 +7,7 @@ </router-link> <div class="title"> <span v-if="model.archived" class="tag">Archived</span> {{ model.name }} - <EditForm class="ml-2 is-primary" :model="model" /> + <EditForm class="ml-2 is-primary" :model-instance="model" mode="edit" /> </div> <div class="subtitle is-5"> <p>Model <ItemId :item-id="model.id" /></p> @@ -51,7 +51,7 @@ </div> <hr /> - <Model :model-id="modelId" /> + <ModelDetails :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> @@ -72,7 +72,8 @@ import { mapState, mapActions } from 'pinia' import { UUID_REGEX } from '@/config' import { ago, errorParser } from '@/helpers' import { UUID } from '@/types' -import Model from '@/components/Model' +import { Model } from '@/types/model' +import ModelDetails from '@/components/Model' import EditForm from '@/components/Model/EditForm.vue' import { useModelStore, useNotificationStore } from '@/stores' import ItemId from '@/components/ItemId.vue' @@ -87,7 +88,7 @@ export default defineComponent({ } }, components: { - Model, + ModelDetails, EditForm, ItemId, ArchivalModal @@ -100,7 +101,7 @@ export default defineComponent({ }), computed: { ...mapState(useModelStore, ['models']), - model () { + model (): Model { return this.models[this.modelId] }, creationDate (): string | null {