This commit is contained in:
j3d1 2026-09-06 00:53:21 +02:00
parent 5cd3ae5a13
commit 240a508306
6 changed files with 313 additions and 11 deletions

View 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>

View 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: []},
])
})

View 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()
})

View 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>"
`;

View file

@ -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"])