fix: add tags optimistically on the detail page
submitTag now shows the chip immediately with the typed name and reconciles it with the server's (normalized) ref on success, rolling back on error — matching the already-optimistic tag removal instead of blocking on the round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.118.0"
|
||||
version = "0.118.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.118.0"
|
||||
version = "0.118.1"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
88
frontend/e2e/detail-tag-add.spec.ts
Normal file
88
frontend/e2e/detail-tag-add.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Adding a tag on the detail page should feel instant: the chip appears
|
||||
// optimistically while the request is in flight, and is reconciled with the
|
||||
// server's (normalized) tag on success.
|
||||
|
||||
const mangaId = 'e1111111-1111-1111-1111-111111111111';
|
||||
|
||||
async function mockDetail(page: Page) {
|
||||
await page.route('**/api/v1/auth/config', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
|
||||
);
|
||||
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
|
||||
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||
);
|
||||
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
|
||||
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||
);
|
||||
// Autocomplete suggestions endpoint — keep it empty/fast.
|
||||
await page.route('**/api/v1/tags*', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) })
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}`, (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
|
||||
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||
authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
|
||||
})
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test('adds a tag optimistically before the request resolves', async ({ page }) => {
|
||||
await mockDetail(page);
|
||||
|
||||
let release: () => void = () => {};
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(`**/api/v1/mangas/${mangaId}/tags`, async (route) => {
|
||||
await gate;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ id: 'tag1', name: 'action', added_by: 'u1' })
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/manga/${mangaId}`);
|
||||
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
|
||||
|
||||
await page.getByTestId('tag-input').fill('action');
|
||||
await page.getByTestId('tag-input').press('Enter');
|
||||
|
||||
// Optimistic: the chip shows while the POST is still gated.
|
||||
await expect(page.getByTestId('manga-tags')).toContainText('action');
|
||||
|
||||
release();
|
||||
// Still present after the server confirms (reconciled to the real tag).
|
||||
await expect(page.getByTestId('manga-tags')).toContainText('action');
|
||||
await expect(page.getByTestId('tag-chip-tag1')).toBeVisible();
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.118.0",
|
||||
"version": "0.118.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -257,18 +257,27 @@
|
||||
async function submitTag(name: string) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || !session.user || tagAddBusy) return;
|
||||
const snapshot = tags;
|
||||
// Show the chip immediately with the typed name; reconcile with the
|
||||
// server ref (which may normalize the name/id) on success. Drop any
|
||||
// existing same-name chip so a re-add doesn't duplicate.
|
||||
const tempId = `optimistic-${crypto.randomUUID()}`;
|
||||
tags = [
|
||||
...tags.filter((t) => t.name.toLowerCase() !== trimmed.toLowerCase()),
|
||||
{ id: tempId, name: trimmed, added_by: session.user.id }
|
||||
];
|
||||
tagDraft = '';
|
||||
suggestions = [];
|
||||
suggestHighlight = -1;
|
||||
tagAddBusy = true;
|
||||
tagError = null;
|
||||
try {
|
||||
const attached = await attachTag(manga.id, trimmed);
|
||||
// If the tag was already attached by someone else, the
|
||||
// server returns 200 + the existing ref — replace any
|
||||
// matching entry to keep local state coherent.
|
||||
tags = [...tags.filter((t) => t.id !== attached.id), attached];
|
||||
tagDraft = '';
|
||||
suggestions = [];
|
||||
suggestHighlight = -1;
|
||||
// Replace the placeholder (and any pre-existing entry the server
|
||||
// may have matched) with the authoritative ref.
|
||||
tags = [...tags.filter((t) => t.id !== tempId && t.id !== attached.id), attached];
|
||||
} catch (e) {
|
||||
tags = snapshot;
|
||||
tagError = (e as Error).message;
|
||||
} finally {
|
||||
tagAddBusy = false;
|
||||
|
||||
Reference in New Issue
Block a user