diff --git a/extension/src/popup/components/__tests__/sections-render.test.ts b/extension/src/popup/components/__tests__/sections-render.test.ts new file mode 100644 index 0000000..435d26f --- /dev/null +++ b/extension/src/popup/components/__tests__/sections-render.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { renderSections } from '../fields'; +import type { Item } from '../../../shared/types'; + +function itemWithSections(sections: Item['sections']): Item { + return { + id: 'aaaaaaaaaaaaaaaa', + title: 'test', + type: 'login', + tags: [], favorite: false, + created: 0, modified: 0, + core: { type: 'login' }, + sections, + attachments: [], + field_history: {}, + }; +} + +describe('renderSections', () => { + it('returns empty string when item has no sections', () => { + const html = renderSections(itemWithSections([]), 'login'); + expect(html).toBe(''); + }); + + it('skips sections with zero fields', () => { + const html = renderSections(itemWithSections([ + { name: 'empty', fields: [] }, + ]), 'login'); + expect(html).not.toContain('empty'); + }); + + it('renders a named section header + field rows', () => { + const html = renderSections(itemWithSections([ + { + name: 'recovery codes', + fields: [ + { id: 'f0000001', label: 'code 1', kind: 'text', + value: { kind: 'text', value: 'abc-123' }, hidden_by_default: false }, + ], + }, + ]), 'login'); + expect(html).toContain('recovery codes'); + expect(html).toContain('code 1'); + expect(html).toContain('abc-123'); + }); + + it('renders concealed password fields with unique ids', () => { + const html = renderSections(itemWithSections([ + { + name: 'backup', + fields: [ + { id: 'f0000002', label: 'pin', kind: 'password', + value: { kind: 'password', value: 'hunter2' }, hidden_by_default: true }, + ], + }, + ]), 'login'); + expect(html).toContain('data-field-id="login-s0-f0"'); + expect(html).toContain('data-revealed="false"'); + expect(html).not.toMatch(/>hunter2); + }); + + it('renders anonymous section with separator not header', () => { + const html = renderSections(itemWithSections([ + { + fields: [ + { id: 'f0000003', label: 'extra', kind: 'text', + value: { kind: 'text', value: 'note' }, hidden_by_default: false }, + ], + }, + ]), 'login'); + expect(html).toContain('section-separator'); + expect(html).not.toContain('section-header'); + }); + + it('silently skips unsupported field kinds', () => { + const html = renderSections(itemWithSections([ + { + fields: [ + { id: 'f0000004', label: 'link', kind: 'url' as any, + value: { kind: 'url', value: 'https://example.com' } as any, + hidden_by_default: false }, + { id: 'f0000005', label: 'note', kind: 'text', + value: { kind: 'text', value: 'kept' }, hidden_by_default: false }, + ], + }, + ]), 'login'); + expect(html).not.toContain('https://example.com'); + expect(html).toContain('kept'); + }); + + it('renders concealed fields for the concealed kind too', () => { + const html = renderSections(itemWithSections([ + { + fields: [ + { id: 'f0000006', label: 'secret', kind: 'concealed', + value: { kind: 'concealed', value: 'shhh' }, hidden_by_default: true }, + ], + }, + ]), 'login'); + expect(html).toContain('data-field-id="login-s0-f0"'); + expect(html).toContain('secret'); + expect(html).not.toMatch(/>shhh); + }); +}); diff --git a/extension/src/popup/components/fields.ts b/extension/src/popup/components/fields.ts index 1e0694a..aee366a 100644 --- a/extension/src/popup/components/fields.ts +++ b/extension/src/popup/components/fields.ts @@ -6,6 +6,7 @@ /// copy click handlers on any rendered rows. import { escapeHtml } from '../popup'; +import type { Item } from '../../shared/types'; export interface RowOpts { label: string; @@ -117,3 +118,42 @@ export function wireFieldHandlers(scope: HTMLElement): void { }); }); } + +/// Render an Item's sections as read-only field rows. Each section with +/// ≥1 field emits a header (if named) or thin separator (if anonymous) +/// plus field rows via renderRow / renderConcealedRow. Sections with +/// 0 fields are skipped. Fields with unsupported kinds are silently +/// skipped (β₂ supports text, password, concealed only). +/// +/// `idPrefix` uniquifies concealed-row IDs (`${idPrefix}-s{i}-f{j}`) +/// so multiple typed-item detail views rendered in sequence don't +/// collide on wireFieldHandlers lookups. +export function renderSections(item: Item, idPrefix: string): string { + let out = ''; + item.sections.forEach((section, sIdx) => { + const visibleFields = section.fields.filter( + (f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed', + ); + if (visibleFields.length === 0) return; + + if (section.name) { + out += `