feat(ext): add-org form + switcher trigger (org_add_config UI)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3b8DA2mXmLWkd4zDbHXHg
This commit is contained in:
adlee-was-taken
2026-06-27 12:28:24 -04:00
parent 11e3aa41d1
commit 59b0fcd985
3 changed files with 312 additions and 1 deletions

View File

@@ -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<typeof vi.fn>;
/** 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<HTMLInputElement | HTMLSelectElement>(`[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<HTMLInputElement>('[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<HTMLSelectElement>('[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<HTMLFormElement>('#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<HTMLFormElement>('#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<HTMLFormElement>('#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<HTMLButtonElement>('#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<HTMLElement>('#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();
});
});

View File

@@ -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 = `
<h2 style="margin:0 0 .5rem">Add organization</h2>
<label>Org ID
<input name="orgId" type="text" required autocomplete="off" />
</label>
<label>Display name
<input name="displayName" type="text" required autocomplete="off" />
</label>
<label>Host type
<select name="hostType">
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
</select>
</label>
<label>Host URL
<input name="hostUrl" type="text" required autocomplete="off" placeholder="https://git.example.com" />
</label>
<label>Repo path
<input name="repoPath" type="text" required autocomplete="off" placeholder="org/vault" />
</label>
<label>API token
<input name="apiToken" type="password" required autocomplete="off" />
</label>
<label>Member ID
<input name="memberId" type="text" required autocomplete="off" />
</label>
<div class="org-add-error" style="color:#c00;min-height:1.2em;font-size:.875rem;"></div>
<div style="display:flex;gap:.5rem;justify-content:flex-end;">
<button type="button" id="org-add-cancel">Cancel</button>
<button type="submit" id="org-add-submit">Add</button>
</div>
`;
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;
}
});
}

View File

@@ -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<void> {
optionsHtml += `<option value="${org.orgId}"${sel}>${org.displayName}</option>`;
}
host.innerHTML =
`<select class="org-switcher" aria-label="Vault context">${optionsHtml}</select>`;
`<select class="org-switcher" aria-label="Vault context">${optionsHtml}</select>` +
`<button class="org-add-btn" type="button" title="Add organization" aria-label="Add organization">&#xFF0B;</button>`;
// 4. Wire the change event.
const select = host.querySelector('select') as HTMLSelectElement;
@@ -87,6 +89,13 @@ export async function renderOrgSwitcher(host: HTMLElement): Promise<void> {
};
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);
}