feat(ext/popup): renderSections helper for custom-field detail rendering
This commit is contained in:
104
extension/src/popup/components/__tests__/sections-render.test.ts
Normal file
104
extension/src/popup/components/__tests__/sections-render.test.ts
Normal file
@@ -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</);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
/// copy click handlers on any rendered rows.
|
/// copy click handlers on any rendered rows.
|
||||||
|
|
||||||
import { escapeHtml } from '../popup';
|
import { escapeHtml } from '../popup';
|
||||||
|
import type { Item } from '../../shared/types';
|
||||||
|
|
||||||
export interface RowOpts {
|
export interface RowOpts {
|
||||||
label: string;
|
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 += `<div class="section-header">${escapeHtml(section.name)}</div>`;
|
||||||
|
} else {
|
||||||
|
out += `<hr class="section-separator">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleFields.forEach((field, fIdx) => {
|
||||||
|
if (field.value.kind === 'text') {
|
||||||
|
out += renderRow({ label: field.label, value: field.value.value, copyable: true });
|
||||||
|
} else {
|
||||||
|
// password or concealed
|
||||||
|
out += renderConcealedRow({
|
||||||
|
id: `${idPrefix}-s${sIdx}-f${fIdx}`,
|
||||||
|
label: field.label,
|
||||||
|
value: field.value.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -508,3 +508,20 @@ textarea {
|
|||||||
.sig-block--green { border-left-color: #3fb950; }
|
.sig-block--green { border-left-color: #3fb950; }
|
||||||
.sig-block--amber { border-left-color: #d29922; }
|
.sig-block--amber { border-left-color: #d29922; }
|
||||||
.sig-block--red { border-left-color: #f85149; }
|
.sig-block--red { border-left-color: #f85149; }
|
||||||
|
|
||||||
|
/* --- custom-section rendering (β₂ slice 1) --- */
|
||||||
|
.section-header {
|
||||||
|
margin-top: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.section-separator {
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user