feat: sharing tests, property tests, tag-share UI, inline errors, route fix
- Add 10 sharing endpoint tests (share/unshare memory, tag shares, shared-with-me,
my-shares, recall with shared, update permission checks)
- Add hypothesis property-based tests for model validation (roundtrip, bounds,
enum, sort_by, limit)
- Tighten model validation: sort_by Literal, limit ge=1/le=500, tags/keywords max_length
- Fix dashboard shared_with_me stat to include tag-based shares
- Add tag-sharing management UI (share/remove tags, user typeahead)
- Replace alert() with inline error messages
- Fix route ordering bug: share-tag routes before {memory_id} parameterized routes
This commit is contained in:
parent
688be268b9
commit
c130bcff33
9 changed files with 503 additions and 43 deletions
|
|
@ -158,6 +158,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<template x-if="errorMsg">
|
||||
<p class="text-sm mb-4" style="color: var(--danger)" x-text="errorMsg"></p>
|
||||
</template>
|
||||
|
||||
<!-- My Memories -->
|
||||
<div class="section-title">My Memories</div>
|
||||
<div class="space-y-2 mb-6">
|
||||
|
|
@ -325,6 +330,64 @@
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tag Shares -->
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="section-title" style="margin-bottom:0">Tag Shares</div>
|
||||
<button class="btn btn-ghost text-xs" @click="showTagShareForm = !showTagShareForm">
|
||||
<span x-show="!showTagShareForm">+ Share Tag</span>
|
||||
<span x-show="showTagShareForm">Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tag Share Form -->
|
||||
<div x-show="showTagShareForm" class="add-form-container mb-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
|
||||
<div>
|
||||
<label class="section-title">Tag</label>
|
||||
<input type="text" class="input-field" x-model="tagShareForm.tag" placeholder="tag name">
|
||||
</div>
|
||||
<div class="relative">
|
||||
<label class="section-title">User</label>
|
||||
<input type="text" class="input-field" x-model="tagShareForm.user" placeholder="Username" @input="tagShareUserFilter = tagShareForm.user" @focus="tagShareUserFilter = tagShareForm.user">
|
||||
<div x-show="tagShareForm.user && filteredTagShareUsers.length > 0" class="typeahead-dropdown">
|
||||
<template x-for="u in filteredTagShareUsers" :key="u">
|
||||
<div class="typeahead-item" @click="selectTagShareUser(u)" x-text="u"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="section-title">Permission</label>
|
||||
<select class="input-field" x-model="tagShareForm.permission">
|
||||
<option value="read">Read</option>
|
||||
<option value="write">Write</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary text-sm" @click="addTagShare()" :disabled="!tagShareForm.tag.trim() || !tagShareForm.user.trim()">Share Tag</button>
|
||||
</div>
|
||||
|
||||
<!-- Existing Tag Shares -->
|
||||
<template x-if="tagShares.length > 0">
|
||||
<div class="space-y-2">
|
||||
<template x-for="ts in tagShares" :key="ts.tag + ts.shared_with">
|
||||
<div class="memory-card flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="tag-pill" x-text="ts.tag"></span>
|
||||
<span class="text-sm" style="color: var(--text-secondary)">shared with</span>
|
||||
<span class="text-sm" style="color: var(--text-primary)" x-text="ts.shared_with"></span>
|
||||
<span class="badge" :class="ts.permission === 'write' ? 'badge-write' : 'badge-read'" x-text="ts.permission"></span>
|
||||
</div>
|
||||
<button class="btn text-xs" style="color: var(--danger)" @click="removeTagShare(ts.tag, ts.shared_with)">Remove</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="tagShares.length === 0 && !showTagShareForm">
|
||||
<p class="text-sm" style="color: var(--text-muted)">No tag shares yet</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Search -->
|
||||
|
|
|
|||
|
|
@ -63,8 +63,12 @@ const api = {
|
|||
return this.fetch(url, { method: 'PUT', body: JSON.stringify(data) });
|
||||
},
|
||||
|
||||
del(url) {
|
||||
return this.fetch(url, { method: 'DELETE' });
|
||||
del(url, data) {
|
||||
const options = { method: 'DELETE' };
|
||||
if (data) {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
return this.fetch(url, options);
|
||||
},
|
||||
|
||||
async login(key) {
|
||||
|
|
|
|||
|
|
@ -23,9 +23,15 @@ function memoriesBrowser() {
|
|||
showShareForm: null,
|
||||
shareForm: { user: '', permission: 'read' },
|
||||
shareUserFilter: '',
|
||||
// Tag shares
|
||||
tagShares: [],
|
||||
showTagShareForm: false,
|
||||
tagShareForm: { tag: '', user: '', permission: 'read' },
|
||||
tagShareUserFilter: '',
|
||||
errorMsg: '',
|
||||
|
||||
async init() {
|
||||
await Promise.all([this.loadMemories(), this.loadShared(), this.loadCategories(), this.loadUsers()]);
|
||||
await Promise.all([this.loadMemories(), this.loadShared(), this.loadCategories(), this.loadUsers(), this.loadTagShares()]);
|
||||
},
|
||||
|
||||
async loadCategories() {
|
||||
|
|
@ -100,6 +106,7 @@ function memoriesBrowser() {
|
|||
},
|
||||
|
||||
async saveEdit(id) {
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
await api.put(`/api/memories/${id}`, {
|
||||
content: this.editForm.content,
|
||||
|
|
@ -110,7 +117,7 @@ function memoriesBrowser() {
|
|||
this.offset = 0;
|
||||
await this.loadMemories();
|
||||
} catch (e) {
|
||||
alert('Save failed: ' + e.message);
|
||||
this.errorMsg = 'Save failed: ' + e.message;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -119,13 +126,14 @@ function memoriesBrowser() {
|
|||
},
|
||||
|
||||
async doDelete(id) {
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
await api.del(`/api/memories/${id}`);
|
||||
this.deleteConfirm = null;
|
||||
this.offset = 0;
|
||||
await this.loadMemories();
|
||||
} catch (e) {
|
||||
alert('Delete failed: ' + e.message);
|
||||
this.errorMsg = 'Delete failed: ' + e.message;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -153,6 +161,7 @@ function memoriesBrowser() {
|
|||
|
||||
async addMemory() {
|
||||
if (!this.addForm.content.trim()) return;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
await api.post('/api/memories', {
|
||||
content: this.addForm.content,
|
||||
|
|
@ -164,7 +173,7 @@ function memoriesBrowser() {
|
|||
this.offset = 0;
|
||||
await Promise.all([this.loadMemories(), this.loadCategories()]);
|
||||
} catch (e) {
|
||||
alert('Failed to add memory: ' + e.message);
|
||||
this.errorMsg = 'Failed to add memory: ' + e.message;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -191,6 +200,7 @@ function memoriesBrowser() {
|
|||
|
||||
async shareMemory(memId) {
|
||||
if (!this.shareForm.user.trim()) return;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
await api.post(`/api/memories/${memId}/share`, {
|
||||
shared_with: this.shareForm.user,
|
||||
|
|
@ -203,7 +213,51 @@ function memoriesBrowser() {
|
|||
await this.toggleShares(memId);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Share failed: ' + e.message);
|
||||
this.errorMsg = 'Share failed: ' + e.message;
|
||||
}
|
||||
},
|
||||
|
||||
async loadTagShares() {
|
||||
try {
|
||||
const data = await api.get('/api/memories/my-shares');
|
||||
this.tagShares = data.tag_shares || [];
|
||||
} catch (e) { console.error('Failed to load tag shares:', e); }
|
||||
},
|
||||
|
||||
get filteredTagShareUsers() {
|
||||
const q = this.tagShareForm.user.toLowerCase();
|
||||
if (!q) return this.allUsers.slice(0, 10);
|
||||
return this.allUsers.filter(u => u.toLowerCase().includes(q));
|
||||
},
|
||||
|
||||
selectTagShareUser(user) {
|
||||
this.tagShareForm.user = user;
|
||||
},
|
||||
|
||||
async addTagShare() {
|
||||
if (!this.tagShareForm.tag.trim() || !this.tagShareForm.user.trim()) return;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
await api.post('/api/memories/share-tag', {
|
||||
tag: this.tagShareForm.tag,
|
||||
shared_with: this.tagShareForm.user,
|
||||
permission: this.tagShareForm.permission,
|
||||
});
|
||||
this.tagShareForm = { tag: '', user: '', permission: 'read' };
|
||||
this.showTagShareForm = false;
|
||||
await this.loadTagShares();
|
||||
} catch (e) {
|
||||
this.errorMsg = 'Failed to share tag: ' + e.message;
|
||||
}
|
||||
},
|
||||
|
||||
async removeTagShare(tag, sharedWith) {
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
await api.del('/api/memories/share-tag', { tag, shared_with: sharedWith });
|
||||
await this.loadTagShares();
|
||||
} catch (e) {
|
||||
this.errorMsg = 'Failed to remove tag share: ' + e.message;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue