## Summary - Closes [#90](#90) - Depends on backend [chat_backend#57](ai_ml_operations/chat_backend#57) (202 + pending status) - Sync now: enqueue, poll until status leaves `pending`, show **error/success alert** - Per-connection `last_sync_error` shown when status is `error` - OAuth green “Drive connected” banner unchanged (connect-only) - Refreshes document list on successful sync ## Test plan - [x] `drive.test` + `DocumentStoragePage.test` - [ ] Manual with backend #57: Sync fails (Drive API off) → red alert with truncated error - [ ] Manual: Sync succeeds → success alert + docs refresh - [ ] Manual: Syncing… / button disabled while pendingReviewed-on: #91
This commit was merged in pull request #91.
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user