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 ` +
+ +
+ ${body} + +
+
+ `; +} + +function renderSectionBlock(section: Section, sIdx: number): string { + const nameDisplay = section.name + ? `${escapeHtml(section.name)}` + : `(anonymous)`; + + const fieldsHtml = section.fields.map((f, fIdx) => renderEditorField(f, sIdx, fIdx)).join(''); + + return ` +
+
+ ${nameDisplay} + + + + +
+ ${fieldsHtml} +
+ + + +
+
+ `; +} + +function renderEditorField(field: Field, sIdx: number, fIdx: number): string { + const valueStr = (field.value.kind === 'text' || field.value.kind === 'password' || field.value.kind === 'concealed') + ? field.value.value + : ''; + const inputType = field.kind === 'text' ? 'text' : 'password'; + const key = `${sIdx}-${fIdx}`; + return ` +
+ + + +
+ `; +} + +/// Wire click + input handlers on a rendered sections-editor. Mutations +/// happen in place on `sectionsDraft`. `rerender` is called after any +/// structural change (add/remove) to regenerate the disclosure body; +/// label/value edits do NOT trigger rerender (would steal focus). +export function wireSectionsEditor( + scope: HTMLElement, + sectionsDraft: Section[], + rerender: () => void, +): void { + const toggle = scope.querySelector('.disclosure__toggle') as HTMLButtonElement | null; + const disclosure = scope.querySelector('.disclosure') as HTMLElement | null; + toggle?.addEventListener('click', () => { + if (!disclosure) return; + const expanded = disclosure.getAttribute('data-expanded') === 'true'; + disclosure.setAttribute('data-expanded', expanded ? 'false' : 'true'); + }); + + scope.querySelector('.add-section')?.addEventListener('click', () => { + sectionsDraft.push({ name: undefined, fields: [] }); + rerender(); + }); + + scope.querySelectorAll('[data-rename-section]').forEach((btn) => { + btn.addEventListener('click', () => { + const sIdx = Number(btn.dataset.renameSection); + const current = sectionsDraft[sIdx]?.name ?? ''; + const name = window.prompt('Section name (empty for none):', current); + if (name === null) return; + const trimmed = name.trim(); + sectionsDraft[sIdx].name = trimmed || undefined; + rerender(); + }); + }); + + scope.querySelectorAll('[data-remove-section]').forEach((btn) => { + btn.addEventListener('click', () => { + const sIdx = Number(btn.dataset.removeSection); + const name = sectionsDraft[sIdx]?.name ?? '(anonymous)'; + if (!window.confirm(`Remove section "${name}" and all its fields?`)) return; + sectionsDraft.splice(sIdx, 1); + rerender(); + }); + }); + + scope.querySelectorAll('[data-add-field]').forEach((btn) => { + btn.addEventListener('click', () => { + const sIdx = Number(btn.dataset.sectionIdx); + const kind = btn.dataset.addField as 'text' | 'password' | 'concealed'; + sectionsDraft[sIdx].fields.push(makeField(kind)); + rerender(); + }); + }); + + scope.querySelectorAll('[data-delete-field]').forEach((btn) => { + btn.addEventListener('click', () => { + const [sIdxStr, fIdxStr] = (btn.dataset.deleteField ?? '0-0').split('-'); + const sIdx = Number(sIdxStr); + const fIdx = Number(fIdxStr); + sectionsDraft[sIdx].fields.splice(fIdx, 1); + rerender(); + }); + }); + + scope.querySelectorAll('[data-field-label]').forEach((input) => { + input.addEventListener('input', () => { + const [sIdxStr, fIdxStr] = (input.dataset.fieldLabel ?? '0-0').split('-'); + const sIdx = Number(sIdxStr); + const fIdx = Number(fIdxStr); + if (sectionsDraft[sIdx]?.fields[fIdx]) { + sectionsDraft[sIdx].fields[fIdx].label = input.value; + } + }); + }); + + scope.querySelectorAll('[data-field-value-input]').forEach((input) => { + input.addEventListener('input', () => { + const [sIdxStr, fIdxStr] = (input.dataset.fieldValueInput ?? '0-0').split('-'); + const sIdx = Number(sIdxStr); + const fIdx = Number(fIdxStr); + const field = sectionsDraft[sIdx]?.fields[fIdx]; + if (!field) return; + const kind = field.value.kind as 'text' | 'password' | 'concealed'; + field.value = { kind, value: input.value }; + }); + }); +} diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 5ba02a9..ff8b307 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -525,3 +525,62 @@ textarea { border: 0; border-top: 1px solid #21262d; } + +/* --- custom-section editor (β₂ slice 2) --- */ +.disclosure { + border-top: 1px solid #21262d; + margin-top: 14px; + padding-top: 10px; +} +.disclosure__toggle { + background: transparent; border: 0; color: #58a6ff; + cursor: pointer; font-size: 12px; padding: 0; + font-family: inherit; +} +.disclosure[data-expanded="false"] .disclosure__body { display: none; } + +.section-editor__head { + display: flex; align-items: baseline; gap: 8px; + margin-top: 10px; margin-bottom: 4px; + font-size: 11px; +} +.section-editor__head .name { color: #c9d1d9; font-weight: 600; } +.section-editor__head .name.anon { color: #8b949e; font-style: italic; font-weight: normal; } +.section-editor__head .actions { color: #8b949e; font-size: 10px; margin-left: auto; } +.section-editor__head .actions button { + background: transparent; border: 0; color: inherit; + cursor: pointer; padding: 0; margin-left: 8px; + font: inherit; +} +.section-editor__head .actions button:hover { color: #c9d1d9; } + +.section-editor__field { + display: grid; grid-template-columns: 120px 1fr auto; + gap: 4px; margin-bottom: 4px; font-size: 11px; +} +.section-editor__field input { + background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; + padding: 3px 6px; border-radius: 3px; font: inherit; font-size: 11px; +} +.section-editor__field .delete-field { + background: transparent; border: 0; color: #f85149; + cursor: pointer; font-size: 14px; padding: 0 4px; +} + +.section-editor__add { + display: flex; gap: 6px; margin-top: 6px; +} +.section-editor__add button { + background: transparent; border: 1px solid #30363d; color: #8b949e; + padding: 2px 10px; border-radius: 3px; cursor: pointer; + font-size: 10px; font-family: inherit; +} +.section-editor__add button:hover { color: #c9d1d9; border-color: #484f58; } + +.disclosure__body .add-section { + margin-top: 12px; background: transparent; + border: 1px dashed #30363d; color: #8b949e; + padding: 6px 10px; border-radius: 4px; cursor: pointer; + width: 100%; font-size: 11px; font-family: inherit; +} +.disclosure__body .add-section:hover { border-color: #484f58; color: #c9d1d9; }