stash
This commit is contained in:
parent
35be834799
commit
8de6ae8d8b
11 changed files with 168 additions and 150 deletions
|
|
@ -86,6 +86,9 @@
|
|||
<a :href="`${itemRoute(item)}/delete`" @click.prevent="tryDeleteItem(item)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</a>
|
||||
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)">
|
||||
<b-icon-qr-code></b-icon-qr-code>
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -111,6 +114,10 @@
|
|||
<router-link :to="`${itemRoute(item)}/edit`"
|
||||
class="btn btn-primary btn-sm">Edit
|
||||
</router-link>
|
||||
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)"
|
||||
class="btn btn-secondary btn-sm">
|
||||
<b-icon-qr-code></b-icon-qr-code>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -120,7 +127,9 @@
|
|||
</div>
|
||||
<div class="card">
|
||||
<button class="btn" @click="fetchItems">Refresh</button>
|
||||
<router-link :to="`/inventory/new?group=${id}`" class="btn btn-primary">Add</router-link>
|
||||
<router-link v-if="isOwnDomain" :to="`/inventory/new?group=${group.id}`"
|
||||
class="btn btn-primary">Add
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<div class="col-12 col-xl-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">My Groups</h5>
|
||||
<h5 class="card-title">Groups</h5>
|
||||
</div>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
<th>Name</th>
|
||||
<th class="d-none d-md-table-cell" style="width:25%">Members</th>
|
||||
<th style="width: 16em">
|
||||
<a @click="fetchGroups" class="align-middle">
|
||||
<a @click="refreshGroups" class="align-middle">
|
||||
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
|
||||
Refresh
|
||||
</a>
|
||||
|
|
@ -27,37 +27,16 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="group in groups" :key="group.id">
|
||||
<tr v-for="group in allGroups" :key="group.key">
|
||||
<td>
|
||||
<router-link :to="`/groups/${encodeHandleForUrl(group.handle)}`">{{ group.handle }}</router-link>
|
||||
<router-link :to="`/groups/${encodeHandleForUrl(group.handle)}`">
|
||||
{{ group.handle }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">{{ group.members.length }}</td>
|
||||
<td class="table-action"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Other groups you're a member of</h5>
|
||||
</div>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th style="width: 16em">
|
||||
<a @click="fetchGroupMemberships" class="align-middle">
|
||||
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
|
||||
Refresh
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="membership in foreignGroupMemberships" :key="membership.id">
|
||||
<td>{{ membership.handle }}</td>
|
||||
<!-- GroupDetail.vue loads a foreign group's roster live by handle (see
|
||||
store.js's fetchGroup) - this list just avoids an N-request roundtrip
|
||||
per row, so a member count is only free for a group hosted here. -->
|
||||
<td class="d-none d-md-table-cell">{{ group.hosted ? group.memberCount : '—' }}</td>
|
||||
<td class="table-action"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
Edit
|
||||
</button>
|
||||
<button type="submit" class="btn btn-danger"
|
||||
@click="deleteInventoryItem(item).then(() => $router.push('/inventory'))">
|
||||
@click="deleteInventoryItem(item).then(() => $router.push(ownerOverviewRoute(item)))">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
Delete
|
||||
</button>
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@
|
|||
</div>
|
||||
<div class="mb-3">
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%"
|
||||
@click="updateInventoryItem(item)">Update
|
||||
@click="updateInventoryItem(item).then(updated => $router.push(ownerOverviewRoute(updated)))">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@
|
|||
</div>
|
||||
<div class="mb-3">
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%"
|
||||
@click="createInventoryItem(item).then(() => $router.push('/inventory'))">Add
|
||||
@click="createInventoryItem(item).then(created => $router.push(ownerOverviewRoute(created)))">Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue