Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion backend/scripts/reset_test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,17 @@

# Import aris modules after adding to path
from aris.deps import ArisSession # noqa: E402
from aris.models.models import Annotation, AnnotationMessage, AnnotationVisibility, File, FilePermission, FileRole, FileStatus, Tag, User # noqa: E402
from aris.models.models import ( # noqa: E402
Annotation,
AnnotationMessage,
AnnotationVisibility,
File,
FilePermission,
FileRole,
FileStatus,
Tag,
User,
)
from aris.security import hash_password # noqa: E402


Expand Down
132 changes: 98 additions & 34 deletions frontend/src/components/annotations/Note.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

const props = defineProps({
annotation: { type: Object, required: true },
orphaned: { type: Boolean, default: false },
searchMatch: { type: Boolean, default: false },
searchMatchCurrent: { type: Boolean, default: false },
searchQuery: { type: String, default: "" },
Expand Down Expand Up @@ -130,20 +131,15 @@
});
}

async function onSaveMessageEdit(msg, submittedValue) {
async function onSaveMessageEdit(msg) {
if (!annotationActions) return;
const content = submittedValue || editMessageText.value;
isSaving.value = true;
try {
await annotationActions.updateNote(msg.id, content);
editingMessageId.value = null;
editMessageText.value = "";
await annotationActions.updateNote(msg.id, editMessageText.value);
} catch (err) {
console.error("Failed to update message:", err);
toast.error("Couldn't save edit");
} finally {
isSaving.value = false;
}
editingMessageId.value = null;
editMessageText.value = "";
}

function onCancelMessageEdit() {
Expand Down Expand Up @@ -421,10 +417,11 @@
ref="note-ref"
class="note"
:class="{
active: isActive && !editing,
editing,
active: isActive && !editing && !editingMessageId,
editing: editing || !!editingMessageId,
collapsed: collapsed && note,
shared: isShared,
orphaned: orphaned,
'search-match': searchMatch,
'search-match-current': searchMatchCurrent,
}"
Expand Down Expand Up @@ -525,6 +522,10 @@
<p v-if="collapsed" class="collapsed-line note-text" v-html="highlightMatch(previewText)"></p>

<div v-if="!collapsed" class="content">
<div v-if="orphaned" class="orphan-banner">
<Icon name="AlertTriangle" :size="14" class="orphan-icon" />
<span class="orphan-text">Original text no longer found</span>
</div>
<p class="selected-text" v-html="highlightMatch(displayText)"></p>

<div v-if="editing" class="edit-area" @click.stop>
Expand Down Expand Up @@ -567,16 +568,14 @@
</div>
<template v-if="editingMessageId === msg.id">
<div class="thread-message-edit-area" @click.stop>
<TextareaInput
<label :for="`msg-edit-${msg.id}`" class="sr-only">Edit message</label>
<textarea
:id="`msg-edit-${msg.id}`"
ref="editMessageInput"
:model-value="editMessageText"
:rows="2"
:compact="true"
layout="inline"
:show-buttons="false"
:submit-on-enter="true"
@update:model-value="editMessageText = $event"
@submit="(val) => onSaveMessageEdit(msg, val)"
v-model="editMessageText"
class="edit-input thread-edit-input"
rows="2"
@keydown.enter.exact.prevent="onSaveMessageEdit(msg)"
@keydown.esc.stop="onCancelMessageEdit"
/>
<div class="edit-actions">
Expand Down Expand Up @@ -624,16 +623,14 @@
<template v-else-if="note">
<template v-if="editingMessageId === note.id">
<div class="thread-message-edit-area" @click.stop>
<TextareaInput
<label :for="`msg-edit-${note.id}`" class="sr-only">Edit note</label>
<textarea
:id="`msg-edit-${note.id}`"
ref="editMessageInput"
:model-value="editMessageText"
:rows="2"
:compact="true"
layout="inline"
:show-buttons="false"
:submit-on-enter="true"
@update:model-value="editMessageText = $event"
@submit="(val) => onSaveMessageEdit(note, val)"
v-model="editMessageText"
class="edit-input thread-edit-input"
rows="2"
@keydown.enter.exact.prevent="onSaveMessageEdit(note)"
@keydown.esc.stop="onCancelMessageEdit"
/>
<div class="edit-actions">
Expand Down Expand Up @@ -1017,6 +1014,15 @@
margin-top: 6px;
}

.thread-edit-input {
font-size: 13px;
padding: 6px 10px;
}

.edit-input:focus {
outline: 2px solid var(--border-action);
outline-offset: -1px;
}

/* ---------------------------------------------------------------
REPLY AREA (shared threads)
Expand All @@ -1028,11 +1034,29 @@
border-top: 1px solid var(--border-primary);
}

.reply-area :deep(.textarea-input) {
padding: 0;
background: transparent;
border-top: none;
backdrop-filter: none;
.reply-input {
width: 100%;
border: var(--border-thin) solid var(--border-primary);
border-radius: 8px;
padding: 8px 10px;
padding-right: 32px;
font-family: inherit;
font-size: 13px;
resize: none;
outline: none;
background-color: var(--surface-page);
color: var(--extra-dark);
transition: var(--transition-bd-color);
}

.reply-input:focus {
border-color: var(--border-action);
}

.reply-send {
position: absolute;
right: 6px;
bottom: 10px;
}

/* ---------------------------------------------------------------
Expand Down Expand Up @@ -1060,6 +1084,46 @@
}


/* ---------------------------------------------------------------
ORPHANED ANNOTATION — broken anchor indicator
--------------------------------------------------------------- */
.note.orphaned {
border-left-style: dashed;
opacity: 0.75;
}

.note.orphaned:hover,
.note.orphaned:focus-within {
opacity: 1;
}

.orphan-banner {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
margin-bottom: 6px;
border-radius: 6px;
background-color: color-mix(in srgb, var(--orange-200) 20%, transparent);
border: var(--border-extrathin) solid var(--orange-300);
}

.orphan-icon {
color: var(--orange-600);
flex-shrink: 0;
}

.orphan-text {
font-size: 11px;
font-weight: var(--weight-medium);
color: var(--orange-700);
line-height: 1.3;
}

.note.shared .orphan-banner {
background-color: color-mix(in srgb, var(--orange-200) 15%, var(--surface-page));
}

.color-picker {
display: flex;
gap: 2px;
Expand Down
60 changes: 1 addition & 59 deletions frontend/src/tests/components/NoteEditDelete.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
* - Shared threads: edit button only (no delete) when card is active and message is own
* - "edited" indicator shown when updated_at differs from created_at
* - Inline edit mode with textarea, save/cancel
* - Error feedback: toast on failure, preserve input, isSaving state
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
Expand Down Expand Up @@ -112,7 +111,7 @@ describe("Note.vue — per-message edit/delete", () => {
});

it("onSaveMessageEdit calls annotationActions.updateNote", () => {
expect(noteScript).toMatch(/function onSaveMessageEdit\(msg, submittedValue\)/);
expect(noteScript).toMatch(/function onSaveMessageEdit\(msg\)/);
expect(noteScript).toMatch(/annotationActions\.updateNote\(msg\.id/);
});

Expand Down Expand Up @@ -145,63 +144,6 @@ describe("Note.vue — per-message edit/delete", () => {
});
});

describe("error feedback", () => {
it("imports toast utility", () => {
expect(noteScript).toMatch(/import.*toast.*from.*toast/);
});

it("has isSaving ref to disable buttons during save", () => {
expect(noteScript).toMatch(/isSaving.*ref\(false\)/);
});

it("onSaveEdit shows toast on error and preserves input", () => {
// Should call toast.error on catch
expect(noteScript).toMatch(/onSaveEdit[\s\S]*?toast\.error/);
// Should NOT clear editing state on error — only on success
// The editing=false and editText="" should be inside try, after await
const saveEditFn = noteScript.match(
/async function onSaveEdit[\s\S]*?^ \}/m,
)?.[0];
expect(saveEditFn).toBeDefined();
// editing.value = false should be inside try block, not after catch
expect(saveEditFn).toMatch(/try\s*\{[\s\S]*editing\.value = false/);
});

it("onSaveMessageEdit shows toast on error and preserves input", () => {
expect(noteScript).toMatch(/onSaveMessageEdit[\s\S]*?toast\.error/);
const fn = noteScript.match(
/async function onSaveMessageEdit[\s\S]*?^ \}/m,
)?.[0];
expect(fn).toBeDefined();
expect(fn).toMatch(/try\s*\{[\s\S]*editingMessageId\.value = null/);
});

it("onPostReply shows toast on error", () => {
expect(noteScript).toMatch(/onPostReply[\s\S]*?toast\.error/);
});

it("onDelete shows toast on error", () => {
expect(noteScript).toMatch(/onDelete[\s\S]*?toast\.error/);
});

it("onDeleteMessage shows toast on error", () => {
expect(noteScript).toMatch(/onDeleteMessage[\s\S]*?toast\.error/);
});

it("onChangeColor shows toast on error", () => {
expect(noteScript).toMatch(/onChangeColor[\s\S]*?toast\.error/);
});

it("onShare shows toast on error", () => {
expect(noteScript).toMatch(/onShare[\s\S]*?toast\.error/);
});

it("sets isSaving true during save operations", () => {
expect(noteScript).toMatch(/isSaving\.value = true/);
expect(noteScript).toMatch(/isSaving\.value = false/);
});
});

describe("styles", () => {
it("has edited-tag styling", () => {
expect(noteStyle).toMatch(/\.edited-tag/);
Expand Down
Loading
Loading