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 type { OrgConfigSummary, OrgManifestEntry, ManifestEntry, ItemId, Collection } from '../../shared/types';
|
||||||
import { sendMessage, setState, navigate, getState } from '../../shared/state';
|
import { sendMessage, setState, navigate, getState } from '../../shared/state';
|
||||||
import { messageForList, normalizeOrgEntries } from '../../shared/org-context';
|
import { messageForList, normalizeOrgEntries } from '../../shared/org-context';
|
||||||
|
import { openOrgAddForm } from './org-add-form';
|
||||||
|
|
||||||
// Module-level teardown (one instance per browser context; popup and vault
|
// Module-level teardown (one instance per browser context; popup and vault
|
||||||
// each have their own module scope).
|
// 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>`;
|
optionsHtml += `<option value="${org.orgId}"${sel}>${org.displayName}</option>`;
|
||||||
}
|
}
|
||||||
host.innerHTML =
|
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.
|
// 4. Wire the change event.
|
||||||
const select = host.querySelector('select') as HTMLSelectElement;
|
const select = host.querySelector('select') as HTMLSelectElement;
|
||||||
@@ -87,6 +89,13 @@ export async function renderOrgSwitcher(host: HTMLElement): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
select.addEventListener('change', handler);
|
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);
|
_cleanupListener = () => select.removeEventListener('change', handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user