From 03936243e4b97d9423a622e5a2acc8baf47422da Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 15:06:25 +0300 Subject: [PATCH] fix(frontend): reflect rule-applied tags in batch edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BulkTagEditor optimistically marked only the clicked tag as common after a bulk add, so tags applied by auto-tag rules (resolved server-side) never appeared. Refetch /files/bulk/common-tags after each change and rebuild the common/partial sets from the response, so rule-applied tags and partial->common shifts show up. Backend bulk path was already correct — covered now by TestBulkTagAutoRule. Co-Authored-By: Claude Opus 4.8 --- backend/internal/integration/server_test.go | 47 +++++++++++++++++++ .../lib/components/file/BulkTagEditor.svelte | 25 ++++++---- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/backend/internal/integration/server_test.go b/backend/internal/integration/server_test.go index 80a8758..6669473 100644 --- a/backend/internal/integration/server_test.go +++ b/backend/internal/integration/server_test.go @@ -715,6 +715,53 @@ func TestRecordFileView(t *testing.T) { require.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String()) } +// TestBulkTagAutoRule verifies the bulk add path also applies then_tags. +func TestBulkTagAutoRule(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + h := setupSuite(t) + adminToken := h.login("admin", "admin") + + resp := h.doJSON("POST", "/tags", map[string]any{"name": "outdoor"}, adminToken) + require.Equal(t, http.StatusCreated, resp.StatusCode) + var outdoor map[string]any + resp.decode(t, &outdoor) + outdoorID := outdoor["id"].(string) + + resp = h.doJSON("POST", "/tags", map[string]any{"name": "nature"}, adminToken) + require.Equal(t, http.StatusCreated, resp.StatusCode) + var nature map[string]any + resp.decode(t, &nature) + natureID := nature["id"].(string) + + resp = h.doJSON("POST", "/tags/"+outdoorID+"/rules", map[string]any{"then_tag_id": natureID}, adminToken) + require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String()) + + file := h.uploadJPEG(adminToken, "park.jpg") + fileID := file["id"].(string) + + // Bulk-add only "outdoor" to the file. + resp = h.doJSON("POST", "/files/bulk/tags", map[string]any{ + "file_ids": []string{fileID}, + "action": "add", + "tag_ids": []string{outdoorID}, + }, adminToken) + require.Equal(t, http.StatusOK, resp.StatusCode, resp.String()) + + // The auto-applied "nature" should be on the file too. + resp = h.doJSON("GET", "/files/"+fileID+"/tags", nil, adminToken) + require.Equal(t, http.StatusOK, resp.StatusCode) + var tagsResp []any + resp.decode(t, &tagsResp) + names := make([]string, 0, len(tagsResp)) + for _, tg := range tagsResp { + names = append(names, tg.(map[string]any)["name"].(string)) + } + assert.ElementsMatch(t, []string{"outdoor", "nature"}, names) +} + // --------------------------------------------------------------------------- // Security regression tests // --------------------------------------------------------------------------- diff --git a/frontend/src/lib/components/file/BulkTagEditor.svelte b/frontend/src/lib/components/file/BulkTagEditor.svelte index 8c8b994..38d4a89 100644 --- a/frontend/src/lib/components/file/BulkTagEditor.svelte +++ b/frontend/src/lib/components/file/BulkTagEditor.svelte @@ -71,14 +71,24 @@ return color ? `background-color: #${color}` : ''; } + // Refetch which tags are common/partial across the selection. Run after any + // bulk change so rule-applied tags (and partial→common shifts) show up — the + // server applies auto-tag rules, so we can't infer the result locally. + async function refreshCommon() { + const res = await api.post<{ common_tag_ids: string[]; partial_tag_ids: string[] }>( + '/files/bulk/common-tags', + { file_ids: fileIds } + ); + commonIds = new Set(res.common_tag_ids ?? []); + partialIds = new Set(res.partial_tag_ids ?? []); + } + async function add(tagId: string) { if (busy) return; busy = true; try { await api.post('/files/bulk/tags', { file_ids: fileIds, action: 'add', tag_ids: [tagId] }); - commonIds = new Set([...commonIds, tagId]); - partialIds.delete(tagId); - partialIds = new Set(partialIds); + await refreshCommon(); } finally { busy = false; } @@ -90,9 +100,7 @@ busy = true; try { await api.post('/files/bulk/tags', { file_ids: fileIds, action: 'add', tag_ids: [tagId] }); - commonIds = new Set([...commonIds, tagId]); - partialIds.delete(tagId); - partialIds = new Set(partialIds); + await refreshCommon(); } finally { busy = false; } @@ -103,10 +111,7 @@ busy = true; try { await api.post('/files/bulk/tags', { file_ids: fileIds, action: 'remove', tag_ids: [tagId] }); - commonIds.delete(tagId); - partialIds.delete(tagId); - commonIds = new Set(commonIds); - partialIds = new Set(partialIds); + await refreshCommon(); } finally { busy = false; }