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:
163
extension/src/popup/components/__tests__/sections-editor.test.ts
Normal file
163
extension/src/popup/components/__tests__/sections-editor.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user