Docs sync progress, Personal/Company list UX, Analytics heatmap (#92–#94) (#95)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m0s

## Summary
- **#92** — Drive sync progress bar (determinate when total known; indeterminate while listing).
- **#93** — Documents list: Personal vs Company title, company helper copy only on company scope, Personal|Company tabs for company members, search/sort/pagination.
- **#94** — Analytics overhaul: Your activity (heatmap + volume charts) vs Company section for managers/members.

Depends on backend PR for #59/#60 + heatmap API.

## Test plan
- [x] Jest: DocumentStoragePage, Analytics, drive utils
- [ ] Manual: Sync Now shows progress N/M
- [ ] Manual: company user tabs Personal/Company; personal-only users see Personal documents only
- [ ] Manual: Analytics shows heatmap under Your activity

Closes #92
Closes #93
Closes #94Reviewed-on: #95
This commit was merged in pull request #95.
This commit is contained in:
2026-08-02 04:50:20 -07:00
parent 6c96c50d99
commit 4501ee8f3d
8 changed files with 917 additions and 97 deletions
@@ -7,6 +7,7 @@ import {
DriveProvider, DriveProvider,
connectDrive, connectDrive,
disconnectDriveConnection, disconnectDriveConnection,
driveSyncProgressPercent,
fetchDriveConnections, fetchDriveConnections,
formatDriveSyncError, formatDriveSyncError,
parseResourceIdsInput, parseResourceIdsInput,
@@ -212,6 +213,44 @@ const SyncErrorText = styled.p`
line-height: 1.4; line-height: 1.4;
`; `;
const ProgressWrap = styled.div`
margin-top: 0.85rem;
`;
const ProgressTrack = styled.div`
width: 100%;
height: 8px;
border-radius: 999px;
overflow: hidden;
background: ${({ theme }) =>
theme.darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)'};
`;
const ProgressFill = styled.div<{ $percent: number | null }>`
height: 100%;
width: ${({ $percent }) => ($percent == null ? '40%' : `${$percent}%`)};
border-radius: 999px;
background: ${({ theme }) => theme.main};
transition: width 0.25s ease;
${({ $percent }) =>
$percent == null
? `
animation: sync-indeterminate 1.2s ease-in-out infinite;
@keyframes sync-indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}
`
: ''}
`;
const ProgressLabel = styled.p`
margin: 0.4rem 0 0 0;
font-size: 0.8rem;
color: ${({ theme }) => theme.colors.text};
opacity: 0.7;
`;
const PROVIDER_LABELS: Record<DriveProvider, string> = { const PROVIDER_LABELS: Record<DriveProvider, string> = {
google: "Google Drive", google: "Google Drive",
microsoft: "OneDrive", microsoft: "OneDrive",
@@ -297,7 +336,9 @@ const DriveConnectionsSection = ({
const settled = const settled =
enqueued.connection.last_sync_status === 'pending' enqueued.connection.last_sync_status === 'pending'
? await waitForDriveSyncSettlement(id) ? await waitForDriveSyncSettlement(id, {
onProgress: (connection) => upsertConnection(connection),
})
: enqueued.connection; : enqueued.connection;
upsertConnection(settled); upsertConnection(settled);
@@ -404,6 +445,9 @@ const DriveConnectionsSection = ({
? conn.selected_resource_labels ? conn.selected_resource_labels
: conn.selected_resource_ids || []; : conn.selected_resource_ids || [];
const isSyncing = action === 'sync' || conn.last_sync_status === 'pending'; const isSyncing = action === 'sync' || conn.last_sync_status === 'pending';
const progressPercent = isSyncing ? driveSyncProgressPercent(conn) : null;
const processed = conn.sync_processed ?? 0;
const total = conn.sync_total ?? 0;
return ( return (
<ConnectionCard key={conn.id}> <ConnectionCard key={conn.id}>
@@ -436,6 +480,19 @@ const DriveConnectionsSection = ({
</ConnectionActions> </ConnectionActions>
</ConnectionHeader> </ConnectionHeader>
{isSyncing && (
<ProgressWrap aria-label="Drive sync progress">
<ProgressTrack>
<ProgressFill $percent={progressPercent} />
</ProgressTrack>
<ProgressLabel>
{progressPercent == null
? 'Discovering files…'
: `${processed} / ${total} files (${progressPercent}%)`}
</ProgressLabel>
</ProgressWrap>
)}
{conn.last_sync_status === 'error' && conn.last_sync_error && ( {conn.last_sync_status === 'error' && conn.last_sync_error && (
<SyncErrorText>{formatDriveSyncError(conn.last_sync_error)}</SyncErrorText> <SyncErrorText>{formatDriveSyncError(conn.last_sync_error)}</SyncErrorText>
)} )}
@@ -0,0 +1,230 @@
import React, { useEffect, useMemo, useState } from 'react';
import styled, { useTheme } from 'styled-components';
import { AxiosResponse } from 'axios';
import { axiosInstance } from '../../../axiosApi';
export type PromptHeatmapData = {
tz: string;
total: number;
max: number;
days: string[];
hours: number[];
matrix: number[][];
most_active_day: string | null;
most_active_hour: number | null;
peak_cell: { day: string; hour: number; count: number } | null;
};
const GlassCard = styled.div`
background: ${({ theme }) => theme.colors.cardBackground};
backdrop-filter: blur(10px);
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 1rem;
padding: 2rem;
width: 100%;
max-width: 1000px;
margin-bottom: 2rem;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
`;
const CardTitle = styled.h2`
font-size: 1.8rem;
margin-bottom: 0.5rem;
color: ${({ theme }) => theme.colors.text};
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
padding-bottom: 1rem;
`;
const Subtitle = styled.p`
margin: 0.75rem 0 1.25rem 0;
color: ${({ theme }) => theme.colors.text};
opacity: 0.7;
font-size: 0.95rem;
line-height: 1.5;
`;
const GridScroll = styled.div`
overflow-x: auto;
`;
const HeatmapGrid = styled.div`
display: grid;
grid-template-columns: 3rem repeat(24, minmax(14px, 1fr));
gap: 3px;
min-width: 520px;
`;
const Corner = styled.div``;
const AxisLabel = styled.div`
font-size: 0.7rem;
color: ${({ theme }) => theme.colors.text};
opacity: 0.55;
display: flex;
align-items: center;
justify-content: center;
`;
const DayLabel = styled(AxisLabel)`
justify-content: flex-start;
padding-left: 0.15rem;
`;
const Cell = styled.button<{ $intensity: number; $color: string }>`
aspect-ratio: 1;
border: none;
border-radius: 3px;
padding: 0;
cursor: default;
background: ${({ $intensity, $color, theme }) =>
$intensity <= 0
? theme.darkMode
? 'rgba(255,255,255,0.08)'
: 'rgba(0,0,0,0.08)'
: $color};
opacity: ${({ $intensity }) => ($intensity <= 0 ? 1 : 0.35 + $intensity * 0.65)};
&:hover {
outline: 1px solid ${({ theme }) => theme.colors.text}66;
}
`;
const Footer = styled.p`
margin: 1rem 0 0 0;
color: ${({ theme }) => theme.colors.text};
opacity: 0.7;
font-size: 0.9rem;
line-height: 1.5;
`;
const StatusText = styled.p`
color: ${({ theme }) => theme.colors.text};
opacity: 0.7;
`;
const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => {
const cleaned = hex.replace('#', '');
if (cleaned.length !== 6) return null;
return {
r: parseInt(cleaned.slice(0, 2), 16),
g: parseInt(cleaned.slice(2, 4), 16),
b: parseInt(cleaned.slice(4, 6), 16),
};
};
const PromptHeatmapCard = (): JSX.Element => {
const theme = useTheme();
const [data, setData] = useState<PromptHeatmapData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [hover, setHover] = useState<{ day: string; hour: number; count: number } | null>(null);
useEffect(() => {
let cancelled = false;
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
(async () => {
try {
const response: AxiosResponse<PromptHeatmapData> = await axiosInstance.get(
'/analytics/user_prompt_heatmap/',
{ params: { tz } }
);
if (!cancelled) {
setData(response.data);
setError(false);
}
} catch {
if (!cancelled) {
setError(true);
setData(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const themeColor = theme?.main || '#4a90e2';
const rgb = useMemo(() => hexToRgb(themeColor), [themeColor]);
const cellColor = (count: number, max: number) => {
if (!rgb || max <= 0 || count <= 0) return themeColor;
const t = count / max;
return `rgb(${Math.round(rgb.r * (0.4 + 0.6 * t))}, ${Math.round(
rgb.g * (0.4 + 0.6 * t)
)}, ${Math.round(rgb.b * (0.4 + 0.6 * t))})`;
};
const hourLabels = [0, 6, 12, 18, 23];
return (
<GlassCard>
<CardTitle>Prompt activity</CardTitle>
<Subtitle>
When you send prompts across the week (local timezone
{data?.tz ? `: ${data.tz}` : ''}).
</Subtitle>
{loading && <StatusText>Loading heatmap</StatusText>}
{error && <StatusText>Could not load prompt activity.</StatusText>}
{!loading && !error && data && (
<>
<GridScroll>
<HeatmapGrid role="img" aria-label="Prompt activity heatmap by weekday and hour">
<Corner />
{data.hours.map((hour) => (
<AxisLabel key={`h-${hour}`}>{hourLabels.includes(hour) ? hour : ''}</AxisLabel>
))}
{data.days.map((day, dayIdx) => (
<React.Fragment key={day}>
<DayLabel>{day}</DayLabel>
{data.hours.map((hour) => {
const count = data.matrix[dayIdx]?.[hour] ?? 0;
const intensity = data.max > 0 ? count / data.max : 0;
return (
<Cell
key={`${day}-${hour}`}
type="button"
$intensity={intensity}
$color={cellColor(count, data.max)}
aria-label={`${day} ${hour}:00 — ${count} prompts`}
onMouseEnter={() => setHover({ day, hour, count })}
onMouseLeave={() => setHover(null)}
onFocus={() => setHover({ day, hour, count })}
onBlur={() => setHover(null)}
/>
);
})}
</React.Fragment>
))}
</HeatmapGrid>
</GridScroll>
<Footer>
{hover
? `${hover.day} ${hover.hour}:00 — ${hover.count} prompt${hover.count === 1 ? '' : 's'}`
: data.total === 0
? 'No prompts yet. Chat a bit and this grid fills in.'
: [
`${data.total} prompt${data.total === 1 ? '' : 's'} total`,
data.most_active_day ? `Most active day: ${data.most_active_day}` : null,
data.most_active_hour != null
? `Most active hour: ${data.most_active_hour}:00`
: null,
data.peak_cell
? `Peak: ${data.peak_cell.day} ${data.peak_cell.hour}:00 (${data.peak_cell.count})`
: null,
]
.filter(Boolean)
.join(' · ')}
</Footer>
</>
)}
</GlassCard>
);
};
export default PromptHeatmapCard;
@@ -0,0 +1,110 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
import AnalyticsPage from './Analytics';
import { AccountContext } from '../../contexts/AccountContext';
import { AuthContext } from '../../contexts/AuthContext';
import { Account } from '../../data';
const mockGet = jest.fn();
jest.mock('../../../axiosApi', () => ({
axiosInstance: {
get: (...args: unknown[]) => mockGet(...args),
defaults: { headers: { common: {} as Record<string, string | null> } },
},
applyAccessToken: jest.fn(),
}));
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
jest.mock('../../components/Header2/Header2', () => () => null);
beforeAll(() => {
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
(global as unknown as { ResizeObserver: typeof ResizeObserverMock }).ResizeObserver =
ResizeObserverMock;
});
const theme = {
main: '#4a90e2',
focus: '#224466',
darkMode: true,
colors: {
text: '#ffffff',
cardBackground: 'rgba(0,0,0,0.3)',
cardBorder: 'rgba(255,255,255,0.1)',
},
};
const emptyHeatmap = {
tz: 'UTC',
total: 0,
max: 0,
days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
hours: Array.from({ length: 24 }, (_, i) => i),
matrix: Array.from({ length: 7 }, () => Array(24).fill(0)),
most_active_day: null,
most_active_hour: null,
peak_cell: null,
};
const renderPage = (accountInit?: ConstructorParameters<typeof Account>[0]) => {
const account = new Account(accountInit || { email: 'user@example.com' });
return render(
<MemoryRouter>
<ThemeProvider theme={theme as never}>
<AuthContext.Provider
value={{
authenticated: true,
setAuthentication: jest.fn(),
needsNewPassword: false,
setNeedsNewPassword: jest.fn(),
loading: false,
}}
>
<AccountContext.Provider value={{ account, setAccount: jest.fn() }}>
<AnalyticsPage />
</AccountContext.Provider>
</AuthContext.Provider>
</ThemeProvider>
</MemoryRouter>
);
};
describe('AnalyticsPage (#94)', () => {
beforeEach(() => {
mockGet.mockReset();
mockGet.mockImplementation((url: string) => {
if (url.includes('user_prompt_heatmap')) {
return Promise.resolve({ data: emptyHeatmap });
}
return Promise.resolve({ data: [] });
});
});
it('shows your activity section with heatmap for all users', async () => {
renderPage();
expect(await screen.findByText('Your activity')).toBeInTheDocument();
expect(await screen.findByText('Prompt activity')).toBeInTheDocument();
expect(screen.queryByText('Company')).not.toBeInTheDocument();
});
it('shows company section for company managers', async () => {
renderPage({
email: 'mgr@example.com',
is_company_manager: true,
company: { id: 1, name: 'Acme', state: '', zipcode: '', address: '' },
});
expect(await screen.findByText('Company')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Team seat activity')).toBeInTheDocument();
});
});
});
+69 -10
View File
@@ -6,9 +6,9 @@ import { axiosInstance } from "../../../axiosApi"
import { AxiosResponse } from "axios" import { AxiosResponse } from "axios"
import { AdminAnalytics, AdminAnalyticsType, CompanyUsageAnalytics, CompanyUsageAnalyticsType, UserConversationAnalytics, UserConvesationAnalyticsType, UserPromptAnalytics, UserPromptAnalyticsType } from "../../data" import { AdminAnalytics, AdminAnalyticsType, CompanyUsageAnalytics, CompanyUsageAnalyticsType, UserConversationAnalytics, UserConvesationAnalyticsType, UserPromptAnalytics, UserPromptAnalyticsType } from "../../data"
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground" import ParticleBackground from "../../components/ParticleBackground/ParticleBackground"
import PromptHeatmapCard from "../../components/PromptHeatmapCard/PromptHeatmapCard"
import styled, { ThemeContext } from "styled-components" import styled, { ThemeContext } from "styled-components"
// Styled Components
const PageContainer = styled.div` const PageContainer = styled.div`
position: relative; position: relative;
width: 100vw; width: 100vw;
@@ -18,7 +18,6 @@ const PageContainer = styled.div`
flex-direction: column; flex-direction: column;
color: ${({ theme }) => theme.colors.text}; color: ${({ theme }) => theme.colors.text};
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
/* background-color: ${({ theme }) => theme.colors.background}; Removed to show particles */
`; `;
const ContentWrapper = styled.div` const ContentWrapper = styled.div`
@@ -42,6 +41,29 @@ const ContentWrapper = styled.div`
} }
`; `;
const Section = styled.section`
width: 100%;
max-width: 1200px;
margin-bottom: 1rem;
`;
const SectionHeading = styled.h1`
width: 100%;
font-size: 1.35rem;
font-weight: 700;
color: ${({ theme }) => theme.colors.text};
margin: 0 0 1rem 0;
letter-spacing: 0.02em;
`;
const SectionHint = styled.p`
margin: -0.5rem 0 1.25rem 0;
color: ${({ theme }) => theme.colors.text};
opacity: 0.65;
font-size: 0.95rem;
line-height: 1.5;
`;
const GlassCard = styled.div` const GlassCard = styled.div`
background: ${({ theme }) => theme.colors.cardBackground}; background: ${({ theme }) => theme.colors.cardBackground};
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
@@ -96,11 +118,11 @@ const UserPromptAnalyticsCard = (): JSX.Element => {
}, []) }, [])
return ( return (
<GlassCard> <GlassCard>
<CardTitle>Prompt Usage</CardTitle> <CardTitle>Prompt volume</CardTitle>
<ChartContainer> <ChartContainer>
<ResponsiveContainer> <ResponsiveContainer>
<BarChart data={data}> <BarChart data={data}>
<XAxis stroke={theme?.colors.text} /> <XAxis dataKey="month" stroke={theme?.colors.text} />
<YAxis stroke={theme?.colors.text} /> <YAxis stroke={theme?.colors.text} />
<Legend wrapperStyle={{ color: theme?.colors.text }} /> <Legend wrapperStyle={{ color: theme?.colors.text }} />
<Tooltip contentStyle={{ backgroundColor: theme?.colors.cardBackground, border: `1px solid ${theme?.colors.cardBorder}`, color: theme?.colors.text }} /> <Tooltip contentStyle={{ backgroundColor: theme?.colors.cardBackground, border: `1px solid ${theme?.colors.cardBorder}`, color: theme?.colors.text }} />
@@ -132,11 +154,11 @@ const UserConversationAnalyticsCard = (): JSX.Element => {
return ( return (
<GlassCard> <GlassCard>
<CardTitle>Conversation Usage</CardTitle> <CardTitle>Conversation volume</CardTitle>
<ChartContainer> <ChartContainer>
<ResponsiveContainer> <ResponsiveContainer>
<BarChart data={data}> <BarChart data={data}>
<XAxis stroke={theme?.colors.text} /> <XAxis dataKey="month" stroke={theme?.colors.text} />
<YAxis stroke={theme?.colors.text} /> <YAxis stroke={theme?.colors.text} />
<Legend wrapperStyle={{ color: theme?.colors.text }} /> <Legend wrapperStyle={{ color: theme?.colors.text }} />
<Tooltip contentStyle={{ backgroundColor: theme?.colors.cardBackground, border: `1px solid ${theme?.colors.cardBorder}`, color: theme?.colors.text }} /> <Tooltip contentStyle={{ backgroundColor: theme?.colors.cardBackground, border: `1px solid ${theme?.colors.cardBorder}`, color: theme?.colors.text }} />
@@ -168,7 +190,7 @@ const CompanyUsageAnalyticsCard = (): JSX.Element => {
return ( return (
<GlassCard> <GlassCard>
<CardTitle>Account Usage</CardTitle> <CardTitle>Team seat activity</CardTitle>
<ChartContainer> <ChartContainer>
<ResponsiveContainer> <ResponsiveContainer>
<BarChart data={data}> <BarChart data={data}>
@@ -202,7 +224,7 @@ const AdminAnalyticsCard = (): JSX.Element => {
}, []) }, [])
return ( return (
<GlassCard> <GlassCard>
<CardTitle>Response Times</CardTitle> <CardTitle>Response times (ops)</CardTitle>
<ChartContainer> <ChartContainer>
<ResponsiveContainer> <ResponsiveContainer>
<ComposedChart data={data}> <ComposedChart data={data}>
@@ -227,20 +249,57 @@ const AdminAnalyticsCard = (): JSX.Element => {
) )
} }
const isOpsAdmin = (email?: string, role?: string): boolean => {
if (role && ['admin', 'ops', 'staff'].includes(role.toLowerCase())) {
return true;
}
return email === "ryan+admin@aimloperations.com";
};
const AnalyticsInner = (): JSX.Element => { const AnalyticsInner = (): JSX.Element => {
const { account } = useContext(AccountContext) const { account } = useContext(AccountContext)
const showCompany = Boolean(account?.is_company_manager || account?.company);
const showAdmin = isOpsAdmin(account?.email, account?.role);
return ( return (
<> <>
<Section aria-labelledby="analytics-you">
<SectionHeading id="analytics-you">Your activity</SectionHeading>
<SectionHint>
Personal prompt timing and volume. Other users&apos; private messages are never shown here.
</SectionHint>
<PromptHeatmapCard />
<GridContainer> <GridContainer>
<UserConversationAnalyticsCard /> <UserConversationAnalyticsCard />
<UserPromptAnalyticsCard /> <UserPromptAnalyticsCard />
</GridContainer> </GridContainer>
</Section>
{account?.is_company_manager ? <CompanyUsageAnalyticsCard /> : <></>} {showCompany && (
{account?.email === "ryan+admin@aimloperations.com" ? <AdminAnalyticsCard /> : <></>} <Section aria-labelledby="analytics-company">
<SectionHeading id="analytics-company">Company</SectionHeading>
<SectionHint>
Aggregated seat and usage trends for your workspace. No message content.
</SectionHint>
{account?.is_company_manager ? (
<CompanyUsageAnalyticsCard />
) : (
<GlassCard>
<CardTitle>Company insights</CardTitle>
<p style={{ opacity: 0.7, margin: 0 }}>
Detailed team seat charts are available to company managers.
</p>
</GlassCard>
)}
</Section>
)}
{showAdmin && (
<Section aria-labelledby="analytics-ops">
<SectionHeading id="analytics-ops">Operations</SectionHeading>
<AdminAnalyticsCard />
</Section>
)}
</> </>
) )
} }
@@ -24,6 +24,9 @@ jest.mock('../../../axiosApi', () => ({
applyAccessToken: jest.fn(), applyAccessToken: jest.fn(),
})); }));
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
jest.mock('../../components/Header2/Header2', () => () => null);
const theme = { const theme = {
main: '#4a90e2', main: '#4a90e2',
focus: '#224466', focus: '#224466',
@@ -60,8 +63,10 @@ const mockGetByUrl = (overrides: { rag: boolean }) => {
if (url === '/finance/subscription/') { if (url === '/finance/subscription/') {
return Promise.resolve(subscriptionResponse(overrides.rag)); return Promise.resolve(subscriptionResponse(overrides.rag));
} }
if (url === '/documents/') { if (typeof url === 'string' && url.startsWith('/documents/')) {
return Promise.resolve({ data: [] }); return Promise.resolve({
data: { count: 0, page: 1, page_size: 20, scope: 'personal', results: [] },
});
} }
if (url === '/drive/connections/') { if (url === '/drive/connections/') {
return Promise.resolve({ data: [] }); return Promise.resolve({ data: [] });
@@ -70,8 +75,14 @@ const mockGetByUrl = (overrides: { rag: boolean }) => {
}); });
}; };
const renderPage = (isCompanyManager = false) => { const renderPage = (options?: { isCompanyManager?: boolean; hasCompany?: boolean }) => {
const account = new Account({ email: 'user@example.com', is_company_manager: isCompanyManager }); const isCompanyManager = options?.isCompanyManager ?? false;
const hasCompany = options?.hasCompany ?? false;
const account = new Account({
email: 'user@example.com',
is_company_manager: isCompanyManager,
company: hasCompany ? { id: 1, name: 'Acme', state: '', zipcode: '', address: '' } : undefined,
});
return render( return render(
<MemoryRouter initialEntries={['/document_storage/']}> <MemoryRouter initialEntries={['/document_storage/']}>
<ThemeProvider theme={theme as never}> <ThemeProvider theme={theme as never}>
@@ -93,7 +104,7 @@ const renderPage = (isCompanyManager = false) => {
); );
}; };
describe('DocumentStoragePage (#81/#82/#83/#84)', () => { describe('DocumentStoragePage (#81/#82/#83/#84/#93)', () => {
beforeEach(() => { beforeEach(() => {
mockGet.mockReset(); mockGet.mockReset();
mockPost.mockReset(); mockPost.mockReset();
@@ -113,27 +124,62 @@ describe('DocumentStoragePage (#81/#82/#83/#84)', () => {
'/account/' '/account/'
); );
expect(screen.queryByText('Company documents')).not.toBeInTheDocument(); expect(screen.queryByText('Company documents')).not.toBeInTheDocument();
expect(screen.queryByText('Personal documents')).not.toBeInTheDocument();
expect(screen.queryByText('Upload a Document')).not.toBeInTheDocument(); expect(screen.queryByText('Upload a Document')).not.toBeInTheDocument();
}); });
it('shows the company documents table, upload card, and personal Cloud drives section when RAG is allowed', async () => { it('shows personal documents for users without a company', async () => {
mockGetByUrl({ rag: true }); mockGetByUrl({ rag: true });
renderPage(false); renderPage({ hasCompany: false });
expect(await screen.findByText('Company documents')).toBeInTheDocument(); expect(await screen.findByText('Personal documents')).toBeInTheDocument();
expect(screen.queryByText('Company documents')).not.toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Company' })).not.toBeInTheDocument();
expect(
screen.queryByText(/shared with your whole company workspace/i)
).not.toBeInTheDocument();
expect(screen.getByText('Upload a Document')).toBeInTheDocument(); expect(screen.getByText('Upload a Document')).toBeInTheDocument();
expect(screen.getByText('Cloud drives')).toBeInTheDocument(); expect(screen.getByText('Cloud drives')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /connect google drive/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /connect onedrive/i })).toBeInTheDocument();
expect(screen.queryByText('Company knowledge sources')).not.toBeInTheDocument(); expect(screen.queryByText('Company knowledge sources')).not.toBeInTheDocument();
}); });
it('shows company/personal tabs and company helper copy for company members', async () => {
mockGetByUrl({ rag: true });
renderPage({ hasCompany: true });
expect(await screen.findByText('Company documents')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Personal' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Company' })).toBeInTheDocument();
expect(screen.getByText(/shared with your whole company workspace/i)).toBeInTheDocument();
});
it('also shows the company knowledge sources section for company managers (#84)', async () => { it('also shows the company knowledge sources section for company managers (#84)', async () => {
mockGetByUrl({ rag: true }); mockGetByUrl({ rag: true });
renderPage(true); renderPage({ isCompanyManager: true, hasCompany: true });
expect(await screen.findByText('Company knowledge sources')).toBeInTheDocument(); expect(await screen.findByText('Company knowledge sources')).toBeInTheDocument();
}); });
it('requests documents with workspace/page/search params (#93)', async () => {
mockGetByUrl({ rag: true });
renderPage({ hasCompany: true });
await screen.findByText('Company documents');
await waitFor(() => {
expect(mockGet).toHaveBeenCalledWith(
'/documents/',
expect.objectContaining({
params: expect.objectContaining({
workspace: 'company',
page: 1,
page_size: 20,
}),
})
);
});
});
}); });
@@ -20,7 +20,6 @@ const PageContainer = styled.div`
flex-direction: column; flex-direction: column;
color: ${({ theme }) => theme.colors.text}; color: ${({ theme }) => theme.colors.text};
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
/* background-color: ${({ theme }) => theme.colors.background}; Removed to show particles */
`; `;
const ContentWrapper = styled.div` const ContentWrapper = styled.div`
@@ -64,6 +63,72 @@ const CardTitle = styled.h2`
padding-bottom: 1rem; padding-bottom: 1rem;
`; `;
const TabRow = styled.div`
display: flex;
gap: 0.5rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
`;
const TabButton = styled.button<{ $active: boolean }>`
background: ${({ $active, theme }) => ($active ? theme.main : 'transparent')};
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 0.5rem;
color: ${({ $active, theme }) => ($active ? '#fff' : theme.colors.text)};
padding: 0.55rem 1rem;
font-weight: 600;
cursor: pointer;
`;
const Toolbar = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin-bottom: 1rem;
`;
const SearchInput = styled.input`
flex: 1;
min-width: 180px;
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'};
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 0.5rem;
padding: 0.6rem 0.8rem;
color: ${({ theme }) => theme.colors.text};
&::placeholder {
color: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.4)'};
}
`;
const PaginationRow = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
margin-top: 1rem;
color: ${({ theme }) => theme.colors.text};
opacity: 0.8;
font-size: 0.9rem;
`;
const PageButton = styled.button`
background: transparent;
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 0.5rem;
color: ${({ theme }) => theme.colors.text};
padding: 0.4rem 0.85rem;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
`;
const StyledTable = styled.table` const StyledTable = styled.table`
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -79,6 +144,15 @@ const Th = styled.th`
font-weight: 600; font-weight: 600;
`; `;
const SortableTh = styled(Th)`
cursor: pointer;
user-select: none;
&:hover {
opacity: 1;
}
`;
const Td = styled.td` const Td = styled.td`
padding: 1rem; padding: 1rem;
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
@@ -251,27 +325,131 @@ const DismissButton = styled.button`
} }
`; `;
const EmptyRow = styled.td`
padding: 1.5rem 1rem;
color: ${({ theme }) => theme.colors.text};
opacity: 0.65;
text-align: center;
`;
export type DocumentWorkspaceScope = 'personal' | 'company';
type DocumentsListResponse = {
count: number;
page: number;
page_size: number;
scope: DocumentWorkspaceScope;
results: DocumentType[];
};
type SortField = 'name' | 'created' | 'processed' | 'active';
type DocumentTableCardProps = { type DocumentTableCardProps = {
scope: DocumentWorkspaceScope;
showTabs: boolean;
onScopeChange: (scope: DocumentWorkspaceScope) => void;
documents: Document[]; documents: Document[];
total: number;
page: number;
pageSize: number;
search: string;
ordering: string;
loading: boolean;
onSearchChange: (value: string) => void;
onSort: (field: SortField) => void;
onPageChange: (page: number) => void;
onToggleActive: (id: number, active: boolean) => void; onToggleActive: (id: number, active: boolean) => void;
} };
const sortLabel = (field: SortField, ordering: string): string => {
const labels: Record<SortField, string> = {
name: 'Name',
created: 'Date Uploaded',
processed: 'Processed',
active: 'Active',
};
if (ordering === field) return `${labels[field]}`;
if (ordering === `-${field}`) return `${labels[field]}`;
return labels[field];
};
const DocumentStorageTableCard = ({
scope,
showTabs,
onScopeChange,
documents,
total,
page,
pageSize,
search,
ordering,
loading,
onSearchChange,
onSort,
onPageChange,
onToggleActive,
}: DocumentTableCardProps): JSX.Element => {
const title = scope === 'company' ? 'Company documents' : 'Personal documents';
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const CompanyDocumentStorageTableCard = ({ documents, onToggleActive }: DocumentTableCardProps): JSX.Element => {
return ( return (
<GlassCard> <GlassCard>
<CardTitle>Company documents</CardTitle> <CardTitle>{title}</CardTitle>
{showTabs && (
<TabRow role="tablist" aria-label="Document workspace">
<TabButton
type="button"
role="tab"
aria-selected={scope === 'personal'}
$active={scope === 'personal'}
onClick={() => onScopeChange('personal')}
>
Personal
</TabButton>
<TabButton
type="button"
role="tab"
aria-selected={scope === 'company'}
$active={scope === 'company'}
onClick={() => onScopeChange('company')}
>
Company
</TabButton>
</TabRow>
)}
<Toolbar>
<SearchInput
type="search"
placeholder="Search documents…"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
aria-label="Search documents"
/>
</Toolbar>
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<StyledTable> <StyledTable>
<thead> <thead>
<tr> <tr>
<Th>Name</Th> <SortableTh onClick={() => onSort('name')}>{sortLabel('name', ordering)}</SortableTh>
<Th>Date Uploaded</Th> <SortableTh onClick={() => onSort('created')}>{sortLabel('created', ordering)}</SortableTh>
<Th>Processed</Th> <SortableTh onClick={() => onSort('processed')}>{sortLabel('processed', ordering)}</SortableTh>
<Th>Active</Th> <SortableTh onClick={() => onSort('active')}>{sortLabel('active', ordering)}</SortableTh>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{documents.map((doc) => ( {loading ? (
<tr>
<EmptyRow colSpan={4}>Loading documents</EmptyRow>
</tr>
) : documents.length === 0 ? (
<tr>
<EmptyRow colSpan={4}>No documents found.</EmptyRow>
</tr>
) : (
documents.map((doc) => (
<tr key={doc.id}> <tr key={doc.id}>
<Td>{doc.name}</Td> <Td>{doc.name}</Td>
<Td>{doc.date_uploaded}</Td> <Td>{doc.date_uploaded}</Td>
@@ -293,23 +471,48 @@ const CompanyDocumentStorageTableCard = ({ documents, onToggleActive }: Document
</ToggleSwitch> </ToggleSwitch>
</Td> </Td>
</tr> </tr>
))} ))
)}
</tbody> </tbody>
</StyledTable> </StyledTable>
</div> </div>
<PaginationRow>
<span>
{total === 0
? '0 documents'
: `Showing ${(page - 1) * pageSize + 1}${Math.min(page * pageSize, total)} of ${total}`}
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<PageButton type="button" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
Previous
</PageButton>
<PageButton
type="button"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Next
</PageButton>
</div>
</PaginationRow>
{scope === 'company' && (
<HelperNote> <HelperNote>
These documents are shared with your whole company workspace. Want to search your own These documents are shared with your whole company workspace. Want to search your own
files privately? Connect a personal cloud drive in the Cloud drives section below. files privately? Connect a personal cloud drive in the Cloud drives section below.
</HelperNote> </HelperNote>
)}
</GlassCard> </GlassCard>
) );
} };
type DocumentUploadCardProps = { type DocumentUploadCardProps = {
scope: DocumentWorkspaceScope;
onUploaded: () => void | Promise<void>; onUploaded: () => void | Promise<void>;
} };
const DocumentUploadCard = ({ onUploaded }: DocumentUploadCardProps): JSX.Element => { const DocumentUploadCard = ({ scope, onUploaded }: DocumentUploadCardProps): JSX.Element => {
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState<boolean>(false); const [uploading, setUploading] = useState<boolean>(false);
@@ -319,23 +522,23 @@ const DocumentUploadCard = ({ onUploaded }: DocumentUploadCardProps): JSX.Elemen
} }
setUploading(true); setUploading(true);
try { try {
await axiosInstance.post('/documents/', { await axiosInstance.post(
file: selectedFile '/documents/',
}, { file: selectedFile },
{ {
headers: { headers: { 'Content-Type': 'multipart/form-data' },
'Content-Type': 'multipart/form-data', params: { workspace: scope },
}, }
}) );
setSelectedFile(null); setSelectedFile(null);
await onUploaded(); await onUploaded();
} catch (error) { } catch (error) {
console.log(error) console.log(error);
} finally { } finally {
setUploading(false); setUploading(false);
} }
} };
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) { if (event.target.files && event.target.files.length > 0) {
@@ -361,35 +564,83 @@ const DocumentUploadCard = ({ onUploaded }: DocumentUploadCardProps): JSX.Elemen
)} )}
</div> </div>
</GlassCard> </GlassCard>
) );
} };
const PAGE_SIZE = 20;
const DocumentStoragePageInner = (): JSX.Element => { const DocumentStoragePageInner = (): JSX.Element => {
const [documents, setDocuments] = useState<Document[]>([]); const [documents, setDocuments] = useState<Document[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [ordering, setOrdering] = useState('-created');
const [loading, setLoading] = useState(true);
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [showDriveConnectedBanner, setShowDriveConnectedBanner] = useState(false); const [showDriveConnectedBanner, setShowDriveConnectedBanner] = useState(false);
const fetchDocuments = useCallback(async () => { const hasCompany = Boolean(account?.company);
try { const [scope, setScope] = useState<DocumentWorkspaceScope>(
const { data, }: AxiosResponse<DocumentType[]> = await axiosInstance.get(`/documents/`); hasCompany ? 'company' : 'personal'
setDocuments(data.map((item) => new Document({ );
useEffect(() => {
if (!hasCompany && scope === 'company') {
setScope('personal');
}
}, [hasCompany, scope]);
useEffect(() => {
const handle = window.setTimeout(() => {
setSearch(searchInput.trim());
setPage(1);
}, 300);
return () => window.clearTimeout(handle);
}, [searchInput]);
const fetchDocuments = useCallback(async () => {
setLoading(true);
try {
const { data }: AxiosResponse<DocumentsListResponse> = await axiosInstance.get(
`/documents/`,
{
params: {
workspace: scope,
page,
page_size: PAGE_SIZE,
search: search || undefined,
ordering,
},
}
);
const results = Array.isArray(data?.results) ? data.results : [];
setTotal(typeof data?.count === 'number' ? data.count : results.length);
setDocuments(
results.map(
(item) =>
new Document({
id: item.id, id: item.id,
name: item.file.replace(/^.*[\\/]/, ''), name: item.file.replace(/^.*[\\/]/, ''),
date_uploaded: item.created.substring(0, 10), date_uploaded: item.created.substring(0, 10),
active: item.active, active: item.active,
processed: item.processed, processed: item.processed,
})
}))) )
);
} catch (error) { } catch (error) {
console.log(error) console.log(error);
setDocuments([]);
setTotal(0);
} finally {
setLoading(false);
} }
}, []) }, [scope, page, search, ordering]);
useEffect(() => { useEffect(() => {
fetchDocuments(); fetchDocuments();
}, [fetchDocuments]) }, [fetchDocuments]);
useEffect(() => { useEffect(() => {
if (searchParams.get('drive_connected') === '1') { if (searchParams.get('drive_connected') === '1') {
@@ -411,6 +662,20 @@ const DocumentStoragePageInner = (): JSX.Element => {
} }
}, []); }, []);
const handleSort = (field: SortField) => {
setPage(1);
setOrdering((prev) => {
if (prev === field) return `-${field}`;
if (prev === `-${field}`) return field;
return field === 'created' ? '-created' : field;
});
};
const handleScopeChange = (next: DocumentWorkspaceScope) => {
setScope(next);
setPage(1);
};
return ( return (
<> <>
{showDriveConnectedBanner && ( {showDriveConnectedBanner && (
@@ -426,8 +691,23 @@ const DocumentStoragePageInner = (): JSX.Element => {
</SuccessBanner> </SuccessBanner>
)} )}
<CompanyDocumentStorageTableCard documents={documents} onToggleActive={handleToggleActive} /> <DocumentStorageTableCard
<DocumentUploadCard onUploaded={fetchDocuments} /> scope={scope}
showTabs={hasCompany}
onScopeChange={handleScopeChange}
documents={documents}
total={total}
page={page}
pageSize={PAGE_SIZE}
search={searchInput}
ordering={ordering}
loading={loading}
onSearchChange={setSearchInput}
onSort={handleSort}
onPageChange={setPage}
onToggleActive={handleToggleActive}
/>
<DocumentUploadCard scope={scope} onUploaded={fetchDocuments} />
<DriveConnectionsSection <DriveConnectionsSection
kind="personal" kind="personal"
@@ -447,8 +727,8 @@ const DocumentStoragePageInner = (): JSX.Element => {
/> />
)} )}
</> </>
) );
} };
const DocumentStoragePage = (): JSX.Element => { const DocumentStoragePage = (): JSX.Element => {
const { hasRag, loading } = useSubscription(); const { hasRag, loading } = useSubscription();
@@ -477,7 +757,7 @@ const DocumentStoragePage = (): JSX.Element => {
)} )}
</ContentWrapper> </ContentWrapper>
</PageContainer> </PageContainer>
) );
} };
export default DocumentStoragePage; export default DocumentStoragePage;
+18
View File
@@ -1,9 +1,27 @@
import { import {
driveConnectUrl, driveConnectUrl,
driveSyncProgressPercent,
formatDriveSyncError, formatDriveSyncError,
parseResourceIdsInput, parseResourceIdsInput,
} from './drive'; } from './drive';
describe('driveSyncProgressPercent', () => {
it('returns null when total is unknown', () => {
expect(driveSyncProgressPercent({ id: 1, provider: 'google', sync_total: 0 })).toBeNull();
});
it('computes rounded percent from processed/total', () => {
expect(
driveSyncProgressPercent({
id: 1,
provider: 'google',
sync_total: 4,
sync_processed: 1,
})
).toBe(25);
});
});
describe('parseResourceIdsInput', () => { describe('parseResourceIdsInput', () => {
it('splits comma and newline separated ids and trims whitespace', () => { it('splits comma and newline separated ids and trims whitespace', () => {
expect(parseResourceIdsInput('abc, def\nghi ,, ')).toEqual(['abc', 'def', 'ghi']); expect(parseResourceIdsInput('abc, def\nghi ,, ')).toEqual(['abc', 'def', 'ghi']);
+21 -1
View File
@@ -21,6 +21,11 @@ export type DriveConnectionType = {
last_sync_at?: string | null; last_sync_at?: string | null;
last_sync_status?: DriveSyncStatus | string | null; last_sync_status?: DriveSyncStatus | string | null;
last_sync_error?: string; last_sync_error?: string;
sync_total?: number;
sync_processed?: number;
sync_added?: number;
sync_updated?: number;
sync_failed?: number;
created?: string; created?: string;
}; };
@@ -73,7 +78,11 @@ const DEFAULT_SYNC_TIMEOUT_MS = 120_000;
/** Poll connections until the target leaves ``pending`` (or timeout). */ /** Poll connections until the target leaves ``pending`` (or timeout). */
export async function waitForDriveSyncSettlement( export async function waitForDriveSyncSettlement(
connectionId: number, connectionId: number,
options?: { intervalMs?: number; timeoutMs?: number } options?: {
intervalMs?: number;
timeoutMs?: number;
onProgress?: (connection: DriveConnectionType) => void;
}
): Promise<DriveConnectionType> { ): Promise<DriveConnectionType> {
const intervalMs = options?.intervalMs ?? DEFAULT_SYNC_POLL_MS; const intervalMs = options?.intervalMs ?? DEFAULT_SYNC_POLL_MS;
const timeoutMs = options?.timeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS; const timeoutMs = options?.timeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS;
@@ -85,6 +94,7 @@ export async function waitForDriveSyncSettlement(
if (!connection) { if (!connection) {
throw new Error('Drive connection disappeared while syncing.'); throw new Error('Drive connection disappeared while syncing.');
} }
options?.onProgress?.(connection);
if (connection.last_sync_status !== 'pending') { if (connection.last_sync_status !== 'pending') {
return connection; return connection;
} }
@@ -93,6 +103,16 @@ export async function waitForDriveSyncSettlement(
throw new Error('Drive sync timed out. Check status and try again.'); throw new Error('Drive sync timed out. Check status and try again.');
} }
/** Percent complete when total is known; otherwise null (indeterminate). */
export function driveSyncProgressPercent(connection: DriveConnectionType): number | null {
const total = connection.sync_total ?? 0;
if (total <= 0) {
return null;
}
const processed = Math.min(connection.sync_processed ?? 0, total);
return Math.round((processed / total) * 100);
}
/** Shorten long provider JSON error blobs for toasts. */ /** Shorten long provider JSON error blobs for toasts. */
export function formatDriveSyncError(raw?: string | null, maxLen = 280): string { export function formatDriveSyncError(raw?: string | null, maxLen = 280): string {
const text = (raw || '').trim(); const text = (raw || '').trim();