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: '',
},
])
- }
+ }}
>
{t('contentAdd')}