diff --git a/extension/src/popup/components/__tests__/sections-editor.test.ts b/extension/src/popup/components/__tests__/sections-editor.test.ts new file mode 100644 index 0000000..3dfcb4d --- /dev/null +++ b/extension/src/popup/components/__tests__/sections-editor.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderSectionsEditor, generateFieldId, wireSectionsEditor } from '../fields'; +import type { Section } from '../../../shared/types'; + +describe('generateFieldId', () => { + it('returns 16 hex chars', () => { + const id = generateFieldId(); + expect(id).toMatch(/^[0-9a-f]{16}$/); + }); + it('returns unique values on successive calls', () => { + const ids = new Set(Array.from({ length: 50 }, () => generateFieldId())); + expect(ids.size).toBe(50); + }); +}); + +describe('renderSectionsEditor', () => { + it('shows the disclosure toggle with the correct count', () => { + const sections: Section[] = [ + { name: 'a', fields: [ + { id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'v' }, hidden_by_default: false }, + { id: 'f1', label: 'l', kind: 'password', value: { kind: 'password', value: 'p' }, hidden_by_default: true }, + ] }, + { fields: [ + { id: 'f2', label: 'l', kind: 'concealed', value: { kind: 'concealed', value: 'c' }, hidden_by_default: true }, + ] }, + ]; + const html = renderSectionsEditor(sections, false); + expect(html).toContain('2 sections'); + expect(html).toContain('3 fields'); + expect(html).toContain('data-expanded="false"'); + }); + + it('shows singular "1 section / 1 field" when applicable', () => { + const sections: Section[] = [ + { name: 'only', fields: [ + { id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'v' }, hidden_by_default: false }, + ] }, + ]; + const html = renderSectionsEditor(sections, false); + expect(html).toContain('1 section'); + expect(html).toContain('1 field'); + expect(html).not.toContain('1 sections'); + expect(html).not.toContain('1 fields'); + }); + + it('renders expanded body when expanded=true', () => { + const html = renderSectionsEditor([], true); + expect(html).toContain('data-expanded="true"'); + expect(html).toContain('add section'); + }); +}); + +describe('wireSectionsEditor', () => { + it('toggle click flips data-expanded', () => { + document.body.innerHTML = renderSectionsEditor([], false); + const sections: Section[] = []; + const rerender = vi.fn(); + wireSectionsEditor(document.body, sections, rerender); + const toggle = document.querySelector('.disclosure__toggle') as HTMLButtonElement; + toggle.click(); + const disclosure = document.querySelector('.disclosure') as HTMLElement; + expect(disclosure.getAttribute('data-expanded')).toBe('true'); + }); + + it('add-section click appends an empty section', () => { + const sections: Section[] = []; + document.body.innerHTML = renderSectionsEditor(sections, true); + const rerender = vi.fn(); + wireSectionsEditor(document.body, sections, rerender); + const addBtn = document.querySelector('.add-section') as HTMLButtonElement; + addBtn.click(); + expect(sections).toHaveLength(1); + expect(sections[0]).toEqual({ name: undefined, fields: [] }); + expect(rerender).toHaveBeenCalled(); + }); + + it('add-text-field click on a section pushes a text field', () => { + const sections: Section[] = [{ name: undefined, fields: [] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + const rerender = vi.fn(); + wireSectionsEditor(document.body, sections, rerender); + const addText = document.querySelector('[data-add-field="text"][data-section-idx="0"]') as HTMLButtonElement; + addText.click(); + expect(sections[0].fields).toHaveLength(1); + expect(sections[0].fields[0].kind).toBe('text'); + expect(sections[0].fields[0].value.kind).toBe('text'); + expect(sections[0].fields[0].value.value).toBe(''); + expect(sections[0].fields[0].hidden_by_default).toBe(false); + expect(sections[0].fields[0].id).toMatch(/^[0-9a-f]{16}$/); + }); + + it('add-password-field sets hidden_by_default=true', () => { + const sections: Section[] = [{ name: undefined, fields: [] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + (document.querySelector('[data-add-field="password"][data-section-idx="0"]') as HTMLButtonElement).click(); + expect(sections[0].fields[0].hidden_by_default).toBe(true); + expect(sections[0].fields[0].kind).toBe('password'); + }); + + it('remove-field button splices field', () => { + const sections: Section[] = [{ name: undefined, fields: [ + { id: 'f0', label: 'a', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false }, + { id: 'f1', label: 'b', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false }, + ] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + const deleteBtn = document.querySelector('[data-delete-field="0-0"]') as HTMLButtonElement; + deleteBtn.click(); + expect(sections[0].fields).toHaveLength(1); + expect(sections[0].fields[0].id).toBe('f1'); + }); + + it('remove-section button splices section (after confirm)', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + const sections: Section[] = [ + { name: 'to-remove', fields: [] }, + { name: 'keep', fields: [] }, + ]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + (document.querySelector('[data-remove-section="0"]') as HTMLButtonElement).click(); + expect(sections).toHaveLength(1); + expect(sections[0].name).toBe('keep'); + confirmSpy.mockRestore(); + }); + + it('remove-section cancelled confirm leaves section intact', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + const sections: Section[] = [{ name: 'stays', fields: [] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + (document.querySelector('[data-remove-section="0"]') as HTMLButtonElement).click(); + expect(sections).toHaveLength(1); + confirmSpy.mockRestore(); + }); + + it('label input change mutates section field label in place (no rerender)', () => { + const sections: Section[] = [{ name: undefined, fields: [ + { id: 'f0', label: 'old', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false }, + ] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + const rerender = vi.fn(); + wireSectionsEditor(document.body, sections, rerender); + const labelInput = document.querySelector('[data-field-label="0-0"]') as HTMLInputElement; + labelInput.value = 'new'; + labelInput.dispatchEvent(new Event('input', { bubbles: true })); + expect(sections[0].fields[0].label).toBe('new'); + expect(rerender).not.toHaveBeenCalled(); + }); + + it('value input change mutates section field value in place', () => { + const sections: Section[] = [{ name: undefined, fields: [ + { id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'old' }, hidden_by_default: false }, + ] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + const valueInput = document.querySelector('[data-field-value-input="0-0"]') as HTMLInputElement; + valueInput.value = 'new'; + valueInput.dispatchEvent(new Event('input', { bubbles: true })); + expect(sections[0].fields[0].value).toEqual({ kind: 'text', value: 'new' }); + }); +}); diff --git a/extension/src/popup/components/fields.ts b/extension/src/popup/components/fields.ts index 485243f..f3b5e16 100644 --- a/extension/src/popup/components/fields.ts +++ b/extension/src/popup/components/fields.ts @@ -6,7 +6,7 @@ /// copy click handlers on any rendered rows. import { escapeHtml } from '../popup'; -import type { Item } from '../../shared/types'; +import type { Item, Section, Field, FieldValue } from '../../shared/types'; export interface RowOpts { label: string; @@ -156,3 +156,175 @@ export function renderSections(item: Item, idPrefix: string): string { }); return out; } + +/// 16-char hex FieldId. crypto.getRandomValues for 8 bytes. +export function generateFieldId(): string { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +function makeField(kind: 'text' | 'password' | 'concealed'): Field { + const value: FieldValue = { kind, value: '' }; + return { + id: generateFieldId(), + label: 'new field', + kind, + value, + hidden_by_default: kind !== 'text', + }; +} + +/// Render the collapsible custom-sections editor. Returns HTML for the +/// disclosure toggle + body. The expanded-state is owned externally +/// (via a module-scope flag in the caller); this helper reads it as +/// the `expanded` parameter. +export function renderSectionsEditor(sections: Section[], expanded: boolean): string { + const sectionCount = sections.length; + const fieldCount = sections.reduce((sum, s) => sum + s.fields.length, 0); + const sectionLabel = sectionCount === 1 ? '1 section' : `${sectionCount} sections`; + const fieldLabel = fieldCount === 1 ? '1 field' : `${fieldCount} fields`; + const summary = sectionCount === 0 && fieldCount === 0 + ? 'no custom fields' + : `${sectionLabel}, ${fieldLabel}`; + + const body = sections.map((section, sIdx) => renderSectionBlock(section, sIdx)).join(''); + + return ` +