diff --git a/src/api/element.ts b/src/api/element.ts index 4b4d66582be7ff77a64f47c408a012054ed1e498..56c57eed0165228c30b611121470c2e9ae7375a5 100644 --- a/src/api/element.ts +++ b/src/api/element.ts @@ -98,13 +98,20 @@ export const deleteElements = unique(({ corpus, ...params }: ElementDestroyParam export const deleteElementChildren = unique(({ id, ...params }: ElementChildrenDestroyParameters) => axios.delete(`/elements/${id}/children/`, { params })) export interface ElementNeighbor { - position: number, - element: ElementLight, - parents: ElementLight[] + ordering: number + previous: ElementLight | null + next: ElementLight | null + /** + * List of parent elements, ending with the most direct parent of this element. + * + * When the path in the database refers to elements that no longer exist ("ghost elements"), this path might contain `null` values. + * An error notification should be displayed to the user to help in troubleshooting those paths. + */ + path: (ElementLight | null)[] } // List an element neighbors -export const listElementNeighbors = unique(async (id: UUID): Promise<PageNumberPagination<ElementNeighbor>> => (await axios.get(`/elements/${id}/neighbors/`)).data) +export const listElementNeighbors = unique(async (id: UUID): Promise<ElementNeighbor[]> => (await axios.get(`/elements/${id}/neighbors/`)).data) // Retrieve an element. export const retrieveElement = unique(async (id: UUID): Promise<Element> => (await axios.get(`/element/${id}/`)).data) diff --git a/src/components/Element/AnnotationPanel.vue b/src/components/Element/AnnotationPanel.vue index 0998b49c1732fef1fb712273bac7b2fdb5948097..466b687403c086fc489ba7d2fa59ab39021c3e17 100644 --- a/src/components/Element/AnnotationPanel.vue +++ b/src/components/Element/AnnotationPanel.vue @@ -84,7 +84,7 @@ export default { } }, computed: { - ...mapState('elements', ['elements', 'neighbors']), + ...mapState('elements', ['elements']), ...mapState('annotation', ['tool']), element () { return this.elements[this.elementId] diff --git a/src/components/Element/DetailsPanel.vue b/src/components/Element/DetailsPanel.vue index f090cce8aad9aa6cf5b4a1216dba0710a1d6f70e..20d9a7961c3fe58f6b3a39c6b135326b462bcade 100644 --- a/src/components/Element/DetailsPanel.vue +++ b/src/components/Element/DetailsPanel.vue @@ -73,7 +73,7 @@ <script> import { mapState, mapGetters } from 'vuex' -import { corporaMixin } from '@/mixins.js' +import { corporaMixin } from '@/mixins' import DropdownContent from '@/components/DropdownContent.vue' import MLClassSelect from '@/components/MLClassSelect.vue' @@ -113,7 +113,7 @@ export default { transcriptionModal: false }), computed: { - ...mapState('elements', ['elements', 'transcriptions', 'neighbors']), + ...mapState('elements', ['elements', 'transcriptions']), ...mapState('process', ['workerVersions', 'workers']), ...mapState('classification', ['hasMlClasses']), ...mapGetters('elements', { @@ -145,12 +145,6 @@ export default { }, metadata () { return (this.element && this.element.metadata) || [] - }, - 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: { diff --git a/src/components/Element/ElementHeader.vue b/src/components/Element/ElementHeader.vue index 010f3c80466239bbb1f48a25f0ec3e7c5b92ae7f..efa296d265bd8c338c6f599db99df73e7c06f3f1 100644 --- a/src/components/Element/ElementHeader.vue +++ b/src/components/Element/ElementHeader.vue @@ -1,226 +1,105 @@ <template> - <div> - <div :class="{ 'has-shadow': !subHeader }"> - <div class="elt-header is-flex"> - <template v-if="element && !loading"> - <div class="columns has-items-centered"> - <div class="is-flex column is-narrow is-hidden-mobile"> - <template v-if="!subHeader"> - <div - v-if="morePaths > 0" - :title="morePaths + ' more path' + (morePaths > 1 ? 's' : '') + ' for this element'" - > - <a - v-on:click="toggleShowPaths" - class="navbar-link" - > - <div class="tag is-link"> - +{{ morePaths }} - </div> - </a> - </div> - </template> - </div> - <div class="is-flex column"> - <nav class="breadcrumb is-flex has-vpadding"> - <ul> - <li> - <router-link :to="corpusLink" class="has-text-weight-semibold"> - {{ truncateShort(element.corpus.name) }} - </router-link> - </li> - <template v-if="currentNeighbor"> - <li - v-for="parent in currentNeighbor.parents" - :key="parent.id" - > - <span class="is-flex has-hpadding"> - <span class="has-text-grey" :title="typeName(parent.type)"> - {{ truncateShort(typeName(parent.type)) }} - </span> - <router-link - class="is-paddingless" - :to="elementLink(parent.id)" - :title="parent.name" - > - {{ truncateLong(parent.name) }} - </router-link> - </span> - </li> - </template> - <li> - <span class="is-flex has-hpadding has-items-centered"> - <span class="is-flex has-text-grey" :title="typeName(element.type)"> - {{ truncateShort(typeName(element.type)) }} - </span> - <EditableName - :instance="element" - :enabled="isVerified && canWrite(corpus)" - dispatch="elements/patch" - class="has-text-weight-bold" - /> - </span> - </li> - </ul> - </nav> - </div> - - <div class="is-flex column is-narrow" v-if="currentNeighbor"> - <div> - <router-link - :to="pathLink(previous)" - :class="{ 'disabled': !previous }" - > - <i class="icon-arrow-left"></i> - </router-link> - {{ currentNeighbor.position + 1 }} - <router-link - :to="pathLink(next)" - :class="{ 'disabled': !next }" - > - <i class="icon-arrow-right"></i> - </router-link> + <div class="has-shadow"> + <div class="elt-header is-flex"> + <div class="columns mx-0 is-align-items-center" v-if="element"> + <div class="column p-0 is-narrow is-hidden-mobile"> + <div + v-if="morePaths > 0" + :title="morePaths + ' more path' + (morePaths > 1 ? 's' : '') + ' for this element'" + > + <a + v-on:click="toggleAllPaths(null)" + class="navbar-link" + > + <div class="tag is-link"> + +{{ morePaths }} </div> - </div> - - <HeaderActions v-if="!subHeader" :corpus-id="corpusId" :element-id="element.id" /> + </a> </div> - </template> - <span v-else class="has-vpadding">Loading…</span> - </div> - <transition name="paths"> - <div v-if="element && showPaths && !subHeader"> - <ElementHeader - v-for="(e, index) in similarElementNeighbors.slice(1)" - :key="index" - :element="element" - :neighbor="e" - :sub-header="true" - /> </div> - </transition> + + <ElementPath + class="column" + :element="element" + :neighbor="sortedNeighbors[0]" + :loading="loading" + keyboard-shortcuts + /> + + <HeaderActions :corpus-id="corpusId" :element-id="element.id" /> + </div> + <span v-else class="loader py-2"></span> </div> + <transition name="paths"> + <div class="px-3" v-if="element && displayAllPaths"> + <ElementPath + v-for="(neighbor, index) in sortedNeighbors.slice(1)" + :key="index" + :element="element" + :neighbor="neighbor" + /> + </div> + </transition> </div> </template> <script> -import { mapState, mapGetters } from 'vuex' -import { isEqual } from 'lodash' -import Mousetrap from 'mousetrap' -import { truncateMixin, corporaMixin } from '@/mixins.js' -import EditableName from '@/components/EditableName.vue' +import { mapState as mapVuexState } from 'vuex' +import { mapState, mapActions } from 'pinia' +import { corporaMixin } from '@/mixins' import HeaderActions from '@/components/HeaderActions.vue' -import ElementHeader from './ElementHeader' +import ElementPath from './ElementPath' +import { useDisplayStore } from '@/stores' export default { mixins: [ - truncateMixin, corporaMixin ], - name: 'ElementHeader', props: { element: { type: Object, required: true - }, - neighbor: { - type: Object, - default: null - }, - /* - * Displays path breadcrumb and neighbors navigation only - * This property is used in recursive display of multiple paths - */ - subHeader: { - type: Boolean, - default: false } }, components: { HeaderActions, - EditableName, - ElementHeader + ElementPath }, data: () => ({ - showPaths: false, loading: false }), - mounted () { - this.showPaths = sessionStorage.getItem('showPaths') === 'true' - if (!this.subHeader) { - Mousetrap.bind('ctrl+left', () => { - if (this.previous) this.$router.push(this.pathLink(this.previous)) - }) - Mousetrap.bind('ctrl+right', () => { - if (this.next) this.$router.push(this.pathLink(this.next)) - }) - } - }, computed: { - ...mapGetters('auth', ['isAdmin', 'isVerified', 'hasFeature']), - ...mapState('elements', ['childrenPagination']), - ...mapState('elements', { neighborsState: 'neighbors' }), + ...mapVuexState('elements', { + elementNeighbors (state) { + return state.neighbors[this.element?.id] ?? [] + } + }), + ...mapState(useDisplayStore, ['displayAllPaths']), corpusId () { return this.element?.corpus?.id ?? null }, - corpusLink () { - const route = { - name: 'navigation', - params: { corpusId: this.element.corpus.id } - } - return route - }, - allElementNeighbors () { - return this.neighborsState[this.element.id]?.results - }, - similarElementNeighbors () { - /* - * Filters neighbors from the state to list only items concerning the current element - * Allows to display the different paths concerning the main element in recursive instances of the ElementHeader - */ - if (!this.element?.id || !this.allElementNeighbors) return [] - const eltNeighbors = this.allElementNeighbors.filter(n => n.element.id === this.element.id) + sortedNeighbors () { + if (!this.element?.id || !this.elementNeighbors?.length) return [] + const eltNeighbors = [...this.elementNeighbors] // If we know the parent folder, display paths containing that parent at the closest position if (eltNeighbors.length > 1 && this.fromFolderId) { eltNeighbors.sort( - neighbor => { + ({ path }) => { /* * Sort paths depending on the parent's position: 0 if a direct parent, 1 if grandparent, Infinite if not found * As parents are listed from top to bottom, look for the parent index from the end of the array (closer to the element) */ - const parents = [...neighbor.parents].reverse() - const index = parents.findIndex(elt => elt.id === this.fromFolderId) - return index === -1 ? Infinity : index + const index = path.findLastIndex(elt => elt.id === this.fromFolderId) + return index === -1 ? Infinity : path.length - 1 - index } ) } + return eltNeighbors }, - currentNeighbor () { - // Current neighbor result, containing its path (ordered parents IDs) and position within siblings - if (this.subHeader && this.neighbor) { - // Return neighbor property for recursive sub-headers - if (this.neighbor.parents.some(p => p === null)) return null - return this.neighbor - } - if (!this.similarElementNeighbors.length) return null - return this.similarElementNeighbors[0] - }, - previous () { - if (!this.currentNeighbor) return null - const previous = this.allElementNeighbors.find(n => isEqual(n.parents, this.currentNeighbor.parents) && n.position < this.currentNeighbor.position) - if (!previous) return null - else return previous.element.id - }, - next () { - if (!this.currentNeighbor) return null - const next = this.allElementNeighbors.find(n => isEqual(n.parents, this.currentNeighbor.parents) && n.position > this.currentNeighbor.position) - if (!next) return null - else return next.element.id - }, morePaths () { - return this.similarElementNeighbors.length - 1 + return this.elementNeighbors.length - 1 }, fromFolderId () { // Query parameter allowing to order path corresponding to the navigation scheme @@ -228,39 +107,9 @@ export default { } }, methods: { - getDirectParent (eltId = null) { - /* - * Returns the direct parent of a specific element in the current path - * In case elementId is null, returns the first parent in the path - */ - const parents = this.currentNeighbor?.parents - if (!parents || parents.length === 0) return - if (!eltId) return parents[parents.length - 1] - - const eltIndex = parents.findIndex(elt => elt.id === eltId) - if (eltIndex <= 0) return - return parents[eltIndex - 1] - }, - elementLink (eltId, routeName = 'element-details', parent = null) { - /* Returns a link to a route (default to element's details) from an element ID */ - if (!eltId) return {} - const from = parent?.id || this.getDirectParent(eltId)?.id - return { - name: routeName, - params: { id: eltId }, - query: { from } - } - }, - pathLink (eltId) { - /* Returns a link to same route but with a different ID parameter */ - return this.elementLink(eltId, this.$route.name, this.getDirectParent()) - }, - toggleShowPaths () { - this.showPaths = !this.showPaths - sessionStorage.setItem('showPaths', this.showPaths) - }, + ...mapActions(useDisplayStore, ['toggleAllPaths']), async load () { - if (!this.element || this.subHeader || this.allElementNeighbors) return + if (!this.element || this.elementNeighbors.length) return this.loading = true try { await this.$store.dispatch('elements/listNeighbors', { id: this.element.id }) @@ -279,13 +128,6 @@ export default { </script> <style lang="scss" scoped> -.disabled { - pointer-events: none; - opacity: 0.4; -} -.has-items-centered > * { - align-items: center; -} .elt-header { & > .columns { width: 100%; @@ -300,12 +142,6 @@ export default { .has-shadow { box-shadow: 0.1rem 0.1rem 0.6rem lightgray; } -.has-hpadding { - padding: 0 0.75em; -} -.has-vpadding { - padding: .5rem 0 .5rem 0 -} .paths-enter-active { transition: all .4s; } diff --git a/src/components/Element/ElementPath.vue b/src/components/Element/ElementPath.vue new file mode 100644 index 0000000000000000000000000000000000000000..8dda5e6c3116f60bd4db52781fe89bc8e6d99794 --- /dev/null +++ b/src/components/Element/ElementPath.vue @@ -0,0 +1,169 @@ +<template> + <div class="columns m-0 is-align-items-center"> + <div class="column p-0"> + <nav class="breadcrumb py-2"> + <ul> + <li> + <router-link :to="{ name: 'navigation', params: { corpusId } }" class="has-text-weight-semibold"> + {{ truncateShort(corpus.name) }} + </router-link> + </li> + + <template v-if="neighbor"> + <li + v-for="(parent, index) in neighbor.path" + :key="index" + > + <!-- Handle nulls when a path refers to an element that no longer exists --> + <span class="is-italic pr-3" v-if="parent === null"> + Deleted element + </span> + <template v-else> + <span class="pl-3 has-text-grey" :title="typeName(parent.type)"> + {{ truncateShort(typeName(parent.type)) }} + </span> + <router-link + class="pl-0 pr-3 parent-name" + :to="elementLink(parent.id)" + :title="parent.name" + > + {{ truncateLong(parent.name) }} + </router-link> + </template> + </li> + </template> + + <li v-if="loading"> + <span class="loader mx-3"></span> + </li> + + <li> + <span class="is-flex is-align-items-center px-3"> + <span class="has-text-grey" :title="typeName(element.type)"> + {{ truncateShort(typeName(element.type)) }} + </span> + <EditableName + :instance="element" + :enabled="isVerified && canWrite(corpus)" + dispatch="elements/patch" + class="has-text-weight-bold" + /> + </span> + </li> + </ul> + </nav> + </div> + + <div class="column is-narrow p-0" v-if="neighbor"> + <router-link + :to="pathLink(neighbor.previous?.id)" + :class="{ 'disabled': neighbor.previous === null }" + > + <i class="icon-arrow-left"></i> + </router-link> + + {{ neighbor.ordering + 1 }} + + <router-link + :to="pathLink(neighbor.next?.id)" + :class="{ 'disabled': neighbor.next === null }" + > + <i class="icon-arrow-right"></i> + </router-link> + </div> + </div> +</template> + +<script> +import Mousetrap from 'mousetrap' +import { mapState } from 'vuex' +import { corporaMixin, truncateMixin } from '@/mixins' +import EditableName from '@/components/EditableName.vue' + +export default { + mixins: [ + truncateMixin, + corporaMixin + ], + props: { + element: { + type: Object, + required: true, + validator: element => typeof element === 'object' && element.id && element.corpus?.id && element.type + }, + neighbor: { + type: Object, + default: null + }, + loading: { + type: Boolean, + default: false + }, + /** + * Whether to assign the Ctrl+Left and Ctrl+Right shortcuts to browsing the previous and next elements on this path. + */ + keyboardShortcuts: { + type: Boolean, + default: false + } + }, + components: { + EditableName + }, + mounted () { + if (this.keyboardShortcuts) { + Mousetrap.bind('ctrl+left', () => { + if (this.neighbor?.previous) this.$router.push(this.pathLink(this.neighbor.previous.id)) + }) + Mousetrap.bind('ctrl+right', () => { + if (this.neighbor?.next) this.$router.push(this.pathLink(this.neighbor.next.id)) + }) + } + }, + beforeUnmount () { + if (this.keyboardShortcuts) { + Mousetrap.unbind('ctrl+left') + Mousetrap.unbind('ctrl+right') + } + }, + computed: { + ...mapState('auth', ['isVerified']), + corpusId () { + return this.element.corpus.id + } + }, + methods: { + elementLink (eltId, routeName = 'element-details', parent = null) { + /* Returns a link to a route (default to element's details) from an element ID */ + if (!eltId) return {} + return { + name: routeName, + params: { id: eltId }, + query: { from: parent?.id } + } + }, + /** + * Returns a link to same route but for another element ID. + * When available, this path's direct parent ID will be used as the ?from= query parameter. + */ + pathLink (eltId) { + return this.elementLink(eltId, this.$route.name, this.neighbor?.path[this.neighbor?.path?.length - 1]) + } + } +} +</script> + +<style> +a.disabled { + pointer-events: none; + opacity: 0.4; +} +@media screen and (max-width: 1200px) { + a.parent-name { + display: inline; + text-overflow: ellipsis; + overflow: hidden; + max-width: 30ch; + } +} +</style> diff --git a/src/components/Element/PanelHeader.vue b/src/components/Element/PanelHeader.vue index 921f454b54295f09fa1b449cb8d1b89874e2817c..93608a15196750560ba8937f55a4db9d2cb0a294 100644 --- a/src/components/Element/PanelHeader.vue +++ b/src/components/Element/PanelHeader.vue @@ -111,11 +111,12 @@ export default { editionModal: false }), computed: { - ...mapState('elements', ['elements', 'neighbors']), + ...mapState('elements', ['elements']), ...mapState('annotation', { annotationEnabled: 'enabled' }), ...mapGetters('elements', { + firstParentId: 'firstParentId', // canWrite and canAdmin are already defined in corporaMixin canWriteElement: 'canWrite', canAdminElement: 'canAdmin' @@ -136,12 +137,6 @@ export default { 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: { @@ -159,7 +154,7 @@ export default { let newRoute = null if (this.elementId === this.mainElementId) { // When deleting the main element, redirect to the first parent element or the corpus. - if (this.firstParentId) newRoute = { name: 'element-details', params: { id: this.firstParentId } } + if (this.firstParentId(this.elementId)) newRoute = { name: 'element-details', params: { id: this.firstParentId(this.elementId) } } else newRoute = { name: 'navigation', params: { corpusId: this.corpusId } } } diff --git a/src/components/HeaderActions.vue b/src/components/HeaderActions.vue index fa92bcdddfd5fecb96f7f972c920eee24053daf5..38e33ab5dcfc957554d398c5d35084baacad1da1 100644 --- a/src/components/HeaderActions.vue +++ b/src/components/HeaderActions.vue @@ -1,5 +1,5 @@ <template> - <div class="is-flex column is-narrow"> + <div class="column is-narrow is-flex-shrink-1 px-0"> <div class="dropdown is-right is-hoverable"> <div class="dropdown-trigger"> <a class="navbar-link"> @@ -534,6 +534,7 @@ export default { 'hasFeature' ]), ...mapVuexGetters('elements', { + firstParentId: 'firstParentId', // canWrite and canAdmin are already defined in corporaMixin canWriteElement: 'canWrite', canAdminElement: 'canAdmin' @@ -569,19 +570,12 @@ export default { totalChildrenCount () { return this.elementId && this.childrenPagination[this.elementId]?.count }, - firstParentId () { - // Returns the first parent element of this element. Used to redirect to a parent when deleting the element - if (!this.elementId || !this.neighbors[this.elementId]?.results) return null - const neighbor = this.neighbors[this.elementId].results.find(n => n.element && 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 - }, elementDirectParents () { - if (!this.elementId || !this.neighbors[this.elementId]?.results) return null + if (!this.elementId || !Array.isArray(this.neighbors[this.elementId])) return null // Fetch all direct parents of this element. - const firstParents = this.neighbors[this.elementId].results - .filter(({ element, parents }) => element?.id === this.elementId && parents?.length > 0) - .map(({ parents }) => parents[parents.length - 1]) + const firstParents = this.neighbors[this.elementId] + .filter(({ path }) => path?.length > 0) + .map(({ path }) => path[path.length - 1]) // Prevent returning duplicated parents by filtering out identical objects. return firstParents.filter((parent, index, parents) => parents.findIndex(p => (p.id === parent.id)) === index) }, @@ -707,8 +701,8 @@ export default { * When removing a corpus, redirect to the corpora list page. */ if (this.elementId) { - if (this.firstParentId) { - this.$router.push({ name: 'element-details', params: { id: this.firstParentId } }) + if (this.firstParentId(this.elementId)) { + this.$router.push({ name: 'element-details', params: { id: this.firstParentId(this.elementId) } }) } else { this.$router.push({ name: 'navigation', params: { corpusId: this.corpusId } }) } @@ -732,8 +726,8 @@ export default { this.moveModal = false // Redirect to the first parent element when available, otherwise redirect to the element's corpus. - if (this.firstParentId) { - this.$router.push({ name: 'element-details', params: { id: this.firstParentId } }) + if (this.firstParentId(this.elementId)) { + this.$router.push({ name: 'element-details', params: { id: this.firstParentId(this.elementId) } }) } else { this.$router.push({ name: 'navigation', params: { corpusId: this.corpusId } }) } diff --git a/src/store/elements.js b/src/store/elements.js index 2320f2dbbeee4b7bc060e31cce652713226bdee2..125c73edc0043af870df842de4c526d82e06b4c3 100644 --- a/src/store/elements.js +++ b/src/store/elements.js @@ -1,4 +1,4 @@ -import { assign, clone, merge } from 'lodash' +import { assign, clone, merge, zip } from 'lodash' import * as api from '@/api' import { ELEMENT_LIST_MAX_AUTO_PAGES } from '@/config' import { errorParser } from '@/helpers' @@ -12,7 +12,9 @@ export const initialState = () => ({ * { [elementId]: id[] } */ parents: {}, - // { [id]: neighbors } + /** + * @type {{ [elementId: import('@/types').UUID]: import('@/api').ElementNeighbor[] }} + */ neighbors: {}, // { [id]: { children: { count: … } } links: {}, @@ -192,8 +194,8 @@ export const mutations = { addNeighbors (state, { element, neighbors }) { const newNeighbors = clone(neighbors) let nullParentError = false - if (newNeighbors && Array.isArray(newNeighbors.results)) { - newNeighbors.results.forEach(neighbor => { + if (newNeighbors && Array.isArray(newNeighbors)) { + newNeighbors.forEach(neighbor => { /* * Detect and remove null values in each neighbor element's parents. * When the path array in ElementPath has IDs that point to elements that no longer exist, @@ -201,8 +203,8 @@ export const mutations = { * We will still update the state, but throw an error after committing to show a notification * and get an explicit alert on Sentry. */ - if (!nullParentError && neighbor.parents.some(parent => parent === null)) nullParentError = true - neighbor.parents = neighbor.parents.filter(parent => parent !== null) + if (!nullParentError && neighbor.path.some(parent => parent === null)) nullParentError = true + neighbor.path = neighbor.path.filter(parent => parent !== null) }) } state.neighbors = { @@ -578,6 +580,24 @@ export const getters = { }, canAdmin: state => id => { return (state.elements[id]?.rights ?? []).includes('admin') + }, + /** + * Returns the first parent element of this element. Used to redirect to a parent when deleting the element + * @type {import('@/types').UUID} + */ + firstParentId: state => id => { + // Find all known paths for the element and reverse them so they start with the most direct parents + const allPaths = state.neighbors?.[id]?.map(({ path }) => [...path].reverse()) ?? [] + /* + * Prevent errors on null parents since ListElementNeighbors can return null parents for paths with 'ghost elements': + * find the first defined parent in any path, starting with the most direct parents. + * This uses zip().flat(1) so that we can look for all the direct parents in all paths first, then the grandparents, etc. + * instead of going through each ancestry line one by one. + * Lodash's zip() operates like itertools.zip_longest and not like the standard Python zip(), + * so it can return arrays with undefined values if all paths are not the same length, + * so we have to handle both null and undefined. + */ + return zip(...allPaths).flat(1).find(parent => parent)?.id } } diff --git a/src/stores/display.ts b/src/stores/display.ts index 0e683e4a7ccc4039bb75d4d30943ced23c3d7e27..6fd1c188dd54ab85926cb6a9894e1b157edb4cbd 100644 --- a/src/stores/display.ts +++ b/src/stores/display.ts @@ -38,6 +38,11 @@ interface State { */ displayElementClasses: boolean + /** + * Whether to expand the element header to show all of the element's paths. + */ + displayAllPaths: boolean + /** * Whether to display elements as a table in navigation views, * instead of thumbnails @@ -87,6 +92,11 @@ const getScreenSize = (): ScreenSize => { return { width: window.innerWidth, height: window.innerHeight } } +const getShowPaths = (): boolean => { + if (typeof window === 'undefined' || !window.sessionStorage) return false + return window.sessionStorage.getItem('showPaths') === 'true' +} + export const useDisplayStore = defineStore('display', { state: (): State => ({ screen: getScreenSize(), @@ -95,6 +105,7 @@ export const useDisplayStore = defineStore('display', { displayAnnotationsTree: true, displayEntityTypes: true, displayElementClasses: false, + displayAllPaths: getShowPaths(), elementsTableLayout: false, compactDisplay: false, imageShow: true, @@ -132,6 +143,14 @@ export const useDisplayStore = defineStore('display', { if (typeof value === 'boolean') this.compactDisplay = value else this.compactDisplay = !this.compactDisplay }, + toggleAllPaths (value: boolean | null = null) { + if (typeof value === 'boolean') this.displayAllPaths = value + else this.displayAllPaths = !this.displayAllPaths + + if (typeof window !== 'undefined' && window.sessionStorage) { + window.sessionStorage.setItem('showPaths', this.displayAllPaths.toString()) + } + }, toggleDropdown (id: string, value: boolean | null = null) { if (typeof value === 'boolean') { this.dropdowns = { diff --git a/src/views/Element.vue b/src/views/Element.vue index 82242357afa735aeb19ffee23dd8753fb072a399..b83e700e1b1cc6a3fe2bbdac7e4034328196706c 100644 --- a/src/views/Element.vue +++ b/src/views/Element.vue @@ -242,8 +242,7 @@ export default { }) }, isNeighbor (id) { - const neighbors = this.neighbors[this.id]?.results.map(n => n.element.id) - return neighbors && neighbors.includes(this.id) && neighbors.includes(id) + return this.neighbors[this.id]?.flatMap(({ previous, next }) => [previous?.id, next?.id]).includes(id) ?? false } }, watch: { diff --git a/tests/unit/samples.js b/tests/unit/samples.js index cc67c266ffde055c7147d3a598f6b55bfda75ec0..0a7e22e2b5ce58ea735b0c60d005bbbeda18868e 100644 --- a/tests/unit/samples.js +++ b/tests/unit/samples.js @@ -297,30 +297,20 @@ export const entitiesInTranscriptionSample = { ] } -export const elementNeighborsSample = makeSampleResults([ +export const elementNeighborsSample = [ { - position: 3, - element: { + ordering: 3, + previous: { id: 'elementidx', type: 'page', name: 'element 3' }, - parents: [ - { - id: 'volumeid', - type: 'volume', - name: 'this is a volume' - } - ] - }, - { - position: 4, - element: { - id: 'elementid', + next: { + id: 'elementidx', type: 'page', name: 'element 4' }, - parents: [ + path: [ { id: 'volumeid', type: 'volume', @@ -329,13 +319,18 @@ export const elementNeighborsSample = makeSampleResults([ ] }, { - position: 5, - element: { - id: 'elementidy', + ordering: 8, + previous: { + id: 'elementidx', type: 'page', - name: 'element 5' + name: 'element 8' }, - parents: [ + next: { + id: 'elementidx', + type: 'page', + name: 'element 10' + }, + path: [ { id: 'volumeid', type: 'volume', @@ -343,7 +338,7 @@ export const elementNeighborsSample = makeSampleResults([ } ] } -]) +] export const linksSample = makeSampleResults( [{ diff --git a/tests/unit/store/elements.spec.js b/tests/unit/store/elements.spec.js index dcd5cef8964d3746689ca54bc80fcc2bfd961aee..c6fe63860b6f08b78136bfb85970ebe476c2ae36 100644 --- a/tests/unit/store/elements.spec.js +++ b/tests/unit/store/elements.spec.js @@ -220,45 +220,42 @@ describe('elements', () => { describe('addNeighbors', () => { it('add neighbors to an element', () => { const state = { neighbors: {} } - const neighbors = { - results: [ - { - element: { id: 'elementid' }, - parents: [ - { id: 'folderid' } - ], - position: 42 - } - ] - } + const neighbors = [ + { + previous: { id: 'elementid' }, + next: { id: 'elementid' }, + path: [ + { id: 'folderid' } + ], + ordering: 42 + } + ] mutations.addNeighbors(state, { element: 'element1', neighbors }) assert.deepStrictEqual(state, { neighbors: { element1: neighbors } }) }) it('removes unknown parents', () => { - const payload = { - results: [ - { - element: { id: 'elementid' }, - parents: [ - { id: 'folderid' }, - null - ], - position: 42 - } - ] - } - const expected = { - results: [ - { - element: { id: 'elementid' }, - parents: [ - { id: 'folderid' } - ], - position: 42 - } - ] - } + const payload = [ + { + previous: { id: 'elementid' }, + next: { id: 'elementid' }, + path: [ + { id: 'folderid' }, + null + ], + ordering: 42 + } + ] + const expected = [ + { + previous: { id: 'elementid' }, + next: { id: 'elementid' }, + path: [ + { id: 'folderid' } + ], + ordering: 42 + } + ] const state = { neighbors: {} } assert.throws( diff --git a/tests/unit/stores/display.spec.js b/tests/unit/stores/display.spec.js index eb1df23ab68a82c3a8fb57ec912306a9da3f9364..baa4a51a7d9563da9b62d694addd4782ee1de7a7 100644 --- a/tests/unit/stores/display.spec.js +++ b/tests/unit/stores/display.spec.js @@ -106,7 +106,24 @@ describe('display', () => { store.toggleCompactDisplay(true) assert.ok(store.compactDisplay) store.toggleCompactDisplay(false) - assert.ok(!store.compactDisplays) + assert.ok(!store.compactDisplay) + }) + }) + + describe('toggleAllPaths', () => { + it('toggles without a value', () => { + assert.ok(!store.displayAllPaths) + store.toggleAllPaths() + assert.ok(store.displayAllPaths) + }) + + it('sets directly with a boolean value', () => { + store.displayAllPaths = false + assert.ok(!store.displayAllPaths) + store.toggleAllPaths(true) + assert.ok(store.displayAllPaths) + store.toggleAllPaths(false) + assert.ok(!store.displayAllPaths) }) })