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' });
|
||||
});
|
||||
});
|
||||
@@ -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 `
|
||||
<div class="disclosure" data-expanded="${expanded ? 'true' : 'false'}">
|
||||
<button type="button" class="disclosure__toggle">▾ custom sections & fields (${escapeHtml(summary)})</button>
|
||||
<div class="disclosure__body">
|
||||
${body}
|
||||
<button type="button" class="add-section">+ add section</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSectionBlock(section: Section, sIdx: number): string {
|
||||
const nameDisplay = section.name
|
||||
? `<span class="name">${escapeHtml(section.name)}</span>`
|
||||
: `<span class="name anon">(anonymous)</span>`;
|
||||
|
||||
const fieldsHtml = section.fields.map((f, fIdx) => renderEditorField(f, sIdx, fIdx)).join('');
|
||||
|
||||
return `
|
||||
<div class="section-editor" data-section-idx="${sIdx}">
|
||||
<div class="section-editor__head">
|
||||
${nameDisplay}
|
||||
<span class="actions">
|
||||
<button type="button" data-rename-section="${sIdx}">rename</button>
|
||||
<button type="button" data-remove-section="${sIdx}">× remove section</button>
|
||||
</span>
|
||||
</div>
|
||||
${fieldsHtml}
|
||||
<div class="section-editor__add">
|
||||
<button type="button" data-add-field="text" data-section-idx="${sIdx}">+ text</button>
|
||||
<button type="button" data-add-field="password" data-section-idx="${sIdx}">+ password</button>
|
||||
<button type="button" data-add-field="concealed" data-section-idx="${sIdx}">+ concealed</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="section-editor__field">
|
||||
<input type="text" data-field-label="${key}" value="${escapeHtml(field.label)}" placeholder="label">
|
||||
<input type="${inputType}" data-field-value-input="${key}" value="${escapeHtml(valueStr)}" placeholder="value">
|
||||
<button type="button" class="delete-field" data-delete-field="${key}">×</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/// 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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLInputElement>('[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<HTMLInputElement>('[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 };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user