From 1d1574e586f0a2ffd7ce6782424bad2fd419af4b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 16 May 2026 13:41:20 -0400 Subject: [PATCH] feat(frontend): tagDirectory rename and merge store actions Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/stores/tagDirectory.js | 34 ++++++++++++- frontend/test/tagDirectory.spec.js | 76 +++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 frontend/test/tagDirectory.spec.js diff --git a/frontend/src/stores/tagDirectory.js b/frontend/src/stores/tagDirectory.js index e0ffe46..4528a17 100644 --- a/frontend/src/stores/tagDirectory.js +++ b/frontend/src/stores/tagDirectory.js @@ -45,6 +45,38 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => { function setKind(k) { kind.value = k; reset() } function setQuery(text) { q.value = text; reset() } + async function rename(id, name) { + try { + const body = await api.patch(`/api/tags/${id}`, { body: { name } }) + const card = cards.value.find((c) => c.id === id) + if (card) card.name = body.name + return { tag: body } + } catch (e) { + if (e.status === 409 && e.body && e.body.target) { + const card = cards.value.find((c) => c.id === id) + return { + collision: { + sourceId: id, + sourceName: card ? card.name : '', + target: e.body.target, + sourceImageCount: e.body.source_image_count, + willAlias: e.body.will_alias + } + } + } + throw e + } + } + + async function merge(sourceId, targetId) { + const result = await api.post(`/api/tags/${sourceId}/merge`, { + body: { target_id: targetId } + }) + const idx = cards.value.findIndex((c) => c.id === sourceId) + if (idx !== -1) cards.value.splice(idx, 1) + return result + } + const hasMore = computed(() => !started || nextCursor.value !== null) const isEmpty = computed( () => !loading.value && cards.value.length === 0 && error.value === null @@ -52,6 +84,6 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => { return { cards, loading, error, kind, q, hasMore, isEmpty, - loadMore, reset, setKind, setQuery + loadMore, reset, setKind, setQuery, rename, merge } }) diff --git a/frontend/test/tagDirectory.spec.js b/frontend/test/tagDirectory.spec.js new file mode 100644 index 0000000..8fc44e2 --- /dev/null +++ b/frontend/test/tagDirectory.spec.js @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useTagDirectoryStore } from '../src/stores/tagDirectory.js' + +function stubFetch(handler) { + globalThis.fetch = vi.fn(async (url, init) => { + const { status, body } = handler(url, init) + return { + ok: status >= 200 && status < 300, + status, + statusText: String(status), + text: async () => (body == null ? '' : JSON.stringify(body)) + } + }) +} + +describe('tagDirectory store: rename / merge', () => { + beforeEach(() => setActivePinia(createPinia())) + afterEach(() => { vi.restoreAllMocks() }) + + it('rename success patches the card name in place', async () => { + const store = useTagDirectoryStore() + store.cards.push({ id: 1, name: 'old', kind: 'general' }) + stubFetch(() => ({ status: 200, body: { id: 1, name: 'new', kind: 'general' } })) + const res = await store.rename(1, 'new') + expect(res.collision).toBeUndefined() + expect(store.cards[0].name).toBe('new') + }) + + it('rename 409 resolves a collision object (does not throw)', async () => { + const store = useTagDirectoryStore() + store.cards.push({ id: 7, name: 'dupe', kind: 'general' }) + stubFetch(() => ({ + status: 409, + body: { + error: 'exists', + target: { id: 2, name: 'Canon' }, + source_image_count: 5, + will_alias: true + } + })) + const res = await store.rename(7, 'Canon') + expect(res.collision).toEqual({ + sourceId: 7, + sourceName: 'dupe', + target: { id: 2, name: 'Canon' }, + sourceImageCount: 5, + willAlias: true + }) + expect(store.cards[0].name).toBe('dupe') // unchanged + }) + + it('merge removes the source card optimistically', async () => { + const store = useTagDirectoryStore() + store.cards.push({ id: 7, name: 'dupe' }, { id: 2, name: 'Canon' }) + stubFetch(() => ({ + status: 200, + body: { + target: { id: 2, name: 'Canon', kind: 'general' }, + merged_count: 3, + alias_created: true, + source_deleted: true + } + })) + const res = await store.merge(7, 2) + expect(store.cards.map(c => c.id)).toEqual([2]) + expect(res.merged_count).toBe(3) + }) + + it('rename non-409 error rethrows', async () => { + const store = useTagDirectoryStore() + store.cards.push({ id: 1, name: 'x' }) + stubFetch(() => ({ status: 400, body: { error: 'bad' } })) + await expect(store.rename(1, '')).rejects.toThrow() + }) +})