From a6a8b66828ad8f954df9aa87e217f7fb073faa14 Mon Sep 17 00:00:00 2001 From: MuRong Date: Sun, 1 Nov 2026 19:53:03 +0800 Subject: [PATCH] fix(content): allow adding rules over HTTP --- web/src/pages/Content.test.tsx | 60 ++++++++++++++++++++++++++++++++-- web/src/pages/Content.tsx | 11 ++++--- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/web/src/pages/Content.test.tsx b/web/src/pages/Content.test.tsx index d60f7618..9beb793e 100644 --- a/web/src/pages/Content.test.tsx +++ b/web/src/pages/Content.test.tsx @@ -1,4 +1,10 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClientProvider } from '@tanstack/react-query' import { expect, it, vi } from 'vitest' @@ -7,7 +13,8 @@ import { createQueryClient } from '@/lib/query' import { authKey } from '@/lib/auth' import { authenticated } from '@/test/fixtures' -it('saves rules without caching patterns and tests a sample without retaining it', async () => { +it('adds, tests and saves rules without randomUUID or retaining patterns and samples', async () => { + vi.stubGlobal('crypto', {}) const client = createQueryClient() client.setQueryData(authKey, authenticated) const saved: unknown[] = [] @@ -98,6 +105,55 @@ it('saves rules without caching patterns and tests a sample without retaining it expect(screen.queryByDisplayValue('SYNTHETIC_SECRET')).toBeNull() }) +it('keeps drafts independent when removing and adding rules', async () => { + const client = createQueryClient() + client.setQueryData(authKey, authenticated) + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ mode: 'off', revision: 0, rules: [] })), + ), + ), + ) + render( + + + , + ) + const user = userEvent.setup() + await user.click( + await screen.findByRole('button', { name: 'Configure rules' }), + ) + for (const name of ['Discarded draft', 'Retained draft']) { + await user.click(screen.getByRole('button', { name: 'Add rule' })) + const draft = within(screen.getByRole('group', { name: 'New rule' })) + await user.type(draft.getByLabelText('Rule name'), name) + await user.type(draft.getByLabelText('Matching content'), name) + } + const retained = within(screen.getByRole('group', { name: 'Retained draft' })) + await user.type(retained.getByLabelText('Test sample'), 'SYNTHETIC_SAMPLE') + await user.click( + within(screen.getByRole('group', { name: 'Discarded draft' })).getByRole( + 'button', + { name: 'Remove rule' }, + ), + ) + await user.click(screen.getByRole('button', { name: 'Add rule' })) + const replacement = within(screen.getByRole('group', { name: 'New rule' })) + await user.type(replacement.getByLabelText('Rule name'), 'Replacement draft') + await user.type(replacement.getByLabelText('Matching content'), 'REPLACEMENT') + expect( + (retained.getByLabelText('Matching content') as HTMLInputElement).value, + ).toBe('Retained draft') + expect( + (retained.getByLabelText('Test sample') as HTMLTextAreaElement).value, + ).toBe('SYNTHETIC_SAMPLE') + await user.click(replacement.getByRole('button', { name: 'Remove rule' })) + expect(screen.getAllByLabelText('Rule name')).toHaveLength(1) + expect(screen.getByRole('group', { name: 'Retained draft' })).toBeTruthy() +}) + it('preserves a saved pattern on metadata edits and reports stale configuration', async () => { const client = createQueryClient() client.setQueryData(authKey, authenticated) diff --git a/web/src/pages/Content.tsx b/web/src/pages/Content.tsx index 19805b87..a3e0a7e8 100644 --- a/web/src/pages/Content.tsx +++ b/web/src/pages/Content.tsx @@ -1,4 +1,4 @@ -import { useId, useState, type FormEvent } from 'react' +import { useId, useRef, useState, type FormEvent } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { @@ -389,6 +389,8 @@ function ContentEditor({ const [rules, setRules] = useState(() => value.rules.map((rule) => ({ ...rule, draftID: rule.id, pattern: '' })), ) + // Local, monotonic keys work over HTTP and never reuse deleted rows' state. + const nextRuleID = useRef(0) const save = useAdminMutation({ userID, mutationFn: saveContent, @@ -447,11 +449,12 @@ function ContentEditor({ type="button" variant="outline" disabled={rules.length >= 50} - onClick={() => + onClick={() => { + const draftID = `new-${nextRuleID.current++}` setRules((current) => [ ...current, { - draftID: crypto.randomUUID(), + draftID, id: '', name: '', kind: 'text', @@ -459,7 +462,7 @@ function ContentEditor({ pattern: '', }, ]) - } + }} >