This commit is contained in:
j3d1 2026-08-27 20:41:19 +02:00
parent 2bb6624d50
commit 7276750c66
15 changed files with 552 additions and 169 deletions

View file

@ -49,8 +49,13 @@ export function decodeHandleFromUrl(segment) {
return segment.replace(/\+/g, "#");
}
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
export function ownerOverviewRoute(item) {
return item.owner_group ? `/groups/${encodeHandleForUrl(item.owner_group)}` : '/inventory';
return item.owner_group ? `/inventory/${encodeHandleForUrl(item.owner_group)}` : '/inventory';
}
export function ownerLocationOverviewRoute(location) {
return location.owner_group ? `/storage-location/${encodeHandleForUrl(location.owner_group)}` : '/storage-location';
}
const EXPANDED_ROUTE_BUILDERS = {
@ -121,9 +126,11 @@ export async function domainQualifiedRoute(domain, ints) {
}
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
path: '/inventory',
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
path: '/inventory/:owner?',
component: Inventory,
meta: {requiresAuth: true}
meta: {requiresAuth: true},
props: true
}, {
path: '/inventory/:handle/:id',
component: InventoryDetail,
@ -150,7 +157,13 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
}
return expandedRoute(deserializeShortId(decodeShortId(to.params.short_id)));
}
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
}, {
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
path: '/inventory/new/:group?',
component: InventoryNew,
meta: {requiresAuth: true},
props: true
}, {
path: '/friends',
component: Friends,
meta: {requiresAuth: true}
@ -223,9 +236,11 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
path: 'preferences/', name: 'preferences', component: Preferences, meta: {requiresAuth: true}
}]
}, {
path: '/storage-location',
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
path: '/storage-location/:owner?',
component: StorageLocation,
meta: {requiresAuth: true}
meta: {requiresAuth: true},
props: true
}, {
path: '/storage-locations/:handle/:id',
component: StorageLocationDetail,
@ -237,9 +252,11 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
meta: {requiresAuth: true},
props: true
}, {
path: '/storage-locations/new',
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
path: '/storage-locations/new/:group?',
component: StorageLocationNew,
meta: {requiresAuth: true}
meta: {requiresAuth: true},
props: true
}, {path: '/:pathMatch(.*)*', redirect: '/'}]
const router = createRouter({

View file

@ -3,31 +3,34 @@
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Inventory</h1>
<div class="row mb-3">
<div class="col-12 col-md-4">
<label for="ownerSelect" class="form-label">Viewing inventory for</label>
<select id="ownerSelect" class="form-select" v-model="selectedOwner">
<option :value="user">{{ user }} (you)</option>
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }}
</option>
<option v-for="friend in friends" :value="friend.handle" :key="friend.handle">
{{ friend.handle }}
</option>
</select>
</div>
</div>
<div class="row">
<div class="col-12 col-xl-12">
<div class="card">
<div class="card-header">
<h5 class="card-title">{{ selectedOwner }}'s Inventory</h5>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
</button>
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
<h5 class="card-title mb-0">{{ selectedOwner }}'s Inventory</h5>
<div class="d-flex align-items-center">
<!--label for="ownerSelect" class="visually-hidden">Viewing inventory for</label-->
<select id="ownerSelect" class="form-select form-select-sm me-2" style="width:auto"
v-model="selectedOwner">
<option :value="user">{{ user }} (you)</option>
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }}
</option>
<option v-for="friend in friends" :value="friend.handle" :key="friend.handle">
{{ friend.handle }}
</option>
</select>
<div class="btn-group">
<button class="btn" @click="fetchItemsForOwner">Refresh</button>
<router-link v-if="canEdit" :to="addItemRoute" class="btn btn-primary">Add</router-link>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
</button>
</div>
</div>
</div>
<table class="table table-striped" v-if="layout === 'table'">
<thead>
@ -106,10 +109,6 @@
</div>
</div>
<div class="card">
<button class="btn" @click="fetchItemsForOwner">Refresh</button>
<router-link v-if="canEdit" :to="addItemRoute" class="btn btn-primary">Add</router-link>
</div>
</div>
</div>
</div>
@ -122,14 +121,20 @@ import {mapActions, mapGetters, mapMutations, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import AuthenticatedImage from "../components/AuthenticatedImage.vue";
import {shortenedRoute, encodeHandleForUrl} from "@/router";
import {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router";
export default {
name: "Inventory",
props: {
// Matches /inventory/:owner; absent means "me". See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
owner: {
type: String,
default: null
}
},
data() {
return {
layout: "grid",
selectedOwner: null,
}
},
components: {
@ -140,31 +145,37 @@ export default {
computed: {
...mapGetters(["inventory_items", "groupInventoryItems", "loaded_items", "identityIdByHandle", "groupIdByHandle"]),
...mapState(["user", "storage_locations", "groups", "groupMemberships", "friends"]),
// Groups hosted here plus groups only known via a GroupMembership pointer - see
// Groups.vue's allGroups and InventoryNew.vue's ownerGroups for the same merge/dedupe.
// Groups hosted here plus GroupMembership-only ones - 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))
},
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
selectedOwner: {
get() {
return this.owner ? decodeHandleFromUrl(this.owner) : this.user
},
set(value) {
const path = value === this.user ? '/inventory' : `/inventory/${encodeHandleForUrl(value)}`
this.$router.replace(path)
}
},
isGroupSelected() {
return !!this.selectedOwner && this.selectedOwner.startsWith('#')
},
// Own items and group items can be created/edited/deleted here; a friend's items are
// shown for browsing only - the backend rejects writes for anyone but the owner or a
// fellow group member (see inventory.py's perform_create/update/destroy).
// Friend items are browse-only - inventory.py's perform_create/update/destroy reject writes from anyone but the owner/a fellow group member.
canEdit() {
return this.selectedOwner === this.user || this.isGroupSelected
},
items() {
// item_map is keyed by owner handle regardless of whether that owner is a group or a
// friend, so the same getter serves both - see store.js's groupInventoryItems.
// item_map is keyed by owner handle regardless of group vs friend - see store.js's groupInventoryItems.
return this.selectedOwner === this.user ? this.inventory_items : this.groupInventoryItems(this.selectedOwner)
},
addItemRoute() {
return this.selectedOwner === this.user
? '/inventory/new'
: `/inventory/new?group=${encodeHandleForUrl(this.selectedOwner)}`
: `/inventory/new/${encodeHandleForUrl(this.selectedOwner)}`
}
},
methods: {
@ -181,8 +192,7 @@ export default {
this.fetchItemsForOwner()
})
},
// The owner handle is derived from the item itself, not the current selection, so a
// link stays correct even if the dropdown selection changes underneath it.
// Derived from the item's own owner, not the current selection, so the link stays correct if the dropdown changes underneath it.
itemRoute(item) {
return `/inventory/${encodeHandleForUrl(item.owner_group || item.owner)}/${item.id}`
},
@ -210,16 +220,12 @@ export default {
},
},
watch: {
selectedOwner() {
owner() {
this.fetchItemsForOwner()
}
},
created() {
// Set before the first render so addItemRoute/items never see selectedOwner=null
// while user is already populated (which would misroute to the group branch).
this.selectedOwner = this.user
},
async mounted() {
this.fetchItemsForOwner()
await this.fetchStorageLocations()
await this.fetchIdMap()
await this.fetchGroups()

View file

@ -95,6 +95,13 @@ export default {
CombinedFileField,
...BIcons
},
props: {
// Matches /inventory/new/:group?. See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
group: {
type: String,
default: null
}
},
data() {
return {
item: {
@ -133,8 +140,8 @@ export default {
await this.fetchStorageLocations();
await this.fetchGroups();
await this.fetchGroupMemberships();
if (this.$route.query.group) {
this.item.owner_group = decodeHandleFromUrl(this.$route.query.group)
if (this.group) {
this.item.owner_group = decodeHandleFromUrl(this.group)
}
}
}

View file

@ -3,28 +3,31 @@
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Storage Locations</h1>
<div class="row mb-3">
<div class="col-12 col-md-4">
<label for="ownerSelect" class="form-label">Viewing storage locations for</label>
<select id="ownerSelect" class="form-select" v-model="selectedOwner">
<option :value="user">{{ user }} (you)</option>
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }}
</option>
</select>
</div>
</div>
<div class="row">
<div class="col-12 col-xl-12">
<div class="card">
<div class="card-header">
<h5 class="card-title">{{ selectedOwner }}'s Storage Locations</h5>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
</button>
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
<h5 class="card-title mb-0">{{ selectedOwner }}'s Storage Locations</h5>
<div class="d-flex align-items-center">
<!--label for="ownerSelect" class="visually-hidden">Viewing storage locations for</label-->
<select id="ownerSelect" class="form-select form-select-sm me-2" style="width:auto"
v-model="selectedOwner">
<option :value="user">{{ user }} (you)</option>
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }}
</option>
</select>
<div class="btn-group">
<button class="btn" @click="fetchLocationsForOwner">Refresh</button>
<router-link :to="addLocationRoute" class="btn btn-primary">Add</router-link>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
</button>
</div>
</div>
</div>
<table class="table table-striped" v-if="layout === 'table'">
<thead>
@ -105,10 +108,6 @@
</div>
</div>
<div class="card">
<button class="btn" @click="fetchLocationsForOwner">Refresh</button>
<router-link :to="addLocationRoute" class="btn btn-primary">Add</router-link>
</div>
</div>
</div>
</div>
@ -120,14 +119,20 @@
import {mapActions, mapGetters, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {shortenedRoute, encodeHandleForUrl} from "@/router";
import {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router";
export default {
name: "StorageLocation",
props: {
// Matches /storage-location/:owner; absent means "me". See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
owner: {
type: String,
default: null
}
},
data() {
return {
layout: "grid",
selectedOwner: null,
}
},
components: {
@ -137,20 +142,29 @@ export default {
computed: {
...mapGetters(["identityIdByHandle", "groupIdByHandle", "groupStorageLocations"]),
...mapState(["user", "storage_locations", "groups", "groupMemberships"]),
// Groups hosted here plus groups only known via a GroupMembership pointer - see
// Groups.vue's allGroups and Inventory.vue's ownerGroups for the same merge/dedupe.
// Groups hosted here plus GroupMembership-only ones - 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))
},
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
selectedOwner: {
get() {
return this.owner ? decodeHandleFromUrl(this.owner) : this.user
},
set(value) {
const path = value === this.user ? '/storage-location' : `/storage-location/${encodeHandleForUrl(value)}`
this.$router.replace(path)
}
},
locations() {
return this.selectedOwner === this.user ? this.storage_locations : this.groupStorageLocations(this.selectedOwner)
},
addLocationRoute() {
return this.selectedOwner === this.user
? '/storage-locations/new'
: `/storage-locations/new?group=${encodeHandleForUrl(this.selectedOwner)}`
: `/storage-locations/new/${encodeHandleForUrl(this.selectedOwner)}`
}
},
methods: {
@ -189,16 +203,12 @@ export default {
},
},
watch: {
selectedOwner() {
owner() {
this.fetchLocationsForOwner()
}
},
created() {
// Set before the first render so addLocationRoute/locations never see selectedOwner=null
// while user is already populated (which would misroute to the group branch).
this.selectedOwner = this.user
},
async mounted() {
this.fetchLocationsForOwner()
await this.fetchIdMap()
await this.fetchGroups()
await this.fetchGroupMemberships()
@ -211,11 +221,11 @@ export default {
font-size: 0.8rem;
}
.btn-group {
.btn-group.mt-2 {
width: 100%;
}
.btn-group .btn {
.btn-group.mt-2 .btn {
flex: 1;
}
</style>

View file

@ -33,7 +33,7 @@
Edit
</button>
<button type="submit" class="btn btn-danger"
@click="deleteStorageLocation(location).then(() => $router.push(ownerOverviewRoute(location)))">
@click="deleteStorageLocation(location).then(() => $router.push(ownerLocationOverviewRoute(location)))">
<b-icon-trash></b-icon-trash>
Delete
</button>
@ -53,7 +53,7 @@
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {mapActions, mapGetters, mapState} from "vuex";
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
import {decodeHandleFromUrl, ownerLocationOverviewRoute} from "@/router";
export default {
name: "StorageLocationDetail",
@ -93,7 +93,7 @@ export default {
}
},
methods: {
ownerOverviewRoute,
ownerLocationOverviewRoute,
...mapActions(["fetchStorageLocationByHandle", "deleteStorageLocation", "fetchGroupMemberships"]),
async loadLocation() {
this.location = await this.fetchStorageLocationByHandle({handle: this.decodedHandle, id: this.id}) || {}

View file

@ -55,7 +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";
import {decodeHandleFromUrl, ownerLocationOverviewRoute} from "@/router";
export default {
name: "StorageLocationEdit",
@ -100,7 +100,7 @@ export default {
}
},
methods: {
ownerOverviewRoute,
ownerLocationOverviewRoute,
...mapActions(["fetchStorageLocationByHandle", "updateStorageLocation", "fetchInfo",
"fetchStorageLocations", "fetchGroupStorageLocations"]),
async loadLocation() {
@ -121,7 +121,7 @@ export default {
category: this.location.category === "" ? null : this.location.category,
parent: this.location.parent === "" ? null : this.location.parent
};
this.updateStorageLocation(locationData).then(updated => this.$router.push(ownerOverviewRoute(updated)));
this.updateStorageLocation(locationData).then(updated => this.$router.push(ownerLocationOverviewRoute(updated)));
},
isChildOf(location, parentId) {
// Simple check to prevent circular references

View file

@ -62,7 +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";
import {decodeHandleFromUrl, ownerLocationOverviewRoute} from "@/router";
export default {
name: "StorageLocationNew",
@ -70,6 +70,13 @@ export default {
BaseLayout,
...BIcons
},
props: {
// Matches /storage-locations/new/:group?. See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
group: {
type: String,
default: null
}
},
data() {
return {
location: {
@ -87,7 +94,7 @@ export default {
}
},
methods: {
ownerOverviewRoute,
ownerLocationOverviewRoute,
...mapActions(['createStorageLocation', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups',
'fetchGroupMemberships', 'fetchGroupStorageLocations']),
async loadParentOptionsForOwner() {
@ -104,7 +111,7 @@ export default {
category: this.location.category === "" ? null : this.location.category,
parent: this.location.parent === "" ? null : this.location.parent
};
this.createStorageLocation(locationData).then(created => this.$router.push(ownerOverviewRoute(created)));
this.createStorageLocation(locationData).then(created => this.$router.push(ownerLocationOverviewRoute(created)));
}
},
computed: {
@ -132,8 +139,8 @@ export default {
await this.fetchStorageLocations();
await this.fetchGroups();
await this.fetchGroupMemberships();
if (this.$route.query.group) {
this.location.owner_group = decodeHandleFromUrl(this.$route.query.group)
if (this.group) {
this.location.owner_group = decodeHandleFromUrl(this.group)
}
}
}