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:
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();
|
||||
});
|
||||
Reference in New Issue
Block a user