stash
This commit is contained in:
parent
5cd3ae5a13
commit
240a508306
6 changed files with 313 additions and 11 deletions
|
|
@ -274,3 +274,10 @@ An inventory item's properties are encoded into the `properties` CSV cell as a c
|
|||
|
||||
### Print Link Shape
|
||||
`printLinkFor` in `Inventory.vue` and `StorageLocation.vue` (and the print buttons in `InventoryDetail.vue`/`StorageLocationDetail.vue`) all route to `Print.vue` with the same `{kind, userHandle, id}` query shape - the thing's raw identity - rather than any pre-built link. `kind` is one of `CONTENT_KINDS`' ids (`"item"`/`"storage-location"`, see Content Kinds Registry above); `label.js`'s `buildLabelFields` reads it generically since every kind's prefill now shares this one shape. That lets the print page derive every representation it needs (qualified handle, owner handle, URL, short link, …) itself, instead of being tied to whichever one the calling button happened to construct. Group-owned items have no individual owner handle - `short-id.js`'s `group_item` kind resolves them via `owner_group` instead (see `shortIdLink`) - so there's no `{userHandle, id}` to build yet; `Inventory.vue`'s `printLinkFor` returns `null` for them until group print support exists. Storage locations are always individually owned (see `StorageLocationViewSet.get_queryset`), so `StorageLocation.vue`'s version never has that fallback to make.
|
||||
|
||||
## Categories
|
||||
|
||||
### Category Tree Reconstruction From Flat Path Strings
|
||||
`Category` (`backend/toolshed/models.py`) is a real parent/children tree (self-referencing FK), but `combined_info`'s `/api/v1/info/` response (`backend/toolshed/api/info.py`) flattens every category to `str(category)` - a `/`-joined ancestor path (`Category.__str__`) - with every ancestor and descendant listed as its own separate string, not nested. `Admin.vue`'s `categoryTree` computed rebuilds the hierarchy client-side by splitting each path on `/` and grouping nodes by shared prefixes, then renders it with `TreeView.vue` (`frontend/src/components/TreeView.vue`), a generic recursive collapsible tree component built for single-line slot content.
|
||||
|
||||
`StorageLocation` (`backend/toolshed/models.py`) is the same self-referencing-FK shape, but its `StorageLocationSerializer` keeps `parent` as a real owner-scoped id rather than flattening it away, so `Admin.vue`'s `storageLocationTree` and `StorageLocation.vue`'s `locationTree` computeds group nodes by that id directly instead of parsing path strings - the same tree-from-flat-list pattern, one step simpler since the parent relation already survives serialization.
|
||||
|
|
|
|||
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