Documents list UX, sync progress bar, and Analytics heatmap overhaul.
Unit Tests / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 10s
Add Drive sync progress UI (#92), Personal/Company document tabs with search/sort/pagination (#93), and Analytics You vs Company sections with prompt activity heatmap (#94).
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
<GridContainer>
|
<Section aria-labelledby="analytics-you">
|
||||||
<UserConversationAnalyticsCard />
|
<SectionHeading id="analytics-you">Your activity</SectionHeading>
|
||||||
<UserPromptAnalyticsCard />
|
<SectionHint>
|
||||||
</GridContainer>
|
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 /> : <></>}
|
{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,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 = {
|
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 key={doc.id}>
|
<tr>
|
||||||
<Td>{doc.name}</Td>
|
<EmptyRow colSpan={4}>Loading documents…</EmptyRow>
|
||||||
<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>
|
</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>
|
</tbody>
|
||||||
</StyledTable>
|
</StyledTable>
|
||||||
</div>
|
</div>
|
||||||
<HelperNote>
|
|
||||||
These documents are shared with your whole company workspace. Want to search your own
|
<PaginationRow>
|
||||||
files privately? Connect a personal cloud drive in the Cloud drives section below.
|
<span>
|
||||||
</HelperNote>
|
{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>
|
</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({
|
);
|
||||||
|
|
||||||
id: item.id,
|
useEffect(() => {
|
||||||
name: item.file.replace(/^.*[\\/]/, ''),
|
if (!hasCompany && scope === 'company') {
|
||||||
date_uploaded: item.created.substring(0, 10),
|
setScope('personal');
|
||||||
active: item.active,
|
|
||||||
processed: item.processed,
|
|
||||||
|
|
||||||
})))
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error)
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [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(() => {
|
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;
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
|||||||
Reference in New Issue
Block a user