feat: online verification, node removals, and knowledge browser
CI / quality (push) Canceled after 0s
CI / quality (push) Canceled after 0s
Adds claim-bound online evidence records and citation verification scripts, node removal transport/adapter, retrieval benchmark and owner-review tooling, GraphCanvas/SourceDetail knowledge browser UI, and the ingest handoff helper. Bumps the agents/gewu pack to the online-verification branch.
This commit is contained in:
1 parent
a3156f4e08
commit
78033bb830
83 files changed
+13476
-1051
No files matched your search
@@ -77,7 +77,7 @@ function AccountsSectionComponent({ csrf, projects, onMessage, onError }: {
|
||||
<AdminDialog open={addOpen} title="Add user" onClose={() => setAddOpen(false)} returnFocusRef={addButtonRef}>
|
||||
<p className="muted-copy">Users need a password with at least 12 characters. They can change it after signing in.</p>
|
||||
<form className="admin-form connect-form" onSubmit={create}>
|
||||
<label>Username<input autoComplete="off" value={username} onChange={event => setUsername(event.target.value)} required maxLength={64} pattern="[a-z0-9][a-z0-9._\\-]*" placeholder="vasily" /></label>
|
||||
<label>New username<input autoComplete="off" value={username} onChange={event => setUsername(event.target.value)} required maxLength={64} pattern="[a-z0-9][a-z0-9._\\-]*" placeholder="vasily" /></label>
|
||||
<label>Initial password<input type="password" autoComplete="new-password" value={password} onChange={event => setPassword(event.target.value)} required minLength={12} maxLength={1024} /></label>
|
||||
<label>Account role<select value={role} onChange={event => { setRole(event.target.value); setGrants([]); }}><option value="editor">Editor</option><option value="viewer">Viewer</option><option value="admin">Administrator</option></select></label>
|
||||
<fieldset><legend>Project access</legend>{role === 'admin' ? <p>All projects</p> : projects.map(project => <label className="admin-check" key={project.project_id}><input type="checkbox" checked={grants.includes(project.project_id)} onChange={event => setGrants(event.target.checked ? [...grants, project.project_id] : grants.filter(id => id !== project.project_id))} />{project.title}</label>)}</fieldset>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
|
||||
export default function AccountLogin({ onLogin, busy, credentialLabel, onError }: {
|
||||
export default function AccountLogin({ onLogin, busy, credentialLabel, onError, submitLabel = 'Sign in' }: {
|
||||
onLogin: (session: { csrf_token: string }, passwordAccount: boolean, restored?: boolean) => Promise<void>;
|
||||
busy: boolean;
|
||||
credentialLabel: string;
|
||||
onError: (message: string) => void;
|
||||
submitLabel?: string;
|
||||
}) {
|
||||
const [checking, setChecking] = useState(true);
|
||||
const callbacks = useRef({ onLogin, onError });
|
||||
@@ -55,11 +56,11 @@ export default function AccountLogin({ onLogin, busy, credentialLabel, onError }
|
||||
|
||||
return <div>
|
||||
<form className="connect-form" onSubmit={submit}>
|
||||
{legacy ? <><label htmlFor="login-credential">{credentialLabel}</label><input id="login-credential" type="password" autoComplete="off" value={token} onChange={e => setToken(e.target.value)} required /></> : <>
|
||||
{legacy ? <><label htmlFor="login-credential">{credentialLabel}</label><input id="login-credential" type="password" autoComplete="off" value={token} onChange={e => setToken(e.target.value)} required /></> : <>\
|
||||
<label htmlFor="login-username">Username</label><input id="login-username" autoComplete="username" value={username} onChange={e => setUsername(e.target.value)} required maxLength={64} pattern="[a-z0-9][a-z0-9._\-]*" />
|
||||
<label htmlFor="login-password">Password</label><input id="login-password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required maxLength={1024} />
|
||||
</>}
|
||||
<button disabled={busy || working}>{working ? 'Signing in…' : legacy ? 'Connect' : 'Sign in'}</button>
|
||||
<button disabled={busy || working}>{working ? 'Signing in…' : legacy ? 'Connect' : submitLabel}</button>
|
||||
</form>
|
||||
<button className="text-button" disabled={busy || working} onClick={() => { setLegacy(!legacy); setPassword(''); setToken(''); }}>{legacy ? 'Use username and password' : 'Use an existing credential'}</button>
|
||||
</div>;
|
||||
|
||||
@@ -1,69 +1,70 @@
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import Admin from './Admin';
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import Admin from "./Admin";
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
it('signs in with a password, creates an owned agent key and changes the password', async () => {
|
||||
it("signs in with a password, creates an owned agent key and changes the password", async () => {
|
||||
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
|
||||
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
|
||||
let result: unknown = [];
|
||||
if (path === '/v1/users/session') result = { csrf_token: 'user-csrf' };
|
||||
if (path === '/v1/projects') result = ['pilot'];
|
||||
if (path === '/v1/me/agent-credentials' && init?.method === 'POST') result = { credential_id: 'agent:codex:fixture', token: 'agent-fixture-secret' };
|
||||
if (path === '/v1/me/password') result = { status: 'password_changed' };
|
||||
if (path === "/v1/users/session") result = { csrf_token: "user-csrf" };
|
||||
if (path === "/v1/projects") result = ["pilot"];
|
||||
if (path === "/v1/me/agent-credentials" && init?.method === "POST") result = { credential_id: "agent:codex:fixture", token: "agent-fixture-secret" };
|
||||
if (path === "/v1/me/password") result = { status: "password_changed" };
|
||||
return { ok: true, json: async () => result };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const user = userEvent.setup();
|
||||
render(<Admin personal />);
|
||||
await user.type(await screen.findByLabelText('Username'), 'alice');
|
||||
await user.type(screen.getByLabelText('Password'), 'initial-password-fixture');
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
await screen.findByRole('heading', { name: 'Add an agent' });
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/users/session', expect.objectContaining({
|
||||
method: 'POST', credentials: 'same-origin', body: JSON.stringify({ username: 'alice', password: 'initial-password-fixture' }),
|
||||
await user.type(await screen.findByLabelText("Username"), "alice");
|
||||
await user.type(screen.getByLabelText("Password"), "initial-password-fixture");
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Add agent key" }));
|
||||
await screen.findByRole("heading", { name: "Add agent key" });
|
||||
expect(fetchMock).toHaveBeenCalledWith("/v1/users/session", expect.objectContaining({
|
||||
method: "POST", credentials: "same-origin", body: JSON.stringify({ username: "alice", password: "initial-password-fixture" }),
|
||||
}));
|
||||
expect(screen.queryByRole('heading', { name: 'User accounts' })).toBeNull();
|
||||
await user.type(screen.getByLabelText('Agent name'), 'codex-laptop');
|
||||
await user.click(screen.getByLabelText('pilot'));
|
||||
await user.click(screen.getByRole('button', { name: 'Create agent key' }));
|
||||
expect(await screen.findByLabelText('New bearer credential')).toHaveValue('agent-fixture-secret');
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/me/agent-credentials', expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'X-GEWU-CSRF': 'user-csrf' }),
|
||||
body: JSON.stringify({ name: 'codex-laptop', project_ids: ['pilot'] }),
|
||||
expect(screen.queryByRole("heading", { name: "User accounts" })).toBeNull();
|
||||
await user.type(screen.getByLabelText("Agent name"), "codex-laptop");
|
||||
await user.click(screen.getByLabelText("pilot"));
|
||||
await user.click(screen.getByRole("button", { name: "Create agent key" }));
|
||||
expect(await screen.findByLabelText("New bearer credential")).toHaveValue("agent-fixture-secret");
|
||||
expect(fetchMock).toHaveBeenCalledWith("/v1/me/agent-credentials", expect.objectContaining({
|
||||
headers: expect.objectContaining({ "X-GEWU-CSRF": "user-csrf" }),
|
||||
body: JSON.stringify({ name: "codex-laptop", project_ids: ["pilot"] }),
|
||||
}));
|
||||
await user.type(screen.getByLabelText('Current password'), 'initial-password-fixture');
|
||||
await user.type(screen.getByLabelText('New password'), 'changed-password-fixture');
|
||||
await user.type(screen.getByLabelText('Confirm new password'), 'changed-password-fixture');
|
||||
await user.click(screen.getByRole('button', { name: 'Change password' }));
|
||||
expect(await screen.findByLabelText('Username')).toBeVisible();
|
||||
expect(screen.queryByLabelText('New bearer credential')).toBeNull();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Password changed.');
|
||||
await user.type(screen.getByLabelText("Current password"), "initial-password-fixture");
|
||||
await user.type(screen.getByLabelText("New password"), "changed-password-fixture");
|
||||
await user.type(screen.getByLabelText("Confirm new password"), "changed-password-fixture");
|
||||
await user.click(screen.getByRole("button", { name: "Change password" }));
|
||||
expect(await screen.findByLabelText("Username")).toBeVisible();
|
||||
expect(screen.queryByLabelText("New bearer credential")).toBeNull();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Password changed.");
|
||||
});
|
||||
|
||||
it('lets the existing administrator create the first password account', async () => {
|
||||
it("lets the existing administrator create the first password account", async () => {
|
||||
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
|
||||
const result = path === '/v1/session' ? { csrf_token: 'operator-csrf' } : [];
|
||||
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
|
||||
const result = path === "/v1/session" ? { csrf_token: "operator-csrf" } : [];
|
||||
return { ok: true, json: async () => result };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const user = userEvent.setup();
|
||||
render(<Admin />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
|
||||
await user.type(screen.getByLabelText('GEWU administrator credential'), 'operator-fixture');
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await user.click(await screen.findByRole('tab', { name: 'Users' }));
|
||||
const accounts = await screen.findByRole('region', { name: 'User accounts' });
|
||||
await user.type(within(accounts).getByLabelText('New username'), 'vasily');
|
||||
await user.type(within(accounts).getByLabelText('Initial password'), 'chosen-password-fixture');
|
||||
await user.selectOptions(within(accounts).getByLabelText('Account role'), 'admin');
|
||||
await user.click(within(accounts).getByRole('button', { name: 'Create user' }));
|
||||
await user.click(await screen.findByRole("button", { name: "Use an existing credential" }));
|
||||
await user.type(screen.getByLabelText("GEWU administrator credential"), "operator-fixture");
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
await user.click(await screen.findByRole("tab", { name: "Users" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Add user" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Add user" });
|
||||
await user.type(within(dialog).getByLabelText("New username"), "vasily");
|
||||
await user.type(within(dialog).getByLabelText("Initial password"), "chosen-password-fixture");
|
||||
await user.selectOptions(within(dialog).getByLabelText("Account role"), "admin");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Add user" }));
|
||||
await screen.findByText(/Account created for vasily/);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/users', expect.objectContaining({
|
||||
body: JSON.stringify({ username: 'vasily', password: 'chosen-password-fixture', role: 'admin', project_ids: [] }),
|
||||
expect(fetchMock).toHaveBeenCalledWith("/v1/admin/users", expect.objectContaining({
|
||||
body: JSON.stringify({ username: "vasily", password: "chosen-password-fixture", role: "admin", project_ids: [] }),
|
||||
}));
|
||||
expect(within(accounts).getByLabelText('Initial password')).toHaveValue('');
|
||||
});
|
||||
@@ -1,133 +1,138 @@
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import Admin from './Admin';
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import Admin from "./Admin";
|
||||
|
||||
const key = { credential_id: 'reader-key', principal_id: 'reader', roles: ['viewer'], project_ids: ['pilot'], revoked: false };
|
||||
const key = { credential_id: "reader-key", principal_id: "reader", roles: ["viewer"], project_ids: ["pilot"], revoked: false };
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
it('creates a scoped key, dismisses its one-time secret, and confirms revocation with CSRF', async () => {
|
||||
it("creates a scoped key, dismisses its one-time secret, and confirms revocation with CSRF", async () => {
|
||||
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
|
||||
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
|
||||
let result: unknown = {};
|
||||
if (path === '/v1/session') result = { csrf_token: 'csrf-fixture' };
|
||||
if (path.startsWith('/v1/admin/credentials?')) result = [key];
|
||||
if (path.startsWith('/v1/admin/users?')) result = [];
|
||||
if (path === '/v1/admin/projects') result = [{ project_id: 'pilot', title: 'Pilot research' }];
|
||||
if (path === '/v1/admin/events?limit=50') result = [];
|
||||
if (path === '/v1/admin/credentials' && init?.method === 'POST') result = { credential_id: 'new-key', token: 'one-time-fixture-secret' };
|
||||
if (path === "/v1/session") result = { csrf_token: "csrf-fixture" };
|
||||
if (path.startsWith("/v1/admin/credentials?")) result = [key];
|
||||
if (path.startsWith("/v1/admin/users?")) result = [];
|
||||
if (path === "/v1/admin/projects") result = [{ project_id: "pilot", title: "Pilot research" }];
|
||||
if (path === "/v1/admin/events?limit=50") result = [];
|
||||
if (path === "/v1/admin/credentials" && init?.method === "POST") result = { credential_id: "new-key", token: "one-time-fixture-secret" };
|
||||
return { ok: true, json: async () => result };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const user = userEvent.setup();
|
||||
render(<Admin />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
|
||||
await user.type(screen.getByLabelText('GEWU administrator credential'), 'admin-fixture');
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await user.click(await screen.findByRole('tab', { name: 'Credentials' }));
|
||||
await screen.findByRole('heading', { name: 'Credentials' });
|
||||
await user.type(screen.getByLabelText('Credential ID'), 'new-key');
|
||||
await user.type(screen.getByLabelText('Principal ID'), 'researcher');
|
||||
await user.click(within(screen.getByRole('region', { name: 'Create a credential' })).getByLabelText('Pilot research'));
|
||||
await user.click(screen.getByRole('button', { name: 'Create credential' }));
|
||||
expect(await screen.findByLabelText('New bearer credential')).toHaveValue('one-time-fixture-secret');
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/credentials', expect.objectContaining({
|
||||
method: 'POST', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }),
|
||||
body: JSON.stringify({ credential_id: 'new-key', principal_id: 'researcher', role: 'viewer', project_ids: ['pilot'] }),
|
||||
await user.click(await screen.findByRole("button", { name: "Use an existing credential" }));
|
||||
await user.type(screen.getByLabelText("GEWU administrator credential"), "admin-fixture");
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
await user.click(await screen.findByRole("tab", { name: "Credentials" }));
|
||||
await screen.findByRole("heading", { name: "Credentials" });
|
||||
await user.click(screen.getByRole("button", { name: "Add credential" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Add credential" });
|
||||
await user.type(within(dialog).getByLabelText("Credential ID"), "new-key");
|
||||
await user.type(within(dialog).getByLabelText("Principal ID"), "researcher");
|
||||
await user.click(within(dialog).getByLabelText("Pilot research"));
|
||||
await user.click(within(dialog).getByRole("button", { name: "Create credential" }));
|
||||
expect(await screen.findByLabelText("New bearer credential")).toHaveValue("one-time-fixture-secret");
|
||||
expect(fetchMock).toHaveBeenCalledWith("/v1/admin/credentials", expect.objectContaining({
|
||||
method: "POST", headers: expect.objectContaining({ "X-GEWU-CSRF": "csrf-fixture" }),
|
||||
body: JSON.stringify({ credential_id: "new-key", principal_id: "researcher", role: "viewer", project_ids: ["pilot"] }),
|
||||
}));
|
||||
await user.click(screen.getByRole('button', { name: 'I have saved it — dismiss' }));
|
||||
expect(screen.queryByLabelText('New bearer credential')).toBeNull();
|
||||
await user.click(screen.getByRole('button', { name: 'Revoke reader-key' }));
|
||||
expect(fetchMock.mock.calls.some(([path]) => path.endsWith('/reader-key'))).toBe(false);
|
||||
const row = screen.getByRole('row', { name: /reader-key/ });
|
||||
await user.click(within(row).getByRole('button', { name: 'Confirm revoke' }));
|
||||
await screen.findByText('Revoked reader-key.');
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/credentials/reader-key', expect.objectContaining({
|
||||
method: 'DELETE', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }),
|
||||
await user.click(screen.getByRole("button", { name: "I have saved it — dismiss" }));
|
||||
expect(screen.queryByLabelText("New bearer credential")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Revoke" }));
|
||||
const confirmBtn = await screen.findByRole("button", { name: "Confirm revoke" });
|
||||
await user.click(confirmBtn);
|
||||
await screen.findByText("Revoked reader-key.");
|
||||
expect(fetchMock).toHaveBeenCalledWith("/v1/admin/credentials/reader-key", expect.objectContaining({
|
||||
method: "DELETE", headers: expect.objectContaining({ "X-GEWU-CSRF": "csrf-fixture" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('reports rejected connections without exposing management controls', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 403, json: async () => ({ detail: 'Administrator role is required' }) })));
|
||||
it("reports rejected connections without exposing management controls", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 403, json: async () => ({ detail: "Administrator role is required" }) })));
|
||||
const user = userEvent.setup();
|
||||
render(<Admin />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
|
||||
await user.type(screen.getByLabelText('GEWU administrator credential'), 'viewer-fixture');
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Administrator role is required');
|
||||
expect(screen.queryByRole('heading', { name: 'Create a credential' })).toBeNull();
|
||||
await user.click(await screen.findByRole("button", { name: "Use an existing credential" }));
|
||||
await user.type(screen.getByLabelText("GEWU administrator credential"), "viewer-fixture");
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Administrator role is required");
|
||||
expect(screen.queryByRole("heading", { name: "Add credential" })).toBeNull();
|
||||
});
|
||||
|
||||
it('clears private panel state and permits reconnection after session expiry', async () => {
|
||||
it("clears private panel state and permits reconnection after session expiry", async () => {
|
||||
let expired = false;
|
||||
vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
|
||||
if (expired) return { ok: false, status: 401, json: async () => ({ detail: 'Browser session is invalid or expired' }) };
|
||||
const result = path === '/v1/session' ? { csrf_token: 'csrf-fixture' }
|
||||
: path.startsWith('/v1/admin/credentials?') ? [key] : [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (path: string, init?: RequestInit) => {
|
||||
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
|
||||
if (expired) return { ok: false, status: 401, json: async () => ({ detail: "Browser session is invalid or expired" }) };
|
||||
const result = path === "/v1/session" ? { csrf_token: "csrf-fixture" }
|
||||
: path.startsWith("/v1/admin/credentials?") ? [key] : [];
|
||||
return { ok: true, json: async () => result };
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
render(<Admin />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
|
||||
await user.type(screen.getByLabelText('GEWU administrator credential'), 'admin-fixture');
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await user.click(await screen.findByRole('tab', { name: 'Credentials' }));
|
||||
await screen.findByRole('heading', { name: 'Credentials' });
|
||||
await user.click(await screen.findByRole("button", { name: "Use an existing credential" }));
|
||||
await user.type(screen.getByLabelText("GEWU administrator credential"), "admin-fixture");
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
await user.click(await screen.findByRole("tab", { name: "Credentials" }));
|
||||
await screen.findByRole("heading", { name: "Credentials" });
|
||||
expired = true;
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
expect(await screen.findByLabelText('Username')).toBeVisible();
|
||||
expect(screen.queryByText('reader-key')).toBeNull();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Your session ended.');
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(await screen.findByLabelText("Username")).toBeVisible();
|
||||
expect(screen.queryByText("reader-key")).toBeNull();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Your session ended.");
|
||||
});
|
||||
|
||||
it('restores the admin session after remount and stops restoring after sign-out', async () => {
|
||||
it("restores the admin session after remount and stops restoring after sign-out", async () => {
|
||||
let active = true;
|
||||
const storage = vi.spyOn(Storage.prototype, 'setItem');
|
||||
const storage = vi.spyOn(Storage.prototype, "setItem");
|
||||
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/v1/session' && init?.method === 'GET') {
|
||||
return active ? { ok: true, json: async () => ({ csrf_token: 'restored-csrf', password_account: true }) }
|
||||
if (path === "/v1/session" && init?.method === "GET") {
|
||||
return active ? { ok: true, json: async () => ({ csrf_token: "restored-csrf", password_account: true }) }
|
||||
: { ok: false, status: 401, json: async () => ({}) };
|
||||
}
|
||||
if (path === '/v1/session' && init?.method === 'DELETE') active = false;
|
||||
const result = path.startsWith('/v1/admin/credentials?') ? [key]
|
||||
: path === '/v1/me' ? { principal_id: 'user:admin' } : [];
|
||||
if (path === "/v1/session" && init?.method === "DELETE") active = false;
|
||||
const result = path.startsWith("/v1/admin/credentials?") ? [key]
|
||||
: path === "/v1/me" ? { principal_id: "user:admin" } : [];
|
||||
return { ok: true, json: async () => result };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const user = userEvent.setup();
|
||||
const first = render(<Admin />);
|
||||
expect(await screen.findByRole('heading', { name: 'Projects' })).toBeVisible();
|
||||
expect(screen.queryByLabelText('Username')).toBeNull();
|
||||
expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Projects', 'Users', 'Credentials', 'Logs', 'Admin Settings']);
|
||||
expect(screen.getAllByRole('tabpanel')).toHaveLength(1);
|
||||
expect(screen.queryByRole('heading', { name: 'Credentials' })).toBeNull();
|
||||
await user.type(screen.getByLabelText('Project name'), 'Unfinished research');
|
||||
const projectsTab = screen.getByRole('tab', { name: 'Projects' });
|
||||
expect(await screen.findByRole("heading", { name: "Projects" })).toBeVisible();
|
||||
expect(screen.queryByLabelText("Username")).toBeNull();
|
||||
expect(screen.getAllByRole("tab").map(tab => tab.textContent)).toEqual(["Projects", "Ingest review", "Users", "Credentials", "Logs", "Admin Settings"]);
|
||||
expect(screen.getAllByRole("tabpanel")).toHaveLength(1);
|
||||
expect(screen.queryByRole("heading", { name: "Credentials" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Create project" }));
|
||||
await user.type(screen.getByLabelText("Project name"), "Unfinished research");
|
||||
const projectsTab = screen.getByRole("tab", { name: "Projects" });
|
||||
projectsTab.focus();
|
||||
await user.keyboard('{ArrowRight}');
|
||||
expect(screen.getByRole('tab', { name: 'Users' })).toHaveFocus();
|
||||
expect(screen.getByRole('heading', { name: 'User accounts' })).toBeVisible();
|
||||
await user.keyboard('{End}');
|
||||
expect(screen.getByRole('tab', { name: 'Admin Settings' })).toHaveFocus();
|
||||
expect(screen.getByRole('heading', { name: 'Change your password' })).toBeVisible();
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
expect(screen.getByRole('heading', { name: 'Recent activity' })).toBeVisible();
|
||||
await user.keyboard('{Home}');
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("tab", { name: "Ingest review" })).toHaveFocus();
|
||||
expect(screen.getByRole("heading", { name: "Ingest review" })).toBeVisible();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("tab", { name: "Users" })).toHaveFocus();
|
||||
expect(screen.getByRole("heading", { name: "Users" })).toBeVisible();
|
||||
await user.keyboard("{End}");
|
||||
expect(screen.getByRole("tab", { name: "Admin Settings" })).toHaveFocus();
|
||||
expect(screen.getByRole("heading", { name: "Change your password" })).toBeVisible();
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(screen.getByRole("heading", { name: "Logs" })).toBeVisible();
|
||||
await user.keyboard("{Home}");
|
||||
expect(projectsTab).toHaveFocus();
|
||||
expect(screen.getByLabelText('Project name')).toHaveValue('Unfinished research');
|
||||
expect(screen.getByLabelText("Project name")).toHaveValue("Unfinished research");
|
||||
first.unmount();
|
||||
render(<Admin />);
|
||||
expect(await screen.findByRole('heading', { name: 'Projects' })).toBeVisible();
|
||||
expect(fetchMock.mock.calls.filter(([path, init]) => path === '/v1/session' && init?.method === 'GET')).toHaveLength(2);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/session', expect.objectContaining({
|
||||
method: 'GET', credentials: 'same-origin', cache: 'no-store', headers: { 'X-GEWU-Session': 'restore' },
|
||||
expect(await screen.findByRole("heading", { name: "Projects" })).toBeVisible();
|
||||
expect(fetchMock.mock.calls.filter(([path, init]) => path === "/v1/session" && init?.method === "GET")).toHaveLength(2);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/v1/session", expect.objectContaining({
|
||||
method: "GET", credentials: "same-origin", cache: "no-store", headers: { "X-GEWU-Session": "restore" },
|
||||
}));
|
||||
await user.click(screen.getByRole('button', { name: 'Disconnect' }));
|
||||
expect(await screen.findByLabelText('Username')).toBeVisible();
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/session', expect.objectContaining({
|
||||
method: 'DELETE', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'restored-csrf' }),
|
||||
await user.click(screen.getByRole("button", { name: "Disconnect" }));
|
||||
expect(await screen.findByLabelText("Username")).toBeVisible();
|
||||
expect(fetchMock).toHaveBeenCalledWith("/v1/session", expect.objectContaining({
|
||||
method: "DELETE", headers: expect.objectContaining({ "X-GEWU-CSRF": "restored-csrf" }),
|
||||
}));
|
||||
expect(storage).not.toHaveBeenCalled();
|
||||
storage.mockRestore();
|
||||
|
||||
+22
-2
@@ -123,8 +123,28 @@ function Admin({ personal = false }: { personal?: boolean }) {
|
||||
setCurrentPrincipal(identity.principal_id ?? '');
|
||||
if (!personal) setProjectRecords(availableProjects as Project[]);
|
||||
}, [personal, offset]);
|
||||
const refreshAdminData = useCallback(() => load(csrf), [load, csrf]);
|
||||
const loadAdminPage = useCallback((page: number) => load(csrf, page), [load, csrf]);
|
||||
const handleSessionError = useCallback((failure: unknown) => {
|
||||
if (failure instanceof AdminRequestError && failure.status === 401) {
|
||||
setCsrf(''); setCredentials([]); setEvents([]); setProjects([]); setIssued(null);
|
||||
setMessage('Your session ended. Connect again to continue.');
|
||||
}
|
||||
}, []);
|
||||
const refreshAdminData = useCallback(async () => {
|
||||
try {
|
||||
await load(csrf);
|
||||
} catch (failure) {
|
||||
handleSessionError(failure);
|
||||
throw failure;
|
||||
}
|
||||
}, [load, csrf, handleSessionError]);
|
||||
const loadAdminPage = useCallback(async (page: number) => {
|
||||
try {
|
||||
await load(csrf, page);
|
||||
} catch (failure) {
|
||||
handleSessionError(failure);
|
||||
throw failure;
|
||||
}
|
||||
}, [load, csrf, handleSessionError]);
|
||||
|
||||
async function connect(session: { csrf_token: string }, isPasswordAccount: boolean, restored = false) {
|
||||
setBusy(true); setError('');
|
||||
|
||||
@@ -14,7 +14,7 @@ export function AdminDialog({ open, title, onClose, children, returnFocusRef }:
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
return <dialog ref={dialogRef} className="admin-dialog" aria-label={title}
|
||||
return <dialog ref={dialogRef} open={open ? true : undefined} className="admin-dialog" aria-label={title}
|
||||
onCancel={event => { event.preventDefault(); onClose(); }}
|
||||
onClose={() => { onClose(); returnFocusRef?.current?.focus(); }}>
|
||||
<div className="admin-heading"><h2>{title}</h2><button className="text-button" type="button" onClick={onClose}>Close</button></div>
|
||||
|
||||
+126
-58
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, useLocation, useNavigate } from 'react-router';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -60,7 +60,9 @@ function mockFetch(
|
||||
resultNode = node,
|
||||
resultCitation = citation,
|
||||
restoreSession = false,
|
||||
browseContinuation = false,
|
||||
) {
|
||||
let browseRequests = 0;
|
||||
const fetchMock = vi.fn(async (...args: [RequestInfo | URL, RequestInit?]) => {
|
||||
const [input] = args;
|
||||
const path = String(input);
|
||||
@@ -68,9 +70,30 @@ function mockFetch(
|
||||
if (path === '/v1/session' && args[1]?.method === 'GET') return restoreSession ? { ok: true, json: async () => ({ csrf_token: 'restored-csrf', password_account: true }) } as Response : { ok: false, status: 401, json: async () => ({}) } as Response;
|
||||
if (path === '/v1/session') data = { csrf_token: 'gwc_test-token', expires_at: '2026-10-13T08:00:00Z' };
|
||||
else if (path === '/v1/projects') data = ['dome-performance'];
|
||||
else if (path === '/v1/search' && searchMode === 'error') {
|
||||
else if (path === '/v1/knowledge/graph') data = {
|
||||
schema_version: '1.0', api_version: '1.0', request_id: 'graph-test',
|
||||
resolved_scope: { projects: ['dome-performance'], shared: false },
|
||||
nodes: [resultNode], edges: [], truncated: false, center_node_id: null,
|
||||
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), warnings: [],
|
||||
};
|
||||
else if ((path === '/v1/search' || path === '/v1/knowledge/browse') && searchMode === 'error') {
|
||||
return { ok: false, status: 503, json: async () => ({ detail: 'Search index unavailable.' }) } as Response;
|
||||
}
|
||||
else if (path === '/v1/knowledge/browse' && searchMode === 'empty') data = {
|
||||
nodes: [], project_node_count: 1, project_source_count: 1, shared_node_count: 0, shared_source_count: 0, filtered_node_count: 0,
|
||||
facets: { tags: { 'installation': 1 }, audience: { developer: 1 }, use_for: { planning: 1 }, domain: { 'interactive systems': 1 } },
|
||||
warnings: [], canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
|
||||
};
|
||||
else if (path === '/v1/knowledge/browse') {
|
||||
const body = args[1]?.body ? JSON.parse(String(args[1].body)) as { cursor?: string } : {};
|
||||
const restoringPageTwo = browseContinuation;
|
||||
const isFirstBrowseRequest = browseRequests++ === 0;
|
||||
data = {
|
||||
nodes: [resultNode], project_node_count: restoringPageTwo ? 21 : 1, project_source_count: 1, shared_node_count: 0, shared_source_count: 0, filtered_node_count: restoringPageTwo ? 21 : 1,
|
||||
facets: { tags: { 'real-time': 1, installation: 1 }, audience: { developer: 1 }, use_for: { planning: 1 }, domain: { 'interactive systems': 1 } },
|
||||
warnings: [], canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: restoringPageTwo && isFirstBrowseRequest && !body.cursor ? 'opaque-browse-cursor' : null,
|
||||
};
|
||||
}
|
||||
else if (path === '/v1/search' && searchMode === 'empty') data = {
|
||||
data: [], citations: [], warnings: [], omissions: [],
|
||||
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
|
||||
@@ -100,8 +123,9 @@ function mockFetch(
|
||||
data: [], citations: [], warnings: [], omissions: [],
|
||||
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
|
||||
};
|
||||
else if (path === '/v1/sources') data = {
|
||||
data: [{
|
||||
else if (path === '/v1/sources') {
|
||||
const body = args[1]?.body ? JSON.parse(String(args[1].body)) as { cursor?: string } : {};
|
||||
const sourcePage = body.cursor ? [] : [{
|
||||
source: {
|
||||
source_id: 'dome:pilot-report',
|
||||
revision_id: 'dome:pilot-report:r1',
|
||||
@@ -115,10 +139,10 @@ function mockFetch(
|
||||
reliability_grade: 'medium',
|
||||
ingested_for: ['dome-performance'],
|
||||
},
|
||||
}],
|
||||
citations: [], warnings: [], omissions: [],
|
||||
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
|
||||
};
|
||||
}];
|
||||
data = { data: sourcePage, filtered_count: 21, citations: [], warnings: [], omissions: [],
|
||||
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: body.cursor ? null : 'sources-next' };
|
||||
}
|
||||
else if (path.endsWith('/history')) data = {
|
||||
data: [
|
||||
{ revision: '1'.repeat(40), node },
|
||||
@@ -182,21 +206,26 @@ describe('GEWU research reader', () => {
|
||||
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
expect(await screen.findByLabelText('PROJECT')).toHaveValue('dome-performance');
|
||||
await user.click(screen.getByRole('link', { name: /stale knowledge/i }));
|
||||
expect(screen.getByLabelText('Past freshness date')).toBeChecked();
|
||||
expect(await screen.findByRole('region', { name: 'Selected project collection' })).toHaveTextContent('1 knowledge nodes');
|
||||
await user.click(screen.getByRole('checkbox', { name: 'Past freshness date' }));
|
||||
expect(screen.getByRole('checkbox', { name: 'Past freshness date' })).toBeChecked();
|
||||
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
|
||||
await user.type(screen.getByLabelText('As of from'), '2024-01-01');
|
||||
await user.type(screen.getByLabelText('As of to'), '2024-12-31');
|
||||
await user.click(screen.getByLabelText('medium'));
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }));
|
||||
const result = await screen.findByRole('button', { name: /visitor count during the season/i });
|
||||
const result = await screen.findByRole('link', { name: /visitor count during the season/i });
|
||||
expect(result).toBeVisible();
|
||||
|
||||
await user.click(result);
|
||||
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('node=dome%3Aseason-visitors');
|
||||
expect((await screen.findAllByText(citation.quote, { exact: false })).length).toBeGreaterThan(0);
|
||||
expect(await screen.findByRole('heading', { name: 'Claim assessments' })).toBeVisible();
|
||||
expect(await screen.findByRole('heading', { name: 'Assumptions and limitations' })).toBeVisible();
|
||||
const claimHeading = await screen.findByRole('heading', { name: 'Claim assessments' });
|
||||
expect(claimHeading).toBeVisible();
|
||||
await user.click(claimHeading);
|
||||
const assumptionHeading = await screen.findByRole('heading', { name: 'Assumptions and limitations' });
|
||||
expect(assumptionHeading).toBeVisible();
|
||||
await user.click(assumptionHeading);
|
||||
expect(screen.getByText(/The source does not identify unique visitors\./)).toBeVisible();
|
||||
expect(screen.getByText('The cited sentence states the count.')).toBeVisible();
|
||||
expect(screen.getByText(/not a truth probability/)).toBeVisible();
|
||||
@@ -229,13 +258,13 @@ describe('GEWU research reader', () => {
|
||||
expect.objectContaining({ headers: { Authorization: 'Bearer viewer-secret' } }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/search',
|
||||
'/v1/knowledge/browse',
|
||||
expect.objectContaining({
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({ 'X-GEWU-CSRF': 'gwc_test-token' }),
|
||||
}),
|
||||
);
|
||||
const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search');
|
||||
const searchCall = [...fetchMock.mock.calls].reverse().find(([url]) => String(url) === '/v1/knowledge/browse');
|
||||
expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({
|
||||
stale_only: true,
|
||||
confidences: ['medium'],
|
||||
@@ -244,9 +273,15 @@ describe('GEWU research reader', () => {
|
||||
});
|
||||
expect(localStorage.length).toBe(0);
|
||||
expect(screen.queryByRole('button', { name: /approve|edit|merge|generate|capture/i })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('link', { name: /sources/i }));
|
||||
await user.click(within(screen.getByRole('navigation', { name: 'Project collections' })).getByRole('button', { name: 'Sources' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Load source records' }));
|
||||
expect(await screen.findByRole('heading', { name: 'Sources' })).toBeVisible();
|
||||
expect(screen.getByText('1–1 of 21')).toBeVisible();
|
||||
await user.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(await screen.findByText('0 of 21')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: 'Previous' })).toBeEnabled();
|
||||
await user.click(screen.getByRole('button', { name: 'Previous' }));
|
||||
expect(await screen.findByText('1–1 of 21')).toBeVisible();
|
||||
expect(
|
||||
(await screen.findAllByRole('heading', { name: 'Pilot installation report' })).length,
|
||||
).toBeGreaterThan(0);
|
||||
@@ -280,7 +315,7 @@ describe('GEWU research reader', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }));
|
||||
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
|
||||
await user.click(await screen.findByRole('link', { name: /visitor count during the season/i }));
|
||||
|
||||
expect(await screen.findByText(/Transcript · 00:14:32\.000–00:14:36\.500 · cue-1 · 48–102/)).toBeVisible();
|
||||
});
|
||||
@@ -309,17 +344,16 @@ describe('GEWU research reader', () => {
|
||||
if (mode === 'error') expect(screen.getByText('Search index unavailable.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('routes Problems and Topics through the typed search facets', async () => {
|
||||
it('shows Problems and Concepts as knowledge type filter options', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetch();
|
||||
render(<MemoryRouter><App /></MemoryRouter>);
|
||||
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
|
||||
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await user.click(screen.getByRole('link', { name: /problems/i }));
|
||||
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveValue('problem');
|
||||
await user.click(screen.getByRole('link', { name: /topics/i }));
|
||||
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveValue('concept');
|
||||
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveDisplayValue('All types');
|
||||
expect(screen.getByRole('option', { name: 'Problems' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Concepts' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies exact tag, audience, use and domain facets and stores them in the URL', async () => {
|
||||
@@ -330,23 +364,31 @@ describe('GEWU research reader', () => {
|
||||
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
|
||||
await user.type(screen.getByLabelText('Tag'), 'installation');
|
||||
await user.type(screen.getByLabelText('Audience'), 'developer');
|
||||
await user.type(screen.getByLabelText('Use'), 'planning');
|
||||
await user.type(screen.getByLabelText('Domain'), 'interactive systems');
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/search')).toHaveLength(0);
|
||||
const filters = screen.getByText('Filters').closest('details');
|
||||
if (!filters) throw new Error('Filters disclosure missing');
|
||||
await user.click(within(filters).getByText('Filters'));
|
||||
await user.click(within(filters).getByText('installation'));
|
||||
await user.click(within(filters).getByText('developer'));
|
||||
await user.click(within(filters).getByText('planning'));
|
||||
await user.click(within(filters).getByText('interactive systems'));
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/knowledge/browse').length).toBeGreaterThan(0);
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }));
|
||||
|
||||
const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search');
|
||||
const searchCalls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/knowledge/browse');
|
||||
const searchCall = [...searchCalls].reverse().find(([, init]) => {
|
||||
const body = JSON.parse(String(init?.body)) as { domain?: string[] };
|
||||
return body.domain?.includes('interactive systems');
|
||||
});
|
||||
expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({
|
||||
tags: ['installation'],
|
||||
audience: ['developer'],
|
||||
use_for: ['planning'],
|
||||
domain: ['interactive systems'],
|
||||
});
|
||||
expect(screen.getByLabelText('Current search URL')).toHaveTextContent(
|
||||
'tag=installation&audience=developer&use=planning&domain=interactive+systems',
|
||||
);
|
||||
expect(screen.getByRole('checkbox', { name: /installation/ })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /developer/ })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /planning/ })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /interactive systems/ })).toBeChecked();
|
||||
});
|
||||
|
||||
it('restores a shared search URL after connecting with its query and exact facets', async () => {
|
||||
@@ -362,12 +404,12 @@ describe('GEWU research reader', () => {
|
||||
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
|
||||
expect(await screen.findByRole('button', { name: /visitor count during the season/i })).toBeVisible();
|
||||
const searchCalls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/search');
|
||||
expect(searchCalls).toHaveLength(1);
|
||||
expect(JSON.parse(String(searchCalls[0]?.[1]?.body))).toMatchObject({
|
||||
expect(await screen.findByRole('link', { name: /visitor count during the season/i })).toBeVisible();
|
||||
const searchCalls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/knowledge/browse');
|
||||
expect(searchCalls.length).toBeGreaterThan(0);
|
||||
expect(JSON.parse(String(searchCalls.at(-1)?.[1]?.body))).toMatchObject({
|
||||
query: 'visitors',
|
||||
scope: { projects: ['dome-performance'] },
|
||||
scope: { projects: ['dome-performance'], include_projects: [], include_shared: false, all_projects: false },
|
||||
tags: ['installation'],
|
||||
audience: ['developer'],
|
||||
confidences: ['medium'],
|
||||
@@ -379,6 +421,20 @@ describe('GEWU research reader', () => {
|
||||
expect(screen.getByLabelText('As of to')).toHaveValue('2024-12-31');
|
||||
});
|
||||
|
||||
it('restores an opaque-cursor page from the page number in the URL', async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchMock = mockFetch('normal', node, citation, false, true);
|
||||
render(<MemoryRouter initialEntries={['/?project=dome-performance&page=2']}><App /><CurrentRoute /></MemoryRouter>);
|
||||
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
|
||||
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await screen.findByRole('link', { name: /visitor count during the season/i });
|
||||
const calls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/knowledge/browse');
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ cursor: expect.any(String) });
|
||||
expect(screen.getByText('21–21 of 21')).toBeVisible();
|
||||
});
|
||||
|
||||
it('restores prior searches with one Back step and reruns them on browser navigation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchMock = mockFetch();
|
||||
@@ -403,30 +459,30 @@ describe('GEWU research reader', () => {
|
||||
await user.click(screen.getByLabelText('medium'));
|
||||
await user.type(screen.getByLabelText('As of to'), '2026-12-31');
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }));
|
||||
await screen.findByText('Search complete. 1 result.');
|
||||
await screen.findByText('Showing 1 of 1 matching knowledge nodes.');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Back in history' }));
|
||||
await screen.findByText('Search complete. 1 result.');
|
||||
await screen.findByText('Showing 1 of 1 matching knowledge nodes.');
|
||||
expect(queryBox).toHaveValue('visitors');
|
||||
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('confidence=medium');
|
||||
expect(screen.getByLabelText('medium')).toBeChecked();
|
||||
expect(screen.getByLabelText('As of from')).toHaveValue('2024-01-01');
|
||||
expect(screen.getByLabelText('As of to')).toHaveValue('');
|
||||
let searchCalls = fetchMock.mock.calls
|
||||
.filter(([url]) => String(url) === '/v1/search')
|
||||
.filter(([url]) => String(url) === '/v1/knowledge/browse')
|
||||
.map(([, init]) => JSON.parse(String(init?.body)).query);
|
||||
expect(searchCalls).toEqual(['visitors', 'methods', 'visitors']);
|
||||
expect(searchCalls.slice(-3)).toEqual(['visitors', 'methods', 'visitors']);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Forward in history' }));
|
||||
await screen.findByText('Search complete. 1 result.');
|
||||
await screen.findByText('Showing 1 of 1 matching knowledge nodes.');
|
||||
expect(queryBox).toHaveValue('methods');
|
||||
expect(screen.getByLabelText('medium')).not.toBeChecked();
|
||||
expect(screen.getByLabelText('As of from')).toHaveValue('2024-01-01');
|
||||
expect(screen.getByLabelText('As of to')).toHaveValue('2026-12-31');
|
||||
searchCalls = fetchMock.mock.calls
|
||||
.filter(([url]) => String(url) === '/v1/search')
|
||||
.filter(([url]) => String(url) === '/v1/knowledge/browse')
|
||||
.map(([, init]) => JSON.parse(String(init?.body)).query);
|
||||
expect(searchCalls).toEqual(['visitors', 'methods', 'visitors', 'methods']);
|
||||
expect(searchCalls.slice(-4)).toEqual(['visitors', 'methods', 'visitors', 'methods']);
|
||||
});
|
||||
|
||||
it('labels the problem reading view and its missing accepted approaches', async () => {
|
||||
@@ -438,28 +494,21 @@ describe('GEWU research reader', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitor problem');
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }));
|
||||
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
|
||||
await user.click(await screen.findByRole('link', { name: /visitor count during the season/i }));
|
||||
expect(await screen.findByRole('heading', { name: 'Approaches and related knowledge' })).toBeVisible();
|
||||
expect(screen.getByText(/No accepted approaches or other relationships are visible/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('browses problem records within the selected scope without requiring a text query', async () => {
|
||||
it('offers problem and concept type filters for project browsing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchMock = mockFetch('normal', { ...node, node_type: 'problem' });
|
||||
mockFetch();
|
||||
render(<MemoryRouter><App /><CurrentRoute /></MemoryRouter>);
|
||||
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
|
||||
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Browse problems' }));
|
||||
|
||||
expect(await screen.findByRole('button', { name: /visitor count during the season/i })).toBeVisible();
|
||||
const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search');
|
||||
expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({
|
||||
query: '',
|
||||
scope: { projects: ['dome-performance'] },
|
||||
node_types: ['problem'],
|
||||
});
|
||||
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('type=problem');
|
||||
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveValue('');
|
||||
expect(screen.getByRole('option', { name: 'Problems' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Concepts' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('restores a linked canonical record after connecting with a shared node URL', async () => {
|
||||
@@ -497,7 +546,7 @@ describe('GEWU research reader', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }));
|
||||
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
|
||||
await user.click(await screen.findByRole('link', { name: /visitor count during the season/i }));
|
||||
expect(await screen.findByRole('heading', { name: 'Claim assessments' })).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Back in history' }));
|
||||
@@ -517,7 +566,7 @@ describe('GEWU research reader', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }));
|
||||
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
|
||||
await user.click(await screen.findByRole('link', { name: /visitor count during the season/i }));
|
||||
await user.click(await screen.findByText('Revision history (2)'));
|
||||
const neighborCallsBeforeHistoricalRead = fetchMock.mock.calls.filter(
|
||||
([url]) => String(url) === '/v1/neighbors',
|
||||
@@ -553,3 +602,22 @@ it('restores library access from the existing browser session without entering a
|
||||
method: 'GET', credentials: 'same-origin', headers: { 'X-GEWU-Session': 'restore' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('loads the authorized project graph when the Graph tab opens', async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchMock = mockFetch();
|
||||
render(<MemoryRouter initialEntries={['/']}><App /></MemoryRouter>);
|
||||
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
|
||||
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await user.click((await screen.findAllByRole('button', { name: 'Graph' }))[0]);
|
||||
expect(await screen.findByText('1 nodes · 0 links')).toBeVisible();
|
||||
await user.click(screen.getByRole('button', { name: 'Visitor count during the season (finding)' }));
|
||||
expect(screen.getByRole('dialog', { name: 'Visitor count during the season' })).toBeVisible();
|
||||
expect(screen.getByRole('link', { name: 'Open node →' })).toBeVisible();
|
||||
expect(screen.queryByRole('button', { name: 'Zoom in' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Project graph' })).not.toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/knowledge/graph', expect.objectContaining({
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
}));
|
||||
});
|
||||
+1272
-541
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,121 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import type { components } from '../generated/api';
|
||||
import { GraphCanvas } from './GraphCanvas';
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
|
||||
const node = {
|
||||
node_id: 'node-1', project_id: 'demo', title: 'Shared scenes preserve story state',
|
||||
summary: 'A scene can be reused when the relevant story state agrees.', node_type: 'technique',
|
||||
} as components['schemas']['KnowledgeGraphEnvelope']['nodes'][number];
|
||||
|
||||
function Fixture() {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
return <MemoryRouter><button>Outside graph</button><GraphCanvas
|
||||
nodes={[node]} edges={[]} drawing={{ viewBox: '0 0 1000 640', points: new Map([['node-1', { x: 200, y: 200 }]]) }}
|
||||
selectedId={selected} onSelect={setSelected} nodeHref={() => '/?node=node-1'}
|
||||
/></MemoryRouter>;
|
||||
}
|
||||
|
||||
it('opens the node link inside a graph popup and restores focus on close', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Fixture />);
|
||||
const target = screen.getByRole('button', { name: `${node.title} (technique)` });
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
await user.click(target);
|
||||
const popup = screen.getByRole('dialog', { name: node.title });
|
||||
expect(popup.closest('.graph-stage')).not.toBeNull();
|
||||
expect(popup).toHaveFocus();
|
||||
expect(within(popup).getByRole('link', { name: 'Open node →' })).toHaveAttribute('href', '/?node=node-1');
|
||||
expect(target).toHaveAttribute('aria-expanded', 'true');
|
||||
await user.click(within(popup).getByRole('button', { name: 'Close node preview' }));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(target).toHaveFocus();
|
||||
});
|
||||
|
||||
it('supports keyboard activation and Escape without requiring hover', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Fixture />);
|
||||
const target = screen.getByRole('button', { name: `${node.title} (technique)` });
|
||||
target.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(screen.getByRole('dialog')).toHaveFocus();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(target).toHaveFocus();
|
||||
await user.keyboard(' ');
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dismisses on empty graph space or an outside pointer interaction', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Fixture />);
|
||||
const target = screen.getByRole('button', { name: `${node.title} (technique)` });
|
||||
await user.click(target);
|
||||
fireEvent.click(document.querySelector('.graph-canvas')!);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
await user.click(target);
|
||||
await user.click(screen.getByRole('button', { name: 'Outside graph' }));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
function canvas() {
|
||||
const element = document.querySelector<SVGSVGElement>('.graph-canvas')!;
|
||||
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 1000, bottom: 640, width: 1000, height: 640, toJSON: () => ({}) });
|
||||
return element;
|
||||
}
|
||||
|
||||
function camera() {
|
||||
const [x, y, zoom] = document.querySelector('.graph-camera')!.getAttribute('transform')!.match(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/g)!.map(Number);
|
||||
return { x, y, zoom };
|
||||
}
|
||||
|
||||
it('zooms around the mouse position and consumes wheel scrolling only on the graph', () => {
|
||||
render(<Fixture />);
|
||||
const graph = canvas();
|
||||
const wheel = new WheelEvent('wheel', { clientX: 200, clientY: 200, deltaY: -100, bubbles: true, cancelable: true });
|
||||
fireEvent(graph, wheel);
|
||||
expect(wheel.defaultPrevented).toBe(true);
|
||||
const view = camera();
|
||||
expect(view.zoom).toBeGreaterThan(1);
|
||||
expect(view.x + 200 * view.zoom).toBeCloseTo(200);
|
||||
expect(view.y + 200 * view.zoom).toBeCloseTo(200);
|
||||
});
|
||||
|
||||
it('pans by dragging a node without accidentally opening its popup', () => {
|
||||
render(<Fixture />);
|
||||
const graph = canvas();
|
||||
const target = screen.getByRole('button', { name: `${node.title} (technique)` });
|
||||
fireEvent.pointerDown(target, { pointerId: 1, button: 0, clientX: 200, clientY: 200 });
|
||||
fireEvent.pointerMove(graph, { pointerId: 1, clientX: 280, clientY: 240 });
|
||||
fireEvent.pointerUp(graph, { pointerId: 1 });
|
||||
fireEvent.click(target);
|
||||
expect(camera().x).toBeCloseTo(80);
|
||||
expect(camera().y).toBeCloseTo(40);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
fireEvent.pointerDown(target, { pointerId: 2, button: 0, clientX: 280, clientY: 240 });
|
||||
fireEvent.pointerUp(target, { pointerId: 2 });
|
||||
fireEvent.click(target);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports touch pinch zoom and keyboard navigation without toolbar buttons', () => {
|
||||
render(<Fixture />);
|
||||
const graph = canvas();
|
||||
fireEvent.pointerDown(graph, { pointerId: 1, pointerType: 'touch', button: 0, clientX: 100, clientY: 200 });
|
||||
fireEvent.pointerDown(graph, { pointerId: 2, pointerType: 'touch', button: 0, clientX: 300, clientY: 200 });
|
||||
fireEvent.pointerMove(graph, { pointerId: 2, pointerType: 'touch', clientX: 400, clientY: 200 });
|
||||
expect(camera().zoom).toBeCloseTo(1.5);
|
||||
fireEvent.pointerCancel(graph, { pointerId: 1 });
|
||||
fireEvent.pointerCancel(graph, { pointerId: 2 });
|
||||
fireEvent.keyDown(graph, { key: 'Home' });
|
||||
expect(camera()).toEqual({ x: 0, y: 0, zoom: 1 });
|
||||
fireEvent.keyDown(graph, { key: 'ArrowRight' });
|
||||
expect(camera().x).toBeLessThan(0);
|
||||
fireEvent.keyDown(graph, { key: '+' });
|
||||
expect(camera().zoom).toBeGreaterThan(1);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import type { components } from '../generated/api';
|
||||
|
||||
type Graph = components['schemas']['KnowledgeGraphEnvelope'];
|
||||
|
||||
export function GraphCanvas({ nodes, edges, drawing, selectedId, onSelect, nodeHref }: {
|
||||
nodes: Graph['nodes'];
|
||||
edges: Graph['edges'];
|
||||
drawing: { viewBox: string; points: Map<string, { x: number; y: number }> };
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
nodeHref: (id: string, project: string) => string;
|
||||
}) {
|
||||
const stage = useRef<HTMLDivElement>(null);
|
||||
const svg = useRef<SVGSVGElement>(null);
|
||||
const [camera, setCamera] = useState({ x: 0, y: 0, zoom: 1 });
|
||||
const [panning, setPanning] = useState(false);
|
||||
const pointers = useRef(new Map<number, { x: number; y: number; startX: number; startY: number }>());
|
||||
const dragged = useRef(false);
|
||||
const popup = useRef<HTMLDivElement>(null);
|
||||
const nodeElements = useRef(new Map<string, SVGGElement>());
|
||||
const markerId = useId();
|
||||
const titleId = useId();
|
||||
const selected = nodes.find(node => node.node_id === selectedId);
|
||||
const [, , width, height] = drawing.viewBox.split(' ').map(Number);
|
||||
|
||||
// Convert screen positions into SVG coordinates, including letterboxing.
|
||||
function graphPoint(x: number, y: number) {
|
||||
const bounds = svg.current!.getBoundingClientRect();
|
||||
const unit = Math.min(bounds.width / width, bounds.height / height) || 1;
|
||||
return {
|
||||
x: (x - bounds.left - (bounds.width - width * unit) / 2) / unit,
|
||||
y: (y - bounds.top - (bounds.height - height * unit) / 2) / unit,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = svg.current;
|
||||
if (!canvas) return;
|
||||
// React's delegated wheel listener is passive; this surface owns its zoom.
|
||||
const wheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const unit = Math.min(bounds.width / width, bounds.height / height) || 1;
|
||||
const point = {
|
||||
x: (event.clientX - bounds.left - (bounds.width - width * unit) / 2) / unit,
|
||||
y: (event.clientY - bounds.top - (bounds.height - height * unit) / 2) / unit,
|
||||
};
|
||||
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? bounds.height : 1);
|
||||
setCamera(previous => {
|
||||
const zoom = Math.max(.3, Math.min(8, previous.zoom * Math.exp(-Math.max(-100, Math.min(100, delta)) * .002)));
|
||||
const ratio = zoom / previous.zoom;
|
||||
return { zoom, x: point.x - (point.x - previous.x) * ratio, y: point.y - (point.y - previous.y) * ratio };
|
||||
});
|
||||
};
|
||||
canvas.addEventListener('wheel', wheel, { passive: false });
|
||||
return () => canvas.removeEventListener('wheel', wheel);
|
||||
}, [width, height]);
|
||||
|
||||
function dismiss() {
|
||||
onSelect(null);
|
||||
if (selectedId) nodeElements.current.get(selectedId)?.focus();
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!selected) return;
|
||||
const placePopup = () => {
|
||||
const card = popup.current;
|
||||
const container = stage.current;
|
||||
const circle = nodeElements.current.get(selected.node_id)?.querySelector('circle');
|
||||
if (!card || !container || !circle) return;
|
||||
const bounds = container.getBoundingClientRect();
|
||||
const anchor = circle.getBoundingClientRect();
|
||||
const gap = 12;
|
||||
const maxLeft = Math.max(gap, bounds.width - card.offsetWidth - gap);
|
||||
let left = anchor.right - bounds.left + gap;
|
||||
if (left > maxLeft) left = anchor.left - bounds.left - card.offsetWidth - gap;
|
||||
card.style.left = `${Math.max(gap, Math.min(maxLeft, left))}px`;
|
||||
card.style.top = `${Math.max(gap, Math.min(bounds.height - card.offsetHeight - gap, anchor.top - bounds.top - gap))}px`;
|
||||
};
|
||||
placePopup();
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(placePopup);
|
||||
if (stage.current) observer?.observe(stage.current);
|
||||
if (popup.current) observer?.observe(popup.current);
|
||||
window.addEventListener('resize', placePopup);
|
||||
return () => { observer?.disconnect(); window.removeEventListener('resize', placePopup); };
|
||||
}, [selected, drawing, camera]);
|
||||
|
||||
useEffect(() => { if (selectedId) popup.current?.focus(); }, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (event.target instanceof Node && !stage.current?.contains(event.target)) onSelect(null);
|
||||
};
|
||||
document.addEventListener('pointerdown', outside);
|
||||
return () => document.removeEventListener('pointerdown', outside);
|
||||
}, [selectedId, onSelect]);
|
||||
|
||||
return (
|
||||
<div className="graph-stage" ref={stage} onKeyDown={event => {
|
||||
if (event.key === 'Escape' && selectedId) { event.stopPropagation(); dismiss(); }
|
||||
}}>
|
||||
<svg ref={svg} className="graph-canvas" data-panning={panning} viewBox={drawing.viewBox} width="100%" height="100%" preserveAspectRatio="xMidYMid meet" aria-label="Knowledge graph. Drag to move, scroll or pinch to zoom. Arrow keys move; plus and minus zoom; Home resets." tabIndex={0}
|
||||
onPointerDown={event => {
|
||||
if (event.button !== 0) return;
|
||||
if (!pointers.current.size) dragged.current = false;
|
||||
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY, startX: event.clientX, startY: event.clientY });
|
||||
}}
|
||||
onPointerMove={event => {
|
||||
const pointer = pointers.current.get(event.pointerId);
|
||||
if (!pointer) return;
|
||||
const before = [...pointers.current.values()].slice(0, 2).map(item => ({ x: item.x, y: item.y }));
|
||||
const wasDragged = dragged.current;
|
||||
pointer.x = event.clientX; pointer.y = event.clientY;
|
||||
if (!dragged.current && pointers.current.size === 1 && Math.hypot(pointer.x - pointer.startX, pointer.y - pointer.startY) < 4) return;
|
||||
dragged.current = true;
|
||||
setPanning(true);
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
const after = [...pointers.current.values()].slice(0, 2);
|
||||
if (!wasDragged && before.length === 1) before[0] = { x: pointer.startX, y: pointer.startY };
|
||||
const midpoint = (points: Array<{ x: number; y: number }>) => graphPoint(points.reduce((sum, item) => sum + item.x, 0) / points.length, points.reduce((sum, item) => sum + item.y, 0) / points.length);
|
||||
const from = midpoint(before); const to = midpoint(after);
|
||||
const distance = (points: Array<{ x: number; y: number }>) => Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
|
||||
const factor = before.length === 2 && distance(before) > 0 ? distance(after) / distance(before) : 1;
|
||||
setCamera(previous => {
|
||||
const zoom = Math.max(.3, Math.min(8, previous.zoom * factor));
|
||||
const ratio = zoom / previous.zoom;
|
||||
return { zoom, x: to.x - (from.x - previous.x) * ratio, y: to.y - (from.y - previous.y) * ratio };
|
||||
});
|
||||
}}
|
||||
onPointerUp={event => {
|
||||
pointers.current.delete(event.pointerId);
|
||||
if (!pointers.current.size) setPanning(false);
|
||||
}}
|
||||
onPointerCancel={event => { pointers.current.delete(event.pointerId); setPanning(false); }}
|
||||
onLostPointerCapture={event => { pointers.current.delete(event.pointerId); if (!pointers.current.size) setPanning(false); }}
|
||||
onClickCapture={event => {
|
||||
if (dragged.current) { event.preventDefault(); event.stopPropagation(); dragged.current = false; }
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
const movement: Record<string, [number, number]> = { ArrowLeft: [40, 0], ArrowRight: [-40, 0], ArrowUp: [0, 40], ArrowDown: [0, -40] };
|
||||
if (event.key in movement) {
|
||||
event.preventDefault();
|
||||
const [x, y] = movement[event.key];
|
||||
setCamera(previous => ({ ...previous, x: previous.x + x, y: previous.y + y }));
|
||||
} else if (['+', '=', '-', '_'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
setCamera(previous => {
|
||||
const zoom = Math.max(.3, Math.min(8, previous.zoom * (event.key === '+' || event.key === '=' ? 1.2 : 1 / 1.2)));
|
||||
const ratio = zoom / previous.zoom;
|
||||
return { zoom, x: width / 2 - (width / 2 - previous.x) * ratio, y: height / 2 - (height / 2 - previous.y) * ratio };
|
||||
});
|
||||
} else if (event.key === 'Home') { event.preventDefault(); setCamera({ x: 0, y: 0, zoom: 1 }); }
|
||||
}}
|
||||
onClick={event => {
|
||||
if (!(event.target as Element).closest('.graph-node')) onSelect(null);
|
||||
}}>
|
||||
<g className="graph-camera" transform={`translate(${camera.x} ${camera.y}) scale(${camera.zoom})`}>
|
||||
{edges.map((edge, index) => {
|
||||
const a = drawing.points.get(edge.source.id); const b = drawing.points.get(edge.target.id);
|
||||
if (!a || !b) return null;
|
||||
return <g key={`${edge.edge_id}:${index}`} className="graph-link graph-link--accepted" aria-hidden="true">
|
||||
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} markerEnd={`url(#${markerId})`} />
|
||||
<text x={(a.x + b.x) / 2} y={(a.y + b.y) / 2 - 7} className="graph-link-label">{edge.edge_type.replaceAll('_', ' ')}</text>
|
||||
</g>;
|
||||
})}
|
||||
<defs><marker id={markerId} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" /></marker></defs>
|
||||
{nodes.map(node => {
|
||||
const point = drawing.points.get(node.node_id);
|
||||
if (!point) return null;
|
||||
return <g key={node.node_id} ref={element => {
|
||||
if (element) nodeElements.current.set(node.node_id, element);
|
||||
else nodeElements.current.delete(node.node_id);
|
||||
}} className={`graph-node graph-node--${node.node_type}${selectedId === node.node_id ? ' graph-node--selected' : ''}`}
|
||||
role="button" tabIndex={0} aria-label={`${node.title} (${node.node_type})`} aria-haspopup="dialog" aria-expanded={selectedId === node.node_id}
|
||||
onClick={() => onSelect(node.node_id)} onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onSelect(node.node_id); }
|
||||
}}>
|
||||
<circle cx={point.x} cy={point.y} r="12" />
|
||||
<text className="graph-node__title" x={point.x + 18} y={point.y + 4} aria-hidden="true">{node.title.length > 56 ? `${node.title.slice(0, 56)}…` : node.title}</text>
|
||||
</g>;
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
{selected ? <div ref={popup} className="graph-popup" role="dialog" aria-labelledby={titleId} tabIndex={-1}>
|
||||
<div className="graph-popup__header"><p className="eyebrow">{selected.node_type}</p><button className="graph-popup__close" type="button" aria-label="Close node preview" onClick={dismiss}>×</button></div>
|
||||
<h3 id={titleId}>{selected.title}</h3>
|
||||
<p>{selected.summary}</p>
|
||||
<Link to={nodeHref(selected.node_id, selected.project_id)}>Open node →</Link>
|
||||
</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +1,65 @@
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import Admin from './Admin';
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import Admin from "./Admin";
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
it('creates a project from the admin form and makes it available for account and agent grants', async () => {
|
||||
it("creates a project from the admin form and makes it available for account and agent grants", async () => {
|
||||
let created = false;
|
||||
let conflict = true;
|
||||
const project = {
|
||||
schema_version: 1, project_id: 'my-research', title: 'My research', status: 'active',
|
||||
north_star: 'Investigate useful techniques.', key_questions: ['What works?', 'Under what conditions?'],
|
||||
owners: ['user:admin'], autonomy_policy_id: 'my-research-policy',
|
||||
schema_version: 1, project_id: "my-research", title: "My research", status: "active",
|
||||
north_star: "Investigate useful techniques.", key_questions: ["What works?", "Under what conditions?"],
|
||||
owners: ["user:admin"], autonomy_policy_id: "my-research-policy",
|
||||
};
|
||||
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
|
||||
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
|
||||
let result: unknown = [];
|
||||
if (path === '/v1/users/session') result = { csrf_token: 'csrf-fixture' };
|
||||
if (path === '/v1/me') result = { principal_id: 'user:admin' };
|
||||
if (path === '/v1/admin/projects') {
|
||||
if (init?.method === 'POST') {
|
||||
if (conflict) return { ok: false, status: 409, json: async () => ({ detail: 'Project ID is already registered' }) };
|
||||
if (path === "/v1/users/session") result = { csrf_token: "csrf-fixture" };
|
||||
if (path === "/v1/me") result = { principal_id: "user:admin" };
|
||||
if (path === "/v1/admin/projects") {
|
||||
if (init?.method === "POST") {
|
||||
if (conflict) return { ok: false, status: 409, json: async () => ({ detail: "Project ID is already registered" }) };
|
||||
created = true; result = project;
|
||||
} else result = created ? [project] : [];
|
||||
}
|
||||
return { ok: true, json: async () => result };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const user = userEvent.setup();
|
||||
render(<Admin />);
|
||||
await user.type(await screen.findByLabelText('Username'), 'admin');
|
||||
await user.type(screen.getByLabelText('Password'), 'password-fixture');
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
const projects = await screen.findByRole('region', { name: 'Projects' });
|
||||
expect(within(projects).getByText('No projects yet. Create your first project below.')).toBeVisible();
|
||||
expect(within(projects).getByLabelText(/Owner ID/)).toHaveValue('user:admin');
|
||||
await user.type(within(projects).getByLabelText('Project name'), 'My research');
|
||||
expect(within(projects).getByLabelText(/Project ID/)).toHaveValue('my-research');
|
||||
await user.type(within(projects).getByLabelText('Project purpose'), 'Investigate useful techniques.');
|
||||
await user.type(within(projects).getByLabelText('Key research questions'), 'What works?\nUnder what conditions?');
|
||||
await user.click(within(projects).getByRole('button', { name: 'Create project' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Project ID is already registered');
|
||||
expect(within(projects).getByLabelText('Project name')).toHaveValue('My research');
|
||||
await user.type(await screen.findByLabelText("Username"), "admin");
|
||||
await user.type(screen.getByLabelText("Password"), "password-fixture");
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
const projects = await screen.findByRole("region", { name: "Projects" });
|
||||
expect(within(projects).getByText("No projects yet. Create your first project below.")).toBeVisible();
|
||||
await user.click(within(projects).getByRole("button", { name: "Create project" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Create a project" });
|
||||
expect(within(dialog).getByLabelText(/Owner ID/)).toHaveValue("user:admin");
|
||||
await user.type(within(dialog).getByLabelText("Project name"), "My research");
|
||||
expect(within(dialog).getByLabelText(/Project ID/)).toHaveValue("my-research");
|
||||
await user.type(within(dialog).getByLabelText("Project purpose"), "Investigate useful techniques.");
|
||||
await user.type(within(dialog).getByLabelText("Key research questions"), "What works?\nUnder what conditions?");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Create project" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Project ID is already registered");
|
||||
expect(within(dialog).getByLabelText("Project name")).toHaveValue("My research");
|
||||
conflict = false;
|
||||
await user.click(within(projects).getByRole('button', { name: 'Create project' }));
|
||||
await screen.findByText('Created My research. It is now available for user access and agent keys.');
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/projects', expect.objectContaining({
|
||||
method: 'POST', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }),
|
||||
await user.click(within(dialog).getByRole("button", { name: "Create project" }));
|
||||
await screen.findByText("Created My research. It is now available for user access and agent keys.");
|
||||
expect(fetchMock).toHaveBeenCalledWith("/v1/admin/projects", expect.objectContaining({
|
||||
method: "POST", headers: expect.objectContaining({ "X-GEWU-CSRF": "csrf-fixture" }),
|
||||
body: JSON.stringify({
|
||||
project: { project_id: 'my-research', title: 'My research', status: 'active', owners: ['user:admin'], north_star: 'Investigate useful techniques.', key_questions: ['What works?', 'Under what conditions?'], autonomy_policy_id: 'my-research-policy', default_includes: [] },
|
||||
policy: { policy_id: 'my-research-policy', policy_version: 1, project_id: 'my-research', automatic_source_registration: true, automatic_node_types: ['finding', 'problem', 'technique', 'case', 'entity', 'event', 'concept', 'datapoint', 'synthesis'], require_supported_assessment: true },
|
||||
project: { project_id: "my-research", title: "My research", status: "active", owners: ["user:admin"], north_star: "Investigate useful techniques.", key_questions: ["What works?", "Under what conditions?"], autonomy_policy_id: "my-research-policy", default_includes: [] },
|
||||
policy: { policy_id: "my-research-policy", policy_version: 1, project_id: "my-research", automatic_source_registration: true, automatic_node_types: ["finding", "problem", "technique", "case", "entity", "event", "concept", "datapoint", "synthesis"], require_supported_assessment: true },
|
||||
}),
|
||||
}));
|
||||
expect(within(projects).getByLabelText('Project name')).toHaveValue('');
|
||||
await user.click(screen.getByRole('tab', { name: 'Users' }));
|
||||
expect(within(screen.getByRole('region', { name: 'User accounts' })).getByLabelText('My research')).toBeVisible();
|
||||
await user.click(screen.getByRole('tab', { name: 'Credentials' }));
|
||||
expect(within(screen.getByRole('region', { name: 'Create a credential' })).getByLabelText('My research')).toBeVisible();
|
||||
await user.click(screen.getByRole("tab", { name: "Users" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Add user" }));
|
||||
const usersDialog = await screen.findByRole("dialog", { name: "Add user" });
|
||||
expect(within(usersDialog).getByLabelText("My research")).toBeVisible();
|
||||
await user.click(screen.getByRole("tab", { name: "Credentials" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Add credential" }));
|
||||
const credsDialog = await screen.findByRole("dialog", { name: "Add credential" });
|
||||
expect(within(credsDialog).getByLabelText("My research")).toBeVisible();
|
||||
});
|
||||
@@ -166,7 +166,7 @@ function ProjectsSection({ csrf, projects, currentOwner, onCreated, onError, onM
|
||||
<label className="admin-wide">Key research questions<textarea required maxLength={16000} rows={4} value={editQuestions} onChange={event => setEditQuestions(event.target.value)} placeholder="One question per line" /></label>
|
||||
<div className="admin-wide"><button disabled={busy}>{busy ? 'Saving…' : 'Save changes'}</button> <button className="text-button" type="button" disabled={busy} onClick={() => setEditing(null)}>Cancel</button></div>
|
||||
</form>}
|
||||
</li>)}</ul> : <p>No projects yet.</p>}
|
||||
</li>)}</ul> : <p>No projects yet. Create your first project below.</p>}
|
||||
<AdminDialog open={Boolean(viewing)} title={viewing?.title ?? 'Project details'} onClose={() => setViewing(null)} returnFocusRef={viewButtonRef}>
|
||||
{viewing && <>
|
||||
<p className="muted-copy">{viewing.project_id} · {viewing.status}</p>
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import type { components } from '../generated/api';
|
||||
|
||||
type SourceRevisionRecord = components['schemas']['SourceRevisionRecord'];
|
||||
type SourceRelatedNode = components['schemas']['SourceRelatedNode'];
|
||||
type SourceRelatedNodesEnvelope = components['schemas']['SourceRelatedNodesEnvelope'];
|
||||
type ReadEnvelope = components['schemas']['ReadEnvelope'];
|
||||
|
||||
interface SourceDetailProps {
|
||||
project: string;
|
||||
revisionId: string;
|
||||
onBack: () => void;
|
||||
onSelectNode: (nodeId: string, projectId: string) => void;
|
||||
csrfToken?: string;
|
||||
includeShared?: boolean;
|
||||
}
|
||||
|
||||
function safeExternalUrl(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return parsed.toString();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SourceDetail({
|
||||
project,
|
||||
revisionId,
|
||||
onBack,
|
||||
onSelectNode,
|
||||
csrfToken,
|
||||
includeShared,
|
||||
}: SourceDetailProps): React.JSX.Element {
|
||||
const [source, setSource] = useState<SourceRevisionRecord | null>(null);
|
||||
const [sourceLoading, setSourceLoading] = useState<boolean>(true);
|
||||
const [sourceError, setSourceError] = useState<string | null>(null);
|
||||
|
||||
const [nodes, setNodes] = useState<SourceRelatedNode[]>([]);
|
||||
const [totalNodes, setTotalNodes] = useState<number>(0);
|
||||
const [nodesLoading, setNodesLoading] = useState<boolean>(true);
|
||||
const [nodesError, setNodesError] = useState<string | null>(null);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [pageCursors, setPageCursors] = useState<(string | null)[]>([null]);
|
||||
const [query, setQuery] = useState<string>('');
|
||||
const [nodeType, setNodeType] = useState<string>('');
|
||||
|
||||
const effectiveProject = source?.ingested_for?.includes(project)
|
||||
? project
|
||||
: (source?.ingested_for?.[0] ?? project);
|
||||
|
||||
// Load source metadata with fallback to shared
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setSourceLoading(true);
|
||||
setSourceError(null);
|
||||
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (csrfToken) {
|
||||
headers['X-GEWU-CSRF'] = csrfToken;
|
||||
}
|
||||
|
||||
const fetchSource = async () => {
|
||||
let res = await fetch(`/v1/projects/${encodeURIComponent(project)}/sources/${encodeURIComponent(revisionId)}`, {
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (res.status === 404 && project !== 'shared') {
|
||||
const sharedRes = await fetch(`/v1/projects/shared/sources/${encodeURIComponent(revisionId)}`, {
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (sharedRes.ok) {
|
||||
res = sharedRes;
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load source (${res.status})`);
|
||||
}
|
||||
return res.json() as Promise<ReadEnvelope>;
|
||||
};
|
||||
|
||||
fetchSource()
|
||||
.then((data) => {
|
||||
if (!active) return;
|
||||
const found = (data.data?.[0] as unknown as SourceRevisionRecord) ?? null;
|
||||
setSource(found);
|
||||
setSourceLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!active) return;
|
||||
setSourceError(err instanceof Error ? err.message : 'Error loading source');
|
||||
setSourceLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [project, revisionId, csrfToken]);
|
||||
|
||||
// Load citing/referencing nodes
|
||||
const loadNodes = useCallback((nextCursor: string | null = null, resetPage = true) => {
|
||||
setNodesLoading(true);
|
||||
setNodesError(null);
|
||||
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (csrfToken) {
|
||||
headers['X-GEWU-CSRF'] = csrfToken;
|
||||
}
|
||||
|
||||
const targetProject = effectiveProject;
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '20');
|
||||
if (includeShared) {
|
||||
params.set('include_shared', 'true');
|
||||
}
|
||||
if (nextCursor) params.set('cursor', nextCursor);
|
||||
if (query.trim()) params.set('query', query.trim());
|
||||
if (nodeType) params.set('node_type', nodeType);
|
||||
|
||||
const fetchRelatedNodes = async () => {
|
||||
let res = await fetch(
|
||||
`/v1/projects/${encodeURIComponent(targetProject)}/sources/${encodeURIComponent(revisionId)}/nodes?${params.toString()}`,
|
||||
{ headers, credentials: 'same-origin' },
|
||||
);
|
||||
if (res.status === 404 && targetProject !== 'shared') {
|
||||
const sharedRes = await fetch(
|
||||
`/v1/projects/shared/sources/${encodeURIComponent(revisionId)}/nodes?${params.toString()}`,
|
||||
{ headers, credentials: 'same-origin' },
|
||||
);
|
||||
if (sharedRes.ok) {
|
||||
res = sharedRes;
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load related nodes (${res.status})`);
|
||||
}
|
||||
return res.json() as Promise<SourceRelatedNodesEnvelope>;
|
||||
};
|
||||
|
||||
fetchRelatedNodes()
|
||||
.then((data) => {
|
||||
setNodes(data.nodes ?? []);
|
||||
setTotalNodes(data.total ?? 0);
|
||||
setCursor(data.next_cursor ?? null);
|
||||
if (resetPage) {
|
||||
setPageCursors([null]);
|
||||
}
|
||||
setNodesLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setNodesError(err instanceof Error ? err.message : 'Error loading related nodes');
|
||||
setNodesLoading(false);
|
||||
});
|
||||
}, [effectiveProject, revisionId, csrfToken, query, nodeType, includeShared]);
|
||||
|
||||
useEffect(() => {
|
||||
loadNodes(null, true);
|
||||
}, [loadNodes]);
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
loadNodes(null, true);
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (pageCursors.length <= 1) return;
|
||||
const nextCursors = pageCursors.slice(0, -1);
|
||||
setPageCursors(nextCursors);
|
||||
loadNodes(nextCursors.at(-1) ?? null, false);
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (!cursor) return;
|
||||
setPageCursors((prev) => [...prev, cursor]);
|
||||
loadNodes(cursor, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="source-detail-view" aria-labelledby="source-detail-heading">
|
||||
<div className="source-detail-header">
|
||||
<button type="button" className="quiet-button back-button" onClick={onBack}>
|
||||
← Back to Sources
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sourceLoading ? (
|
||||
<p className="state-card" role="status">Loading source record…</p>
|
||||
) : sourceError ? (
|
||||
<div className="state-card" role="alert">
|
||||
<h3>Source record unavailable</h3>
|
||||
<p>{sourceError}</p>
|
||||
<button type="button" onClick={() => onBack()}>Return to sources</button>
|
||||
</div>
|
||||
) : source ? (
|
||||
<>
|
||||
<header className="source-meta-card">
|
||||
<div className="source-meta-badges">
|
||||
<span className="badge badge--type">{source.source_type}</span>
|
||||
<span className="badge badge--grade">{source.reliability_grade} reliability</span>
|
||||
<span className="badge badge--status">{source.archive_status}</span>
|
||||
</div>
|
||||
<h1 id="source-detail-heading" className="source-detail-title">{source.title}</h1>
|
||||
<p className="source-detail-publisher">
|
||||
{source.publisher ? <strong>{source.publisher}</strong> : 'Publisher not recorded'}
|
||||
{source.captured_at ? ` · Captured on ${source.captured_at.slice(0, 10)}` : ''}
|
||||
{source.license ? ` · License: ${source.license}` : ''}
|
||||
</p>
|
||||
|
||||
<div className="source-actions">
|
||||
{safeExternalUrl(source.original_url ?? '') ? (
|
||||
<a
|
||||
className="action-button action-button--primary"
|
||||
href={safeExternalUrl(source.original_url ?? '') ?? undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open original URL ↗
|
||||
</a>
|
||||
) : null}
|
||||
{source.archive_status === 'archived' ? (
|
||||
<a
|
||||
className="action-button action-button--secondary"
|
||||
href={`/v1/projects/${encodeURIComponent(effectiveProject)}/sources/${encodeURIComponent(source.revision_id)}/artifact`}
|
||||
download
|
||||
>
|
||||
Download archived artifact
|
||||
</a>
|
||||
) : (
|
||||
<span className="muted-copy">No local artifact available</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details className="source-tech-details">
|
||||
<summary>Technical & extraction metadata</summary>
|
||||
<dl className="tech-meta-grid">
|
||||
<div>
|
||||
<dt>Source Revision ID</dt>
|
||||
<dd><code>{source.revision_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Project</dt>
|
||||
<dd>{project}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Media Type</dt>
|
||||
<dd>{source.media_type || 'Unknown'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Artifact SHA-256</dt>
|
||||
<dd><code>{source.artifact_sha256 || 'None'}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Byte Length</dt>
|
||||
<dd>{source.byte_length ? `${source.byte_length.toLocaleString()} bytes` : '0'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Capture Method</dt>
|
||||
<dd>{source.capture_method || 'Unknown'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
</header>
|
||||
|
||||
<section className="source-related-nodes" aria-labelledby="related-nodes-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">RESEARCH EVIDENCE & CITATIONS</p>
|
||||
<h2 id="related-nodes-heading">
|
||||
Knowledge referencing this source
|
||||
</h2>
|
||||
<p className="scope-note">
|
||||
Nodes that cite this source in their evidence claims or connect via knowledge graph edges.
|
||||
</p>
|
||||
</div>
|
||||
<span className="result-count">{totalNodes} referencing {totalNodes === 1 ? 'node' : 'nodes'}</span>
|
||||
</div>
|
||||
|
||||
<form className="source-nodes-filter" onSubmit={handleSearchSubmit}>
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Filter referencing nodes"
|
||||
placeholder="Filter by keyword…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
aria-label="Filter by node type"
|
||||
value={nodeType}
|
||||
onChange={(e) => setNodeType(e.target.value)}
|
||||
>
|
||||
<option value="">All node types</option>
|
||||
<option value="observation">Observation</option>
|
||||
<option value="claim">Claim</option>
|
||||
<option value="concept">Concept</option>
|
||||
<option value="problem">Problem</option>
|
||||
<option value="technique">Technique</option>
|
||||
<option value="case">Case</option>
|
||||
<option value="entity">Entity</option>
|
||||
<option value="event">Event</option>
|
||||
<option value="synthesis">Synthesis</option>
|
||||
<option value="datapoint">Datapoint</option>
|
||||
</select>
|
||||
<button type="submit" disabled={nodesLoading}>Filter</button>
|
||||
{(query || nodeType) && (
|
||||
<button
|
||||
type="button"
|
||||
className="quiet-button"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
setNodeType('');
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{nodesLoading ? (
|
||||
<p className="state-card" role="status">Loading referencing knowledge…</p>
|
||||
) : nodesError ? (
|
||||
<div className="state-card" role="alert">
|
||||
<h3>Could not load referencing nodes</h3>
|
||||
<p>{nodesError}</p>
|
||||
<button type="button" onClick={() => loadNodes(null, true)}>Retry</button>
|
||||
</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div className="state-card">
|
||||
<h3>No referencing knowledge nodes found</h3>
|
||||
<p>
|
||||
{query || nodeType
|
||||
? 'No nodes match your filter criteria for this source.'
|
||||
: 'This source record is not yet referenced by any accepted knowledge notes in this project scope.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="result-list" aria-label="Referencing knowledge nodes">
|
||||
{nodes.map(({ node, relation_basis }) => (
|
||||
<li key={node.node_id}>
|
||||
<div
|
||||
className="result-row"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelectNode(node.node_id, node.project_id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelectNode(node.node_id, node.project_id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="result-row__meta">
|
||||
<span className={`node-type-tag node-type--${node.node_type}`}>
|
||||
{node.node_type}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className={`basis-badge basis-badge--${relation_basis}`}>
|
||||
{relation_basis === 'citation'
|
||||
? 'Cited in claim'
|
||||
: relation_basis === 'edge'
|
||||
? 'Graph relationship'
|
||||
: 'Cited & Graph edge'}
|
||||
</span>
|
||||
</div>
|
||||
<strong>{node.title}</strong>
|
||||
<span className="result-row__summary">{node.summary}</span>
|
||||
<span className="result-row__foot">
|
||||
As of {node.as_of ?? 'undated'} · {node.lifecycle}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="pagination-controls">
|
||||
<button
|
||||
type="button"
|
||||
disabled={nodesLoading || pageCursors.length < 2}
|
||||
onClick={handlePrevPage}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
{nodes.length
|
||||
? `${(pageCursors.length - 1) * 20 + 1}–${(pageCursors.length - 1) * 20 + nodes.length} of ${totalNodes}`
|
||||
: `0 of ${totalNodes}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={nodesLoading || !cursor}
|
||||
onClick={handleNextPage}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user