diff --git a/src/api/dataset.ts b/src/api/dataset.ts index ca791fc3ce8aeb37fe33b1f3142d86c7fa94b2ce..04744c66802f6dbaf502328f0c531a67915d9af3 100644 --- a/src/api/dataset.ts +++ b/src/api/dataset.ts @@ -1,6 +1,6 @@ import axios from 'axios' import { PageNumberPaginationParameters, unique } from '.' -import { Dataset, PageNumberPagination, UUID } from '@/types' +import { Dataset, ElementDataset, PageNumberPagination, UUID } from '@/types' interface DatasetCreate extends Omit<Dataset, 'id' | 'state'> { corpusId: UUID @@ -21,15 +21,28 @@ export const listCorpusDataset = unique( ) export const createDataset = unique( - async ({corpusId, ...data}: DatasetCreate): Promise<Dataset> => + async ({ corpusId, ...data }: DatasetCreate): Promise<Dataset> => (await axios.post(`/corpus/${corpusId}/datasets/`, data)).data ) export const updateDataset = unique( - async ({id, ...data}: DatasetEdit): Promise<Dataset> => + 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 +) + +export interface ElementDatasetParams extends PageNumberPaginationParameters { + /** + * ID of the element to list dataset from. + */ + eltId: UUID +} + +// List datasets containing a specific element +export const listElementDatasets = unique( + async ({ eltId, ...params }: ElementDatasetParams): Promise<PageNumberPagination<ElementDataset>> => + (await axios.get(`/element/${eltId}/datasets/`, { params })).data +) diff --git a/src/components/Element/DetailsPanel.vue b/src/components/Element/DetailsPanel.vue index 20d9a7961c3fe58f6b3a39c6b135326b462bcade..5896916d1335f6134e5422bff9d7b47a34a46186 100644 --- a/src/components/Element/DetailsPanel.vue +++ b/src/components/Element/DetailsPanel.vue @@ -63,6 +63,26 @@ </DropdownContent> <hr /> + <DropdownContent id="datasets" title="Datasets"> + <div class="loader m-auto is-size-3" v-if="datasets === null"> + </div> + <div class="message-body has-text-grey" v-else-if="datasets.length === 0"> + No datasets + </div> + <table v-else class="table is-fullwidth is-hoverable"> + <thead> + <th>Name</th> + <th>Set</th> + </thead> + <tbody> + <tr v-for="dataset in datasets" :key="dataset.id + dataset.set"> + <td>{{ dataset.dataset_name }}</td> + <td>{{ dataset.set }}</td> + </tr> + </tbody> + </table> + </DropdownContent> + <EntityLinks :element-id="element.id" v-if="elementType.folder === false" @@ -71,21 +91,30 @@ </div> </template> -<script> -import { mapState, mapGetters } from 'vuex' +<script lang="ts"> +import { + mapState as mapVuexState, + mapGetters as mapVuexGetters, + mapActions as mapVuexActions +} from 'vuex' import { corporaMixin } from '@/mixins' +import { defineComponent, PropType } from 'vue' +import { UUID_REGEX } from '@/config' +import { UUID, Element, ElementDataset } from '@/types' +import { mapState, mapActions } from 'pinia' +import { useElementsStore } from '@/stores' import DropdownContent from '@/components/DropdownContent.vue' import MLClassSelect from '@/components/MLClassSelect.vue' import EntityLinks from '@/components/Entity/Links.vue' -import Classifications from './Classifications' -import ElementMetadata from './Metadata' -import OrientationPanel from './OrientationPanel' -import Transcriptions from './Transcription' -import TranscriptionsModal from './Transcription/Modal' -import TranscriptionCreationForm from './Transcription/CreationForm' +import Classifications from '@/components/Element/Classifications' +import ElementMetadata from '@/components/Element/Metadata' +import OrientationPanel from '@/components/Element/OrientationPanel.vue' +import Transcriptions from '@/components/Element/Transcription' +import TranscriptionsModal from '@/components/Element/Transcription/Modal.vue' +import TranscriptionCreationForm from '@/components/Element/Transcription/CreationForm.vue' -export default { +export default defineComponent({ mixins: [ corporaMixin ], @@ -101,8 +130,12 @@ export default { TranscriptionsModal }, props: { + /** + * Id of the element + */ elementId: { - type: String, + type: String as PropType<UUID>, + validator: value => typeof value === 'string' && UUID_REGEX.test(value), required: true } }, @@ -113,18 +146,19 @@ export default { transcriptionModal: false }), computed: { - ...mapState('elements', ['elements', 'transcriptions']), - ...mapState('process', ['workerVersions', 'workers']), - ...mapState('classification', ['hasMlClasses']), - ...mapGetters('elements', { + ...mapVuexState('elements', ['elements', 'transcriptions']), + ...mapVuexState('process', ['workerVersions', 'workers']), + ...mapVuexState('classification', ['hasMlClasses']), + ...mapState(useElementsStore, ['elementDatasets']), + ...mapVuexGetters('elements', { // canWrite and canAdmin are already defined in corporaMixin canWriteElement: 'canWrite', canAdminElement: 'canAdmin' }), - element () { - return this.elements[this.elementId] + element (): Element | null { + return this.elements?.[this.elementId] ?? null }, - corpusId () { + corpusId (): UUID | null { return this.element?.corpus?.id ?? null }, elementType () { @@ -144,20 +178,27 @@ export default { return (this.element && this.element.classifications) || [] }, metadata () { - return (this.element && this.element.metadata) || [] + // @ts-expect-error Some Element attributes like metadata are set on the fly on the former store + return (this.element && this.element?.metadata) || [] + }, + datasets (): ElementDataset[] | null { + return this.elementDatasets?.[this.elementId] ?? null } }, methods: { + ...mapVuexActions('classification', { classificationCreate: 'create' }), + ...mapVuexActions('elements', { retrieveElement: 'get' }), + ...mapActions(useElementsStore, ['listElementDatasets']), async createClassification () { if (!this.canCreateClassification) return this.isSavingNewClassification = true try { - await this.$store.dispatch('classification/create', { + await this.classificationCreate({ elementId: this.elementId, mlClass: this.selectedNewClassification }) } finally { - this.$refs.newClassificationSelect?.clear() + this.selectedNewClassification = '' this.isSavingNewClassification = false } } @@ -165,7 +206,7 @@ export default { watch: { elementId: { immediate: true, - handler (id) { + handler (id: UUID) { if (!id) return /* * Do not retrieve the element again if it already exists in the store, @@ -174,14 +215,20 @@ export default { * This ensures there are no strange behaviors where some actions are only sometimes disabled when they shouldn't, * or some element attributes are not displayed at all. */ - if (!this.element || this.element.id !== id || !this.element.rights || !this.element.classifications) this.$store.dispatch('elements/get', { id }) + if (!this.element || this.element.id !== id || !this.element.rights || !this.element.classifications) this.retrieveElement({ id }) + } + }, + datasets: { + immediate: true, + async handler (value) { + if (value === null) this.listElementDatasets({ eltId: this.elementId }) } }, selectedNewClassification () { this.createClassification() } } -} +}) </script> <style scoped> diff --git a/src/store/index.js b/src/store/index.js index 311957e27c3347c5d19fea2ba0c9d753bc83ec11..bb79687556a5d32246180ddb20042974b44a7532 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,6 +1,7 @@ import { createStore } from 'vuex' import { useDisplayStore, + useElementsStore, useFolderPickerStore, useImageStore, useIngestStore, @@ -38,6 +39,7 @@ const moduleNames = [ */ export const piniaStores = [ useDisplayStore, + useElementsStore, useFolderPickerStore, useImageStore, useIngestStore, diff --git a/src/stores/elements.ts b/src/stores/elements.ts new file mode 100644 index 0000000000000000000000000000000000000000..429bca6547df6f4c0df4abd894b35fc2f533d345 --- /dev/null +++ b/src/stores/elements.ts @@ -0,0 +1,45 @@ +import { defineStore } from 'pinia' +import { listElementDatasets as listElementDatasetsAPI, ElementDatasetParams, unique } from '@/api' +import { errorParser } from '@/helpers' +import { ElementDataset, UUID, PageNumberPagination } from '@/types' +import { useNotificationStore } from '@/stores' +import { isAxiosError } from 'axios' +import axios from 'axios' + +interface State { + /** + * List of dataset that contains a specific element + */ + elementDatasets: { [elementId: UUID]: ElementDataset[] } +} + +export const useElementsStore = defineStore('elements', { + state: (): State => ({ + elementDatasets: {} + }), + actions: { + async listElementDatasets (data: ElementDatasetParams, url: string | null = null) { + // Avoid listing datasets twice for an element + if (url === null && (this.elementDatasets?.[data.eltId] || null) !== null) return + // List datasets containing a specific element through all pages + //let results, pagination + try { + const { results, ...pagination } = ( + url === null ? + await listElementDatasetsAPI(data) + : (await axios.get(url)).data as PageNumberPagination<ElementDataset> + ) + // Progressively add results to the store + this.elementDatasets[data.eltId] = [ + ...this.elementDatasets?.[data.eltId] || [], + ...results + ] + // Follow pagination without awaiting, until we fetched all the data + if (pagination.next !== null) this.listElementDatasets(data, pagination.next) + } catch (err) { + useNotificationStore().notify({ type: 'error', text: errorParser(err) }) + throw err + } + } + } +}) diff --git a/src/stores/index.ts b/src/stores/index.ts index 4bdcee58b34872d2579b305b58657e35bcd6aa4f..79ea0d28cb0fdf18ce1464b67d61a89d3d9b0431 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -1,4 +1,5 @@ export { useDisplayStore } from './display' +export { useElementsStore } from './elements' export { useFolderPickerStore } from './folderpicker' export { useImageStore } from './image' export { useIngestStore } from './ingest' diff --git a/src/types/index.ts b/src/types/index.ts index 3b8f833cc9509c1c3a7ad7c4eb2836e8e5aec8fe..0aeb06ef64c0bdf8655dcb537a5c277585180947 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -306,6 +306,12 @@ export interface Dataset { sets: string[] } +export interface ElementDataset { + dataset_id: UUID + dataset_name: string + set: string +} + export type JobStatus = 'queued' | 'started' | 'deferred' | 'finished' | 'stopped' | 'scheduled' | 'canceled' | 'failed' /** diff --git a/tests/unit/samples.js b/tests/unit/samples.js index b699aeda7d9d668d14f57440592d568f7e53222c..f647e955f894d3d219de169aaea80c1c9bca6ac3 100644 --- a/tests/unit/samples.js +++ b/tests/unit/samples.js @@ -636,3 +636,15 @@ export const s3ProcessSample = { mode: 's3', corpus: 'some corpus' } + +export const elementDatasetsSample = makeSampleResults([ + { + dataset_id: 'dataset_1', + dataset_name: 'dataset', + set: 'test' + }, { + dataset_id: 'dataset_1', + dataset_name: 'dataset', + set: 'train' + } +]) diff --git a/tests/unit/stores/elements.spec.js b/tests/unit/stores/elements.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a89d8ae6ff39e0f5d0d14d23a349a6851b78cf09 --- /dev/null +++ b/tests/unit/stores/elements.spec.js @@ -0,0 +1,76 @@ +import { assert } from 'chai' +import { setActivePinia } from 'pinia' +import axios from 'axios' +import { useElementsStore, useNotificationStore } from '@/stores' +import { assertRejects, FakeAxios, setUpTestPinia, actionsCompletedPlugin } from '../testhelpers.js' +import { elementDatasetsSample } from '../samples.js' + +describe('elements.ts', () => { + let pinia, mock, store, notificationStore + + before(() => { + [pinia] = setUpTestPinia([actionsCompletedPlugin]) + setActivePinia(pinia) + mock = new FakeAxios(axios) + store = useElementsStore() + notificationStore = useNotificationStore() + }) + + beforeEach(() => { + mock.reset() + store.$reset() + notificationStore.$reset() + }) + + after('Removing Axios mock', () => { + mock.restore() + }) + + describe('actions', () => { + describe('listElementDatasets', () => { + it('returns early in case datasets have been fetched', async () => { + store.elementDatasets = { element_id: [] } + await store.listElementDatasets({ eltId: 'element_id' }) + await store.actionsCompleted() + }) + + it('automatically loads datasets for an element', async () => { + const pagination1 = { + count: 2, + previous: null, + next: '/element/element_id/datasets/?page=2', + results: [elementDatasetsSample.results[0]] + } + const pagination2 = { + count: 2, + previous: 'http://previous', + next: null, + results: [elementDatasetsSample.results[1]] + } + mock.onGet('/element/element_id/datasets/').reply(200, pagination1) + mock.onGet('/element/element_id/datasets/?page=2').reply(200, pagination2) + + await store.listElementDatasets({ eltId: 'element_id' }) + await store.actionsCompleted() + + assert.deepStrictEqual(store.elementDatasets, { + element_id: [ + elementDatasetsSample.results[0], + elementDatasetsSample.results[1] + ] + }) + }) + + it('handles errors', async () => { + mock.onGet('/element/element_id/datasets/').reply(403, { detail: ['Forbidden'] }) + + await assertRejects(async () => + await store.listElementDatasets({ eltId: 'element_id' }) + ) + assert.deepStrictEqual(notificationStore.notifications, [ + { id: 0, type: 'error', text: 'Forbidden' } + ]) + }) + }) + }) +})