Skip to content
Snippets Groups Projects
Commit 626b6871 authored by Yoann Schneider's avatar Yoann Schneider :tennis: Committed by Bastien Abadie
Browse files

Create new annotation panel

parent 8cf30155
No related branches found
No related tags found
1 merge request!1071Create new annotation panel
......@@ -4,7 +4,6 @@ import { shallowMount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import { FakeAxios } from '~/test/testhelpers'
import store from '~/test/store'
import DetailsPanel from '~/vue/Element/DetailsPanel'
import Modal from '~/vue/Modal'
import Vue from 'vue'
import Vuex from 'vuex'
import AsyncComputed from 'vue-async-computed'
......@@ -67,8 +66,6 @@ describe('Element/DetailsPanel.vue', () => {
RouterLink: RouterLinkStub
},
propsData: {
corpusId: 'corpusid',
hasMlClasses: false,
elementId: 'elementid'
}
})
......@@ -104,8 +101,6 @@ describe('Element/DetailsPanel.vue', () => {
RouterLink: RouterLinkStub
},
propsData: {
corpusId: 'corpusid',
hasMlClasses: false,
elementId: ''
}
})
......@@ -114,44 +109,4 @@ describe('Element/DetailsPanel.vue', () => {
assert.strictEqual(mock.history.all.length, 0)
assert.strictEqual(store.history.length, 0)
})
it('forbids deleting an element without admin access', async () => {
store.state.corpora.corpora.corpusid.rights = ['read', 'write']
store.state.elements.elements.elementid = {
id: 'elementid',
name: 'Le Element',
type: 'element',
metadata: [],
classifications: [],
corpus: store.state.corpora.corpora.corpusid
}
mock.onGet('/element/elementid/transcriptions/').reply(200, { count: 0, results: [] })
const wrapper = shallowMount(DetailsPanel, {
store,
localVue,
mocks: {
$route
},
stubs: {
RouterLink: RouterLinkStub,
Modal
},
propsData: {
corpusId: 'corpusid',
hasMlClasses: false,
elementId: 'elementid'
}
})
await store.actionsCompleted()
await Vue.nextTick()
store.history = []
const deleteButton = wrapper.get('a.icon-trash')
const deleteModal = wrapper.get('.modal')
assert.ok(deleteButton.classes('has-text-grey-light'))
assert.strictEqual(deleteButton.attributes('title'), 'Admin access on the project is required to delete this element')
// No modal displayed
assert.ok(!deleteModal.classes('is-active'))
})
})
import assert from 'assert'
import axios from 'axios'
import { shallowMount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import { FakeAxios } from '~/test/testhelpers'
import store from '~/test/store'
import Vuex from 'vuex'
import Vue from 'vue'
import AsyncComputed from 'vue-async-computed'
import PanelHeader from '~/vue/Element/PanelHeader'
import Modal from '~/vue/Modal'
// Setup local Vue instance, with store
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(AsyncComputed)
describe('Element/PanelHeader.vue', () => {
let mock
const $route = {
params: {}
}
before('Setting up mocks', () => {
mock = new FakeAxios(axios)
})
beforeEach(() => {
mock.reset()
store.state.corpora.corpora = {
corpusid: {
id: 'corpusid',
rights: ['read', 'write', 'admin'],
types: {
element: {
slug: 'element',
display_name: 'Element type',
folder: false
}
}
}
}
})
afterEach(() => {
mock.reset()
store.reset()
})
after('Removing Axios mock', () => {
mock.restore()
})
it('forbids deleting an element without admin access', async () => {
store.state.corpora.corpora.corpusid.rights = ['read', 'write']
store.state.elements.elements.elementid = {
id: 'elementid',
name: 'Le Element',
type: 'element',
metadata: [],
classifications: [],
corpus: store.state.corpora.corpora.corpusid
}
const wrapper = shallowMount(PanelHeader, {
store,
localVue,
mocks: {
$route
},
stubs: {
RouterLink: RouterLinkStub,
Modal
},
propsData: {
elementId: 'elementid'
}
})
await store.actionsCompleted()
await Vue.nextTick()
store.history = []
const deleteModal = wrapper.get('.modal')
const deleteButton = wrapper.get('a.icon-trash')
assert.ok(deleteButton.classes('has-text-grey-light'))
assert.strictEqual(deleteButton.attributes('title'), 'Admin access on the project is required to delete this element')
// No modal displayed
assert.ok(!deleteModal.classes('is-active'))
})
})
<template>
<div>
<div v-if="!element" class="loading-content loader"></div>
<template v-else>
<DropdownContent title="Element type" :disabled="!canWrite(corpus)">
<div class="control">
<div class="select is-fullwidth">
<select v-model="defaultType">
<option value="" disabled selected>Type…</option>
<option v-for="t in corpus.types" :key="t.slug" :value="t.slug">
{{ t.display_name | truncateSelect }}
</option>
</select>
</div>
</div>
</DropdownContent>
<hr />
</template>
</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex'
import { truncateMixin, corporaMixin } from '~/js/mixins'
import DropdownContent from '~/vue/DropdownContent'
export default {
mixins: [
truncateMixin,
corporaMixin
],
components: {
DropdownContent
},
props: {
elementId: {
type: String,
required: true
}
},
computed: {
...mapState('elements', ['elements', 'neighbors', 'defaultCorpusTypes']),
element () {
return this.elements[this.elementId]
},
corpusId () {
return this.element.corpus.id
},
defaultType: {
get () {
return this.defaultCorpusTypes[this.corpusId]
},
set (newValue) {
this.setDefaultType({ corpusId: this.corpusId, type: newValue })
}
}
},
methods: {
...mapMutations('elements', ['setDefaultType'])
}
}
</script>
<style scoped>
.loading-content {
font-size: 2.5rem;
margin: 2.5rem auto 0 auto;
}
.button.has-tooltip-multiline {
width: 1rem;
height: 1.5rem;
margin-right: 1ch;
}
</style>
<template>
<div>
<div v-if="element" class="mb-3">
<template v-if="element.worker_version || element.worker_version_id || element.creator">
<p class="is-pulled-right">
Created by
<!-- Allow both worker_version and worker_version_id because the list and retrieve endpoints are inconsistent -->
<WorkerVersionDetails
v-if="element.worker_version || element.worker_version_id"
:worker-version-id="element.worker_version || element.worker_version_id"
/>
<strong v-else>{{ element.creator }}</strong>
</p>
</template>
<span class="subtitle is-5">
<span :title="element.type" class="has-text-grey">{{ typeName(element.type) }}</span>
<strong :title="element.name">{{ element.name }}</strong>
</span>
<router-link
v-if="element && element.id !== mainElementId"
:to="{ name: 'element-details', params: { id: element.id } }"
>
<i class="icon-link" :title="`Navigate to ${element.name}`"></i>
</router-link>
<a
class="icon-trash"
:class="canAdminElement(elementId) ? 'has-text-danger' : 'has-text-grey-light'"
:title="canAdminElement(elementId) ? 'Delete this element' : 'Admin access on the project is required to delete this element'"
v-on:click="deleteModal = canAdminElement(elementId)"
></a>
<Modal v-model="deleteModal" :title="'Delete ' + typeName(element.type) + ' ' + element.name">
<p>
Are you sure you want to delete the {{ typeName(element.type) }}
<strong>{{ element.name }}</strong>
?<br />
Child elements will also be deleted recursively.<br />
This action is irreversible.
</p>
<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>
</div>
<div v-if="!element" class="loading-content loader"></div>
<template v-else>
<template v-if="elementType.folder === false">
......@@ -52,7 +7,7 @@
<hr />
</template>
<DropdownContent title="Classifications" :disabled="(!canWriteElement(elementId) || !hasMlClasses) && !classifications.length">
<DropdownContent title="Classifications" :disabled="(!canWriteElement(elementId) || !hasMlClasses[corpusId]) && !classifications.length">
<HelpModal class="is-pulled-right" title="Help for classifications" :data="CLASSIFICATIONS_HELP" />
<form v-on:submit.prevent="createClassification">
<div v-if="canWriteElement(elementId) && hasMlClasses" class="field has-addons">
......@@ -132,7 +87,6 @@ import { CLASSIFICATIONS_HELP } from '~/js/help'
import { MANUAL_WORKER_VERSION } from '~/js/config'
import { groupBy, orderBy } from 'lodash'
import Modal from '~/vue/Modal'
import GroupedTranscriptions from './Transcription'
import HelpModal from '~/vue/HelpModal'
import EntityLinks from '~/vue/Entity/Links'
......@@ -140,7 +94,6 @@ import Classifications from './Classifications'
import MLClassSelect from '~/vue/MLClassSelect'
import ElementMetadata from './Metadata'
import DropdownContent from '~/vue/DropdownContent'
import WorkerVersionDetails from '~/vue/Process/Workers/Versions/Details'
import TranscriptionsForm from './Transcription/Form'
import OrientationPanel from './OrientationPanel'
......@@ -149,7 +102,6 @@ export default {
corporaMixin
],
components: {
Modal,
GroupedTranscriptions,
EntityLinks,
Classifications,
......@@ -157,7 +109,6 @@ export default {
ElementMetadata,
DropdownContent,
HelpModal,
WorkerVersionDetails,
TranscriptionsForm,
OrientationPanel
},
......@@ -165,16 +116,6 @@ export default {
elementId: {
type: String,
required: true
},
// TODO: Remove these two properties as they can be deduced from the element ID
corpusId: {
type: String,
required: true
},
hasMlClasses: {
// Defines if the corpus has available ML classes
type: Boolean,
default: false
}
},
data: () => ({
......@@ -183,13 +124,12 @@ export default {
selectedNewClassification: '',
validClassification: null,
isSavingNewClassification: false,
transcriptionModal: false,
deleteModal: false,
deleteLoading: false
transcriptionModal: false
}),
computed: {
...mapState('elements', ['elements', 'transcriptions', 'neighbors']),
...mapState('process', ['workerVersions', 'workers']),
...mapState('classification', ['hasMlClasses']),
...mapGetters('elements', {
// canWrite and canAdmin are already defined in corporaMixin
canWriteElement: 'canWrite',
......@@ -198,6 +138,9 @@ export default {
element () {
return this.elements[this.elementId]
},
corpusId () {
return this.element.corpus.id
},
elementType () {
return this.element ? this.getType(this.element.type) : {}
},
......@@ -217,10 +160,6 @@ export default {
metadata () {
return (this.element && this.element.metadata) || []
},
mainElementId () {
const routeParams = this.$route && this.$route.params
return routeParams && routeParams.id
},
firstParentId () {
// Returns the first parent element of this element. Used to redirect to a parent when deleting the element
const neighbor = this.neighbors?.[this.elementId]?.results?.find(n => n.element?.id === this.elementId)
......@@ -243,26 +182,6 @@ export default {
this.$refs.newClassificationSelect.clear()
this.isSavingNewClassification = false
}
},
async performDelete () {
if (!this.canAdminElement(this.elementId) || this.deleteLoading) return
this.deleteLoading = true
try {
await this.$store.dispatch('elements/delete', { id: this.elementId })
this.deleteModal = false
if (this.elementId === this.mainElementId) {
// When deleting the main element, redirect to the first parent element or the corpus.
if (this.firstParentId) {
this.$router.push({ name: 'element-details', params: { id: this.firstParentId } })
} else {
this.$router.push({ name: 'navigation', params: { corpusId: this.corpusId } })
}
} else {
this.$store.commit('elements/selectElement', null)
}
} finally {
this.deleteLoading = false
}
}
},
asyncComputed: {
......
......@@ -53,10 +53,14 @@
<transition name="sidebar">
<div class="column is-one-third" v-if="displayDetails && corpusId">
<PanelHeader :element-id="elementDetailsId" />
<AnnotationPanel
:element-id="elementDetailsId"
v-if="hasImage && imageEdit"
/>
<DetailsPanel
:element-id="elementDetailsId"
:corpus-id="corpusId"
:has-ml-classes="hasMlClasses[corpusId]"
v-else
/>
</div>
</transition>
......@@ -77,8 +81,10 @@ import ChildElement from './ChildElement'
import ElementList from '~/vue/Navigation/ElementList'
import ElementHeader from '~/vue/Element/ElementHeader'
import DetailsPanel from '~/vue/Element/DetailsPanel'
import AnnotationPanel from '~/vue/Element/AnnotationPanel'
import ChildrenTree from '~/vue/Navigation/ChildrenTree'
import InteractiveImage from '~/vue/Image/InteractiveImage'
import PanelHeader from './PanelHeader'
export default {
mixins: [
......@@ -99,9 +105,11 @@ export default {
ElementList,
ElementHeader,
DetailsPanel,
AnnotationPanel,
ChildrenTree,
InteractiveImage,
ChildElement
ChildElement,
PanelHeader
},
data: () => ({
loadingParents: true
......@@ -128,7 +136,7 @@ export default {
...mapGetters('auth', ['isLoggedOn', 'isVerified']),
...mapState('elements', ['elements', 'selectedElement', 'links', 'parents', 'neighbors']),
...mapState('classification', ['hasMlClasses']),
...mapState('display', ['displayDetails', 'displayFolderTree', 'displayAnnotationsTree']),
...mapState('display', ['displayDetails', 'displayFolderTree', 'displayAnnotationsTree', 'imageEdit']),
element () {
return this.elements[this.id]
},
......
<template>
<div class="mb-3">
<span class="subtitle is-5">
<span :title="element.type" class="has-text-grey">{{ typeName(element.type) }}</span>
<strong :title="element.name">{{ element.name }}</strong>
</span>
<router-link
v-if="element && element.id !== mainElementId"
:to="{ name: 'element-details', params: { id: element.id } }"
>
<i class="icon-link" :title="`Navigate to ${element.name}`"></i>
</router-link>
<a
class="icon-trash"
:class="canAdminElement(elementId) ? 'has-text-danger' : 'has-text-grey-light'"
:title="canAdminElement(elementId) ? 'Delete this element' : 'Admin access on the project is required to delete this element'"
v-on:click="deleteModal = canAdminElement(elementId)"
></a>
<template v-if="element.worker_version || element.worker_version_id || element.creator">
<p class="is-pulled-right">
Created by
<!-- Allow both worker_version and worker_version_id because the list and retrieve endpoints are inconsistent -->
<WorkerVersionDetails
v-if="element.worker_version || element.worker_version_id"
:worker-version-id="element.worker_version || element.worker_version_id"
/>
<strong v-else>{{ element.creator }}</strong>
</p>
</template>
<Modal v-model="deleteModal" :title="'Delete ' + typeName(element.type) + ' ' + element.name">
<p>
Are you sure you want to delete the {{ typeName(element.type) }}
<strong>{{ element.name }}</strong>
?<br />
Child elements will also be deleted recursively.<br />
This action is irreversible.
</p>
<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>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex'
import { corporaMixin } from '~/js/mixins'
import WorkerVersionDetails from '~/vue/Process/Workers/Versions/Details'
import Modal from '~/vue/Modal'
export default {
mixins: [
corporaMixin
],
components: {
WorkerVersionDetails,
Modal
},
props: {
elementId: {
type: String,
required: true
}
},
data: () => ({
deleteModal: false,
deleteLoading: false
}),
computed: {
...mapState('elements', ['elements']),
...mapGetters('elements', {
// canWrite and canAdmin are already defined in corporaMixin
canWriteElement: 'canWrite',
canAdminElement: 'canAdmin'
}),
element () {
return this.elements[this.elementId]
},
corpusId () {
return this.element.corpus.id
},
elementType () {
return this.element ? this.getType(this.element.type) : {}
},
mainElementId () {
const routeParams = this.$route && this.$route.params
return routeParams && routeParams.id
},
firstParentId () {
// Returns the first parent element of this element. Used to redirect to a parent when deleting the element
const neighbor = this.neighbors?.[this.elementId]?.results?.find(n => n.element?.id === this.elementId)
// Prevent errors on null parents since ListElementNeighbors can return null parents for paths with 'ghost elements'
return [...neighbor?.parents ?? []].reverse().find(parent => parent !== null)?.id
}
},
methods: {
async performDelete () {
if (!this.canAdminElement(this.elementId) || this.deleteLoading) return
this.deleteLoading = true
try {
await this.$store.dispatch('elements/delete', { id: this.elementId })
this.deleteModal = false
if (this.elementId === this.mainElementId) {
// When deleting the main element, redirect to the first parent element or the corpus.
if (this.firstParentId) {
this.$router.push({ name: 'element-details', params: { id: this.firstParentId } })
} else {
this.$router.push({ name: 'navigation', params: { corpusId: this.corpusId } })
}
} else {
this.$store.commit('elements/selectElement', null)
}
} finally {
this.deleteLoading = false
}
}
},
watch: {
elementId: {
immediate: true,
handler (id) {
if (!id) return
/*
* Do not retrieve the element again if it already exists in the store,
* unless it lacks some of the attributes only available from RetrieveElement.
* Some elements in the store can come from list endpoints such as those of the children tree.
* 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 })
}
}
}
}
</script>
<style scoped>
.loading-content {
font-size: 2.5rem;
margin: 2.5rem auto 0 auto;
}
.button.has-tooltip-multiline {
width: 1rem;
height: 1.5rem;
margin-right: 1ch;
}
</style>
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