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,
connectDrive,
disconnectDriveConnection,
driveSyncProgressPercent,
fetchDriveConnections,
formatDriveSyncError,
parseResourceIdsInput,
@@ -212,6 +213,44 @@ const SyncErrorText = styled.p`
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> = {
google: "Google Drive",
microsoft: "OneDrive",
@@ -297,7 +336,9 @@ const DriveConnectionsSection = ({
const settled =
enqueued.connection.last_sync_status === 'pending'
? await waitForDriveSyncSettlement(id)
? await waitForDriveSyncSettlement(id, {
onProgress: (connection) => upsertConnection(connection),
})
: enqueued.connection;
upsertConnection(settled);
@@ -404,6 +445,9 @@ const DriveConnectionsSection = ({
? conn.selected_resource_labels
: conn.selected_resource_ids || [];
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 (
<ConnectionCard key={conn.id}>
@@ -436,6 +480,19 @@ const DriveConnectionsSection = ({
</ConnectionActions>
</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 && (
<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();
});
});
});
+73 -14
View File
@@ -6,9 +6,9 @@ import { axiosInstance } from "../../../axiosApi"
import { AxiosResponse } from "axios"
import { AdminAnalytics, AdminAnalyticsType, CompanyUsageAnalytics, CompanyUsageAnalyticsType, UserConversationAnalytics, UserConvesationAnalyticsType, UserPromptAnalytics, UserPromptAnalyticsType } from "../../data"
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground"
import PromptHeatmapCard from "../../components/PromptHeatmapCard/PromptHeatmapCard"
import styled, { ThemeContext } from "styled-components"
// Styled Components
const PageContainer = styled.div`
position: relative;
width: 100vw;
@@ -18,7 +18,6 @@ const PageContainer = styled.div`
flex-direction: column;
color: ${({ theme }) => theme.colors.text};
font-family: 'Inter', sans-serif;
/* background-color: ${({ theme }) => theme.colors.background}; Removed to show particles */
`;
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`
background: ${({ theme }) => theme.colors.cardBackground};
backdrop-filter: blur(10px);
@@ -96,11 +118,11 @@ const UserPromptAnalyticsCard = (): JSX.Element => {
}, [])
return (
<GlassCard>
<CardTitle>Prompt Usage</CardTitle>
<CardTitle>Prompt volume</CardTitle>
<ChartContainer>
<ResponsiveContainer>
<BarChart data={data}>
<XAxis stroke={theme?.colors.text} />
<XAxis dataKey="month" stroke={theme?.colors.text} />
<YAxis stroke={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 }} />
@@ -132,11 +154,11 @@ const UserConversationAnalyticsCard = (): JSX.Element => {
return (
<GlassCard>
<CardTitle>Conversation Usage</CardTitle>
<CardTitle>Conversation volume</CardTitle>
<ChartContainer>
<ResponsiveContainer>
<BarChart data={data}>
<XAxis stroke={theme?.colors.text} />
<XAxis dataKey="month" stroke={theme?.colors.text} />
<YAxis stroke={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 }} />
@@ -168,7 +190,7 @@ const CompanyUsageAnalyticsCard = (): JSX.Element => {
return (
<GlassCard>
<CardTitle>Account Usage</CardTitle>
<CardTitle>Team seat activity</CardTitle>
<ChartContainer>
<ResponsiveContainer>
<BarChart data={data}>
@@ -202,7 +224,7 @@ const AdminAnalyticsCard = (): JSX.Element => {
}, [])
return (
<GlassCard>
<CardTitle>Response Times</CardTitle>
<CardTitle>Response times (ops)</CardTitle>
<ChartContainer>
<ResponsiveContainer>
<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 { account } = useContext(AccountContext)
const showCompany = Boolean(account?.is_company_manager || account?.company);
const showAdmin = isOpsAdmin(account?.email, account?.role);
return (
<>
<GridContainer>
<UserConversationAnalyticsCard />
<UserPromptAnalyticsCard />
</GridContainer>
<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>
<UserConversationAnalyticsCard />
<UserPromptAnalyticsCard />
</GridContainer>
</Section>
{account?.is_company_manager ? <CompanyUsageAnalyticsCard /> : <></>}
{account?.email === "ryan+admin@aimloperations.com" ? <AdminAnalyticsCard /> : <></>}
{showCompany && (
<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(),
}));
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
jest.mock('../../components/Header2/Header2', () => () => null);
const theme = {
main: '#4a90e2',
focus: '#224466',
@@ -60,8 +63,10 @@ const mockGetByUrl = (overrides: { rag: boolean }) => {
if (url === '/finance/subscription/') {
return Promise.resolve(subscriptionResponse(overrides.rag));
}
if (url === '/documents/') {
return Promise.resolve({ data: [] });
if (typeof url === 'string' && url.startsWith('/documents/')) {
return Promise.resolve({
data: { count: 0, page: 1, page_size: 20, scope: 'personal', results: [] },
});
}
if (url === '/drive/connections/') {
return Promise.resolve({ data: [] });
@@ -70,8 +75,14 @@ const mockGetByUrl = (overrides: { rag: boolean }) => {
});
};
const renderPage = (isCompanyManager = false) => {
const account = new Account({ email: 'user@example.com', is_company_manager: isCompanyManager });
const renderPage = (options?: { isCompanyManager?: boolean; hasCompany?: boolean }) => {
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(
<MemoryRouter initialEntries={['/document_storage/']}>
<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(() => {
mockGet.mockReset();
mockPost.mockReset();
@@ -113,27 +124,62 @@ describe('DocumentStoragePage (#81/#82/#83/#84)', () => {
'/account/'
);
expect(screen.queryByText('Company documents')).not.toBeInTheDocument();
expect(screen.queryByText('Personal documents')).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 });
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('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();
});
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 () => {
mockGetByUrl({ rag: true });
renderPage(true);
renderPage({ isCompanyManager: true, hasCompany: true });
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;
color: ${({ theme }) => theme.colors.text};
font-family: 'Inter', sans-serif;
/* background-color: ${({ theme }) => theme.colors.background}; Removed to show particles */
`;
const ContentWrapper = styled.div`
@@ -64,6 +63,72 @@ const CardTitle = styled.h2`
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`
width: 100%;
border-collapse: collapse;
@@ -79,6 +144,15 @@ const Th = styled.th`
font-weight: 600;
`;
const SortableTh = styled(Th)`
cursor: pointer;
user-select: none;
&:hover {
opacity: 1;
}
`;
const Td = styled.td`
padding: 1rem;
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
@@ -251,65 +325,194 @@ 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 = {
scope: DocumentWorkspaceScope;
showTabs: boolean;
onScopeChange: (scope: DocumentWorkspaceScope) => void;
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;
}
};
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 (
<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' }}>
<StyledTable>
<thead>
<tr>
<Th>Name</Th>
<Th>Date Uploaded</Th>
<Th>Processed</Th>
<Th>Active</Th>
<SortableTh onClick={() => onSort('name')}>{sortLabel('name', ordering)}</SortableTh>
<SortableTh onClick={() => onSort('created')}>{sortLabel('created', ordering)}</SortableTh>
<SortableTh onClick={() => onSort('processed')}>{sortLabel('processed', ordering)}</SortableTh>
<SortableTh onClick={() => onSort('active')}>{sortLabel('active', ordering)}</SortableTh>
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id}>
<Td>{doc.name}</Td>
<Td>{doc.date_uploaded}</Td>
<Td>
{doc.processed ? (
<StatusIcon status="success"></StatusIcon>
) : (
<StatusIcon status="pending"></StatusIcon>
)}
</Td>
<Td>
<ToggleSwitch>
<Checkbox
type="checkbox"
checked={doc.active}
onChange={(event) => onToggleActive(doc.id, event.target.checked)}
/>
<Slider />
</ToggleSwitch>
</Td>
{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}>
<Td>{doc.name}</Td>
<Td>{doc.date_uploaded}</Td>
<Td>
{doc.processed ? (
<StatusIcon status="success"></StatusIcon>
) : (
<StatusIcon status="pending"></StatusIcon>
)}
</Td>
<Td>
<ToggleSwitch>
<Checkbox
type="checkbox"
checked={doc.active}
onChange={(event) => onToggleActive(doc.id, event.target.checked)}
/>
<Slider />
</ToggleSwitch>
</Td>
</tr>
))
)}
</tbody>
</StyledTable>
</div>
<HelperNote>
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.
</HelperNote>
<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>
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.
</HelperNote>
)}
</GlassCard>
)
}
);
};
type DocumentUploadCardProps = {
scope: DocumentWorkspaceScope;
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 [uploading, setUploading] = useState<boolean>(false);
@@ -319,23 +522,23 @@ const DocumentUploadCard = ({ onUploaded }: DocumentUploadCardProps): JSX.Elemen
}
setUploading(true);
try {
await axiosInstance.post('/documents/', {
file: selectedFile
},
await axiosInstance.post(
'/documents/',
{ file: selectedFile },
{
headers: {
'Content-Type': 'multipart/form-data',
},
})
headers: { 'Content-Type': 'multipart/form-data' },
params: { workspace: scope },
}
);
setSelectedFile(null);
await onUploaded();
} catch (error) {
console.log(error)
console.log(error);
} finally {
setUploading(false);
}
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
@@ -361,35 +564,83 @@ const DocumentUploadCard = ({ onUploaded }: DocumentUploadCardProps): JSX.Elemen
)}
</div>
</GlassCard>
)
}
);
};
const PAGE_SIZE = 20;
const DocumentStoragePageInner = (): JSX.Element => {
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 [searchParams, setSearchParams] = useSearchParams();
const [showDriveConnectedBanner, setShowDriveConnectedBanner] = useState(false);
const fetchDocuments = useCallback(async () => {
try {
const { data, }: AxiosResponse<DocumentType[]> = await axiosInstance.get(`/documents/`);
setDocuments(data.map((item) => new Document({
const hasCompany = Boolean(account?.company);
const [scope, setScope] = useState<DocumentWorkspaceScope>(
hasCompany ? 'company' : 'personal'
);
id: item.id,
name: item.file.replace(/^.*[\\/]/, ''),
date_uploaded: item.created.substring(0, 10),
active: item.active,
processed: item.processed,
})))
} catch (error) {
console.log(error)
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,
name: item.file.replace(/^.*[\\/]/, ''),
date_uploaded: item.created.substring(0, 10),
active: item.active,
processed: item.processed,
})
)
);
} catch (error) {
console.log(error);
setDocuments([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [scope, page, search, ordering]);
useEffect(() => {
fetchDocuments();
}, [fetchDocuments])
}, [fetchDocuments]);
useEffect(() => {
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 (
<>
{showDriveConnectedBanner && (
@@ -426,8 +691,23 @@ const DocumentStoragePageInner = (): JSX.Element => {
</SuccessBanner>
)}
<CompanyDocumentStorageTableCard documents={documents} onToggleActive={handleToggleActive} />
<DocumentUploadCard onUploaded={fetchDocuments} />
<DocumentStorageTableCard
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
kind="personal"
@@ -447,8 +727,8 @@ const DocumentStoragePageInner = (): JSX.Element => {
/>
)}
</>
)
}
);
};
const DocumentStoragePage = (): JSX.Element => {
const { hasRag, loading } = useSubscription();
@@ -477,7 +757,7 @@ const DocumentStoragePage = (): JSX.Element => {
)}
</ContentWrapper>
</PageContainer>
)
}
);
};
export default DocumentStoragePage;
+18
View File
@@ -1,9 +1,27 @@
import {
driveConnectUrl,
driveSyncProgressPercent,
formatDriveSyncError,
parseResourceIdsInput,
} 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', () => {
it('splits comma and newline separated ids and trims whitespace', () => {
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_status?: DriveSyncStatus | string | null;
last_sync_error?: string;
sync_total?: number;
sync_processed?: number;
sync_added?: number;
sync_updated?: number;
sync_failed?: number;
created?: string;
};
@@ -73,7 +78,11 @@ const DEFAULT_SYNC_TIMEOUT_MS = 120_000;
/** Poll connections until the target leaves ``pending`` (or timeout). */
export async function waitForDriveSyncSettlement(
connectionId: number,
options?: { intervalMs?: number; timeoutMs?: number }
options?: {
intervalMs?: number;
timeoutMs?: number;
onProgress?: (connection: DriveConnectionType) => void;
}
): Promise<DriveConnectionType> {
const intervalMs = options?.intervalMs ?? DEFAULT_SYNC_POLL_MS;
const timeoutMs = options?.timeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS;
@@ -85,6 +94,7 @@ export async function waitForDriveSyncSettlement(
if (!connection) {
throw new Error('Drive connection disappeared while syncing.');
}
options?.onProgress?.(connection);
if (connection.last_sync_status !== 'pending') {
return connection;
}
@@ -93,6 +103,16 @@ export async function waitForDriveSyncSettlement(
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. */
export function formatDriveSyncError(raw?: string | null, maxLen = 280): string {
const text = (raw || '').trim();