stash
This commit is contained in:
parent
5cd3ae5a13
commit
240a508306
6 changed files with 313 additions and 11 deletions
131
frontend/src/components/TreeView.vue
Normal file
131
frontend/src/components/TreeView.vue
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<template>
|
||||
<ul class="tree-view" :class="{'tree-view-root': depth === 0}">
|
||||
<li v-for="node in items" :key="node[itemKey]" class="tree-view-item">
|
||||
<div class="tree-view-row">
|
||||
<button v-if="hasChildren(node)" type="button" class="tree-view-toggle"
|
||||
:aria-expanded="isExpanded(node)" @click="toggleNode(node)">
|
||||
<b-icon-chevron-right v-if="!isExpanded(node)"></b-icon-chevron-right>
|
||||
<b-icon-chevron-down v-else></b-icon-chevron-down>
|
||||
</button>
|
||||
<span v-else class="tree-view-toggle-spacer"></span>
|
||||
<div class="tree-view-content">
|
||||
<slot :item="node" :expanded="isExpanded(node)" :has-children="hasChildren(node)"
|
||||
:depth="depth" :toggle="() => toggleNode(node)"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<tree-view v-if="hasChildren(node) && isExpanded(node)" :items="node[childrenKey]"
|
||||
:item-key="itemKey" :children-key="childrenKey" :default-expanded="defaultExpanded"
|
||||
:depth="depth + 1" @toggle="(...args) => $emit('toggle', ...args)">
|
||||
<template #default="slotProps">
|
||||
<slot v-bind="slotProps"></slot>
|
||||
</template>
|
||||
</tree-view>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tree-view {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.tree-view .tree-view {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.tree-view-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
|
||||
.tree-view-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-view-toggle-spacer {
|
||||
flex: 0 0 auto;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.tree-view-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: "TreeView",
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
itemKey: {
|
||||
type: String,
|
||||
default: "id"
|
||||
},
|
||||
childrenKey: {
|
||||
type: String,
|
||||
default: "children"
|
||||
},
|
||||
// Whether nodes start out expanded. Only read once per rendered level, see expandedKeys below.
|
||||
defaultExpanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
emits: ["toggle"],
|
||||
data() {
|
||||
return {
|
||||
// Tracks which of this level's own nodes are expanded. Nested levels are separate
|
||||
// TreeView instances with their own set, since they only exist while their parent
|
||||
// node is expanded in the first place.
|
||||
expandedKeys: new Set(this.defaultExpanded ? this.items.map(item => item[this.itemKey]) : [])
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hasChildren(node) {
|
||||
return Array.isArray(node[this.childrenKey]) && node[this.childrenKey].length > 0
|
||||
},
|
||||
isExpanded(node) {
|
||||
return this.expandedKeys.has(node[this.itemKey])
|
||||
},
|
||||
toggleNode(node) {
|
||||
const key = node[this.itemKey]
|
||||
if (this.expandedKeys.has(key)) {
|
||||
this.expandedKeys.delete(key)
|
||||
} else {
|
||||
this.expandedKeys.add(key)
|
||||
}
|
||||
this.$emit("toggle", node, this.expandedKeys.has(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
56
frontend/src/tests/Admin.js
Normal file
56
frontend/src/tests/Admin.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import Admin from '../views/Admin.vue'
|
||||
|
||||
test('Admin categoryTree groups flat "/"-joined category paths into a tree', () => {
|
||||
const categories = [
|
||||
'Electronics',
|
||||
'Electronics/Passives',
|
||||
'Electronics/Passives/Resistors',
|
||||
'Electronics/Actives',
|
||||
'Tools',
|
||||
]
|
||||
|
||||
const categoryTree = Admin.computed.categoryTree.call({categories})
|
||||
|
||||
expect(categoryTree).toEqual([
|
||||
{
|
||||
id: 'Electronics', name: 'Electronics', children: [
|
||||
{id: 'Electronics/Actives', name: 'Actives', children: []},
|
||||
{
|
||||
id: 'Electronics/Passives', name: 'Passives', children: [
|
||||
{id: 'Electronics/Passives/Resistors', name: 'Resistors', children: []},
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{id: 'Tools', name: 'Tools', children: []},
|
||||
])
|
||||
})
|
||||
|
||||
test('Admin storageLocationTree groups locations by their real parent id', () => {
|
||||
const storage_locations = [
|
||||
{id: 3, name: 'Shelf B', parent: 1},
|
||||
{id: 1, name: 'Garage', parent: null},
|
||||
{id: 5, name: 'Attic', parent: null},
|
||||
{id: 2, name: 'Shelf A', parent: 1},
|
||||
{id: 4, name: 'Drawer 1', parent: 3},
|
||||
// A location whose parent was deleted/isn't in this owner's own list falls back to root.
|
||||
{id: 6, name: 'Orphan Box', parent: 99},
|
||||
]
|
||||
|
||||
const storageLocationTree = Admin.computed.storageLocationTree.call({storage_locations})
|
||||
|
||||
expect(storageLocationTree).toEqual([
|
||||
{id: 5, name: 'Attic', children: []},
|
||||
{
|
||||
id: 1, name: 'Garage', children: [
|
||||
{id: 2, name: 'Shelf A', children: []},
|
||||
{
|
||||
id: 3, name: 'Shelf B', children: [
|
||||
{id: 4, name: 'Drawer 1', children: []},
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{id: 6, name: 'Orphan Box', children: []},
|
||||
])
|
||||
})
|
||||
58
frontend/src/tests/TreeView.js
Normal file
58
frontend/src/tests/TreeView.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import {mount} from '@vue/test-utils'
|
||||
import TreeView from '../components/TreeView.vue'
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Garage',
|
||||
children: [
|
||||
{id: 2, name: 'Shelf A'},
|
||||
{id: 3, name: 'Shelf B', children: [{id: 4, name: 'Drawer 1'}]},
|
||||
]
|
||||
},
|
||||
{id: 5, name: 'Attic'},
|
||||
]
|
||||
|
||||
test('TreeView component', async () => {
|
||||
expect(TreeView).toBeTruthy()
|
||||
|
||||
const wrapper = mount(TreeView, {
|
||||
props: {items},
|
||||
slots: {
|
||||
default: '<span class="label">{{ params.item.name }}</span>',
|
||||
},
|
||||
})
|
||||
|
||||
// Children start collapsed - only the two top-level nodes are visible.
|
||||
expect(wrapper.findAll('.tree-view-item').length).toBe(2)
|
||||
expect(wrapper.text()).toContain('Garage')
|
||||
expect(wrapper.text()).toContain('Attic')
|
||||
expect(wrapper.text()).not.toContain('Shelf A')
|
||||
|
||||
// Nothing is expanded yet, so these are exactly the two top-level nodes.
|
||||
const topLevelItems = wrapper.findAll('.tree-view-item')
|
||||
|
||||
// The leaf node ("Attic") has no toggle button, just a spacer.
|
||||
expect(topLevelItems[1].find('.tree-view-toggle').exists()).toBe(false)
|
||||
expect(topLevelItems[1].find('.tree-view-toggle-spacer').exists()).toBe(true)
|
||||
|
||||
// Expanding "Garage" reveals its two direct children, recursively rendered.
|
||||
await topLevelItems[0].find('.tree-view-toggle').trigger('click')
|
||||
expect(wrapper.text()).toContain('Shelf A')
|
||||
expect(wrapper.text()).toContain('Shelf B')
|
||||
expect(wrapper.text()).not.toContain('Drawer 1')
|
||||
expect(wrapper.emitted('toggle')[0]).toEqual([items[0], true])
|
||||
|
||||
// Expanding the nested "Shelf B" reveals the grandchild. Match on the exact text of its own
|
||||
// row (not just "contains"), since the ancestor "Garage" item's text also contains "Shelf B".
|
||||
const shelfB = wrapper.findAll('.tree-view-item').find(item => item.text().trim() === 'Shelf B')
|
||||
await shelfB.find('.tree-view-toggle').trigger('click')
|
||||
expect(wrapper.text()).toContain('Drawer 1')
|
||||
|
||||
// Collapsing "Garage" again hides its whole subtree.
|
||||
await topLevelItems[0].find('.tree-view-toggle').trigger('click')
|
||||
expect(wrapper.text()).not.toContain('Shelf A')
|
||||
expect(wrapper.text()).not.toContain('Drawer 1')
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot()
|
||||
})
|
||||
20
frontend/src/tests/__snapshots__/TreeView.js.snap
Normal file
20
frontend/src/tests/__snapshots__/TreeView.js.snap
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`TreeView component 1`] = `
|
||||
"<ul data-v-4a3416e7=\\"\\" class=\\"tree-view tree-view-root\\">
|
||||
<li data-v-4a3416e7=\\"\\" class=\\"tree-view-item\\">
|
||||
<div data-v-4a3416e7=\\"\\" class=\\"tree-view-row\\"><button data-v-4a3416e7=\\"\\" type=\\"button\\" class=\\"tree-view-toggle\\" aria-expanded=\\"false\\"><svg data-v-4a3416e7=\\"\\" width=\\"1em\\" height=\\"1em\\" viewBox=\\"0 0 16 16\\" fill=\\"currentColor\\" role=\\"img\\" focusable=\\"false\\">
|
||||
<path fill-rule=\\"evenodd\\" d=\\"M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708\\"></path>
|
||||
</svg></button>
|
||||
<div data-v-4a3416e7=\\"\\" class=\\"tree-view-content\\"><span class=\\"label\\">Garage</span></div>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</li>
|
||||
<li data-v-4a3416e7=\\"\\" class=\\"tree-view-item\\">
|
||||
<div data-v-4a3416e7=\\"\\" class=\\"tree-view-row\\"><span data-v-4a3416e7=\\"\\" class=\\"tree-view-toggle-spacer\\"></span>
|
||||
<div data-v-4a3416e7=\\"\\" class=\\"tree-view-content\\"><span class=\\"label\\">Attic</span></div>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
|
@ -36,11 +36,9 @@
|
|||
<h5 class="card-title">Categories</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li v-for="category in categories.sort()" :key="category.id">
|
||||
{{ category }}
|
||||
</li>
|
||||
</ul>
|
||||
<TreeView :items="categoryTree">
|
||||
<template #default="{item}">{{ item.name }}</template>
|
||||
</TreeView>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
|
|
@ -84,11 +82,9 @@
|
|||
<h5 class="card-title">Storage Locations</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li v-for="location in storage_locations.sort()" :key="location.id">
|
||||
{{ location.path }}
|
||||
</li>
|
||||
</ul>
|
||||
<TreeView :items="storageLocationTree">
|
||||
<template #default="{item}">{{ item.name }}</template>
|
||||
</TreeView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -102,15 +98,49 @@
|
|||
import {mapActions, mapState} from "vuex";
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import TreeView from "@/components/TreeView.vue";
|
||||
|
||||
export default {
|
||||
name: "Admin",
|
||||
components: {
|
||||
BaseLayout,
|
||||
TreeView,
|
||||
...BIcons
|
||||
},
|
||||
computed: {
|
||||
...mapState(["tags", "properties", "categories", "availability_policies", "visibility_policies", "domains", "storage_locations"])
|
||||
...mapState(["tags", "properties", "categories", "availability_policies", "visibility_policies", "domains", "storage_locations"]),
|
||||
// See docs/implementation.md#category-tree-reconstruction-from-flat-path-strings
|
||||
categoryTree() {
|
||||
const roots = []
|
||||
const nodesByPath = {}
|
||||
for (const path of [...this.categories].sort()) {
|
||||
let siblings = roots
|
||||
let currentPath = ""
|
||||
for (const segment of path.split("/")) {
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment
|
||||
if (!nodesByPath[currentPath]) {
|
||||
nodesByPath[currentPath] = {id: currentPath, name: segment, children: []}
|
||||
siblings.push(nodesByPath[currentPath])
|
||||
}
|
||||
siblings = nodesByPath[currentPath].children
|
||||
}
|
||||
}
|
||||
return roots
|
||||
},
|
||||
storageLocationTree() {
|
||||
const nodesById = {}
|
||||
for (const location of this.storage_locations) {
|
||||
nodesById[location.id] = {id: location.id, name: location.name, children: []}
|
||||
}
|
||||
const roots = []
|
||||
for (const location of [...this.storage_locations].sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const node = nodesById[location.id]
|
||||
// Falls back to root if the parent isn't in this owner's own location list.
|
||||
const parent = location.parent != null ? nodesById[location.parent] : null
|
||||
;(parent ? parent.children : roots).push(node)
|
||||
}
|
||||
return roots
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchInfo", "fetchStorageLocations"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue