Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 75 additions & 5 deletions platform/test/e2e/dashboard-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,37 @@ test('settings cards load independently and preserve their layout', async ({ pag
} finally { await scenario.clear(); }
});

test('notification panel stays opaque and above the Sources form on a narrow screen', async ({ page }, testInfo) => {
const scenario = await accountScenario(page, { responses: { '/v1/notifications': { body: { notifications: Array.from({ length: 5 }, (_, index) => ({
id: `notice-${index}`, type: 'monitor', title: 'New video', body: 'A monitor found a match', data_json: '{}',
read_at: null, created_at: Date.now() - index * 60_000,
})) } } } });
try {
await page.setViewportSize({ width: 491, height: 610 });
await page.goto('/dashboard/sources');
const trigger = page.getByRole('button', { name: '5 unread notifications' });
await trigger.click();
const panel = page.getByRole('dialog', { name: 'Notifications' });
await expect(panel).toBeVisible();
const panelBox = (await panel.boundingBox())!;
const inspectBox = (await page.getByRole('button', { name: /Inspect/ }).boundingBox())!;
const left = Math.max(panelBox.x, inspectBox.x);
const right = Math.min(panelBox.x + panelBox.width, inspectBox.x + inspectBox.width);
const top = Math.max(panelBox.y, inspectBox.y);
const bottom = Math.min(panelBox.y + panelBox.height, inspectBox.y + inspectBox.height);
expect(right).toBeGreaterThan(left);
expect(bottom).toBeGreaterThan(top);
const x = (left + right) / 2, y = (top + bottom) / 2;
await page.screenshot({ path: testInfo.outputPath('notifications-over-sources.png') });
const overlay = await page.evaluate<{ panelIsTopmost: boolean; background: string }>(`({
panelIsTopmost: Boolean(document.elementFromPoint(${x}, ${y})?.closest('.notification-popover')),
background: getComputedStyle(document.querySelector('.notification-popover')).backgroundColor,
})`);
expect(overlay.panelIsTopmost).toBe(true);
expect(overlay.background).not.toMatch(/\/\s*0(?:\.0+)?\)/);
} finally { await scenario.clear(); }
});

test('dashboard navigation reuses account data without browser refetches', async ({ page }) => {
const scenario = await accountScenario(page, {});
const reads: string[] = [];
Expand Down Expand Up @@ -545,28 +576,67 @@ test('warm settings stays usable while a return visit server read is delayed', a
} finally { await scenario.clear(); }
});

test('settings sidebar opens the selected project and the new-project dialog', async ({ page }) => {
test('settings sidebar expands project sources and opens the new-project dialog', async ({ page }) => {
const scenario = await accountScenario(page, { responses: { '/v1/projects': { body: { projects: [{ id: 'research', name: 'Saved research' }] } } } });
try {
await page.route('**/api/platform/v1/projects/research', route => route.fulfill({ json: { id: 'research', name: 'Saved research', items: [] } }));
await page.route('**/api/platform/v1/projects/research', route => route.fulfill({ json: { id: 'research', name: 'Saved research', items: [{ id: 'item-1', provider: 'youtube', entity_type: 'video', entity_id: videoId, title: 'Sample video' }] } }));
await page.goto('/dashboard/settings');
await page.getByRole('button', { name: 'Saved research', exact: true }).click();
const folder = page.getByRole('button', { name: 'Saved research', exact: true }).first();
await folder.click();
await expect(folder).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByRole('group', { name: 'Sources in Saved research' }).first().getByRole('button', { name: 'Sample video' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Saved research', exact: true })).toBeVisible();
await folder.click();
await expect(folder).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByRole('group', { name: 'Sources in Saved research' }).first()).toHaveCount(0);
await page.getByRole('link', { name: 'Settings', exact: true }).click();
await page.getByRole('button', { name: 'Create a new project', exact: true }).first().click();
await expect(page.getByRole('dialog', { name: 'Name this line of inquiry' })).toBeVisible();
await expect(page.getByRole('dialog', { name: 'New project' })).toBeVisible();
} finally { await scenario.clear(); }
});

test('project creation shows progress and adds the project without another list request', async ({ page }) => {
const scenario = await accountScenario(page, { responses: { '/v1/projects': { body: { projects: [] } } } });
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
let creates = 0;
await page.route('**/api/platform/v1/projects', async route => {
if (route.request().method() !== 'POST') return route.continue();
creates++;
await gate;
return route.fulfill({ status: 201, json: { id: 'new-research', name: 'Video research' } });
});
try {
await page.goto('/dashboard/projects');
const readsBefore = (await scenario.reads())['/v1/projects'] ?? 0;
await page.getByRole('button', { name: 'New project', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'New project' });
await expect(dialog.getByText('Keep related sources in one place.')).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Create', exact: true })).toBeDisabled();
await dialog.getByRole('textbox', { name: 'Name' }).fill('Video research');
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
await expect(dialog).toHaveAttribute('aria-busy', 'true');
await expect(dialog.getByRole('button', { name: 'Creating…' })).toBeDisabled();
expect(creates).toBe(1);
release();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Video research', exact: true }).first()).toBeVisible();
expect((await scenario.reads())['/v1/projects'] ?? 0).toBe(readsBefore);
} finally { release(); await scenario.clear(); }
});

test('settings renders while navigation access checks are pending', async ({page})=>{
const scenario=await accountScenario(page,{delays:['/v1/agent/access','/v1/admin/access']});
try{
await page.goto('/dashboard/settings',{waitUntil:'commit'});
await expect(page.getByRole('switch',{name:/In-app alerts/})).toBeEnabled();
await expect(page.getByRole('button',{name:'Upgrade to Builder'})).toBeEnabled();
const navigation = page.getByRole('navigation', { name: 'Dashboard navigation' });
await expect(page.getByRole('link',{name:'Agent',exact:true})).toHaveCount(0);
await expect.poll(async () => (await navigation.locator('a').allTextContents()).slice(0, 2).map(label => label.trim())).toEqual(['Sources', 'Trends']);
await scenario.release();
await expect(page.getByRole('link',{name:'Agent',exact:true})).toBeVisible();
await expect.poll(async () => (await navigation.locator('a').allTextContents()).slice(0, 3).map(label => label.trim())).toEqual(['Sources', 'Agent', 'Trends']);
}finally{await scenario.clear();}
});

Expand Down Expand Up @@ -599,7 +669,7 @@ test('an active trend request survives projects navigation without restarting',a
await page.getByRole('link',{name:'Projects',exact:true}).click();
await expect(page.getByRole('heading',{name:'Your projects'})).toBeVisible();
release();
await page.getByRole('link',{name:'Trend Lab',exact:true}).click();
await page.getByRole('link',{name:'Trends',exact:true}).click();
await expect(page.getByRole('alert').filter({hasText:'Retained scan completed'})).toBeVisible();
expect(reads).toBe(1);
});
Expand Down
4 changes: 4 additions & 0 deletions platform/test/e2e/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ test('signed-out dashboard visits redirect to login and preserve the destination
await page.goto('/login?returnTo=%2Fdashboard%2Fdeveloper');
await expect(page).toHaveURL('/dashboard/developer');
await page.goto('/login?returnTo=https%3A%2F%2Fevil.test');
await expect(page).toHaveURL('/dashboard/sources');
await page.goto('/dashboard');
await expect(page).toHaveURL('/dashboard/sources');
await page.goto('/dashboard?section=trends');
await expect(page).toHaveURL('/dashboard/trends');
});

Expand Down
15 changes: 13 additions & 2 deletions web/app/dashboard/DashboardSidebar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,19 @@
.projectHeading { display: flex; align-items: center; justify-content: space-between; padding-left: .25rem; }
.projectHeading button { width: 1.75rem; height: 1.75rem; }
.project { display: flex; align-items: center; gap: .625rem; width: 100%; border: 0; border-radius: .375rem; padding: .5rem .375rem; color: var(--color-dashboard-ink-soft); background: transparent; font: inherit; font-size: .75rem; text-align: left; cursor: pointer; }
.project > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.projectDot { width: .375rem; height: .375rem; flex-shrink: 0; border-radius: 50%; background: var(--color-dashboard-accent); }
.project > svg { flex: 0 0 1.0625rem; color: var(--color-dashboard-muted); }
.project[aria-expanded='true'] { color: var(--color-dashboard-ink); }
.project[aria-expanded='true'] > svg { color: var(--color-dashboard-accent); }
.projectName { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.projectItems { display: grid; gap: .125rem; margin: .125rem 0 .375rem 1rem; padding-left: .625rem; border-left: 1px solid var(--color-dashboard-rule); }
.projectItem { display: flex; align-items: center; gap: .5rem; min-width: 0; width: 100%; padding: .375rem .5rem; border: 0; border-radius: .375rem; background: transparent; color: var(--color-dashboard-ink-soft); font: inherit; font-size: .6875rem; text-align: left; cursor: pointer; }
.projectItem:hover { background: var(--color-dashboard-surface-muted); color: var(--color-dashboard-ink); }
.projectItem > svg { flex: 0 0 .875rem; color: var(--color-dashboard-muted); }
.projectItem > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.projectEmpty { padding: .375rem .5rem; color: var(--color-dashboard-muted); font-size: .6875rem; }
.projectItemSkeleton { display: grid; gap: .5rem; padding: .5rem; }
.projectItemSkeleton > i { width: 75%; height: .625rem; }
.projectItemSkeleton > i:last-child { width: 55%; }
.projects p { margin: .375rem .25rem; color: var(--color-dashboard-muted); font-size: .6875rem; line-height: 1.6; }
.quickCreate { display: none; }
.footer { display: grid; gap: .25rem; padding-top: .5rem; }
Expand Down
51 changes: 45 additions & 6 deletions web/app/dashboard/DashboardSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
'use client';

import { useAccountResource } from './DashboardDataProvider';
import { useAccountResource, useDashboardDraft } from './DashboardDataProvider';

import Link from 'next/link';
import { dashboardPath, SOURCES_HOME_EVENT } from './dashboard-routes';
import { useDashboardSession } from './DashboardSessionProvider';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { SidebarSimpleIcon, KeyIcon, BookOpenIcon, CoinsIcon, SignOutIcon, CaretDownIcon, ListIcon, XIcon } from '@phosphor-icons/react';
import { SidebarSimpleIcon, KeyIcon, BookOpenIcon, CoinsIcon, SignOutIcon, CaretDownIcon, ListIcon, XIcon, FolderIcon, FolderOpenIcon } from '@phosphor-icons/react';
import { platformRequest as api, isAbortError } from '../../lib/platform-request';
import type { ProjectDetail, ProjectItem } from './research-types';
import styles from './DashboardSidebar.module.css';

export type DashboardSection = 'trends' | 'discover' | 'projects' | 'monitors' | 'settings';
Expand Down Expand Up @@ -38,6 +40,7 @@ type DashboardSidebarProps<Project extends SidebarProject> = {
onNavigate: (section: DashboardSection) => void;
onNewProject: () => void;
onOpenProject: (project: Project) => void;
onOpenProjectItem: (item: ProjectItem) => void;
onSignIn: () => void;
accountName?: string;
credits?: number;
Expand All @@ -46,13 +49,28 @@ type DashboardSidebarProps<Project extends SidebarProject> = {

const COLLAPSED_KEY = 'video2ctx.sidebar.collapsed';

export function DashboardSidebar<Project extends SidebarProject>({ activeSection, projects, onNavigate, onNewProject, onOpenProject, onSignIn, accountName, credits, onSignOut }: DashboardSidebarProps<Project>) {
export function DashboardSidebar<Project extends SidebarProject>({ activeSection, projects, onNavigate, onNewProject, onOpenProject, onOpenProjectItem, onSignIn, accountName, credits, onSignOut }: DashboardSidebarProps<Project>) {
const { agentAccess, adminAccess, isSigningOut } = useDashboardSession();
const projectsResource = useAccountResource('projects', []);
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [expandedProjectId, setExpandedProjectId] = useDashboardDraft<string | null>('expanded-sidebar-project', null);
const [projectDetail, setProjectDetail] = useState<ProjectDetail | null>(null);
const [projectError, setProjectError] = useState('');
const [retryProject, setRetryProject] = useState(0);
const dialog = useRef<HTMLDialogElement>(null);

useEffect(() => {
if (!expandedProjectId || !projects.some(project => project.id === expandedProjectId)) return;
const controller = new AbortController();
setProjectDetail(null);
setProjectError('');
void api<ProjectDetail>(`/v1/projects/${encodeURIComponent(expandedProjectId)}`, { signal: controller.signal })
.then(setProjectDetail)
.catch(cause => { if (!isAbortError(cause)) setProjectError(cause instanceof Error ? cause.message : 'Could not load saved sources.'); });
return () => controller.abort();
}, [expandedProjectId, projects, retryProject]);

useEffect(() => {
try { setCollapsed(localStorage.getItem(COLLAPSED_KEY) === 'true'); } catch { /* Storage may be disabled. */ }
const desktop = window.matchMedia('(min-width: 701px)');
Expand All @@ -67,6 +85,12 @@ export function DashboardSidebar<Project extends SidebarProject>({ activeSection
try { localStorage.setItem(COLLAPSED_KEY, String(next)); } catch { /* Keep the toggle usable without storage. */ }
};
const run = (action: () => void) => { dialog.current?.close(); action(); };
const toggleProject = (project: Project) => {
if (expandedProjectId === project.id) { setExpandedProjectId(null); return; }
setProjectDetail(null);
setExpandedProjectId(project.id);
onOpenProject(project);
};
const navButton = (section: DashboardSection, label: string, icon: IconName, suffix?: ReactNode) => (
<Link href={dashboardPath(section)} prefetch={false} aria-label={label} title={collapsed ? label : undefined} data-tooltip={label} aria-current={activeSection === section ? 'page' : undefined} className={styles.item} onClick={event => {
dialog.current?.close();
Expand All @@ -87,9 +111,9 @@ export function DashboardSidebar<Project extends SidebarProject>({ activeSection
<div className={styles.scrollArea}>
<nav aria-label='Dashboard navigation' className={styles.navigation}>
<div className={styles.group}>
{navButton('trends', 'Trend Lab', 'trend')}
{navButton('discover', 'Sources', 'search')}
{agentAccess && <Link aria-label='Agent' title={collapsed ? 'Agent' : undefined} data-tooltip='Agent' aria-current={activeSection === 'sessions' ? 'page' : undefined} className={styles.item} href='/dashboard/sessions' prefetch={false} onClick={() => dialog.current?.close()}><span className={styles.iconTile}><Icon name='spark' /></span><span className={styles.label}>Agent</span></Link>}
{navButton('trends', 'Trends', 'trend')}
</div>
<div className={styles.group}>
<p className={styles.groupLabel}>Workspace</p>
Expand All @@ -105,8 +129,23 @@ export function DashboardSidebar<Project extends SidebarProject>({ activeSection
</nav>
<section className={styles.projects} aria-label='Recent projects'>
<div className={styles.projectHeading}><span>Recent projects</span><button type='button' aria-label='Create a new project' onClick={() => run(onNewProject)}><Icon name='plus' size={15} /></button></div>
{projects.slice(0, 5).map(project => <button type='button' className={styles.project} key={project.id} title={project.name} onClick={() => run(() => onOpenProject(project))}><span className={styles.projectDot} /><span>{project.name}</span></button>)}
{!projectsResource.ready && !projectsResource.error && <div role='status' aria-label='Loading recent projects'>{[0, 1, 2].map(index => <div className={styles.project} key={index} aria-hidden='true'><span className={styles.projectDot} /><span className='skeleton-action'><i className='ui-bar' /></span></div>)}</div>}
{projects.slice(0, 5).map(project => {
const expanded = expandedProjectId === project.id;
return <div className={styles.projectGroup} key={project.id}>
<button type='button' className={styles.project} title={project.name} aria-expanded={expanded} onClick={() => toggleProject(project)}>
{expanded ? <FolderOpenIcon size={17} aria-hidden='true' /> : <FolderIcon size={17} aria-hidden='true' />}
<span className={styles.projectName}>{project.name}</span>
</button>
{expanded && <div className={styles.projectItems} role='group' aria-label={`Sources in ${project.name}`}>
{!projectDetail && !projectError && <div role='status' aria-label={`Loading sources in ${project.name}`} className={styles.projectItemSkeleton}><i className='ui-bar' /><i className='ui-bar' /></div>}
{projectError && <button type='button' className={styles.projectItem} onClick={() => setRetryProject(value => value + 1)}>Retry loading sources</button>}
{projectDetail?.id === project.id && (projectDetail.items.length
? projectDetail.items.map(item => <button type='button' className={styles.projectItem} key={item.id} title={item.title || item.entity_id} onClick={() => run(() => onOpenProjectItem(item))}><Icon name='search' size={14} /><span>{item.title || item.entity_id}</span></button>)
: <span className={styles.projectEmpty}>No saved sources</span>)}
</div>}
</div>;
})}
{!projectsResource.ready && !projectsResource.error && <div role='status' aria-label='Loading recent projects'>{[0, 1, 2].map(index => <div className={styles.project} key={index} aria-hidden='true'><FolderIcon size={17} /><span className='skeleton-action'><i className='ui-bar' /></span></div>)}</div>}
{projectsResource.ready && !projects.length && <p>Save a source to start a project.</p>}
</section>
<button type='button' className={styles.item + ' ' + styles.quickCreate} aria-label='Create a new project' title='New project' data-tooltip='New project' onClick={() => run(onNewProject)}><span className={styles.iconTile}><Icon name='plus' /></span></button>
Expand Down
Loading
Loading