From 99d689b9b02c5fe11be21cab2abe504a72d17d55 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 23 Apr 2026 22:26:49 -0400 Subject: [PATCH] feat(ext/popup): SecureNote view + form on shared helpers --- extension/src/popup/components/item-detail.ts | 4 +- extension/src/popup/components/item-form.ts | 4 +- .../types/__tests__/secure-note.save.test.ts | 49 ++++++ .../src/popup/components/types/secure-note.ts | 161 ++++++++++++++++++ 4 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 extension/src/popup/components/types/__tests__/secure-note.save.test.ts create mode 100644 extension/src/popup/components/types/secure-note.ts diff --git a/extension/src/popup/components/item-detail.ts b/extension/src/popup/components/item-detail.ts index 5a522d7..15e15a3 100644 --- a/extension/src/popup/components/item-detail.ts +++ b/extension/src/popup/components/item-detail.ts @@ -5,19 +5,21 @@ import { navigate } from '../popup'; import type { Item } from '../../shared/types'; import { getState } from '../popup'; import * as login from './types/login'; +import * as secureNote from './types/secure-note'; export async function renderItemDetail(app: HTMLElement): Promise { // Tear down any tickers/handlers from a previous detail render before // the next one boots up. Each type module owns its own teardown; we // call all of them since the dispatcher doesn't know which was active. login.teardown(); + secureNote.teardown(); const item = getState().selectedItem; if (!item) { navigate('list'); return; } switch (item.type) { case 'login': return login.renderDetail(app, item); - case 'secure_note': + case 'secure_note': return secureNote.renderDetail(app, item); case 'identity': case 'card': case 'key': diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index 7a4b5a1..5622f12 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -4,16 +4,18 @@ import { navigate, getState } from '../popup'; import type { Item, ItemType } from '../../shared/types'; import * as login from './types/login'; +import * as secureNote from './types/secure-note'; export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { login.teardown(); // detail-view's ticker/listener don't leak into form + secureNote.teardown(); const state = getState(); const existing = mode === 'edit' ? state.selectedItem : null; const type: ItemType = existing?.type ?? state.newType ?? 'login'; switch (type) { case 'login': return login.renderForm(app, mode, existing); - case 'secure_note': + case 'secure_note': return secureNote.renderForm(app, mode, existing); case 'identity': case 'card': case 'key': diff --git a/extension/src/popup/components/types/__tests__/secure-note.save.test.ts b/extension/src/popup/components/types/__tests__/secure-note.save.test.ts new file mode 100644 index 0000000..f915bce --- /dev/null +++ b/extension/src/popup/components/types/__tests__/secure-note.save.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../popup', async () => { + const navigate = vi.fn(); + const setState = vi.fn(); + const sendMessage = vi.fn(); + const getState = vi.fn(() => ({ + view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0, + searchQuery: '', activeGroup: null, error: null, loading: false, + capturedTabId: null, capturedUrl: '', newType: 'secure_note', + })); + const escapeHtml = (s: string) => s + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + return { navigate, setState, sendMessage, getState, escapeHtml }; +}); + +import { renderForm } from '../secure-note'; +import { sendMessage } from '../../../popup'; + +describe('SecureNote save shape', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } }); + }); + + it('builds an Item with type=secure_note and the body in core', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + + (document.getElementById('f-title') as HTMLInputElement).value = 'My Secret Note'; + (document.getElementById('f-body') as HTMLTextAreaElement).value = 'hello\nworld'; + + document.getElementById('save-btn')!.click(); + await new Promise(r => setTimeout(r, 5)); + + const calls = vi.mocked(sendMessage).mock.calls; + const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item'); + expect(addCall).toBeDefined(); + const msg = addCall![0] as { type: 'add_item'; item: any }; + expect(msg.item.title).toBe('My Secret Note'); + expect(msg.item.type).toBe('secure_note'); + expect(msg.item.core).toEqual({ type: 'secure_note', body: 'hello\nworld' }); + expect(msg.item.trashed_at).toBeUndefined(); + expect(msg.item.sections).toEqual([]); + expect(msg.item.attachments).toEqual([]); + }); +}); diff --git a/extension/src/popup/components/types/secure-note.ts b/extension/src/popup/components/types/secure-note.ts new file mode 100644 index 0000000..ace0a50 --- /dev/null +++ b/extension/src/popup/components/types/secure-note.ts @@ -0,0 +1,161 @@ +/// SecureNote: a single multiline body field. Concealed by default in the +/// detail view; the form is just a big +
+ + +
+ + `; + + document.getElementById('cancel-btn')?.addEventListener('click', () => { + setState({ error: null }); + navigate(mode === 'edit' ? 'detail' : 'list'); + }); + document.getElementById('save-btn')?.addEventListener('click', async () => { + await saveSecureNote(mode, existing); + }); + + const escHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + setState({ error: null }); + navigate(mode === 'edit' ? 'detail' : 'list'); + } + }; + document.addEventListener('keydown', escHandler); + + (document.getElementById('f-title') as HTMLInputElement | null)?.focus(); +} + +async function saveSecureNote(mode: 'add' | 'edit', existing: Item | null): Promise { + const state = getState(); + const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); + const body = (document.getElementById('f-body') as HTMLTextAreaElement).value; + if (!title) { setState({ error: 'Title is required' }); return; } + + const now = Math.floor(Date.now() / 1000); + const item: Item = { + id: existing?.id ?? '', + title, type: 'secure_note', + tags: existing?.tags ?? [], + favorite: existing?.favorite ?? false, + group: existing?.group, + notes: existing?.notes, + created: existing?.created ?? now, + modified: now, + trashed_at: undefined, + core: { type: 'secure_note', body }, + sections: existing?.sections ?? [], + attachments: existing?.attachments ?? [], + field_history: existing?.field_history ?? {}, + }; + + setState({ loading: true, error: null }); + const resp = mode === 'add' + ? await sendMessage({ type: 'add_item', item }) + : await sendMessage({ type: 'update_item', id: state.selectedId!, item }); + if (resp.ok) { + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items, selectedId: null, selectedItem: null }); + } else navigate('list'); + } else { + setState({ loading: false, error: resp.error }); + } +}