Skip to content
Snippets Groups Projects
Commit aea0f37b authored by ml bonhomme's avatar ml bonhomme :bee: Committed by Erwan Rouchet
Browse files

Frontend dataset management

parent 56b9b195
No related branches found
No related tags found
1 merge request!1487Frontend dataset management
import axios from 'axios'
import { PageNumberPaginationParameters, unique } from '.'
import { Dataset, PageNumberPagination, UUID } from '@/types'
interface DatasetCreate extends Omit<Dataset, 'id' | 'state'> {
corpusId: UUID
}
interface DatasetEdit extends Omit<Dataset, 'state'> {}
export interface DatasetParams extends PageNumberPaginationParameters {
/**
* ID of the corpus to retrieve datasets from.
*/
corpusId: UUID
}
export const listCorpusDataset = unique(
async ({ corpusId, ...params }: DatasetParams): Promise<PageNumberPagination<Dataset>> =>
(await axios.get(`/corpus/${corpusId}/datasets/`, { params })).data
)
export const createDataset = unique(
async ({corpusId, ...data}: DatasetCreate): Promise<Dataset> =>
(await axios.post(`/corpus/${corpusId}/datasets/`, data)).data
)
export const updateDataset = unique(
async ({id, ...data}: DatasetEdit): Promise<Dataset> =>
(await axios.patch(`/datasets/${id}/`, data)).data
)
export const deleteDataset = unique(
async (id: UUID) => await axios.delete(`/datasets/${id}/`)
)
\ No newline at end of file
......@@ -5,6 +5,7 @@
*/
export * from './classification'
export * from './corpus'
export * from './dataset'
export * from './element'
export * from './elementType'
export * from './entity'
......
<template>
<Modal
:model-value="modelValue"
v-on:update:model-value="value => $emit('update:modelValue', value)"
:title="modalTitle"
>
<form v-on:submit.prevent="performCreate">
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Name</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input
class="input"
v-model="newDataset.name"
type="text"
placeholder="Dataset name"
/>
</div>
<template v-if="fieldErrors.name">
<p class="help is-danger">{{ fieldErrors.name }}</p>
</template>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Description</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<textarea
class="textarea"
v-model="newDataset.description"
type="text"
placeholder="Dataset description"
></textarea>
</div>
<template v-if="fieldErrors.description">
<p class="help is-danger">{{ fieldErrors.description }}</p>
</template>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Sets</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input
class="input"
v-model="newDataset.sets"
type="text"
placeholder="Dataset sets"
/>
</div>
<template v-if="fieldErrors.sets">
<p v-if="!(typeof fieldErrors.sets === 'string')">
<span
class="help is-danger"
v-for="(v, k) in fieldErrors.sets"
:key="k"
>
{{ k }}: {{ v }}
</span>
</p>
<p v-else>
<span class="help is-danger">{{ fieldErrors.sets }}</span>
</p>
</template>
<p class="help">
Enter the set names, separated by commas.<br />If this field is left empty, the created sets will be Training, Test and Validation.
</p>
</div>
</div>
</div>
</form>
<template v-slot:footer="{ close }">
<button class="button" v-on:click="close">Cancel</button>
<button
type="submit"
class="button is-primary"
:class="{ 'is-loading': createLoading }"
v-on:click="save"
:disabled="!canSave"
:title="saveButtonTitle"
>
<span v-if="datasetInstance">Save</span>
<span v-else>Create</span>
</button>
</template>
</Modal>
</template>
<script>
import { mapActions, mapGetters, mapMutations } from 'vuex'
import { corporaMixin, truncateMixin } from '@/mixins'
import { isEmpty } from 'lodash'
import Modal from '@/components/Modal.vue'
import { errorParser } from '@/helpers'
export default {
mixins: [corporaMixin, truncateMixin],
components: {
Modal
},
emits: ['dataset-action', 'update:modelValue'],
props: {
corpusId: {
type: String,
required: true
},
modelValue: {
type: Boolean,
default: false
},
datasetInstance: {
type: Object,
default: null
}
},
data: () => ({
newDataset: {
name: '',
description: '',
sets: ''
},
createLoading: false,
fieldErrors: {}
}),
mounted () {
if (this.datasetInstance) {
this.newDataset = {
name: this.datasetInstance.name,
description: this.datasetInstance.description,
sets: this.datasetInstance.sets.join(', ')
}
}
},
computed: {
...mapGetters('auth', ['isVerified']),
hasContribPrivilege () {
return this.isVerified && this.corpus && this.canWrite(this.corpus)
},
modalTitle () {
if (!this.datasetInstance) return 'Create a new dataset'
else return `Edit dataset ${this.truncateShort(this.datasetInstance.name)}`
},
setList () {
// split the input of the sets field on ',' to make a list
let setList = this.newDataset.sets.split(',')
// trim list items and ignore empty items
setList = setList.map(item => item.trim()).filter(item => item.length > 0)
return setList
},
canSave () {
return this.newDataset.name.trim() && this.newDataset.description.trim()
},
saveButtonTitle () {
if (!this.canSave) return 'Name and description fields cannot be empty'
else if (this.datasetInstance) return 'Save'
else return 'Create'
}
},
methods: {
...mapActions('corpora', ['createCorpusDataset', 'updateCorpusDataset']),
...mapMutations('notifications', ['notify']),
async save () {
if (!this.hasContribPrivilege || this.createLoading || this.invalidForm) { return }
this.createLoading = true
const data = {
name: this.newDataset.name.trim(),
description: this.newDataset.description.trim()
}
if (!isEmpty(this.setList)) data.sets = this.setList
try {
if (this.datasetInstance) {
await this.updateCorpusDataset({
corpusId: this.corpusId,
datasetId: this.datasetInstance.id,
...data
})
} else {
await this.createCorpusDataset({
corpusId: this.corpusId,
...data
})
}
this.createLoading = false
// Sending a custom event to let the parent know that it must reload the list of datasets
this.$emit('dataset-action')
// Close the modal
this.$emit('update:modelValue', false)
} catch (e) {
if (e.response?.status === 400 && e.response.data) {
this.fieldErrors = Object.fromEntries(
Object
.entries(e.response.data)
.map(([key, value]) => [key, this.parseFieldErrors(value)])
)
}
} finally {
this.createLoading = false
}
},
parseFieldErrors (errors) {
if (Array.isArray(errors)) return errorParser(errors)
return Object.fromEntries(
Object
.entries(errors)
.map(([key, value]) => [key, this.parseFieldErrors(value)])
)
}
},
watch: {
modelValue: {
immediate: true,
handler () {
if (this.datasetInstance) {
this.newDataset = {
name: this.datasetInstance.name,
description: this.datasetInstance.description,
sets: this.datasetInstance.sets.join(', ')
}
} else {
this.newDataset = {
name: '',
description: '',
sets: ''
}
}
this.fieldErrors = {}
}
}
}
}
</script>
<style lang="scss" scoped>
.field-label { min-width: 11ch }
</style>
<template>
<table class="table is-fullwidth is-hoverable is-narrow">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>State</th>
<th class="is-narrow">Actions</th>
</tr>
</thead>
<tbody>
<Dataset
v-for="dataset in corpusDatasets[corpusId]"
:key="dataset.name"
:dataset="dataset"
:corpus-id="corpusId"
/>
<tr>
<td colspan="3"></td>
<td class="is-narrow has-text-right">
<button
class="button is-primary"
:disabled="!hasContribPrivilege || null"
v-on:click="createModal = hasContribPrivilege"
:title="
hasContribPrivilege
? 'Create dataset'
: 'You must have a contributor access to this corpus in order to perform this action.'
"
>
<i class="icon-plus"></i>
</button>
</td>
<EditModal
v-model="createModal"
:corpus-id="corpusId"
v-on:dataset-action="listDataset"
/>
</tr>
</tbody>
</table>
</template>
<script>
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
import { errorParser } from '@/helpers'
import Dataset from './Row'
import { corporaMixin } from '@/mixins.js'
import EditModal from './EditModal'
export default {
mixins: [
corporaMixin
],
components: {
Dataset,
EditModal
},
props: {
corpusId: {
type: String,
required: true
}
},
data: () => ({
loading: false,
createModal: false
}),
computed: {
...mapState('corpora', ['corpusDatasets']),
...mapGetters('auth', ['isVerified']),
hasContribPrivilege () {
return this.isVerified && this.corpus && this.canWrite(this.corpus)
}
},
methods: {
...mapActions('corpora', ['listCorpusDatasets']),
...mapMutations('notifications', ['notify']),
async listDataset () {
if (this.corpusDatasets[this.corpusId]) return
this.loading = true
try {
await this.listCorpusDatasets({ corpusId: this.corpusId })
} catch (err) {
this.notify({ type: 'error', text: `An error occurred listing datasets: ${errorParser(err)}` })
} finally {
this.loading = false
}
}
},
watch: {
corpusId: {
handler: 'listDataset',
immediate: true
}
}
}
</script>
<template>
<tr v-if="dataset">
<td>{{ dataset.name }}</td>
<td><p>{{ dataset.description }}</p></td>
<td>
<span
class="tag"
:class="stateColor(dataset.state)"
>
{{ DATASET_STATES[dataset.state].display_name }}
</span>
</td>
<td>
<span class="is-inline-flex">
<button
class="button has-text-primary mr-2"
:disabled="!hasContribPrivilege || null"
v-on:click="editModal = hasContribPrivilege"
:title="
hasContribPrivilege
? 'Edit dataset'
: 'You must have a contributor access to this corpus in order to perform this action.'
"
>
<i class="icon-edit"></i>
</button>
<button
class="button has-text-danger"
:disabled="!canDelete || null"
v-on:click="deleteModal = canDelete"
:title="deleteButtonTitle"
>
<i class="icon-trash"></i>
</button>
</span>
</td>
<Modal v-model="deleteModal" title="Delete a dataset">
<span>
Are you sure you want to delete the dataset <strong>{{ truncateShort(dataset.name) }}</strong>?
</span>
<br />
<span>This action is irreversible.</span>
<template v-slot:footer="{ close }">
<button class="button" v-on:click="close">Cancel</button>
<button
class="button is-danger"
:class="{ 'is-loading': deleteLoading }"
v-on:click="performDelete"
>
Delete
</button>
</template>
</Modal>
<EditModal
v-model="editModal"
:dataset-instance="dataset"
:corpus-id="corpusId"
/>
</tr>
</template>
<script>
import { mapActions, mapGetters } from 'vuex'
import { corporaMixin, truncateMixin } from '@/mixins.js'
import Modal from '@/components/Modal.vue'
import EditModal from './EditModal'
import { DATASET_STATES } from '@/config'
export default {
mixins: [
corporaMixin,
truncateMixin
],
components: {
Modal,
EditModal
},
props: {
dataset: {
type: Object,
required: true
},
corpusId: {
type: String,
required: true
}
},
emits: ['dataset-action'],
data: () => ({
deleteModal: false,
deleteLoading: false,
editModal: false,
editLoading: false,
DATASET_STATES
}),
computed: {
...mapGetters('auth', ['isVerified']),
hasAdminPrivilege () {
return this.isVerified && this.corpus && this.canAdmin(this.corpus)
},
hasContribPrivilege () {
return this.isVerified && this.corpus && this.canWrite(this.corpus)
},
canDelete () {
return this.hasAdminPrivilege && this.dataset.state === 'open'
},
deleteButtonTitle () {
if (!this.hasAdminPrivilege) return 'You must have an admin access to this corpus in order to perform this action.'
else if (this.dataset.state !== 'open') return 'Only open datasets can be deleted.'
else return 'Delete dataset'
}
},
methods: {
...mapActions('corpora', ['updateCorpusDataset', 'deleteCorpusDataset']),
async performDelete () {
if (!this.hasAdminPrivilege || this.deleteLoading) return
this.deleteLoading = true
try {
await this.deleteCorpusDataset({ corpusId: this.corpusId, datasetId: this.dataset.id })
this.deleteModal = false
// Sending a custom event to let the parent know that it must reload the list of datasets
this.$emit('dataset-action')
} finally {
this.deleteLoading = false
}
},
stateColor (state) {
return DATASET_STATES[state].color
}
}
}
</script>
export { default } from './List.vue'
\ No newline at end of file
......@@ -362,6 +362,25 @@ export const EXPORT_STATES = {
failed: 'Failed'
} as const
export const DATASET_STATES = {
open: {
display_name: 'Open',
color: 'is-info'
},
building: {
display_name: 'Building',
color: 'is-warning'
},
complete: {
display_name: 'Complete',
color: 'is-success'
},
error: {
display_name: 'Error',
color: 'is-danger'
}
} as const
export const GIT_REF_COLORS: { [key in GitRefType]: string } = {
branch: 'is-secondary',
tag: 'is-success'
......
......@@ -14,6 +14,8 @@ export const initialState = () => ({
corpusAllowedMetadata: {},
// { [corpusId]: EntityTypes[] }
corpusEntityTypes: {},
// { [corpusId]: Dataset[] }
corpusDatasets: {},
// A single corpus' exports.
exports: {}
})
......@@ -152,6 +154,37 @@ export const mutations = {
state.corpusAllowedMetadata[corpusId] = mdList
},
setCorpusDatasets (state, { corpusId, results }) {
const datasetList = state.corpusDatasets[corpusId] || []
results.forEach(newDataset => {
// Prevent duplicating datasets
if (!datasetList.some(dataset => dataset.id === newDataset.id)) datasetList.push(newDataset)
})
// Merge corpus datasets
state.corpusDatasets = {
...state.corpusDatasets,
[corpusId]: datasetList
}
},
updateCorpusDatasets (state, { corpusId, data }) {
if (!state.corpusDatasets[corpusId]) throw new Error(`Datasets for corpus ${corpusId} not found`)
const datasetList = [...state.corpusDatasets[corpusId]]
const index = datasetList.findIndex(item => item.id === data.id)
if (index < 0) throw new Error(`Dataset ${data.id} not found in corpus ${corpusId}`)
datasetList.splice(index, 1, data)
state.corpusDatasets[corpusId] = datasetList
},
removeCorpusDataset (state, { corpusId, datasetId }) {
if (!state.corpusDatasets[corpusId]) throw new Error(`Datasets for corpus ${corpusId} not found`)
const datasetList = [...state.corpusDatasets[corpusId]]
const index = datasetList.findIndex(item => item.id === datasetId)
if (index < 0) throw new Error(`Dataset ${datasetId} not found in corpus ${corpusId}`)
datasetList.splice(index, 1)
state.corpusDatasets[corpusId] = datasetList
},
addDefaultCorpus (state, { id, name }) {
state.corpora = {
...state.corpora,
......@@ -351,6 +384,49 @@ export const actions = {
}
},
async listCorpusDatasets ({ state, commit, dispatch }, { corpusId, page = 1 }) {
// Do not start fetching corpus datasets if they have been retrieved already
if (page === 1 && state.corpusDatasets[corpusId]) return
const data = await api.listCorpusDataset({ corpusId, page })
commit('setCorpusDatasets', { corpusId, results: data.results })
if (!data || !data.number || page !== data.number) {
// Avoid any loop
throw new Error(`Pagination failed listing datasets for corpus "${corpusId}"`)
}
// Load other pages
if (data.next) await dispatch('listCorpusDatasets', { corpusId, page: page + 1 })
},
async createCorpusDataset ({ commit }, { corpusId, ...dataset }) {
try {
const data = await api.createDataset({ ...dataset, corpusId })
commit('setCorpusDatasets', { corpusId, results: [data] })
} catch (err) {
commit('notifications/notify', { type: 'error', text: errorParser(err) }, { root: true })
throw err
}
},
async updateCorpusDataset ({ commit }, { corpusId, datasetId, ...dataset }) {
try {
const data = await api.updateDataset({ id: datasetId, ...dataset })
commit('updateCorpusDatasets', { corpusId, data })
} catch (err) {
commit('notifications/notify', { type: 'error', text: errorParser(err) }, { root: true })
throw err
}
},
async deleteCorpusDataset ({ commit }, { corpusId, datasetId }) {
try {
await api.deleteDataset(datasetId)
commit('removeCorpusDataset', { corpusId, datasetId })
} catch (err) {
commit('notifications/notify', { type: 'error', text: errorParser(err) }, { root: true })
throw err
}
},
/**
* Recursively list WorkerVersions for a specific corpus.
* Used to filter elements by existing versions.
......
import { METADATA_TYPES, PROCESS_STATES, PROCESS_MODES } from './config'
import { METADATA_TYPES, PROCESS_STATES, PROCESS_MODES, DATASET_STATES } from './config'
import { S3FileStatus, GitRefType, WorkerVersionGPUUsage, WorkerVersionState, ProcessActivityState, ModelVersionState } from './enums'
/**
......@@ -29,6 +29,8 @@ export type Polygon = Point[]
export type MetaDataType = keyof typeof METADATA_TYPES
export type DatasetState = keyof typeof DATASET_STATES
export type DateType = 'exact' | 'lower' | 'upper' | 'unknown'
export interface ImageServer {
......@@ -291,3 +293,11 @@ export interface WorkerRun {
configuration: WorkerConfiguration | null
model_version: ModelVersion | null
}
export interface Dataset {
id: UUID
state: DatasetState
name: string
description: string
sets: string[]
}
\ No newline at end of file
......@@ -20,9 +20,15 @@
</template>
<h1 class="title" v-else>New project</h1>
<div class="notification is-danger" v-if="corpus.id && !canAdmin(corpus)">
You are not allowed to edit this project.
</div>
<template v-if="corpus.id">
<div class="notification is-danger" v-if="!canWrite(corpus)">
You are not allowed to edit this project.
</div>
<div class="notification is-warning" v-else-if="!canAdmin(corpus)">
You are not allowed to edit this project's properties.<br />
You can manage this project's datasets and create new ones from the Datasets tab.
</div>
</template>
<Tabs :tabs="tabs" auto-hide>
<template v-slot:details>
......@@ -40,6 +46,9 @@
<template v-slot:entitytypes>
<EntityType :corpus-id="corpusId" />
</template>
<template v-slot:datasets>
<Datasets :corpus-id="corpusId" />
</template>
<template v-slot:members>
<ListMembers
content-type="corpus"
......@@ -64,6 +73,7 @@ import ListMembers from '@/components/Memberships/ListMembers.vue'
import EditionForm from '@/components/Corpus/EditionForm'
import ElementType from '@/components/Corpus/ElementType'
import EntityType from '@/components/Corpus/EntityType'
import Datasets from '@/components/Corpus/Datasets'
import AllowedMetaData from '@/components/Corpus/AllowedMetaData'
import Classes from '@/components/Corpus/Classes'
import StartIndexation from '@/components/Corpus/StartIndexation.vue'
......@@ -79,6 +89,7 @@ export default {
ElementType,
EntityType,
AllowedMetaData,
Datasets,
ListMembers,
Classes,
StartIndexation
......@@ -104,6 +115,7 @@ export default {
allowedmetadata: 'Allowed metadata',
entitytypes: 'Entity types',
mlclasses: 'Classes',
datasets: 'Datasets',
members: 'Members',
search: 'Search'
}
......
This diff is collapsed.
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment