diff --git a/frontend/src/label-layouts.js b/frontend/src/label-layouts.js index 84dc502..7d8fed1 100644 --- a/frontend/src/label-layouts.js +++ b/frontend/src/label-layouts.js @@ -1,3 +1,5 @@ +import {encodeHandleForUrl} from "@/router"; + // A template's layout tree, content resolution, and selectability contract. See // docs/implementation.md#template-layout-tree. const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"}; @@ -248,7 +250,7 @@ export const DERIVED_VARS = { // defaults to this origin but is editable, since any frontend can resolve any handle. itemUrl: { inputs: ["webdomain", "userHandle", "itemId"], - calc: (f) => `${f.webdomain}/i/${f.userHandle}/${f.itemId}`, + calc: (f) => `${f.webdomain}/i/${encodeHandleForUrl(f.userHandle)}/${f.itemId}`, }, // Compact "owner handle + id" form (see docs/design-in-progress/items-labels.md) - meaningful // only where context already makes clear it's a Toolshed item, unlike itemUrl. @@ -307,11 +309,6 @@ export function templateIsAvailable(t, fields) { return available; } -// Resolves t's content leaves against fields into a tree for label.js's -// drawLabel/drawFallbackLabel. Throws the same {message, short} shape as label.js's own -// capacity errors (see docs/implementation.md#missing-data-is-a-templatecontent-error) when a -// leaf isn't resolved yet, rather than returning null, so a caller's single catch around -// drawLabel/drawFallbackLabel handles both without a separate isAvailable pre-check. export function templateContent(t, fields) { if (!templateIsAvailable(t, fields)) { const err = new Error("This layout needs more fields filled in before it can be drawn."); diff --git a/frontend/src/router.js b/frontend/src/router.js index c0dc882..b37ece4 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -51,6 +51,10 @@ function itemDetailRoute(handle, item_local_id) { return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null; } +export function ownerOverviewRoute(item) { + return item.owner_group ? `/groups/${encodeHandleForUrl(item.owner_group)}` : '/inventory'; +} + const EXPANDED_ROUTE_BUILDERS = { item: ({owner_identity_id, item_local_id}) => itemDetailRoute(store.getters.identityHandleById[owner_identity_id], item_local_id), diff --git a/frontend/src/store.js b/frontend/src/store.js index e9e725e..162b980 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -8,6 +8,11 @@ import {serializeWorkflowPayload, deserializeWorkflowPayload} from "@/workflows. //import sharedStatePlugin from "@/../extras/shared-state-plugin"; //import persistentStatePlugin from "@/../extras/persistent-state-plugin"; +function splitGroupHandle(handle) { + const [name, domain] = handle.slice(1).split('@') + return {name, domain} +} + const defaultPreferenceDefinitions = [ { key: 'ui.compact_mode', @@ -398,14 +403,22 @@ export default createStore({ 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 = await dispatch('getHomeServers') + const servers = item.owner_group + ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) + : await dispatch('getHomeServers') const data = {availability_policy: 'friends', ...item} data.files = data.files.map(file => file.id) return await servers.patch(getters.signAuth, '/api/inventory_items/' + item.id + '/', data) }, async deleteInventoryItem({state, dispatch, getters}, item) { - const servers = await dispatch('getHomeServers') + const servers = item.owner_group + ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) + : await dispatch('getHomeServers') const ret = await servers.delete(getters.signAuth, '/api/inventory_items/' + item.id + '/') dispatch('fetchInventoryItems') return ret @@ -452,19 +465,21 @@ export default createStore({ return null; } }, - // Group handles have no owner-handle GET route yet, so they resolve differently than - // personal handles here. See docs/implementation.md#fetch-item-by-handle-group-vs-personal-handles. + // 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('#')) { - // idmap isn't guaranteed loaded on a direct/refreshed visit here; fetchIdMap is - // cheap and already called unconditionally by every other caller too. - await dispatch('fetchIdMap') - const groupId = getters.groupIdByHandle[handle] - if (groupId === undefined) { - return null + try { + const group = await dispatch('fetchGroup', {handle}) + const items = await dispatch('fetchGroupInventoryItems', {groupId: group.id, domain: group.domain}) + return items.find(item => item.id === parseInt(id)) || null + } catch (error) { + console.error(`Failed to fetch group item ${id} for ${handle}:`, error); + return null; } - const items = await dispatch('fetchGroupInventoryItems', groupId) - return items.find(item => item.id === parseInt(id)) || null } return dispatch('fetchForeignItem', {owner: handle, id}) }, @@ -515,9 +530,14 @@ export default createStore({ const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/friends/' + id + '/') }, - // Groups are only ever hosted on the current user's own home backend for now (see - // docs/design-in-progress/groups-mvp.md), so every group action below uses getHomeServers - // rather than resolving a per-group domain. + // 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/') @@ -530,16 +550,18 @@ export default createStore({ commit('setIdMap', idmap) return idmap }, - async fetchGroup({dispatch, getters}, {id}) { - const servers = await dispatch('getHomeServers') - return await servers.get(getters.signAuth, '/api/groups/' + id + '/') + // handle is "#name@domain". + async fetchGroup({dispatch, getters}, {handle}) { + const {name, domain} = splitGroupHandle(handle) + const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) + return await servers.get(getters.signAuth, `/api/groups/handle/${name}/${domain}/`) }, async createGroup({dispatch, getters}, {name}) { const servers = await dispatch('getHomeServers') return await servers.post(getters.signAuth, '/api/groups/', {name}) }, - async removeGroupMember({dispatch, getters}, {groupId, identityId}) { - const servers = await dispatch('getHomeServers') + async removeGroupMember({dispatch, getters}, {groupId, domain, identityId}) { + const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) return await servers.delete(getters.signAuth, '/api/groups/' + groupId + '/members/' + identityId + '/') }, async fetchGroupInvites({commit, dispatch, getters}) { @@ -554,11 +576,11 @@ export default createStore({ commit('setGroupMemberships', data) return data }, - async inviteToGroup({state, dispatch, getters}, {groupId, groupHandle, invitee}) { - const home_servers = await dispatch('getHomeServers') - const home_reply = await home_servers.post( + async inviteToGroup({state, dispatch, getters}, {groupId, domain, groupHandle, invitee}) { + const group_servers = await dispatch('getFriendServers', {username: 'x@' + domain}) + const group_reply = await group_servers.post( getters.signAuth, '/api/groups/' + groupId + '/invites/', {invitee}) - if (!home_reply.secret) { + if (!group_reply.secret) { return false } const invitee_servers = await dispatch('getFriendServers', {username: invitee}) @@ -567,7 +589,7 @@ export default createStore({ inviter: state.user, inviter_key: nacl.to_hex(state.keypair.signPk), invitee, - secret: home_reply.secret + secret: group_reply.secret }) return true }, @@ -588,10 +610,13 @@ export default createStore({ const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/') }, - async fetchGroupInventoryItems({commit, dispatch, getters}, groupId) { - const servers = await dispatch('getHomeServers') + async fetchGroupInventoryItems({commit, dispatch, getters}, {groupId, domain}) { + const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupId) - commit('setInventoryItems', {url: '/group/' + groupId, items}) + // Namespaced by domain, not just groupId: that 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}) return items }, async fetchFiles({state, commit, dispatch, getters}) { @@ -823,7 +848,7 @@ export default createStore({ inventory_items(state) { return state.item_map['/'] || [] }, - groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + groupId] || [], + groupInventoryItems: (state) => (groupId, domain) => state.item_map['/group/' + domain + '/' + groupId] || [], identityIdByHandle(state) { return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id])) }, diff --git a/frontend/src/views/GroupDetail.vue b/frontend/src/views/GroupDetail.vue index 448f639..21eaaa9 100644 --- a/frontend/src/views/GroupDetail.vue +++ b/frontend/src/views/GroupDetail.vue @@ -86,6 +86,9 @@ + + + @@ -111,6 +114,10 @@ Edit + + + @@ -120,7 +127,9 @@
- Add + Add +
@@ -159,42 +168,55 @@ export default { }, computed: { ...mapState(['user']), - id() { - return this.groupIdByHandle[decodeHandleFromUrl(this.handle)] + decodedHandle() { + return decodeHandleFromUrl(this.handle) }, items() { - return this.groupInventoryItems(this.id) + return this.group ? this.groupInventoryItems(this.group.id, this.group.domain) : [] }, - ...mapGetters(['groupInventoryItems', 'groupIdByHandle']) + isOwnDomain() { + return this.group && this.group.domain === this.user.split('@')[1] + }, + ...mapGetters(['groupInventoryItems']) }, methods: { ...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems', - 'deleteInventoryItem', 'fetchIdMap']), + 'deleteInventoryItem']), + // Loads by handle regardless of which backend actually hosts the group - see + // store.js's fetchGroup. refresh() { - this.fetchGroup({id: this.id}).then((group) => { + return this.fetchGroup({handle: this.decodedHandle}).then((group) => { this.group = group }) }, fetchItems() { - this.fetchGroupInventoryItems(this.id) + if (this.group) { + this.fetchGroupInventoryItems({groupId: this.group.id, domain: this.group.domain}) + } }, itemRoute(item) { return `/inventory/${encodeHandleForUrl(this.group.handle)}/${item.id}` }, + printLinkFor(item) { + return {path: '/print', query: {kind: 'item', userHandle: this.group.handle, item: item.id}} + }, tryInvite() { - this.inviteToGroup({groupId: this.id, groupHandle: this.group.handle, invitee: this.invitee}) - .then((ok) => { - if (ok) { - this.show_invite = false - this.invitee = "" - } - }).catch(() => { + this.inviteToGroup({ + groupId: this.group.id, domain: this.group.domain, groupHandle: this.group.handle, + invitee: this.invitee + }).then((ok) => { + if (ok) { + this.show_invite = false + this.invitee = "" + } + }).catch(() => { }) }, tryRemoveMember(member) { - this.removeGroupMember({groupId: this.id, identityId: member.id}).then(() => { - this.refresh() - }).catch(() => { + this.removeGroupMember({groupId: this.group.id, domain: this.group.domain, identityId: member.id}) + .then(() => { + this.refresh() + }).catch(() => { }) }, tryDeleteItem(item) { @@ -204,15 +226,7 @@ export default { } }, mounted() { - if (this.id === undefined) { - this.fetchIdMap().then(() => { - this.refresh() - this.fetchItems() - }) - } else { - this.refresh() - this.fetchItems() - } + this.refresh().then(() => this.fetchItems()) } } diff --git a/frontend/src/views/Groups.vue b/frontend/src/views/Groups.vue index f8fbc61..e2bd0d2 100644 --- a/frontend/src/views/Groups.vue +++ b/frontend/src/views/Groups.vue @@ -7,7 +7,7 @@
-
My Groups
+
Groups
@@ -15,7 +15,7 @@ - + - - - - -
Name Members - + Refresh @@ -27,37 +27,16 @@
- {{ group.handle }} + + {{ group.handle }} + {{ group.members.length }}
-
-
-
-
-
-
Other groups you're a member of
-
- - - - - - - - - - + + @@ -121,15 +100,25 @@ export default { }, computed: { ...mapState(['groups', 'groupInvites', 'groupMemberships']), - foreignGroupMemberships() { - const hostedHandles = new Set(this.groups.map(group => group.handle)) - return this.groupMemberships.filter(membership => !hostedHandles.has(membership.handle)) + allGroups() { + const hosted = this.groups.map(group => ({ + key: `hosted:${group.id}`, handle: group.handle, memberCount: group.members.length, hosted: true + })) + const hostedHandles = new Set(hosted.map(group => group.handle)) + const foreign = this.groupMemberships + .filter(membership => !hostedHandles.has(membership.handle)) + .map(membership => ({key: `foreign:${membership.id}`, handle: membership.handle, hosted: false})) + return [...hosted, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle)) }, }, methods: { encodeHandleForUrl, ...mapActions(['fetchGroups', 'fetchGroupInvites', 'fetchGroupMemberships', 'acceptGroupInvite', 'declineGroupInvite']), + refreshGroups() { + this.fetchGroups() + this.fetchGroupMemberships() + }, tryAcceptInvite(invite) { this.acceptGroupInvite(invite).then(() => { this.fetchGroupInvites() diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue index 5adca48..15b8727 100644 --- a/frontend/src/views/Inventory.vue +++ b/frontend/src/views/Inventory.vue @@ -150,11 +150,10 @@ export default { if (owner_identity_id === undefined) return null return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id}) }, - // Routes to Print.vue with this item's raw identity rather than a pre-built link. - // See docs/implementation.md#print-link-shape-for-personal-items. printLinkFor(item) { - if (!item.owner) return null - return {path: '/print', query: {kind: 'item', userHandle: item.owner, item: item.id}} + const userHandle = item.owner || item.owner_group + if (!userHandle) return null + return {path: '/print', query: {kind: 'item', userHandle, item: item.id}} }, }, async mounted() { diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue index 0685771..3d9d847 100644 --- a/frontend/src/views/InventoryDetail.vue +++ b/frontend/src/views/InventoryDetail.vue @@ -46,7 +46,7 @@ Edit @@ -68,7 +68,7 @@ import * as BIcons from "bootstrap-icons-vue"; import BaseLayout from "@/components/BaseLayout.vue"; import {mapActions, mapGetters, mapState} from "vuex"; import AuthenticatedImage from "@/components/AuthenticatedImage.vue"; -import {decodeHandleFromUrl} from "@/router"; +import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router"; export default { name: "InventoryDetail", @@ -94,23 +94,30 @@ export default { }, computed: { ...mapGetters(["getNameFromHandle", "groupIdByHandle"]), - ...mapState(["user"]), + ...mapState(["user", "groupMemberships"]), decodedHandle() { return decodeHandleFromUrl(this.handle) }, + // Am I a member of this group, hosted here (groupIdByHandle, from idmap) or elsewhere + // (groupMemberships - see GroupMembership/docs/design-in-progress/groups-mvp.md)? Either + // way the backend actually hosting the group is what authorizes the write - this is just + // enough of a client-side hint to show/hide the buttons for it. + isGroupMember() { + return this.decodedHandle in this.groupIdByHandle + || this.groupMemberships.some(m => m.handle === this.decodedHandle) + }, // Edit/Delete require actual authorization (own item or member group), not just view // access - get_shared_item's friends_or_self() lets a friend view but never act. canEdit() { - return this.decodedHandle === this.user || this.decodedHandle in this.groupIdByHandle + return this.decodedHandle === this.user || this.isGroupMember }, - // Printed labels only support a personal owner handle so far (see label.js's - // splitUserHandle); group items get no print link until that's supported too. canPrint() { - return this.decodedHandle === this.user + return this.decodedHandle === this.user || this.isGroupMember } }, methods: { - ...mapActions(["fetchItemByHandle", "deleteInventoryItem"]), + ownerOverviewRoute, + ...mapActions(["fetchItemByHandle", "deleteInventoryItem", "fetchGroupMemberships"]), async loadItem() { this.item = await this.fetchItemByHandle({handle: this.decodedHandle, id: this.id}) || {} } @@ -121,6 +128,9 @@ export default { }, mounted() { this.loadItem() + if (this.decodedHandle.startsWith('#')) { + this.fetchGroupMemberships() + } } } diff --git a/frontend/src/views/InventoryEdit.vue b/frontend/src/views/InventoryEdit.vue index da2d7d2..d539c84 100644 --- a/frontend/src/views/InventoryEdit.vue +++ b/frontend/src/views/InventoryEdit.vue @@ -57,7 +57,8 @@
@@ -75,7 +76,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 {decodeHandleFromUrl} from "@/router"; +import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router"; export default { name: "InventoryEdit", @@ -120,6 +121,7 @@ export default { } }, methods: { + ownerOverviewRoute, ...mapActions(["fetchItemByHandle", "updateInventoryItem", "fetchInfo", "fetchStorageLocations"]), changeFiles(files) { this.item.files = files diff --git a/frontend/src/views/InventoryNew.vue b/frontend/src/views/InventoryNew.vue index 0287d0e..3c1065e 100644 --- a/frontend/src/views/InventoryNew.vue +++ b/frontend/src/views/InventoryNew.vue @@ -66,7 +66,7 @@
@@ -84,6 +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"; export default { name: "InventoryNew", @@ -111,6 +112,7 @@ export default { } }, methods: { + ownerOverviewRoute, ...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups']) }, computed: { diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue index c4ad0d4..bca75a7 100644 --- a/frontend/src/views/Print.vue +++ b/frontend/src/views/Print.vue @@ -337,10 +337,7 @@ export default { }; }, computed: { - ...mapGetters(["identityIdByHandle"]), - // Plain passthroughs of the form's base/derived vars, keeping the template from importing - // label-layouts.js just for these. SHORT_URL_VAR/SHORT_ID_VAR exclusion: see - // docs/implementation.md#calculated-short-link-fields. + ...mapGetters(["identityIdByHandle", "groupIdByHandle"]), baseVars() { return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR); }, @@ -461,12 +458,17 @@ export default { varLabel(v) { return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase()); }, - - // Builds the bare short-id.js token for the current fields. See docs/implementation.md#shortid-resolution. shortId(f) { if (!f.userHandle) { return null; } + if (f.userHandle.startsWith("#")) { + const owner_group_id = this.groupIdByHandle[f.userHandle]; + if (owner_group_id === undefined || !f.itemId) { + return null; + } + return shortenedRoute({kind: "group_item", owner_group_id, item_local_id: f.itemId}).slice(1); + } const owner_identity_id = this.identityIdByHandle[f.userHandle]; if (owner_identity_id === undefined) { return null; diff --git a/frontend/src/views/Scan.vue b/frontend/src/views/Scan.vue index 51af3bd..918bf92 100644 --- a/frontend/src/views/Scan.vue +++ b/frontend/src/views/Scan.vue @@ -148,33 +148,14 @@ export default { error: null, insecureContext: window.isSecureContext, insecureOrigin: `${window.location.protocol}//${window.location.hostname}`, - - // One-shot: cleared by maybeVisitFirstMatch as soon as it navigates, so it doesn't - // keep firing router.push for every later scan/decode while left switched on. - // Defaults on when the page itself was opened with ?navigate=immediate (e.g. a link - // shared for a "scan and go" workflow), so the toggle doesn't need a manual flip first. visitFirstMatch: this.$route.query.navigate === "immediate", cameraLog: [], }; }, methods: { - ...mapActions(["fetchItemByHandle", "fetchGroup", "fetchStorageLocations", "fetchIdMap"]), + ...mapActions(["fetchItemByHandle", "fetchStorageLocations", "fetchIdMap"]), - // Resolves entry.link into entry.description ("[#7] Cordless drill") for the non-URL - // formats classifyScanText recognizes - mutates the already-rendered entry in place once - // the lookup lands, rather than delaying the log/result list from showing the raw scanned - // text and link immediately. Left as `undefined` (template shows "resolving...") while in - // flight, and settles to a string or `null` (nothing else known to show, e.g. a workflow - // short id, or the owner/item genuinely couldn't be resolved). - // - // descriptionCache (keyed by the raw scanned text) memoizes the outcome - a still-in-frame - // code gets re-decoded and re-logged several times a second (see logDecode), and - // re-scanning the same printed label later is common too, so without this every repeat - // would re-fire the same fetchItemByHandle/fetchGroup/fetchStorageLocations/fetchIdMap - // round trip. Caching the in-flight promise itself (not just its settled value) also - // dedupes concurrent lookups for the same still-in-frame code, rather than firing one - // request per decode. resolveDescription(entry) { const {link, text} = entry; if (!link || link.href) { @@ -185,10 +166,6 @@ export default { return; } if (!this.descriptionCache.has(text)) { - // Wrapped as {error} rather than swallowed to null: a lookup can fail for very - // different reasons (not logged in, item not shared with this viewer, a genuine - // network error) and collapsing them all to "no description" made every one of - // them look identical to "nothing to show" - undiagnosable from the UI. this.descriptionCache.set(text, this.describeLink(link).catch(e => ({error: e.message ?? String(e)}))); } this.descriptionCache.get(text).then(result => { @@ -198,19 +175,12 @@ export default { return; } entry.description = result; - // Only a truthy description confirms the target actually exists - a null/empty - // one (unresolvable, or a kind with no title lookup wired up) shouldn't count as - // a "match" to auto-visit. if (result) { this.maybeVisitFirstMatch(link); } }); }, - // Sends the viewer straight to the first scan this session that's confirmed to resolve - // (immediately for a plain URL, or once resolveDescription confirms a real target for a - // token/handle) while the "Visit first match" toggle is on. One-shot: switches the toggle - // back off so it doesn't fire again for every later scan of the same or another code. maybeVisitFirstMatch(link) { if (!this.visitFirstMatch || !link?.to) { return; @@ -242,8 +212,12 @@ export default { return handle === undefined ? null : this.describeItem(handle, decoded.item_local_id); } if (decoded.kind === "group") { - const group = await this.fetchGroup({id: decoded.group_id}); - return group ? group.handle : null; + let handle = this.$store.getters.groupHandleById[decoded.group_id]; + if (handle === undefined) { + await this.fetchIdMap(); + handle = this.$store.getters.groupHandleById[decoded.group_id]; + } + return handle === undefined ? null : handle; } if (decoded.kind === "storage_location") { if (!this.$store.state.storage_locations.length) {
Group - - - Refresh - -
{{ membership.handle }}{{ group.hosted ? group.memberCount : '—' }}