diff --git a/js/store/elements.js b/js/store/elements.js index 6fe2ed7c855ce764f752130b62e3e40d64ccde83..9647b7ab52260b7dd7bb311e35cdf015a5ba2c5f 100644 --- a/js/store/elements.js +++ b/js/store/elements.js @@ -630,9 +630,19 @@ export const actions = { } } +export const getters = { + canWrite: state => id => { + return (state.elements[id]?.rights ?? []).includes('write') + }, + canAdmin: state => id => { + return (state.elements[id]?.rights ?? []).includes('admin') + } +} + export default { namespaced: true, state: initialState(), mutations, - actions + actions, + getters } diff --git a/test/store/elements.js b/test/store/elements.js index b03e83dad8087c416ee78cdeb2d13bfdc4d67ffc..f30594b5beebc9c2f8d1e0a8ac0e8aba4ff7dc54 100644 --- a/test/store/elements.js +++ b/test/store/elements.js @@ -1,7 +1,7 @@ import assert from 'assert' import axios from 'axios' import { pick } from 'lodash' -import { mutations } from '~/js/store/elements' +import { mutations, getters } from '~/js/store/elements' import { assertRejects, assertThrows, FakeAxios } from '~/test/testhelpers' import store from '~/test/store' import { elementNeighborsSample, jobsSample, transcriptionsPage1, transcriptionsPage2 } from '~/test/samples' @@ -1789,4 +1789,86 @@ describe('elements', () => { }) }) }) + + describe('getters', () => { + describe('canWrite', () => { + it('returns true if the user has write access on an element', () => { + const getter = getters.canWrite({ + elements: { + elementid: { + rights: ['read', 'write'] + } + } + }) + assert.ok(getter('elementid')) + }) + + it('returns false if the user does not have write access', () => { + const getter = getters.canWrite({ + elements: { + elementid: { + rights: ['read', 'admin'] + } + } + }) + assert.ok(!getter('elementid')) + }) + + it('returns false if there are no access rights on the element', () => { + const getter = getters.canWrite({ + elements: { + elementid: { + id: 'elementid' + } + } + }) + assert.ok(!getter('elementid')) + }) + + it('returns false for nonexistent elements', () => { + const getter = getters.canWrite({ elements: {} }) + assert.ok(!getter('elementid')) + }) + }) + + describe('canAdmin', () => { + it('returns true if the user has admin access on an element', () => { + const getter = getters.canAdmin({ + elements: { + elementid: { + rights: ['read', 'admin'] + } + } + }) + assert.ok(getter('elementid')) + }) + + it('returns false if the user does not have admin access', () => { + const getter = getters.canAdmin({ + elements: { + elementid: { + rights: ['read', 'write'] + } + } + }) + assert.ok(!getter('elementid')) + }) + + it('returns false if there are no access rights on the element', () => { + const getter = getters.canAdmin({ + elements: { + elementid: { + id: 'elementid' + } + } + }) + assert.ok(!getter('elementid')) + }) + + it('returns false for nonexistent elements', () => { + const getter = getters.canAdmin({ elements: {} }) + assert.ok(!getter('elementid')) + }) + }) + }) }) diff --git a/vue/Element/DetailsPanel.vue b/vue/Element/DetailsPanel.vue index f7ff357a4e1cedab93f34bec446f01e9cdcd096c..02a02a18f37870dcb0568374acd5493ccb6d91b5 100644 --- a/vue/Element/DetailsPanel.vue +++ b/vue/Element/DetailsPanel.vue @@ -21,9 +21,9 @@ </router-link> <a class="icon-trash" - :class="canAdmin(corpus) ? 'has-text-danger' : 'has-text-grey-light'" - :title="canAdmin(corpus) ? 'Delete this element' : 'Admin access on the project is required to delete this element'" - v-on:click="deleteModal = canAdmin(corpus)" + :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"> @@ -48,9 +48,9 @@ <hr /> </template> - <DropdownContent title="Classifications" :disabled="(!canWrite(corpus) || !hasMlClasses) && !classifications.length"> + <DropdownContent title="Classifications" :disabled="(!canWriteElement(elementId) || !hasMlClasses) && !classifications.length"> <HelpModal class="is-pulled-right" title="Help for classifications" :data="CLASSIFICATIONS_HELP" /> - <div v-if="canWrite(corpus) && hasMlClasses" class="field has-addons"> + <div v-if="canWriteElement(elementId) && hasMlClasses" class="field has-addons"> <p class="control"> <MLClassSelect ref="newClassificationSelect" @@ -85,7 +85,7 @@ :transcriptions="transcriptionGroup[1]" :worker-id="transcriptionGroup[0]" /> - <template v-if="canWrite(corpus)"> + <template v-if="canWriteElement(elementId)"> <a v-on:click="transcriptionModal = true"> <i class="icon-add-square"></i> Add @@ -104,7 +104,7 @@ <hr /> </template> - <DropdownContent title="Metadata" v-model="toggleMetas" :disabled="!metadata.length && !canWrite(corpus)"> + <DropdownContent title="Metadata" v-model="toggleMetas" :disabled="!metadata.length && !canWriteElement(elementId)"> <ElementMetadata :corpus-id="element.corpus.id" :element-id="element.id" :opened="toggleMetas" /> </DropdownContent> <hr /> @@ -120,7 +120,7 @@ </template> <script> -import { mapState, mapActions } from 'vuex' +import { mapState, mapActions, mapGetters } from 'vuex' import { corporaMixin } from '~/js/mixins' import { CLASSIFICATIONS_HELP } from '~/js/help' import { MANUAL_WORKER_VERSION } from '~/js/config' @@ -185,6 +185,11 @@ export default { computed: { ...mapState('elements', ['elements', 'transcriptions', 'neighbors']), ...mapState('process', ['workerVersions', 'workers']), + ...mapGetters('elements', { + // canWrite and canAdmin are already defined in corporaMixin + canWriteElement: 'canWrite', + canAdminElement: 'canAdmin' + }), element () { return this.elements[this.elementId] }, @@ -232,7 +237,7 @@ export default { } }, async performDelete () { - if (!this.canAdmin(this.corpus) || this.deleteLoading) return + if (!this.canAdminElement(this.elementId) || this.deleteLoading) return this.deleteLoading = true try { await this.$store.dispatch('elements/delete', { id: this.elementId }) @@ -277,10 +282,15 @@ export default { watch: { elementId: { immediate: true, - async handler (id) { + handler (id) { if (!id) return - // Prevent retrieving element details multiple times - if (this.element?.id !== id) await this.$store.dispatch('elements/get', { id }) + /* + * Do not retrieve the element again if it already exists in the store, + * unless it lacks the `rights` attribute: this attribute is only available from RetrieveElement, + * and 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. + */ + if (this.element?.id !== id || !this.element?.rights) this.$store.dispatch('elements/get', { id }) } }, elementType: { diff --git a/vue/Element/EditionForm.vue b/vue/Element/EditionForm.vue index b33807e9d43849cd6ef1917b95752ec844aae8de..48cd2d07b896ccaf779ae1b766d157b74563faac 100644 --- a/vue/Element/EditionForm.vue +++ b/vue/Element/EditionForm.vue @@ -30,7 +30,7 @@ <div class="field"> <div class="control"> <input - :disabled="!canWrite(corpus)" + :disabled="!canWriteElement(element.id)" type="text" class="input mousetrap" :class="{ 'is-danger': fieldErrors.name }" @@ -54,7 +54,7 @@ <div class="control"> <div class="control" title="Filter by type"> <span class="select is-fullwidth" :class="{ 'is-danger': fieldErrors.type }"> - <select v-model="type" class="mousetrap" :disabled="!canWrite(corpus)"> + <select v-model="type" class="mousetrap" :disabled="!canWriteElement(element.id)"> <option value="" disabled selected>Type…</option> <option v-for="t in corpus.types" :key="t.slug" :value="t.slug"> {{ t.display_name | truncateSelect }} @@ -83,9 +83,9 @@ </router-link> <span class="button is-danger" - :disabled="loading || !canAdmin(corpus)" + :disabled="!canDelete" :class="{ 'is-loading': deleteLoading }" - :title="canAdmin(corpus) ? 'Delete this element and its children' : 'A project administrator right is required to delete this element and its children'" + :title="canDelete ? 'Delete this element and its children' : 'A project administrator right is required to delete this element and its children'" v-on:click="deleteElement" > <i class="icon-trash"></i> @@ -95,7 +95,7 @@ <button class="button is-success has-margin-left" :class="{ 'is-loading': updateLoading }" - :disabled="loading || !canUpdate || !canWrite(corpus)" + :disabled="loading || !canUpdate" :title="canUpdateTitle" v-on:click="updateElement" > @@ -106,7 +106,7 @@ </template> <script> -import { mapMutations, mapActions, mapState } from 'vuex' +import { mapState, mapMutations, mapGetters } from 'vuex' import { ELEMENT_NAME_MAX_LENGTH } from '~/js/config' import { errorParser } from '~/js/helpers' import { truncateMixin, corporaMixin } from '~/js/mixins' @@ -147,6 +147,11 @@ export default { computed: { ...mapState('annotation', { defaultType: 'type' }), ...mapState('elements', ['selectedElement']), + ...mapGetters('elements', { + // canWrite and canAdmin are already defined on corporaMixin + canWriteElement: 'canWrite', + canAdminElement: 'canAdmin' + }), loading () { return this.deleteLoading || this.updateLoading }, @@ -163,20 +168,24 @@ export default { return this.type && this.validClassification }, canUpdate () { - // Type and name are valid and different from elements current values - if (!this.type) return false - return this.type !== this.element.type || (this.name && this.name !== this.element.name) + // User has write access and type and name are valid and different from the element's current values + return !this.loading && this.canWriteElement(this.element.id) && ( + (this.type && this.type !== this.element.type) || + (this.name && this.name !== this.element.name) + ) }, canUpdateTitle () { - if (!this.canWrite(this.corpus)) return 'A project write access is required to update this element' + if (!this.canWriteElement(this.element.id)) return 'A project write access is required to update this element' if (!this.canUpdate) return 'A valid updated type and name are required to update the element' return 'Update element' + }, + canDelete () { + return !this.loading && this.canAdminElement(this.element.id) } }, methods: { ...mapMutations('notifications', ['notify']), ...mapMutations('elements', ['selectElement']), - ...mapActions('elements', ['patch', 'delete']), nameFromElement () { // Update name input to default value if not defined if (!this.name) this.name = this.element.name @@ -188,7 +197,7 @@ export default { else this.fieldErrors = error.response.data }, async updateElement () { - if (this.loading || !this.canUpdate || !this.canWrite(this.corpus)) return + if (this.loading || !this.canUpdate || !this.canWriteElement(this.element.id)) return this.updateLoading = true this.setErrors(null) this.nameFromElement() @@ -209,7 +218,7 @@ export default { } }, async deleteElement () { - if (!this.corpus || !this.canAdmin(this.corpus)) return + if (!this.canDelete) return this.deleteLoading = true try { await this.$store.dispatch('elements/delete', { id: this.element.id }) diff --git a/vue/HeaderActions.vue b/vue/HeaderActions.vue index 79f33085d70966e9ff8f520a3087a0b204a4bf87..6aeb8f222166a5f2f381a05e8f50393703f1065d 100644 --- a/vue/HeaderActions.vue +++ b/vue/HeaderActions.vue @@ -496,6 +496,11 @@ export default { 'isVerified', 'hasFeature' ]), + ...mapGetters('elements', { + // canWrite and canAdmin are already defined in corporaMixin + canWriteElement: 'canWrite', + canAdminElement: 'canAdmin' + }), ...mapGetters('navigation', { canDeleteFiltered: 'canDelete' }), ...mapState('navigation', { filteredElements: 'elements' }), ...mapState('elements', [ @@ -538,10 +543,24 @@ export default { return firstParents.filter((parent, index, parents) => parents.findIndex(p => (p.id === parent.id)) === index) }, hasContribPrivilege () { - return this.isVerified && this.corpus && this.canWrite(this.corpus) + /* + * Either these actions are shown for an element and the user has write rights on the element, + * or the user has write rights on the corpus. + */ + return this.isVerified && ( + (this.elementId && this.canWriteElement(this.elementId)) || + (this.corpus && this.canWrite(this.corpus)) + ) }, hasAdminPrivilege () { - return this.isVerified && this.corpus && this.canAdmin(this.corpus) + /* + * Either these actions are shown for an element and the user has admin rights on the element, + * or the user has admin rights on the corpus. + */ + return this.isVerified && ( + (this.elementId && this.canAdminElement(this.elementId)) || + (this.corpus && this.canAdmin(this.corpus)) + ) }, canContain () { // Determine if the current view show a corpus or a folder