Docs sync progress, Personal/Company list UX, Analytics heatmap (#92–#94) #95
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' 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;
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
Reference in New Issue
Block a user