diff --git a/docs/implementation.md b/docs/implementation.md index f6c6f9c..700e20c 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -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. diff --git a/frontend/src/components/TreeView.vue b/frontend/src/components/TreeView.vue new file mode 100644 index 0000000..0a78484 --- /dev/null +++ b/frontend/src/components/TreeView.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/frontend/src/tests/Admin.js b/frontend/src/tests/Admin.js new file mode 100644 index 0000000..d800280 --- /dev/null +++ b/frontend/src/tests/Admin.js @@ -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: []}, + ]) +}) diff --git a/frontend/src/tests/TreeView.js b/frontend/src/tests/TreeView.js new file mode 100644 index 0000000..31d166a --- /dev/null +++ b/frontend/src/tests/TreeView.js @@ -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: '{{ params.item.name }}', + }, + }) + + // 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() +}) diff --git a/frontend/src/tests/__snapshots__/TreeView.js.snap b/frontend/src/tests/__snapshots__/TreeView.js.snap new file mode 100644 index 0000000..c5812d0 --- /dev/null +++ b/frontend/src/tests/__snapshots__/TreeView.js.snap @@ -0,0 +1,20 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TreeView component 1`] = ` +"" +`; diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue index e1751de..d424f6c 100644 --- a/frontend/src/views/Admin.vue +++ b/frontend/src/views/Admin.vue @@ -36,11 +36,9 @@
Categories
- + + +
@@ -84,11 +82,9 @@
Storage Locations
- + + +
@@ -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"])