stash
This commit is contained in:
parent
4c9e8f942e
commit
23185e1721
13 changed files with 365 additions and 107 deletions
|
|
@ -51,6 +51,11 @@ function itemDetailRoute(handle, item_local_id) {
|
||||||
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
|
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) {
|
export function ownerOverviewRoute(item) {
|
||||||
return item.owner_group ? `/groups/${encodeHandleForUrl(item.owner_group)}` : '/inventory';
|
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];
|
const handle = store.getters.groupHandleById[group_id];
|
||||||
return handle ? `/groups/${encodeHandleForUrl(handle)}` : null;
|
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}`,
|
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.
|
// 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}) {
|
export function expandedRoute({kind, ...fields}) {
|
||||||
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
|
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
|
||||||
|
|
@ -188,12 +196,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
component: StorageLocation,
|
component: StorageLocation,
|
||||||
meta: {requiresAuth: true}
|
meta: {requiresAuth: true}
|
||||||
}, {
|
}, {
|
||||||
path: '/storage-locations/:id',
|
path: '/storage-locations/:handle/:id',
|
||||||
component: StorageLocationDetail,
|
component: StorageLocationDetail,
|
||||||
meta: {requiresAuth: true},
|
meta: {requiresAuth: true},
|
||||||
props: true
|
props: true
|
||||||
}, {
|
}, {
|
||||||
path: '/storage-locations/:id/edit',
|
path: '/storage-locations/:handle/:id/edit',
|
||||||
component: StorageLocationEdit,
|
component: StorageLocationEdit,
|
||||||
meta: {requiresAuth: true},
|
meta: {requiresAuth: true},
|
||||||
props: true
|
props: true
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,12 @@ const SCHEMAS = {
|
||||||
fields: ['category_id'],
|
fields: ['category_id'],
|
||||||
interpret: ([category_id]) => ({kind: 'category', 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-_'
|
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ export default createStore({
|
||||||
idmap: {identities: [], groups: []},
|
idmap: {identities: [], groups: []},
|
||||||
idmapLoaded: false,
|
idmapLoaded: false,
|
||||||
item_map: {},
|
item_map: {},
|
||||||
|
location_map: {},
|
||||||
home_servers: null,
|
home_servers: null,
|
||||||
all_friends_servers: null,
|
all_friends_servers: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
|
@ -105,6 +106,9 @@ export default createStore({
|
||||||
setInventoryItems(state, {url, items}) {
|
setInventoryItems(state, {url, items}) {
|
||||||
state.item_map[url] = items;
|
state.item_map[url] = items;
|
||||||
},
|
},
|
||||||
|
setLocationsForKey(state, {url, locations}) {
|
||||||
|
state.location_map[url] = locations;
|
||||||
|
},
|
||||||
setFriends(state, friends) {
|
setFriends(state, friends) {
|
||||||
state.friends = friends;
|
state.friends = friends;
|
||||||
},
|
},
|
||||||
|
|
@ -397,16 +401,17 @@ export default createStore({
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
async createInventoryItem({state, dispatch, getters}, item) {
|
async createInventoryItem({state, dispatch, getters}, item) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = item.owner_group
|
||||||
const data = {availability_policy: 'private', ...item}
|
? 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)
|
const reply = await servers.post(getters.signAuth, '/api/inventory_items/', data)
|
||||||
state.last_load.files = 0
|
state.last_load.files = 0
|
||||||
return reply
|
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) {
|
async updateInventoryItem({state, dispatch, getters}, item) {
|
||||||
const servers = item.owner_group
|
const servers = item.owner_group
|
||||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
|
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
|
||||||
|
|
@ -465,16 +470,10 @@ export default createStore({
|
||||||
return null;
|
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}) {
|
async fetchItemByHandle({dispatch, getters}, {handle, id}) {
|
||||||
if (handle.startsWith('#')) {
|
if (handle.startsWith('#')) {
|
||||||
try {
|
try {
|
||||||
const group = await dispatch('fetchGroup', {handle})
|
const items = await dispatch('fetchGroupInventoryItems', {groupHandle: handle})
|
||||||
const items = await dispatch('fetchGroupInventoryItems', {groupId: group.id, domain: group.domain})
|
|
||||||
return items.find(item => item.id === parseInt(id)) || null
|
return items.find(item => item.id === parseInt(id)) || null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to fetch group item ${id} for ${handle}:`, error);
|
console.error(`Failed to fetch group item ${id} for ${handle}:`, error);
|
||||||
|
|
@ -530,14 +529,6 @@ export default createStore({
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
return await servers.delete(getters.signAuth, '/api/friends/' + id + '/')
|
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}) {
|
async fetchGroups({commit, dispatch, getters}) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
const data = await servers.get(getters.signAuth, '/api/groups/')
|
const data = await servers.get(getters.signAuth, '/api/groups/')
|
||||||
|
|
@ -552,17 +543,19 @@ export default createStore({
|
||||||
},
|
},
|
||||||
// handle is "#name@domain".
|
// handle is "#name@domain".
|
||||||
async fetchGroup({dispatch, getters}, {handle}) {
|
async fetchGroup({dispatch, getters}, {handle}) {
|
||||||
const {name, domain} = splitGroupHandle(handle)
|
const {domain} = splitGroupHandle(handle)
|
||||||
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
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}) {
|
async createGroup({dispatch, getters}, {name}) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
return await servers.post(getters.signAuth, '/api/groups/', {name})
|
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})
|
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}) {
|
async fetchGroupInvites({commit, dispatch, getters}) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
|
|
@ -576,10 +569,11 @@ export default createStore({
|
||||||
commit('setGroupMemberships', data)
|
commit('setGroupMemberships', data)
|
||||||
return 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_servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||||
const group_reply = await group_servers.post(
|
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) {
|
if (!group_reply.secret) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -610,13 +604,14 @@ export default createStore({
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/')
|
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 servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||||
const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupId)
|
const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupHandle.slice(1))
|
||||||
// Namespaced by domain, not just groupId: that pk is only unique within its own
|
// 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
|
// backend's database, so two different domains could otherwise collide on the same
|
||||||
// item_map key.
|
// item_map key - the handle already carries the domain, so no separate namespacing is needed.
|
||||||
commit('setInventoryItems', {url: '/group/' + domain + '/' + groupId, items})
|
commit('setInventoryItems', {url: '/group/' + groupHandle, items})
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
async fetchFiles({state, commit, dispatch, getters}) {
|
async fetchFiles({state, commit, dispatch, getters}) {
|
||||||
|
|
@ -722,23 +717,50 @@ export default createStore({
|
||||||
commit('setStorageLocations', data)
|
commit('setStorageLocations', data)
|
||||||
state.last_load.storage_locations = Date.now()
|
state.last_load.storage_locations = Date.now()
|
||||||
return data
|
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) {
|
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 + '/')
|
const ret = await servers.delete(getters.signAuth, '/api/storage_locations/' + location.id + '/')
|
||||||
dispatch('fetchStorageLocations')
|
dispatch('fetchStorageLocations')
|
||||||
return ret
|
return ret
|
||||||
},
|
},
|
||||||
|
// location.owner_group is the group's own "#name@domain" handle - see
|
||||||
|
// createInventoryItem's own comment.
|
||||||
async createStorageLocation({state, dispatch, getters}, location) {
|
async createStorageLocation({state, dispatch, getters}, location) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = location.owner_group
|
||||||
const data = {...location}
|
? 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
|
if (data.parent === '') data.parent = null
|
||||||
const reply = await servers.post(getters.signAuth, '/api/storage_locations/', data)
|
const reply = await servers.post(getters.signAuth, '/api/storage_locations/', data)
|
||||||
state.last_load.storage_locations = 0
|
state.last_load.storage_locations = 0
|
||||||
return reply
|
return reply
|
||||||
},
|
},
|
||||||
async updateStorageLocation({state, dispatch, getters}, location) {
|
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}
|
const data = {...location}
|
||||||
if (data.parent === '') data.parent = null
|
if (data.parent === '') data.parent = null
|
||||||
const reply = await servers.patch(getters.signAuth, '/api/storage_locations/' + location.id + '/', data)
|
const reply = await servers.patch(getters.signAuth, '/api/storage_locations/' + location.id + '/', data)
|
||||||
|
|
@ -848,15 +870,14 @@ export default createStore({
|
||||||
inventory_items(state) {
|
inventory_items(state) {
|
||||||
return state.item_map['/'] || []
|
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) {
|
identityIdByHandle(state) {
|
||||||
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
|
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
|
||||||
},
|
},
|
||||||
groupIdByHandle(state) {
|
groupIdByHandle(state) {
|
||||||
return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id]))
|
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) {
|
identityHandleById(state) {
|
||||||
return Object.fromEntries(state.idmap.identities.map(i => [i.id, i.username]))
|
return Object.fromEntries(state.idmap.identities.map(i => [i.id, i.username]))
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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_item', owner_group_id: 5, item_local_id: 42},
|
||||||
{kind: 'group', group_id: 11},
|
{kind: 'group', group_id: 11},
|
||||||
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
|
{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: 'file', file_id: 123},
|
||||||
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,55 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<button class="btn" @click="fetchItems">Refresh</button>
|
<button class="btn" @click="fetchItems">Refresh</button>
|
||||||
<router-link v-if="isOwnDomain" :to="`/inventory/new?group=${group.id}`"
|
<router-link :to="`/inventory/new?group=${encodeHandleForUrl(group.handle)}`"
|
||||||
|
class="btn btn-primary">Add
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">Group storage locations</h5>
|
||||||
|
</div>
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:40%;">Name</th>
|
||||||
|
<th style="width:25%">Path</th>
|
||||||
|
<th class="d-none d-md-table-cell" style="width:25%">Category</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="location in storageLocations" :key="location.id">
|
||||||
|
<td>
|
||||||
|
<router-link :to="locationRoute(location)">{{ location.name }}</router-link>
|
||||||
|
</td>
|
||||||
|
<td class="d-none d-md-table-cell">
|
||||||
|
<span class="text-muted">{{ location.path }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="d-none d-md-table-cell">
|
||||||
|
<span class="badge bg-info text-white" v-if="location.category">{{ location.category }}</span>
|
||||||
|
<span class="text-muted" v-else>-</span>
|
||||||
|
</td>
|
||||||
|
<td class="table-action">
|
||||||
|
<router-link :to="`${locationRoute(location)}/edit`">
|
||||||
|
<b-icon-pencil-square></b-icon-pencil-square>
|
||||||
|
</router-link>
|
||||||
|
<a :href="`${locationRoute(location)}/delete`" @click.prevent="tryDeleteLocation(location)">
|
||||||
|
<b-icon-trash></b-icon-trash>
|
||||||
|
</a>
|
||||||
|
<router-link v-if="locationPrintLinkFor(location)" :to="locationPrintLinkFor(location)">
|
||||||
|
<b-icon-qr-code></b-icon-qr-code>
|
||||||
|
</router-link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<button class="btn" @click="fetchLocations">Refresh</button>
|
||||||
|
<router-link :to="`/storage-locations/new?group=${encodeHandleForUrl(group.handle)}`"
|
||||||
class="btn btn-primary">Add
|
class="btn btn-primary">Add
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -139,7 +187,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {mapActions, mapGetters, mapState} from "vuex";
|
import {mapActions, mapGetters} from "vuex";
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import UserNameTag from "@/components/UserNameTag.vue";
|
import UserNameTag from "@/components/UserNameTag.vue";
|
||||||
|
|
@ -167,21 +215,21 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['user']),
|
|
||||||
decodedHandle() {
|
decodedHandle() {
|
||||||
return decodeHandleFromUrl(this.handle)
|
return decodeHandleFromUrl(this.handle)
|
||||||
},
|
},
|
||||||
items() {
|
items() {
|
||||||
return this.group ? this.groupInventoryItems(this.group.id, this.group.domain) : []
|
return this.group ? this.groupInventoryItems(this.group.handle) : []
|
||||||
},
|
},
|
||||||
isOwnDomain() {
|
storageLocations() {
|
||||||
return this.group && this.group.domain === this.user.split('@')[1]
|
return this.group ? this.groupStorageLocations(this.group.handle) : []
|
||||||
},
|
},
|
||||||
...mapGetters(['groupInventoryItems'])
|
...mapGetters(['groupInventoryItems', 'groupStorageLocations'])
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
encodeHandleForUrl,
|
||||||
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
|
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
|
||||||
'deleteInventoryItem']),
|
'deleteInventoryItem', 'fetchGroupStorageLocations', 'deleteStorageLocation']),
|
||||||
// Loads by handle regardless of which backend actually hosts the group - see
|
// Loads by handle regardless of which backend actually hosts the group - see
|
||||||
// store.js's fetchGroup.
|
// store.js's fetchGroup.
|
||||||
refresh() {
|
refresh() {
|
||||||
|
|
@ -191,7 +239,7 @@ export default {
|
||||||
},
|
},
|
||||||
fetchItems() {
|
fetchItems() {
|
||||||
if (this.group) {
|
if (this.group) {
|
||||||
this.fetchGroupInventoryItems({groupId: this.group.id, domain: this.group.domain})
|
this.fetchGroupInventoryItems({groupHandle: this.group.handle})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemRoute(item) {
|
itemRoute(item) {
|
||||||
|
|
@ -200,11 +248,24 @@ export default {
|
||||||
printLinkFor(item) {
|
printLinkFor(item) {
|
||||||
return {path: '/print', query: {kind: 'item', userHandle: this.group.handle, item: item.id}}
|
return {path: '/print', query: {kind: 'item', userHandle: this.group.handle, item: item.id}}
|
||||||
},
|
},
|
||||||
|
fetchLocations() {
|
||||||
|
if (this.group) {
|
||||||
|
this.fetchGroupStorageLocations({groupHandle: this.group.handle})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
locationRoute(location) {
|
||||||
|
return `/storage-locations/${encodeHandleForUrl(this.group.handle)}/${location.id}`
|
||||||
|
},
|
||||||
|
locationPrintLinkFor(location) {
|
||||||
|
return {path: '/print', query: {kind: 'storage-location', userHandle: this.group.handle, location: location.id}}
|
||||||
|
},
|
||||||
|
tryDeleteLocation(location) {
|
||||||
|
this.deleteStorageLocation(location).then(() => {
|
||||||
|
this.fetchLocations()
|
||||||
|
})
|
||||||
|
},
|
||||||
tryInvite() {
|
tryInvite() {
|
||||||
this.inviteToGroup({
|
this.inviteToGroup({groupHandle: this.group.handle, invitee: this.invitee}).then((ok) => {
|
||||||
groupId: this.group.id, domain: this.group.domain, groupHandle: this.group.handle,
|
|
||||||
invitee: this.invitee
|
|
||||||
}).then((ok) => {
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
this.show_invite = false
|
this.show_invite = false
|
||||||
this.invitee = ""
|
this.invitee = ""
|
||||||
|
|
@ -213,7 +274,7 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
tryRemoveMember(member) {
|
tryRemoveMember(member) {
|
||||||
this.removeGroupMember({groupId: this.group.id, domain: this.group.domain, identityId: member.id})
|
this.removeGroupMember({groupHandle: this.group.handle, identityId: member.id})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.refresh()
|
this.refresh()
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
|
|
@ -226,7 +287,10 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refresh().then(() => this.fetchItems())
|
this.refresh().then(() => {
|
||||||
|
this.fetchItems()
|
||||||
|
this.fetchLocations()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
<label for="owner" class="form-label">Owner</label>
|
<label for="owner" class="form-label">Owner</label>
|
||||||
<select class="form-select" id="owner" name="owner" v-model="item.owner_group">
|
<select class="form-select" id="owner" name="owner" v-model="item.owner_group">
|
||||||
<option :value="null">Myself</option>
|
<option :value="null">Myself</option>
|
||||||
<option v-for="group in groups" :value="group.id" :key="group.id">
|
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
|
||||||
{{ group.handle }}
|
{{ group.handle }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -84,7 +84,7 @@ import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import TagField from "@/components/TagField.vue";
|
import TagField from "@/components/TagField.vue";
|
||||||
import PropertyField from "@/components/PropertyField.vue";
|
import PropertyField from "@/components/PropertyField.vue";
|
||||||
import CombinedFileField from "@/components/CombinedFileField.vue";
|
import CombinedFileField from "@/components/CombinedFileField.vue";
|
||||||
import {ownerOverviewRoute} from "@/router";
|
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "InventoryNew",
|
name: "InventoryNew",
|
||||||
|
|
@ -107,23 +107,34 @@ export default {
|
||||||
properties: [],
|
properties: [],
|
||||||
files: [],
|
files: [],
|
||||||
storage_location: null,
|
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
|
owner_group: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
ownerOverviewRoute,
|
ownerOverviewRoute,
|
||||||
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups'])
|
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups',
|
||||||
|
'fetchGroupMemberships'])
|
||||||
},
|
},
|
||||||
computed: {
|
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() {
|
async mounted() {
|
||||||
await this.fetchInfo();
|
await this.fetchInfo();
|
||||||
await this.fetchStorageLocations();
|
await this.fetchStorageLocations();
|
||||||
await this.fetchGroups();
|
await this.fetchGroups();
|
||||||
|
await this.fetchGroupMemberships();
|
||||||
if (this.$route.query.group) {
|
if (this.$route.query.group) {
|
||||||
this.item.owner_group = parseInt(this.$route.query.group, 10)
|
this.item.owner_group = decodeHandleFromUrl(this.$route.query.group)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -464,10 +464,18 @@ export default {
|
||||||
}
|
}
|
||||||
if (f.userHandle.startsWith("#")) {
|
if (f.userHandle.startsWith("#")) {
|
||||||
const owner_group_id = this.groupIdByHandle[f.userHandle];
|
const owner_group_id = this.groupIdByHandle[f.userHandle];
|
||||||
if (owner_group_id === undefined || !f.itemId) {
|
if (owner_group_id === undefined) {
|
||||||
return null;
|
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];
|
const owner_identity_id = this.identityIdByHandle[f.userHandle];
|
||||||
if (owner_identity_id === undefined) {
|
if (owner_identity_id === undefined) {
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ export default {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchItemByHandle", "fetchStorageLocations", "fetchIdMap"]),
|
...mapActions(["fetchItemByHandle", "fetchStorageLocationByHandle", "fetchIdMap"]),
|
||||||
|
|
||||||
resolveDescription(entry) {
|
resolveDescription(entry) {
|
||||||
const {link, text} = entry;
|
const {link, text} = entry;
|
||||||
|
|
@ -219,12 +219,19 @@ export default {
|
||||||
}
|
}
|
||||||
return handle === undefined ? null : handle;
|
return handle === undefined ? null : handle;
|
||||||
}
|
}
|
||||||
if (decoded.kind === "storage_location") {
|
if (decoded.kind === "storage_location" || decoded.kind === "group_storage_location") {
|
||||||
if (!this.$store.state.storage_locations.length) {
|
const byId = decoded.kind === "storage_location"
|
||||||
await this.fetchStorageLocations();
|
? 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 handle === undefined ? null : this.describeLocation(handle, decoded.storage_location_id);
|
||||||
return location ? `[#${location.id}] ${location.name}` : null;
|
|
||||||
}
|
}
|
||||||
return null; // workflow, category, file: no per-item title lookup wired up yet
|
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;
|
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.
|
// Handler for CameraScanner's "scan" event - logs every code from the batch it decoded.
|
||||||
onCameraScan(codes) {
|
onCameraScan(codes) {
|
||||||
codes.forEach(this.logDecode);
|
codes.forEach(this.logDecode);
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ const EXAMPLES = [
|
||||||
{kind: 'group_item', owner_group_id: 5, item_local_id: 42},
|
{kind: 'group_item', owner_group_id: 5, item_local_id: 42},
|
||||||
{kind: 'file', file_id: 123},
|
{kind: 'file', file_id: 123},
|
||||||
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
|
{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: 'category', category_id: 5},
|
||||||
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="location in storage_locations" :key="location.id">
|
<tr v-for="location in storage_locations" :key="location.id">
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="`/storage-locations/${location.id}`">{{ location.name }}</router-link>
|
<router-link :to="locationRoute(location)">{{ location.name }}</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td class="d-none d-md-table-cell">
|
<td class="d-none d-md-table-cell">
|
||||||
<span class="text-muted">{{ location.path }}</span>
|
<span class="text-muted">{{ location.path }}</span>
|
||||||
|
|
@ -37,10 +37,10 @@
|
||||||
<span class="text-muted" v-else>-</span>
|
<span class="text-muted" v-else>-</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="table-action">
|
<td class="table-action">
|
||||||
<router-link :to="`/storage-locations/${location.id}/edit`">
|
<router-link :to="`${locationRoute(location)}/edit`">
|
||||||
<b-icon-pencil-square></b-icon-pencil-square>
|
<b-icon-pencil-square></b-icon-pencil-square>
|
||||||
</router-link>
|
</router-link>
|
||||||
<a :href="`/storage-locations/${location.id}/delete`" @click.prevent="deleteStorageLocation(location)">
|
<a :href="`${locationRoute(location)}/delete`" @click.prevent="deleteStorageLocation(location)">
|
||||||
<b-icon-trash></b-icon-trash>
|
<b-icon-trash></b-icon-trash>
|
||||||
</a>
|
</a>
|
||||||
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
|
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-0">
|
<h5 class="card-title mb-0">
|
||||||
<router-link :to="`/storage-locations/${location.id}`">
|
<router-link :to="locationRoute(location)">
|
||||||
{{ location.name }}
|
{{ location.name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</h5>
|
</h5>
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
<button class="btn btn-danger btn-sm"
|
<button class="btn btn-danger btn-sm"
|
||||||
@click="deleteStorageLocation(location.id)">Delete
|
@click="deleteStorageLocation(location.id)">Delete
|
||||||
</button>
|
</button>
|
||||||
<router-link :to="`/storage-locations/${location.id}/edit`"
|
<router-link :to="`${locationRoute(location)}/edit`"
|
||||||
class="btn btn-primary btn-sm">Edit
|
class="btn btn-primary btn-sm">Edit
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)"
|
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)"
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
import {mapActions, mapGetters, mapState} from "vuex";
|
import {mapActions, mapGetters, mapState} from "vuex";
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import {shortenedRoute} from "@/router";
|
import {shortenedRoute, encodeHandleForUrl} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "StorageLocation",
|
name: "StorageLocation",
|
||||||
|
|
@ -123,12 +123,25 @@ export default {
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters(["identityIdByHandle"]),
|
...mapGetters(["identityIdByHandle", "groupIdByHandle"]),
|
||||||
...mapState(["user", "storage_locations"]),
|
...mapState(["user", "storage_locations"]),
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchStorageLocations", "deleteStorageLocation", "fetchIdMap"]),
|
...mapActions(["fetchStorageLocations", "deleteStorageLocation", "fetchIdMap"]),
|
||||||
|
// This list is always the caller's own personal locations (fetchStorageLocations has no
|
||||||
|
// group content), so the owner_group branches below are currently unreachable here - kept
|
||||||
|
// for parity with Inventory.vue's shortIdLink/printLinkFor in case that ever changes.
|
||||||
|
locationRoute(location) {
|
||||||
|
return `/storage-locations/${encodeHandleForUrl(location.owner_group || location.owner)}/${location.id}`
|
||||||
|
},
|
||||||
shortIdLink(location) {
|
shortIdLink(location) {
|
||||||
|
if (location.owner_group) {
|
||||||
|
const owner_group_id = this.groupIdByHandle[location.owner_group]
|
||||||
|
if (owner_group_id === undefined) return null
|
||||||
|
return shortenedRoute({
|
||||||
|
kind: 'group_storage_location', owner_group_id, storage_location_id: location.id
|
||||||
|
})
|
||||||
|
}
|
||||||
const owner_identity_id = this.identityIdByHandle[location.owner]
|
const owner_identity_id = this.identityIdByHandle[location.owner]
|
||||||
if (owner_identity_id === undefined) return null
|
if (owner_identity_id === undefined) return null
|
||||||
return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id})
|
return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id})
|
||||||
|
|
@ -136,7 +149,8 @@ export default {
|
||||||
// Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's
|
// Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's
|
||||||
// printLinkFor. See docs/implementation.md#print-link-shape-for-storage-locations.
|
// printLinkFor. See docs/implementation.md#print-link-shape-for-storage-locations.
|
||||||
printLinkFor(location) {
|
printLinkFor(location) {
|
||||||
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, location: location.id}}
|
const userHandle = location.owner_group || location.owner
|
||||||
|
return {path: '/print', query: {kind: 'storage-location', userHandle, location: location.id}}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
|
||||||
|
|
@ -23,20 +23,25 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="owner" class="form-label">Owner</label>
|
<label for="owner" class="form-label">Owner</label>
|
||||||
<div>{{ location.owner?.username || '-' }}</div>
|
<div>{{ location.owner || location.owner_group || '-' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card" v-if="canEdit">
|
||||||
<button class="btn btn-primary" @click="$router.push('/storage-locations/' + id + '/edit')">
|
<button class="btn btn-primary" @click="$router.push(`/storage-locations/${handle}/${id}/edit`)">
|
||||||
<b-icon-pencil-square></b-icon-pencil-square>
|
<b-icon-pencil-square></b-icon-pencil-square>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-danger"
|
<button type="submit" class="btn btn-danger"
|
||||||
@click="deleteStorageLocation(location).then(() => $router.push('/storage-location'))">
|
@click="deleteStorageLocation(location).then(() => $router.push(ownerOverviewRoute(location)))">
|
||||||
<b-icon-trash></b-icon-trash>
|
<b-icon-trash></b-icon-trash>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary"
|
||||||
|
@click="$router.push({path: '/print', query: {kind: 'storage-location', userHandle: decodedHandle, location: id}})">
|
||||||
|
<b-icon-printer></b-icon-printer>
|
||||||
|
Print label
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -47,7 +52,8 @@
|
||||||
<script>
|
<script>
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import {mapActions, mapState} from "vuex";
|
import {mapActions, mapGetters, mapState} from "vuex";
|
||||||
|
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "StorageLocationDetail",
|
name: "StorageLocationDetail",
|
||||||
|
|
@ -56,22 +62,52 @@ export default {
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
handle: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
id: {
|
id: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
location: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(["storage_locations"]),
|
...mapGetters(["groupIdByHandle"]),
|
||||||
location() {
|
...mapState(["user", "groupMemberships"]),
|
||||||
return this.storage_locations.find(loc => loc.id === parseInt(this.id)) || {}
|
decodedHandle() {
|
||||||
|
return decodeHandleFromUrl(this.handle)
|
||||||
|
},
|
||||||
|
// Am I a member of this group, hosted here or elsewhere - see InventoryDetail.vue's
|
||||||
|
// isGroupMember for the same check on the item side.
|
||||||
|
isGroupMember() {
|
||||||
|
return this.decodedHandle in this.groupIdByHandle
|
||||||
|
|| this.groupMemberships.some(m => m.handle === this.decodedHandle)
|
||||||
|
},
|
||||||
|
canEdit() {
|
||||||
|
return this.decodedHandle === this.user || this.isGroupMember
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchStorageLocations", "deleteStorageLocation"]),
|
ownerOverviewRoute,
|
||||||
|
...mapActions(["fetchStorageLocationByHandle", "deleteStorageLocation", "fetchGroupMemberships"]),
|
||||||
|
async loadLocation() {
|
||||||
|
this.location = await this.fetchStorageLocationByHandle({handle: this.decodedHandle, id: this.id}) || {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
watch: {
|
||||||
await this.fetchStorageLocations()
|
handle: 'loadLocation',
|
||||||
|
id: 'loadLocation'
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadLocation()
|
||||||
|
if (this.decodedHandle.startsWith('#')) {
|
||||||
|
this.fetchGroupMemberships()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import {mapActions, mapState} from "vuex";
|
import {mapActions, mapState} from "vuex";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "StorageLocationEdit",
|
name: "StorageLocationEdit",
|
||||||
|
|
@ -63,32 +64,56 @@ export default {
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
handle: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
id: {
|
id: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
data() {
|
||||||
...mapState(["storage_locations", "categories"]),
|
return {
|
||||||
location() {
|
location: {
|
||||||
return {
|
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
category: null,
|
category: null,
|
||||||
parent: 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() {
|
availableParents() {
|
||||||
// Filter out self and children to prevent circular references
|
// 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) &&
|
loc.id !== parseInt(this.id) &&
|
||||||
!this.isChildOf(loc, parseInt(this.id))
|
!this.isChildOf(loc, parseInt(this.id))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
submitForm() {
|
||||||
// Convert empty strings to null for ForeignKey fields
|
// Convert empty strings to null for ForeignKey fields
|
||||||
const locationData = {
|
const locationData = {
|
||||||
|
|
@ -96,21 +121,25 @@ export default {
|
||||||
category: this.location.category === "" ? null : this.location.category,
|
category: this.location.category === "" ? null : this.location.category,
|
||||||
parent: this.location.parent === "" ? null : this.location.parent
|
parent: this.location.parent === "" ? null : this.location.parent
|
||||||
};
|
};
|
||||||
this.updateStorageLocation(locationData);
|
this.updateStorageLocation(locationData).then(updated => this.$router.push(ownerOverviewRoute(updated)));
|
||||||
},
|
},
|
||||||
isChildOf(location, parentId) {
|
isChildOf(location, parentId) {
|
||||||
// Simple check to prevent circular references
|
// Simple check to prevent circular references
|
||||||
let current = location;
|
let current = location;
|
||||||
while (current && current.parent) {
|
while (current && current.parent) {
|
||||||
if (current.parent === parentId) return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
handle: 'loadLocation',
|
||||||
|
id: 'loadLocation'
|
||||||
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
await this.fetchInfo();
|
await this.fetchInfo();
|
||||||
await this.fetchStorageLocations();
|
await this.loadLocation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,21 @@
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="owner" class="form-label">Owner</label>
|
||||||
|
<select class="form-select" id="owner" name="owner" v-model="location.owner_group">
|
||||||
|
<option :value="null">Myself</option>
|
||||||
|
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
|
||||||
|
{{ group.handle }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="parent" class="form-label">Parent Location</label>
|
<label for="parent" class="form-label">Parent Location</label>
|
||||||
<select class="form-select" id="parent" name="parent"
|
<select class="form-select" id="parent" name="parent"
|
||||||
v-model="location.parent">
|
v-model="location.parent">
|
||||||
<option value="">No Parent</option>
|
<option value="">No Parent</option>
|
||||||
<option v-for="parent in storage_locations" :value="parent.id">
|
<option v-for="parent in availableParents" :value="parent.id">
|
||||||
{{ parent.path }}
|
{{ parent.path }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -53,6 +62,7 @@
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import {mapActions, mapState} from "vuex";
|
import {mapActions, mapState} from "vuex";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "StorageLocationNew",
|
name: "StorageLocationNew",
|
||||||
|
|
@ -66,12 +76,27 @@ export default {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
category: null,
|
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: {
|
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() {
|
submitForm() {
|
||||||
// Convert empty strings to null for ForeignKey fields
|
// Convert empty strings to null for ForeignKey fields
|
||||||
const locationData = {
|
const locationData = {
|
||||||
|
|
@ -79,15 +104,37 @@ export default {
|
||||||
category: this.location.category === "" ? null : this.location.category,
|
category: this.location.category === "" ? null : this.location.category,
|
||||||
parent: this.location.parent === "" ? null : this.location.parent
|
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: {
|
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() {
|
async mounted() {
|
||||||
await this.fetchInfo();
|
await this.fetchInfo();
|
||||||
await this.fetchStorageLocations();
|
await this.fetchStorageLocations();
|
||||||
|
await this.fetchGroups();
|
||||||
|
await this.fetchGroupMemberships();
|
||||||
|
if (this.$route.query.group) {
|
||||||
|
this.location.owner_group = decodeHandleFromUrl(this.$route.query.group)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue