Surface Drive sync pending/error state after Sync now (#90).
Unit Tests / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 10s
Poll until last_sync_status leaves pending, show error/success alerts, and keep the OAuth connected banner separate from sync outcome.
This commit is contained in:
@@ -8,9 +8,11 @@ import {
|
|||||||
connectDrive,
|
connectDrive,
|
||||||
disconnectDriveConnection,
|
disconnectDriveConnection,
|
||||||
fetchDriveConnections,
|
fetchDriveConnections,
|
||||||
|
formatDriveSyncError,
|
||||||
parseResourceIdsInput,
|
parseResourceIdsInput,
|
||||||
saveDriveResourceSelection,
|
saveDriveResourceSelection,
|
||||||
syncDriveConnection,
|
syncDriveConnection,
|
||||||
|
waitForDriveSyncSettlement,
|
||||||
} from "../../utils/drive";
|
} from "../../utils/drive";
|
||||||
|
|
||||||
const Section = styled.div`
|
const Section = styled.div`
|
||||||
@@ -171,6 +173,45 @@ const EmptyState = styled.p`
|
|||||||
opacity: 0.6;
|
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> = {
|
const PROVIDER_LABELS: Record<DriveProvider, string> = {
|
||||||
google: "Google Drive",
|
google: "Google Drive",
|
||||||
microsoft: "OneDrive",
|
microsoft: "OneDrive",
|
||||||
@@ -183,6 +224,7 @@ type DriveConnectionsSectionProps = {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
connectIntent: DriveConnectIntent;
|
connectIntent: DriveConnectIntent;
|
||||||
|
onSynced?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DriveConnectionsSection = ({
|
const DriveConnectionsSection = ({
|
||||||
@@ -190,11 +232,15 @@ const DriveConnectionsSection = ({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
connectIntent,
|
connectIntent,
|
||||||
|
onSynced,
|
||||||
}: DriveConnectionsSectionProps): JSX.Element => {
|
}: DriveConnectionsSectionProps): JSX.Element => {
|
||||||
const [connections, setConnections] = useState<DriveConnectionType[]>([]);
|
const [connections, setConnections] = useState<DriveConnectionType[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState(false);
|
const [loadError, setLoadError] = useState(false);
|
||||||
const [connectError, setConnectError] = useState<string | null>(null);
|
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 [resourceInputs, setResourceInputs] = useState<Record<number, string>>({});
|
||||||
const [pendingAction, setPendingAction] = useState<Record<number, PendingAction | undefined>>({});
|
const [pendingAction, setPendingAction] = useState<Record<number, PendingAction | undefined>>({});
|
||||||
|
|
||||||
@@ -219,6 +265,16 @@ const DriveConnectionsSection = ({
|
|||||||
setPendingAction((prev) => ({ ...prev, [id]: action }));
|
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) => {
|
const handleDisconnect = async (id: number) => {
|
||||||
setAction(id, 'disconnect');
|
setAction(id, 'disconnect');
|
||||||
try {
|
try {
|
||||||
@@ -226,6 +282,7 @@ const DriveConnectionsSection = ({
|
|||||||
setConnections((prev) => prev.filter((conn) => conn.id !== id));
|
setConnections((prev) => prev.filter((conn) => conn.id !== id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
|
setSyncBanner({ tone: 'error', message: 'Could not disconnect this drive. Try again.' });
|
||||||
} finally {
|
} finally {
|
||||||
setAction(id, undefined);
|
setAction(id, undefined);
|
||||||
}
|
}
|
||||||
@@ -233,11 +290,33 @@ const DriveConnectionsSection = ({
|
|||||||
|
|
||||||
const handleSync = async (id: number) => {
|
const handleSync = async (id: number) => {
|
||||||
setAction(id, 'sync');
|
setAction(id, 'sync');
|
||||||
|
setSyncBanner(null);
|
||||||
try {
|
try {
|
||||||
await syncDriveConnection(id);
|
const enqueued = await syncDriveConnection(id);
|
||||||
await loadConnections();
|
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) {
|
} 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 {
|
} finally {
|
||||||
setAction(id, undefined);
|
setAction(id, undefined);
|
||||||
}
|
}
|
||||||
@@ -258,6 +337,7 @@ const DriveConnectionsSection = ({
|
|||||||
setResourceInputs((prev) => ({ ...prev, [id]: '' }));
|
setResourceInputs((prev) => ({ ...prev, [id]: '' }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
|
setSyncBanner({ tone: 'error', message: 'Could not save folder selection. Try again.' });
|
||||||
} finally {
|
} finally {
|
||||||
setAction(id, undefined);
|
setAction(id, undefined);
|
||||||
}
|
}
|
||||||
@@ -268,6 +348,19 @@ const DriveConnectionsSection = ({
|
|||||||
<SectionTitle>{title}</SectionTitle>
|
<SectionTitle>{title}</SectionTitle>
|
||||||
{description && <SectionDescription>{description}</SectionDescription>}
|
{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>
|
<ConnectButtonRow>
|
||||||
<ConnectButton
|
<ConnectButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -310,6 +403,7 @@ const DriveConnectionsSection = ({
|
|||||||
const selectedLabels = conn.selected_resource_labels?.length
|
const selectedLabels = conn.selected_resource_labels?.length
|
||||||
? 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';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConnectionCard key={conn.id}>
|
<ConnectionCard key={conn.id}>
|
||||||
@@ -328,9 +422,9 @@ const DriveConnectionsSection = ({
|
|||||||
<SmallButton
|
<SmallButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSync(conn.id)}
|
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>
|
</SmallButton>
|
||||||
<DangerButton
|
<DangerButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -342,6 +436,10 @@ const DriveConnectionsSection = ({
|
|||||||
</ConnectionActions>
|
</ConnectionActions>
|
||||||
</ConnectionHeader>
|
</ConnectionHeader>
|
||||||
|
|
||||||
|
{conn.last_sync_status === 'error' && conn.last_sync_error && (
|
||||||
|
<SyncErrorText>{formatDriveSyncError(conn.last_sync_error)}</SyncErrorText>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedLabels.length > 0 && (
|
{selectedLabels.length > 0 && (
|
||||||
<ResourceTagList>
|
<ResourceTagList>
|
||||||
{selectedLabels.map((label, idx) => (
|
{selectedLabels.map((label, idx) => (
|
||||||
|
|||||||
@@ -434,6 +434,7 @@ const DocumentStoragePageInner = (): JSX.Element => {
|
|||||||
title="Cloud drives"
|
title="Cloud drives"
|
||||||
description="Connect your personal Google Drive or OneDrive so its files can be searched in chat."
|
description="Connect your personal Google Drive or OneDrive so its files can be searched in chat."
|
||||||
connectIntent="link_drive"
|
connectIntent="link_drive"
|
||||||
|
onSynced={fetchDocuments}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{account?.is_company_manager && (
|
{account?.is_company_manager && (
|
||||||
@@ -442,6 +443,7 @@ const DocumentStoragePageInner = (): JSX.Element => {
|
|||||||
title="Company knowledge sources"
|
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."
|
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"
|
connectIntent="link_company_drive"
|
||||||
|
onSynced={fetchDocuments}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { driveConnectUrl, parseResourceIdsInput } from './drive';
|
import {
|
||||||
|
driveConnectUrl,
|
||||||
|
formatDriveSyncError,
|
||||||
|
parseResourceIdsInput,
|
||||||
|
} from './drive';
|
||||||
|
|
||||||
describe('parseResourceIdsInput', () => {
|
describe('parseResourceIdsInput', () => {
|
||||||
it('splits comma and newline separated ids and trims whitespace', () => {
|
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', () => {
|
describe('driveConnectUrl', () => {
|
||||||
const originalEnv = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
|
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). */
|
/** 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 DriveConnectIntent = 'link_drive' | 'link_company_drive';
|
||||||
|
|
||||||
|
export type DriveSyncStatus = 'ok' | 'error' | 'pending' | 'never';
|
||||||
|
|
||||||
export type DriveConnectionType = {
|
export type DriveConnectionType = {
|
||||||
id: number;
|
id: number;
|
||||||
provider: DriveProvider;
|
provider: DriveProvider;
|
||||||
@@ -17,11 +19,16 @@ export type DriveConnectionType = {
|
|||||||
selected_resource_ids?: string[];
|
selected_resource_ids?: string[];
|
||||||
selected_resource_labels?: string[];
|
selected_resource_labels?: string[];
|
||||||
last_sync_at?: string | null;
|
last_sync_at?: string | null;
|
||||||
last_sync_status?: string | null;
|
last_sync_status?: DriveSyncStatus | string | null;
|
||||||
last_sync_error?: string;
|
last_sync_error?: string;
|
||||||
created?: string;
|
created?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DriveSyncEnqueueResponse = {
|
||||||
|
queued?: boolean;
|
||||||
|
connection: DriveConnectionType;
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchDriveConnections(): Promise<DriveConnectionType[]> {
|
export async function fetchDriveConnections(): Promise<DriveConnectionType[]> {
|
||||||
const { data } = await axiosInstance.get<DriveConnectionType[]>('/drive/connections/');
|
const { data } = await axiosInstance.get<DriveConnectionType[]>('/drive/connections/');
|
||||||
return Array.isArray(data) ? data : [];
|
return Array.isArray(data) ? data : [];
|
||||||
@@ -47,12 +54,55 @@ export async function saveDriveResourceSelection(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncDriveConnection(connectionId: number): Promise<DriveConnectionType | null> {
|
/** Enqueue a Drive sync (#57/#90). Returns quickly with pending status. */
|
||||||
const { data } = await axiosInstance.post<{
|
export async function syncDriveConnection(
|
||||||
connection?: DriveConnectionType;
|
connectionId: number
|
||||||
} & DriveConnectionType>(`/drive/connections/${connectionId}/sync/`);
|
): Promise<DriveSyncEnqueueResponse> {
|
||||||
if (!data) return null;
|
const { data } = await axiosInstance.post<DriveSyncEnqueueResponse>(
|
||||||
return data.connection || data;
|
`/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). */
|
/** Absolute backend OAuth start URL for a drive-link flow (personal or company). */
|
||||||
|
|||||||
Reference in New Issue
Block a user