diff --git a/extension/src/popup/components/__tests__/org-add-form.test.ts b/extension/src/popup/components/__tests__/org-add-form.test.ts new file mode 100644 index 0000000..419264b --- /dev/null +++ b/extension/src/popup/components/__tests__/org-add-form.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../shared/state', () => ({ + getState: vi.fn(() => ({ orgContext: 'personal', orgConfigs: [], orgOffline: false })), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), +})); + +import { openOrgAddForm } from '../org-add-form'; +import { sendMessage } from '../../../shared/state'; + +const send = sendMessage as ReturnType; + +/** Fill all 7 form fields with the given config values. */ +function fillForm(config: { + orgId: string; displayName: string; hostType: string; + hostUrl: string; repoPath: string; apiToken: string; memberId: string; +}): void { + const f = (name: string) => + document.querySelector(`[name="${name}"]`)!; + (f('orgId') as HTMLInputElement).value = config.orgId; + (f('displayName') as HTMLInputElement).value = config.displayName; + (f('hostType') as HTMLSelectElement).value = config.hostType; + (f('hostUrl') as HTMLInputElement).value = config.hostUrl; + (f('repoPath') as HTMLInputElement).value = config.repoPath; + (f('apiToken') as HTMLInputElement).value = config.apiToken; + (f('memberId') as HTMLInputElement).value = config.memberId; +} + +const SAMPLE_CONFIG = { + orgId: 'acme', + displayName: 'Acme Corp', + hostType: 'gitea', + hostUrl: 'https://git.acme.com', + repoPath: 'acme/vault', + apiToken: 'tok-secret', + memberId: 'alice', +}; + +describe('org-add-form', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('renders all 7 fields when opened', () => { + openOrgAddForm(); + + expect(document.querySelector('[name="orgId"]')).toBeTruthy(); + expect(document.querySelector('[name="displayName"]')).toBeTruthy(); + expect(document.querySelector('[name="hostType"]')).toBeTruthy(); + expect(document.querySelector('[name="hostUrl"]')).toBeTruthy(); + expect(document.querySelector('[name="repoPath"]')).toBeTruthy(); + expect(document.querySelector('[name="apiToken"]')).toBeTruthy(); + expect(document.querySelector('[name="memberId"]')).toBeTruthy(); + }); + + it('apiToken field is type=password', () => { + openOrgAddForm(); + const input = document.querySelector('[name="apiToken"]')!; + expect(input.type).toBe('password'); + }); + + it('renders the submit and cancel buttons with stable ids', () => { + openOrgAddForm(); + expect(document.querySelector('#org-add-submit')).toBeTruthy(); + expect(document.querySelector('#org-add-cancel')).toBeTruthy(); + }); + + it('renders an (initially empty) error slot', () => { + openOrgAddForm(); + const slot = document.querySelector('.org-add-error'); + expect(slot).toBeTruthy(); + expect(slot!.textContent).toBe(''); + }); + + it('hostType select has gitea and github options', () => { + openOrgAddForm(); + const sel = document.querySelector('[name="hostType"]')!; + const values = Array.from(sel.options).map((o) => o.value); + expect(values).toContain('gitea'); + expect(values).toContain('github'); + }); + + it('filling fields + clicking Add sends org_add_config with all 7 values', async () => { + send.mockResolvedValueOnce({ ok: true }); + const onAdded = vi.fn(); + + openOrgAddForm(onAdded); + fillForm(SAMPLE_CONFIG); + + const form = document.querySelector('#org-add-form')!; + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + + expect(send).toHaveBeenCalledWith({ + type: 'org_add_config', + config: SAMPLE_CONFIG, + }); + }); + + it('on ok:true removes the overlay and calls onAdded with the orgId', async () => { + send.mockResolvedValueOnce({ ok: true }); + const onAdded = vi.fn(); + + openOrgAddForm(onAdded); + fillForm(SAMPLE_CONFIG); + + const form = document.querySelector('#org-add-form')!; + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + + expect(document.querySelector('#org-add-overlay')).toBeNull(); + expect(onAdded).toHaveBeenCalledWith('acme'); + }); + + it('on ok:false keeps the overlay open and shows error copy in the error slot', async () => { + send.mockResolvedValueOnce({ ok: false, error: 'org_already_configured' }); + + openOrgAddForm(); + fillForm(SAMPLE_CONFIG); + + const form = document.querySelector('#org-add-form')!; + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + + // Overlay must still be in the DOM. + expect(document.querySelector('#org-add-overlay')).toBeTruthy(); + + // The friendly copy for org_already_configured. + const slot = document.querySelector('.org-add-error')!; + expect(slot.textContent).toContain('already'); + }); + + it('Cancel button removes the overlay without calling sendMessage', async () => { + openOrgAddForm(); + + const cancel = document.querySelector('#org-add-cancel')!; + cancel.click(); + await Promise.resolve(); + + expect(document.querySelector('#org-add-overlay')).toBeNull(); + expect(send).not.toHaveBeenCalled(); + }); + + it('clicking the backdrop removes the overlay without calling sendMessage', async () => { + openOrgAddForm(); + + const overlay = document.querySelector('#org-add-overlay')!; + overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + + expect(document.querySelector('#org-add-overlay')).toBeNull(); + expect(send).not.toHaveBeenCalled(); + }); + + it('Esc key removes the overlay without calling sendMessage', async () => { + openOrgAddForm(); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await Promise.resolve(); + + expect(document.querySelector('#org-add-overlay')).toBeNull(); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/popup/components/org-add-form.ts b/extension/src/popup/components/org-add-form.ts new file mode 100644 index 0000000..e7d486a --- /dev/null +++ b/extension/src/popup/components/org-add-form.ts @@ -0,0 +1,128 @@ +// org-add-form.ts — Modal form for adding a new org config. +// +// Self-contained: appended to document.body so it works in both the popup +// and the vault sidebar without touching either surface's router. +// +// Sends `org_add_config { config: OrgConfig }` on submit and calls the +// optional `onAdded` callback with the new orgId on success. + +import type { OrgConfig } from '../../shared/types'; +import { sendMessage } from '../../shared/state'; +import { lookupErrorCopy } from '../../shared/error-copy'; + +export function openOrgAddForm(onAdded?: (orgId: string) => void): void { + // Build the overlay + form. + const overlay = document.createElement('div'); + overlay.id = 'org-add-overlay'; + overlay.style.cssText = + 'position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;' + + 'align-items:center;justify-content:center;z-index:9999;'; + + const form = document.createElement('form'); + form.id = 'org-add-form'; + form.style.cssText = + 'background:#fff;border-radius:8px;padding:1.5rem;min-width:320px;' + + 'display:flex;flex-direction:column;gap:.75rem;'; + + form.innerHTML = ` +

Add organization

+ + + + + + + + + + + + + + + +
+ +
+ + +
+ `; + + overlay.appendChild(form); + document.body.appendChild(overlay); + + const errorSlot = form.querySelector('.org-add-error') as HTMLElement; + + function close(): void { + overlay.remove(); + } + + // Cancel button. + form.querySelector('#org-add-cancel')!.addEventListener('click', close); + + // Backdrop click. + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + + // Esc key. + function onKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + close(); + document.removeEventListener('keydown', onKeydown); + } + } + document.addEventListener('keydown', onKeydown); + + // Clean up the keydown listener when the overlay is removed (cancel/success). + const observer = new MutationObserver(() => { + if (!document.body.contains(overlay)) { + document.removeEventListener('keydown', onKeydown); + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true }); + + // Submit. + form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorSlot.textContent = ''; + + const data = new FormData(form); + const config: OrgConfig = { + orgId: (data.get('orgId') as string).trim(), + displayName: (data.get('displayName') as string).trim(), + hostType: (data.get('hostType') as 'gitea' | 'github'), + hostUrl: (data.get('hostUrl') as string).trim(), + repoPath: (data.get('repoPath') as string).trim(), + apiToken: (data.get('apiToken') as string).trim(), + memberId: (data.get('memberId') as string).trim(), + }; + + const resp = await sendMessage({ type: 'org_add_config', config }); + if (resp.ok) { + close(); + onAdded?.(config.orgId); + } else { + errorSlot.textContent = lookupErrorCopy(resp.error ?? '').body; + } + }); +} diff --git a/extension/src/popup/components/org-switcher.ts b/extension/src/popup/components/org-switcher.ts index a2e5a0c..1598aaa 100644 --- a/extension/src/popup/components/org-switcher.ts +++ b/extension/src/popup/components/org-switcher.ts @@ -9,6 +9,7 @@ import type { OrgConfigSummary, OrgManifestEntry, ManifestEntry, ItemId, Collection } from '../../shared/types'; import { sendMessage, setState, navigate, getState } from '../../shared/state'; import { messageForList, normalizeOrgEntries } from '../../shared/org-context'; +import { openOrgAddForm } from './org-add-form'; // Module-level teardown (one instance per browser context; popup and vault // each have their own module scope). @@ -35,7 +36,8 @@ export async function renderOrgSwitcher(host: HTMLElement): Promise { optionsHtml += ``; } host.innerHTML = - ``; + `` + + ``; // 4. Wire the change event. const select = host.querySelector('select') as HTMLSelectElement; @@ -87,6 +89,13 @@ export async function renderOrgSwitcher(host: HTMLElement): Promise { }; select.addEventListener('change', handler); + + // 5. Wire the "Add organization" button. + const addBtn = host.querySelector('.org-add-btn') as HTMLButtonElement; + addBtn.addEventListener('click', () => { + openOrgAddForm((_orgId) => { void renderOrgSwitcher(host); }); + }); + _cleanupListener = () => select.removeEventListener('change', handler); }