diff --git a/src/api/model.ts b/src/api/model.ts index 3d4fb32821a577bbf9d0255a27d7dfb44e00ea14..3464e8d5c194613fc17a162c6a96c7c37f548458 100644 --- a/src/api/model.ts +++ b/src/api/model.ts @@ -4,6 +4,7 @@ import { Model, ModelVersion } from '@/types/model' import { PageNumberPaginationParameters, unique } from '.' export type CreateModelPayload = Pick<Model, 'name'> & Partial<Pick<Model, 'description'>> +export type UpdateModelPayload = Pick<Model, 'name' | 'description'> export const createModel = unique(async (params: CreateModelPayload): Promise<Model> => (await axios.post('/models/', params)).data) @@ -19,6 +20,8 @@ export interface ModelListParameters extends PageNumberPaginationParameters { name?: string } +export const updateModel = unique(async (id: UUID, params: UpdateModelPayload): Promise<Model> => (await axios.patch(`/model/${id}/`, params)).data) + export const listModels = unique(async (params: ModelListParameters): Promise<PageNumberPagination<Model>> => (await axios.get('/models/', { params })).data) export const retrieveModel = unique(async (id: UUID): Promise<Model> => (await axios.get(`/model/${id}/`)).data) diff --git a/src/components/Model/EditForm.vue b/src/components/Model/EditForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..57e6dd84b6c700b976365649cc0c95b7339736aa --- /dev/null +++ b/src/components/Model/EditForm.vue @@ -0,0 +1,140 @@ +<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> + + <Modal v-model="openModal" :title="modalTitle"> + <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> + </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> + </div> + </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> + +<script lang="ts"> +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 { PropType, defineComponent } from 'vue' +import { isAxiosError } from 'axios' +import { Model } from '@/types/model' + +export default defineComponent({ + components: { + Modal + }, + props: { + model: { + type: Object as PropType<Model>, + required: true + } + }, + data: () => ({ + openModal: false, + payload: { + name: '', + description: '' + } as UpdateModelPayload, + loading: false, + fieldErrors: {} as Partial<Record<keyof UpdateModelPayload, string[]>> + }), + computed: { + allowEdition () { + return this.model.rights.includes('write') + }, + allowTitle () { + return this.allowEdition + ? 'Edit model' + : 'A contributor access level is required to edit a model' + }, + modalTitle () { + return `Edit model "${this.model.name}"` + } + }, + methods: { + ...mapActions(useModelStore, ['updateModel']), + ...mapActions(useNotificationStore, ['notify']), + async update () { + if (this.loading) return + this.loading = true + this.fieldErrors = {} + try { + await this.updateModel(this.model.id, this.payload) + this.notify({ type: 'info', text: 'Model updated successfully.' }) + this.openModal = false + } catch (err) { + this.notify({ type: 'error', text: errorParser(err) }) + if (isAxiosError(err)) { + this.fieldErrors = err?.response?.data + ? Object.fromEntries(Object.entries(err?.response?.data).map(([k, v]) => [k, ensureArray(v)])) + : {} + } + } finally { + this.loading = false + } + } + }, + watch: { + model: { + immediate: true, + handler () { + this.payload.name = this.model.name + this.payload.description = this.model.description + } + } + } +}) +</script> diff --git a/src/stores/model.ts b/src/stores/model.ts index e38c9ba63d23f9139abbfceacbafbb2272adfb57..f4f22225c5cb83aa7fe72c918f72b1be5a78a6c5 100644 --- a/src/stores/model.ts +++ b/src/stores/model.ts @@ -4,8 +4,10 @@ import { UUID } from '@/types' import { Model, ModelVersion } from '@/types/model' import { CreateModelPayload, + UpdateModelPayload, ModelListParameters, createModel, + updateModel, listModels, listModelVersions, retrieveModel, @@ -31,6 +33,12 @@ export const useModelStore = defineStore('model', { return model }, + async updateModel (id: UUID, params: UpdateModelPayload) { + const model = await updateModel(id, params) + this.models[model.id] = model + return model + }, + async listModels (params: ModelListParameters) { const resp = await listModels(params) this.models = { @@ -69,4 +77,4 @@ export const useModelStore = defineStore('model', { } } } -}) \ No newline at end of file +}) diff --git a/src/views/Model/List.vue b/src/views/Model/List.vue index f1aa09a23f9c6c4d8db902ac3bcea5dbafdd91c7..60af1160408dddf0ea86cb7d73983a308e95e47c 100644 --- a/src/views/Model/List.vue +++ b/src/views/Model/List.vue @@ -70,8 +70,9 @@ v-on:click="selectedModel = model.id" class="is-clickable" :class="{ 'is-selected': selectedModel === model.id }" + :title="model.name" > - <td :title="model.name"> + <td> {{ truncateLong(model.name) }} </td> </tr> diff --git a/src/views/Model/Model.vue b/src/views/Model/Model.vue index 0d5964f70bb38d91df0d56d7bdcab297f0bb41c0..df5404b72df279f608e19702de294be1c0dfaf23 100644 --- a/src/views/Model/Model.vue +++ b/src/views/Model/Model.vue @@ -5,7 +5,10 @@ <i class="icon-arrow-left"></i> Models </router-link> - <div class="title">{{ model.name }}</div> + <div class="title"> + {{ model.name }} + <EditForm class="ml-2 is-primary" :model="model" /> + </div> <p class="subtitle is-5">Model <ItemId :item-id="model.id" /></p> <div class="columns"> @@ -51,6 +54,7 @@ import { UUID_REGEX } from '@/config' import { ago, errorParser } from '@/helpers' import { UUID } from '@/types' import Model from '@/components/Model' +import EditForm from '@/components/Model/EditForm.vue' import { useModelStore, useNotificationStore } from '@/stores' import ItemId from '@/components/ItemId.vue' @@ -64,6 +68,7 @@ export default defineComponent({ }, components: { Model, + EditForm, ItemId }, data: () => ({ diff --git a/tests/unit/stores/model.spec.js b/tests/unit/stores/model.spec.js index 4a76fc9a79b7710ba6d535dc35ef0c11db6accc6..7d8112b77dfd8698c8760c2330222af7c7077e89 100644 --- a/tests/unit/stores/model.spec.js +++ b/tests/unit/stores/model.spec.js @@ -50,6 +50,27 @@ describe('model', () => { }) }) + describe('updateModel', () => { + it('updates a model', async () => { + store.models = { modelid: { id: 'modelid' } } + const attrs = { name: 'test', description: 'A' } + mock.onPatch('/model/modelid/').reply(201, { id: 'modelid', ...attrs }) + + await store.updateModel('modelid', attrs) + + assert.deepStrictEqual(mock.history.all.map(req => pick(req, ['method', 'url', 'data'])), [ + { + method: 'patch', + url: '/model/modelid/', + data: attrs + } + ]) + assert.deepStrictEqual(store.models, { + modelid: { id: 'modelid', name: 'test', description: 'A' } + }) + }) + }) + describe('listModels', () => { it('lists stored models with filters', async () => { const reply = {