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:
174
extension/src/popup/components/__tests__/org-add-form.test.ts
Normal file
174
extension/src/popup/components/__tests__/org-add-form.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
128
extension/src/popup/components/org-add-form.ts
Normal file
128
extension/src/popup/components/org-add-form.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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">+</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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user