feat(ext/popup): renderSectionsEditor + wireSectionsEditor helpers

Adds the collapsible custom-fields editor (disclosure toggle, add/remove
sections + fields, in-place label/value mutation). Module-level helpers
only: caller owns the sectionsDraft and triggers rerender on structural
changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-24 18:10:09 -04:00
parent 553d9d7ca9
commit 3264cccb60
3 changed files with 395 additions and 1 deletions

View File

@@ -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' });
});
});