From 23185e1721b91b3758f50d3352b99e1338c2ebf8 Mon Sep 17 00:00:00 2001 From: jedi Date: Wed, 26 Aug 2026 22:18:02 +0200 Subject: [PATCH] stash --- frontend/src/router.js | 16 +++- frontend/src/short-id.js | 6 ++ frontend/src/store.js | 99 ++++++++++++-------- frontend/src/tests/short-id.js | 1 + frontend/src/views/GroupDetail.vue | 94 ++++++++++++++++--- frontend/src/views/InventoryNew.vue | 21 ++++- frontend/src/views/Print.vue | 12 ++- frontend/src/views/Scan.vue | 24 +++-- frontend/src/views/ShortId.vue | 1 + frontend/src/views/StorageLocation.vue | 30 ++++-- frontend/src/views/StorageLocationDetail.vue | 58 +++++++++--- frontend/src/views/StorageLocationEdit.vue | 51 +++++++--- frontend/src/views/StorageLocationNew.vue | 59 ++++++++++-- 13 files changed, 365 insertions(+), 107 deletions(-) diff --git a/frontend/src/router.js b/frontend/src/router.js index b37ece4..fd8d9d1 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -51,6 +51,11 @@ function itemDetailRoute(handle, item_local_id) { return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null; } +// storage_location/group_storage_location share this /storage-locations/:handle/:id shape, same idea as itemDetailRoute above. +function storageLocationDetailRoute(handle, storage_location_id) { + return handle ? `/storage-locations/${encodeHandleForUrl(handle)}/${storage_location_id}` : null; +} + export function ownerOverviewRoute(item) { return item.owner_group ? `/groups/${encodeHandleForUrl(item.owner_group)}` : '/inventory'; } @@ -64,12 +69,15 @@ const EXPANDED_ROUTE_BUILDERS = { const handle = store.getters.groupHandleById[group_id]; return handle ? `/groups/${encodeHandleForUrl(handle)}` : null; }, - storage_location: ({storage_location_id}) => `/storage-locations/${storage_location_id}`, + storage_location: ({owner_identity_id, storage_location_id}) => + storageLocationDetailRoute(store.getters.identityHandleById[owner_identity_id], storage_location_id), + group_storage_location: ({owner_group_id, storage_location_id}) => + storageLocationDetailRoute(store.getters.groupHandleById[owner_group_id], storage_location_id), workflow: ({workflow_id}) => `/workflows/${workflow_id}`, }; // Kinds whose route needs identityHandleById/groupHandleById (from state.idmap); ShortId.vue only waits on an idmap fetch for these. -export const NEEDS_IDMAP = new Set(['item', 'group_item', 'group']); +export const NEEDS_IDMAP = new Set(['item', 'group_item', 'group', 'storage_location', 'group_storage_location']); export function expandedRoute({kind, ...fields}) { const buildRoute = EXPANDED_ROUTE_BUILDERS[kind]; @@ -188,12 +196,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { component: StorageLocation, meta: {requiresAuth: true} }, { - path: '/storage-locations/:id', + path: '/storage-locations/:handle/:id', component: StorageLocationDetail, meta: {requiresAuth: true}, props: true }, { - path: '/storage-locations/:id/edit', + path: '/storage-locations/:handle/:id/edit', component: StorageLocationEdit, meta: {requiresAuth: true}, props: true diff --git a/frontend/src/short-id.js b/frontend/src/short-id.js index f26e4f7..1f79672 100644 --- a/frontend/src/short-id.js +++ b/frontend/src/short-id.js @@ -40,6 +40,12 @@ const SCHEMAS = { fields: ['category_id'], interpret: ([category_id]) => ({kind: 'category', category_id}) }, + '4-2': { + name: 'group_storage_location', + fields: ['owner_group_id', 'storage_location_id'], + interpret: ([owner_group_id, storage_location_id]) => + ({kind: 'group_storage_location', owner_group_id, storage_location_id}) + }, } const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' diff --git a/frontend/src/store.js b/frontend/src/store.js index 162b980..b89377d 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -83,6 +83,7 @@ export default createStore({ idmap: {identities: [], groups: []}, idmapLoaded: false, item_map: {}, + location_map: {}, home_servers: null, all_friends_servers: null, messages: [], @@ -105,6 +106,9 @@ export default createStore({ setInventoryItems(state, {url, items}) { state.item_map[url] = items; }, + setLocationsForKey(state, {url, locations}) { + state.location_map[url] = locations; + }, setFriends(state, friends) { state.friends = friends; }, @@ -397,16 +401,17 @@ export default createStore({ return items }, async createInventoryItem({state, dispatch, getters}, item) { - const servers = await dispatch('getHomeServers') - const data = {availability_policy: 'private', ...item} + const servers = item.owner_group + ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) + : await dispatch('getHomeServers') + const data = { + availability_policy: 'private', ...item, + owner_group: item.owner_group ? item.owner_group.slice(1) : null + } const reply = await servers.post(getters.signAuth, '/api/inventory_items/', data) state.last_load.files = 0 return reply }, - // A group-owned item lives on its owning group's own backend, not necessarily the - // caller's home one (see the group actions below) - item.owner_group ("#name@domain") - // already carries that domain, so update/delete resolve it the same way rather than - // needing the caller to track it separately. async updateInventoryItem({state, dispatch, getters}, item) { const servers = item.owner_group ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) @@ -465,16 +470,10 @@ export default createStore({ return null; } }, - // A group handle resolves via fetchGroup (itself domain-routed, see below), so a group - // hosted elsewhere works exactly like one hosted on the caller's own backend - no idmap - // detour needed here. ServerSet.get() never checks response.ok, so a 404 (nonexistent or - // non-member group) surfaces as a JSON-parse rejection rather than resolving falsy - same - // try/catch shape as fetchForeignItem. async fetchItemByHandle({dispatch, getters}, {handle, id}) { if (handle.startsWith('#')) { try { - const group = await dispatch('fetchGroup', {handle}) - const items = await dispatch('fetchGroupInventoryItems', {groupId: group.id, domain: group.domain}) + const items = await dispatch('fetchGroupInventoryItems', {groupHandle: handle}) return items.find(item => item.id === parseInt(id)) || null } catch (error) { console.error(`Failed to fetch group item ${id} for ${handle}:`, error); @@ -530,14 +529,6 @@ export default createStore({ const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/friends/' + id + '/') }, - // Listing/creating a group is inherently home-scoped - it's this backend's own view of - // "groups I host you in" - but every action below that acts on an *existing* group - // (found via its handle, e.g. from a GroupMembership pointer to a group hosted - // elsewhere - see GroupMembership/docs/design-in-progress/groups-mvp.md) resolves that - // group's own domain and talks to it directly, the same way requestFriend/ - // fetchForeignItem resolve a friend's domain, rather than assuming home. A group hosted - // on the caller's own domain goes through exactly the same code path - there's nothing - // to special-case. async fetchGroups({commit, dispatch, getters}) { const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/groups/') @@ -552,17 +543,19 @@ export default createStore({ }, // handle is "#name@domain". async fetchGroup({dispatch, getters}, {handle}) { - const {name, domain} = splitGroupHandle(handle) + const {domain} = splitGroupHandle(handle) const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) - return await servers.get(getters.signAuth, `/api/groups/handle/${name}/${domain}/`) + return await servers.get(getters.signAuth, `/api/groups/${handle.slice(1)}/`) }, async createGroup({dispatch, getters}, {name}) { const servers = await dispatch('getHomeServers') return await servers.post(getters.signAuth, '/api/groups/', {name}) }, - async removeGroupMember({dispatch, getters}, {groupId, domain, identityId}) { + async removeGroupMember({dispatch, getters}, {groupHandle, identityId}) { + const {domain} = splitGroupHandle(groupHandle) const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) - return await servers.delete(getters.signAuth, '/api/groups/' + groupId + '/members/' + identityId + '/') + return await servers.delete( + getters.signAuth, '/api/groups/' + groupHandle.slice(1) + '/members/' + identityId + '/') }, async fetchGroupInvites({commit, dispatch, getters}) { const servers = await dispatch('getHomeServers') @@ -576,10 +569,11 @@ export default createStore({ commit('setGroupMemberships', data) return data }, - async inviteToGroup({state, dispatch, getters}, {groupId, domain, groupHandle, invitee}) { + async inviteToGroup({state, dispatch, getters}, {groupHandle, invitee}) { + const {domain} = splitGroupHandle(groupHandle) const group_servers = await dispatch('getFriendServers', {username: 'x@' + domain}) const group_reply = await group_servers.post( - getters.signAuth, '/api/groups/' + groupId + '/invites/', {invitee}) + getters.signAuth, '/api/groups/' + groupHandle.slice(1) + '/invites/', {invitee}) if (!group_reply.secret) { return false } @@ -610,13 +604,14 @@ export default createStore({ const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/') }, - async fetchGroupInventoryItems({commit, dispatch, getters}, {groupId, domain}) { + async fetchGroupInventoryItems({commit, dispatch, getters}, {groupHandle}) { + const {domain} = splitGroupHandle(groupHandle) const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) - const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupId) - // Namespaced by domain, not just groupId: that pk is only unique within its own + const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupHandle.slice(1)) + // Keyed by the full handle, not a bare id: a group pk is only unique within its own // backend's database, so two different domains could otherwise collide on the same - // item_map key. - commit('setInventoryItems', {url: '/group/' + domain + '/' + groupId, items}) + // item_map key - the handle already carries the domain, so no separate namespacing is needed. + commit('setInventoryItems', {url: '/group/' + groupHandle, items}) return items }, async fetchFiles({state, commit, dispatch, getters}) { @@ -722,23 +717,50 @@ export default createStore({ commit('setStorageLocations', data) state.last_load.storage_locations = Date.now() return data + },async fetchGroupStorageLocations({commit, dispatch, getters}, {groupHandle}) { + const {domain} = splitGroupHandle(groupHandle) + const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) + const locations = await servers.get(getters.signAuth, '/api/storage_locations/?group=' + groupHandle.slice(1)) + commit('setLocationsForKey', {url: '/group/' + groupHandle, locations}) + return locations + }, + async fetchStorageLocationByHandle({dispatch, getters}, {handle, id}) { + if (handle.startsWith('#')) { + try { + const locations = await dispatch('fetchGroupStorageLocations', {groupHandle: handle}) + return locations.find(location => location.id === parseInt(id)) || null + } catch (error) { + console.error(`Failed to fetch group storage location ${id} for ${handle}:`, error); + return null; + } + } + const locations = await dispatch('fetchStorageLocations') + return locations.find(location => location.id === parseInt(id)) || null }, async deleteStorageLocation({state, dispatch, getters}, location) { - const servers = await dispatch('getHomeServers') + const servers = location.owner_group + ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain}) + : await dispatch('getHomeServers') const ret = await servers.delete(getters.signAuth, '/api/storage_locations/' + location.id + '/') dispatch('fetchStorageLocations') return ret }, + // location.owner_group is the group's own "#name@domain" handle - see + // createInventoryItem's own comment. async createStorageLocation({state, dispatch, getters}, location) { - const servers = await dispatch('getHomeServers') - const data = {...location} + const servers = location.owner_group + ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain}) + : await dispatch('getHomeServers') + const data = {...location, owner_group: location.owner_group ? location.owner_group.slice(1) : null} if (data.parent === '') data.parent = null const reply = await servers.post(getters.signAuth, '/api/storage_locations/', data) state.last_load.storage_locations = 0 return reply }, async updateStorageLocation({state, dispatch, getters}, location) { - const servers = await dispatch('getHomeServers') + const servers = location.owner_group + ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain}) + : await dispatch('getHomeServers') const data = {...location} if (data.parent === '') data.parent = null const reply = await servers.patch(getters.signAuth, '/api/storage_locations/' + location.id + '/', data) @@ -848,15 +870,14 @@ export default createStore({ inventory_items(state) { return state.item_map['/'] || [] }, - groupInventoryItems: (state) => (groupId, domain) => state.item_map['/group/' + domain + '/' + groupId] || [], + groupInventoryItems: (state) => (groupHandle) => state.item_map['/group/' + groupHandle] || [], + groupStorageLocations: (state) => (groupHandle) => state.location_map['/group/' + groupHandle] || [], identityIdByHandle(state) { return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id])) }, groupIdByHandle(state) { return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id])) }, - // Reverse of the two getters above: turns a short-id's raw owner_identity_id/owner_group_id - // back into a handle (see router.js EXPANDED_ROUTE_BUILDERS), no backend lookup needed. identityHandleById(state) { return Object.fromEntries(state.idmap.identities.map(i => [i.id, i.username])) }, diff --git a/frontend/src/tests/short-id.js b/frontend/src/tests/short-id.js index df53525..8e92fb5 100644 --- a/frontend/src/tests/short-id.js +++ b/frontend/src/tests/short-id.js @@ -21,6 +21,7 @@ test('every kind round-trips through serialize/encode/decode/deserialize', () => {kind: 'group_item', owner_group_id: 5, item_local_id: 42}, {kind: 'group', group_id: 11}, {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}, + {kind: 'group_storage_location', owner_group_id: 5, storage_location_id: 1000}, {kind: 'file', file_id: 123}, {kind: 'workflow', owner_identity_id: 2, workflow_id: 9}, ] diff --git a/frontend/src/views/GroupDetail.vue b/frontend/src/views/GroupDetail.vue index 21eaaa9..b079b59 100644 --- a/frontend/src/views/GroupDetail.vue +++ b/frontend/src/views/GroupDetail.vue @@ -127,7 +127,55 @@
- Add + +
+ +
+
+
+
Group storage locations
+
+ + + + + + + + + + + + + + + + + +
NamePathCategoryActions
+ {{ location.name }} + + {{ location.path }} + + {{ location.category }} + - + + + + + + + + + + +
+
+
+ + Add
@@ -139,7 +187,7 @@ diff --git a/frontend/src/views/InventoryNew.vue b/frontend/src/views/InventoryNew.vue index 3c1065e..7042431 100644 --- a/frontend/src/views/InventoryNew.vue +++ b/frontend/src/views/InventoryNew.vue @@ -28,7 +28,7 @@ @@ -84,7 +84,7 @@ import BaseLayout from "@/components/BaseLayout.vue"; import TagField from "@/components/TagField.vue"; import PropertyField from "@/components/PropertyField.vue"; import CombinedFileField from "@/components/CombinedFileField.vue"; -import {ownerOverviewRoute} from "@/router"; +import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router"; export default { name: "InventoryNew", @@ -107,23 +107,34 @@ export default { properties: [], files: [], storage_location: null, + // The group's own "#name@domain" handle once picked, or null for a personal + // item - createInventoryItem resolves the target domain from it directly. owner_group: null } } }, methods: { ownerOverviewRoute, - ...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups']) + ...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups', + 'fetchGroupMemberships']) }, computed: { - ...mapState(["availability_policies", "storage_locations", "groups"]), + ...mapState(["availability_policies", "storage_locations", "groups", "groupMemberships"]), + // Groups hosted here plus groups only known via a GroupMembership pointer (see + // Groups.vue's allGroups for the same merge/dedupe). + ownerGroups() { + const hostedHandles = new Set(this.groups.map(group => group.handle)) + const foreign = this.groupMemberships.filter(m => !hostedHandles.has(m.handle)) + return [...this.groups, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle)) + } }, async mounted() { await this.fetchInfo(); await this.fetchStorageLocations(); await this.fetchGroups(); + await this.fetchGroupMemberships(); if (this.$route.query.group) { - this.item.owner_group = parseInt(this.$route.query.group, 10) + this.item.owner_group = decodeHandleFromUrl(this.$route.query.group) } } } diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue index bca75a7..4b1ffab 100644 --- a/frontend/src/views/Print.vue +++ b/frontend/src/views/Print.vue @@ -464,10 +464,18 @@ export default { } if (f.userHandle.startsWith("#")) { const owner_group_id = this.groupIdByHandle[f.userHandle]; - if (owner_group_id === undefined || !f.itemId) { + if (owner_group_id === undefined) { return null; } - return shortenedRoute({kind: "group_item", owner_group_id, item_local_id: f.itemId}).slice(1); + if (f.itemId) { + return shortenedRoute({kind: "group_item", owner_group_id, item_local_id: f.itemId}).slice(1); + } + if (f.locationId) { + return shortenedRoute({ + kind: "group_storage_location", owner_group_id, storage_location_id: f.locationId + }).slice(1); + } + return null; } const owner_identity_id = this.identityIdByHandle[f.userHandle]; if (owner_identity_id === undefined) { diff --git a/frontend/src/views/Scan.vue b/frontend/src/views/Scan.vue index 918bf92..719f058 100644 --- a/frontend/src/views/Scan.vue +++ b/frontend/src/views/Scan.vue @@ -154,7 +154,7 @@ export default { }; }, methods: { - ...mapActions(["fetchItemByHandle", "fetchStorageLocations", "fetchIdMap"]), + ...mapActions(["fetchItemByHandle", "fetchStorageLocationByHandle", "fetchIdMap"]), resolveDescription(entry) { const {link, text} = entry; @@ -219,12 +219,19 @@ export default { } return handle === undefined ? null : handle; } - if (decoded.kind === "storage_location") { - if (!this.$store.state.storage_locations.length) { - await this.fetchStorageLocations(); + if (decoded.kind === "storage_location" || decoded.kind === "group_storage_location") { + const byId = decoded.kind === "storage_location" + ? this.$store.getters.identityHandleById + : this.$store.getters.groupHandleById; + const ownerId = decoded.kind === "storage_location" ? decoded.owner_identity_id : decoded.owner_group_id; + let handle = byId[ownerId]; + if (handle === undefined) { + await this.fetchIdMap(); + handle = (decoded.kind === "storage_location" + ? this.$store.getters.identityHandleById + : this.$store.getters.groupHandleById)[ownerId]; } - const location = this.$store.state.storage_locations.find(l => l.id === decoded.storage_location_id); - return location ? `[#${location.id}] ${location.name}` : null; + return handle === undefined ? null : this.describeLocation(handle, decoded.storage_location_id); } return null; // workflow, category, file: no per-item title lookup wired up yet }, @@ -234,6 +241,11 @@ export default { return item ? `[#${item.id}] ${item.name}` : null; }, + async describeLocation(handle, id) { + const location = await this.fetchStorageLocationByHandle({handle, id}); + return location ? `[#${location.id}] ${location.name}` : null; + }, + // Handler for CameraScanner's "scan" event - logs every code from the batch it decoded. onCameraScan(codes) { codes.forEach(this.logDecode); diff --git a/frontend/src/views/ShortId.vue b/frontend/src/views/ShortId.vue index 03fff50..d71ba81 100644 --- a/frontend/src/views/ShortId.vue +++ b/frontend/src/views/ShortId.vue @@ -73,6 +73,7 @@ const EXAMPLES = [ {kind: 'group_item', owner_group_id: 5, item_local_id: 42}, {kind: 'file', file_id: 123}, {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}, + {kind: 'group_storage_location', owner_group_id: 5, storage_location_id: 1000}, {kind: 'category', category_id: 5}, {kind: 'workflow', owner_identity_id: 2, workflow_id: 9}, ]; diff --git a/frontend/src/views/StorageLocation.vue b/frontend/src/views/StorageLocation.vue index 3d23bb7..579788d 100644 --- a/frontend/src/views/StorageLocation.vue +++ b/frontend/src/views/StorageLocation.vue @@ -27,7 +27,7 @@ - {{ location.name }} + {{ location.name }} {{ location.path }} @@ -37,10 +37,10 @@ - - + - + @@ -60,7 +60,7 @@
- + {{ location.name }}
@@ -75,7 +75,7 @@ - Edit
-
{{ location.owner?.username || '-' }}
+
{{ location.owner || location.owner_group || '-' }}
-
- +
@@ -47,7 +52,8 @@ diff --git a/frontend/src/views/StorageLocationEdit.vue b/frontend/src/views/StorageLocationEdit.vue index b792950..6d330c3 100644 --- a/frontend/src/views/StorageLocationEdit.vue +++ b/frontend/src/views/StorageLocationEdit.vue @@ -55,6 +55,7 @@ import * as BIcons from "bootstrap-icons-vue"; import {mapActions, mapState} from "vuex"; import BaseLayout from "@/components/BaseLayout.vue"; +import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router"; export default { name: "StorageLocationEdit", @@ -63,32 +64,56 @@ export default { ...BIcons }, props: { + handle: { + type: String, + required: true + }, id: { type: String, required: true } }, - computed: { - ...mapState(["storage_locations", "categories"]), - location() { - return { + data() { + return { + location: { name: "", description: "", category: null, parent: null, - ...this.storage_locations.find(loc => loc.id === parseInt(this.id)) - } + }, + // The location's own owner's full location list, for the parent dropdown - own + // personal locations, or the owning group's own (see loadLocation). + siblingLocations: [] + } + }, + computed: { + ...mapState(["categories"]), + decodedHandle() { + return decodeHandleFromUrl(this.handle) }, availableParents() { // Filter out self and children to prevent circular references - return this.storage_locations.filter(loc => + return this.siblingLocations.filter(loc => loc.id !== parseInt(this.id) && !this.isChildOf(loc, parseInt(this.id)) ) } }, methods: { - ...mapActions(["fetchStorageLocations", "updateStorageLocation", "fetchInfo"]), + ownerOverviewRoute, + ...mapActions(["fetchStorageLocationByHandle", "updateStorageLocation", "fetchInfo", + "fetchStorageLocations", "fetchGroupStorageLocations"]), + async loadLocation() { + const loaded = await this.fetchStorageLocationByHandle({handle: this.decodedHandle, id: this.id}) + if (loaded) { + this.location = {...this.location, ...loaded} + } + if (this.decodedHandle.startsWith('#')) { + this.siblingLocations = await this.fetchGroupStorageLocations({groupHandle: this.decodedHandle}) + } else { + this.siblingLocations = await this.fetchStorageLocations() + } + }, submitForm() { // Convert empty strings to null for ForeignKey fields const locationData = { @@ -96,21 +121,25 @@ export default { category: this.location.category === "" ? null : this.location.category, parent: this.location.parent === "" ? null : this.location.parent }; - this.updateStorageLocation(locationData); + this.updateStorageLocation(locationData).then(updated => this.$router.push(ownerOverviewRoute(updated))); }, isChildOf(location, parentId) { // Simple check to prevent circular references let current = location; while (current && current.parent) { if (current.parent === parentId) return true; - current = this.storage_locations.find(loc => loc.id === current.parent); + current = this.siblingLocations.find(loc => loc.id === current.parent); } return false; } }, + watch: { + handle: 'loadLocation', + id: 'loadLocation' + }, async mounted() { await this.fetchInfo(); - await this.fetchStorageLocations(); + await this.loadLocation(); } } diff --git a/frontend/src/views/StorageLocationNew.vue b/frontend/src/views/StorageLocationNew.vue index 0bbc130..074afdc 100644 --- a/frontend/src/views/StorageLocationNew.vue +++ b/frontend/src/views/StorageLocationNew.vue @@ -26,12 +26,21 @@ +
+ + +
@@ -53,6 +62,7 @@ import * as BIcons from "bootstrap-icons-vue"; import {mapActions, mapState} from "vuex"; import BaseLayout from "@/components/BaseLayout.vue"; +import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router"; export default { name: "StorageLocationNew", @@ -66,12 +76,27 @@ export default { name: "", description: "", category: null, - parent: null - } + parent: null, + // The group's own "#name@domain" handle once picked, or null for a personal + // location - createStorageLocation resolves the target domain from it directly. + owner_group: null + }, + // The selected owner's own location list, for the parent dropdown - own personal + // locations, or the chosen group's own (see loadParentOptionsForOwner). + groupLocations: [] } }, methods: { - ...mapActions(['createStorageLocation', 'fetchInfo', 'fetchStorageLocations']), + ownerOverviewRoute, + ...mapActions(['createStorageLocation', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups', + 'fetchGroupMemberships', 'fetchGroupStorageLocations']), + async loadParentOptionsForOwner() { + if (!this.location.owner_group) { + this.groupLocations = [] + return + } + this.groupLocations = await this.fetchGroupStorageLocations({groupHandle: this.location.owner_group}) + }, submitForm() { // Convert empty strings to null for ForeignKey fields const locationData = { @@ -79,15 +104,37 @@ export default { category: this.location.category === "" ? null : this.location.category, parent: this.location.parent === "" ? null : this.location.parent }; - this.createStorageLocation(locationData).then(() => this.$router.push('/storage-location')); + this.createStorageLocation(locationData).then(created => this.$router.push(ownerOverviewRoute(created))); } }, computed: { - ...mapState(["categories", "storage_locations"]), + ...mapState(["categories", "storage_locations", "groups", "groupMemberships"]), + // Groups hosted here plus groups only known via a GroupMembership pointer (see + // Groups.vue's allGroups for the same merge/dedupe). + ownerGroups() { + const hostedHandles = new Set(this.groups.map(group => group.handle)) + const foreign = this.groupMemberships.filter(m => !hostedHandles.has(m.handle)) + return [...this.groups, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle)) + }, + availableParents() { + return this.location.owner_group ? this.groupLocations : this.storage_locations + } + }, + watch: { + 'location.owner_group'() { + // Previously-selected parent likely doesn't belong to the newly-chosen owner. + this.location.parent = null + this.loadParentOptionsForOwner() + } }, async mounted() { await this.fetchInfo(); await this.fetchStorageLocations(); + await this.fetchGroups(); + await this.fetchGroupMemberships(); + if (this.$route.query.group) { + this.location.owner_group = decodeHandleFromUrl(this.$route.query.group) + } } }