Drive Sync now pending UX + error alerts (#90) #91

Merged
westfarn merged 1 commits from feature/drive-sync-error-ux-90 into master 2026-08-02 04:07:22 -07:00
4 changed files with 180 additions and 13 deletions
@@ -8,9 +8,11 @@ import {
connectDrive,
disconnectDriveConnection,
fetchDriveConnections,
formatDriveSyncError,
parseResourceIdsInput,
saveDriveResourceSelection,
syncDriveConnection,
waitForDriveSyncSettlement,
} from "../../utils/drive";
const Section = styled.div`
@@ -171,6 +173,45 @@ const EmptyState = styled.p`
opacity: 0.6;
`;
const AlertBanner = styled.div<{ $tone: 'error' | 'success' }>`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin: 0 0 1.25rem 0;
padding: 0.85rem 1rem;
border-radius: 0.5rem;
border: 1px solid
${({ $tone }) => ($tone === 'error' ? 'rgba(255, 107, 107, 0.45)' : 'rgba(46, 204, 113, 0.45)')};
background: ${({ $tone }) =>
$tone === 'error' ? 'rgba(255, 71, 87, 0.12)' : 'rgba(46, 204, 113, 0.12)'};
color: ${({ theme }) => theme.colors.text};
font-size: 0.9rem;
line-height: 1.4;
`;
const AlertDismiss = styled.button`
background: transparent;
border: none;
color: inherit;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
opacity: 0.7;
padding: 0;
&:hover {
opacity: 1;
}
`;
const SyncErrorText = styled.p`
margin: 0.75rem 0 0 0;
font-size: 0.85rem;
color: #ff6b6b;
line-height: 1.4;
`;
const PROVIDER_LABELS: Record<DriveProvider, string> = {
google: "Google Drive",
microsoft: "OneDrive",
@@ -183,6 +224,7 @@ type DriveConnectionsSectionProps = {
title: string;
description?: string;
connectIntent: DriveConnectIntent;
onSynced?: () => void;
};
const DriveConnectionsSection = ({
@@ -190,11 +232,15 @@ const DriveConnectionsSection = ({
title,
description,
connectIntent,
onSynced,
}: DriveConnectionsSectionProps): JSX.Element => {
const [connections, setConnections] = useState<DriveConnectionType[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const [syncBanner, setSyncBanner] = useState<{ tone: 'error' | 'success'; message: string } | null>(
null
);
const [resourceInputs, setResourceInputs] = useState<Record<number, string>>({});
const [pendingAction, setPendingAction] = useState<Record<number, PendingAction | undefined>>({});
@@ -219,6 +265,16 @@ const DriveConnectionsSection = ({
setPendingAction((prev) => ({ ...prev, [id]: action }));
};
const upsertConnection = (updated: DriveConnectionType) => {
setConnections((prev) => {
const exists = prev.some((conn) => conn.id === updated.id);
if (!exists) {
return (updated.kind || 'personal') === kind ? [...prev, updated] : prev;
}
return prev.map((conn) => (conn.id === updated.id ? { ...conn, ...updated } : conn));
});
};
const handleDisconnect = async (id: number) => {
setAction(id, 'disconnect');
try {
@@ -226,6 +282,7 @@ const DriveConnectionsSection = ({
setConnections((prev) => prev.filter((conn) => conn.id !== id));
} catch (err) {
console.log(err);
setSyncBanner({ tone: 'error', message: 'Could not disconnect this drive. Try again.' });
} finally {
setAction(id, undefined);
}
@@ -233,11 +290,33 @@ const DriveConnectionsSection = ({
const handleSync = async (id: number) => {
setAction(id, 'sync');
setSyncBanner(null);
try {
await syncDriveConnection(id);
await loadConnections();
const enqueued = await syncDriveConnection(id);
upsertConnection(enqueued.connection);
const settled =
enqueued.connection.last_sync_status === 'pending'
? await waitForDriveSyncSettlement(id)
: enqueued.connection;
upsertConnection(settled);
if (settled.last_sync_status === 'error') {
setSyncBanner({
tone: 'error',
message: formatDriveSyncError(settled.last_sync_error),
});
} else if (settled.last_sync_status === 'ok') {
setSyncBanner({ tone: 'success', message: 'Drive sync finished.' });
onSynced?.();
}
} catch (err) {
console.log(err);
console.error(err);
const message =
err instanceof Error ? err.message : 'Drive sync failed. Try again.';
setSyncBanner({ tone: 'error', message });
await loadConnections();
} finally {
setAction(id, undefined);
}
@@ -258,6 +337,7 @@ const DriveConnectionsSection = ({
setResourceInputs((prev) => ({ ...prev, [id]: '' }));
} catch (err) {
console.log(err);
setSyncBanner({ tone: 'error', message: 'Could not save folder selection. Try again.' });
} finally {
setAction(id, undefined);
}
@@ -268,6 +348,19 @@ const DriveConnectionsSection = ({
<SectionTitle>{title}</SectionTitle>
{description && <SectionDescription>{description}</SectionDescription>}
{syncBanner && (
<AlertBanner $tone={syncBanner.tone} role="alert">
<span>{syncBanner.message}</span>
<AlertDismiss
type="button"
aria-label="Dismiss"
onClick={() => setSyncBanner(null)}
>
×
</AlertDismiss>
</AlertBanner>
)}
<ConnectButtonRow>
<ConnectButton
type="button"
@@ -310,6 +403,7 @@ const DriveConnectionsSection = ({
const selectedLabels = conn.selected_resource_labels?.length
? conn.selected_resource_labels
: conn.selected_resource_ids || [];
const isSyncing = action === 'sync' || conn.last_sync_status === 'pending';
return (
<ConnectionCard key={conn.id}>
@@ -328,9 +422,9 @@ const DriveConnectionsSection = ({
<SmallButton
type="button"
onClick={() => handleSync(conn.id)}
disabled={Boolean(action)}
disabled={Boolean(action) || conn.last_sync_status === 'pending'}
>
{action === 'sync' ? 'Syncing…' : 'Sync now'}
{isSyncing ? 'Syncing…' : 'Sync now'}
</SmallButton>
<DangerButton
type="button"
@@ -342,6 +436,10 @@ const DriveConnectionsSection = ({
</ConnectionActions>
</ConnectionHeader>
{conn.last_sync_status === 'error' && conn.last_sync_error && (
<SyncErrorText>{formatDriveSyncError(conn.last_sync_error)}</SyncErrorText>
)}
{selectedLabels.length > 0 && (
<ResourceTagList>
{selectedLabels.map((label, idx) => (
@@ -434,6 +434,7 @@ const DocumentStoragePageInner = (): JSX.Element => {
title="Cloud drives"
description="Connect your personal Google Drive or OneDrive so its files can be searched in chat."
connectIntent="link_drive"
onSynced={fetchDocuments}
/>
{account?.is_company_manager && (
@@ -442,6 +443,7 @@ const DocumentStoragePageInner = (): JSX.Element => {
title="Company knowledge sources"
description="Connect a shared Google Shared Drive or Microsoft 365 site so your whole team can search these files in chat."
connectIntent="link_company_drive"
onSynced={fetchDocuments}
/>
)}
</>
+18 -1
View File
@@ -1,4 +1,8 @@
import { driveConnectUrl, parseResourceIdsInput } from './drive';
import {
driveConnectUrl,
formatDriveSyncError,
parseResourceIdsInput,
} from './drive';
describe('parseResourceIdsInput', () => {
it('splits comma and newline separated ids and trims whitespace', () => {
@@ -10,6 +14,19 @@ describe('parseResourceIdsInput', () => {
});
});
describe('formatDriveSyncError', () => {
it('returns a fallback when empty', () => {
expect(formatDriveSyncError('')).toMatch(/Drive sync failed/);
});
it('truncates long provider error payloads', () => {
const long = 'x'.repeat(400);
const formatted = formatDriveSyncError(long, 50);
expect(formatted.length).toBeLessThanOrEqual(51);
expect(formatted.endsWith('…')).toBe(true);
});
});
describe('driveConnectUrl', () => {
const originalEnv = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
+57 -7
View File
@@ -8,6 +8,8 @@ export type DriveConnectionKind = 'personal' | 'company';
/** intent query param sent to /auth/oauth/:provider/start/ for drive-linking flows (#83/#84). */
export type DriveConnectIntent = 'link_drive' | 'link_company_drive';
export type DriveSyncStatus = 'ok' | 'error' | 'pending' | 'never';
export type DriveConnectionType = {
id: number;
provider: DriveProvider;
@@ -17,11 +19,16 @@ export type DriveConnectionType = {
selected_resource_ids?: string[];
selected_resource_labels?: string[];
last_sync_at?: string | null;
last_sync_status?: string | null;
last_sync_status?: DriveSyncStatus | string | null;
last_sync_error?: string;
created?: string;
};
export type DriveSyncEnqueueResponse = {
queued?: boolean;
connection: DriveConnectionType;
};
export async function fetchDriveConnections(): Promise<DriveConnectionType[]> {
const { data } = await axiosInstance.get<DriveConnectionType[]>('/drive/connections/');
return Array.isArray(data) ? data : [];
@@ -47,12 +54,55 @@ export async function saveDriveResourceSelection(
return data;
}
export async function syncDriveConnection(connectionId: number): Promise<DriveConnectionType | null> {
const { data } = await axiosInstance.post<{
connection?: DriveConnectionType;
} & DriveConnectionType>(`/drive/connections/${connectionId}/sync/`);
if (!data) return null;
return data.connection || data;
/** Enqueue a Drive sync (#57/#90). Returns quickly with pending status. */
export async function syncDriveConnection(
connectionId: number
): Promise<DriveSyncEnqueueResponse> {
const { data } = await axiosInstance.post<DriveSyncEnqueueResponse>(
`/drive/connections/${connectionId}/sync/`
);
if (!data?.connection) {
throw new Error('Drive sync did not return a connection.');
}
return data;
}
const DEFAULT_SYNC_POLL_MS = 1500;
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 }
): Promise<DriveConnectionType> {
const intervalMs = options?.intervalMs ?? DEFAULT_SYNC_POLL_MS;
const timeoutMs = options?.timeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const connections = await fetchDriveConnections();
const connection = connections.find((item) => item.id === connectionId);
if (!connection) {
throw new Error('Drive connection disappeared while syncing.');
}
if (connection.last_sync_status !== 'pending') {
return connection;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error('Drive sync timed out. Check status and try again.');
}
/** Shorten long provider JSON error blobs for toasts. */
export function formatDriveSyncError(raw?: string | null, maxLen = 280): string {
const text = (raw || '').trim();
if (!text) {
return 'Drive sync failed. Try again or check provider API settings.';
}
if (text.length <= maxLen) {
return text;
}
return `${text.slice(0, maxLen).trim()}`;
}
/** Absolute backend OAuth start URL for a drive-link flow (personal or company). */