This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronLeft, ChevronRight, ExternalLink, FileSpreadsheet, LoaderCircle, Search, Unplug } from 'lucide-react';
|
||||
import { ApiError, api, get, post, relativeTime } from '@/lib/api';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export interface GoogleParsedTable {
|
||||
fileName: string;
|
||||
sheetName: string | null;
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface ConnectionStatus {
|
||||
configured: boolean;
|
||||
connected: boolean;
|
||||
connectedAt: string | null;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
interface DriveFile {
|
||||
id: string;
|
||||
name: string;
|
||||
modifiedTime: string | null;
|
||||
}
|
||||
|
||||
interface FilePage {
|
||||
files: DriveFile[];
|
||||
nextPageToken: string | null;
|
||||
incomplete: boolean;
|
||||
}
|
||||
|
||||
interface SpreadsheetMetadata {
|
||||
title: string;
|
||||
sheets: { sheetId: number; title: string; rowCount: number; columnCount: number }[];
|
||||
}
|
||||
|
||||
export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedTable): void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [pageToken, setPageToken] = useState<string | null>(null);
|
||||
const [previousTokens, setPreviousTokens] = useState<(string | null)[]>([]);
|
||||
const [spreadsheetId, setSpreadsheetId] = useState('');
|
||||
const [sheetId, setSheetId] = useState('');
|
||||
const [range, setRange] = useState('A1:Z2001');
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['google-sheets', 'status'],
|
||||
queryFn: () => get<ConnectionStatus>('/api/imports/google/status'),
|
||||
});
|
||||
const connect = useMutation({
|
||||
mutationFn: () => post<{ authorizationUrl: string }>('/api/imports/google/connect', {}),
|
||||
onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl),
|
||||
});
|
||||
const disconnect = useMutation({
|
||||
mutationFn: () => api<void>('/api/imports/google/connection', { method: 'DELETE' }),
|
||||
onSuccess: async () => {
|
||||
setSpreadsheetId('');
|
||||
setSheetId('');
|
||||
await queryClient.invalidateQueries({ queryKey: ['google-sheets'] });
|
||||
},
|
||||
});
|
||||
const files = useQuery({
|
||||
queryKey: ['google-sheets', 'files', search, pageToken],
|
||||
queryFn: () => {
|
||||
const parameters = new URLSearchParams();
|
||||
if (search) parameters.set('search', search);
|
||||
if (pageToken) parameters.set('pageToken', pageToken);
|
||||
return get<FilePage>(`/api/imports/google/files?${parameters}`);
|
||||
},
|
||||
enabled: status?.connected === true,
|
||||
});
|
||||
const metadata = useQuery({
|
||||
queryKey: ['google-sheets', 'metadata', spreadsheetId],
|
||||
queryFn: () => get<SpreadsheetMetadata>(`/api/imports/google/spreadsheets/${encodeURIComponent(spreadsheetId)}/sheets`),
|
||||
enabled: Boolean(spreadsheetId),
|
||||
});
|
||||
const load = useMutation({
|
||||
mutationFn: () => post<GoogleParsedTable>('/api/imports/google/table', {
|
||||
spreadsheetId,
|
||||
sheetId: Number(sheetId),
|
||||
range,
|
||||
}),
|
||||
onSuccess: onLoaded,
|
||||
});
|
||||
|
||||
if (statusLoading) return <Skeleton className="h-44" />;
|
||||
if (!status?.configured) {
|
||||
return <Card><EmptyState icon={<FileSpreadsheet className="size-8" />} title="Google Sheets is not configured" description="An operator must configure the Google OAuth client and PIG settings encryption key before connecting." /></Card>;
|
||||
}
|
||||
if (!status.connected) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Connect Google Sheets</CardTitle></CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted">PIG requests read-only spreadsheet values and Drive metadata only when you start an import. Tokens remain encrypted on the server.</p>
|
||||
<Button variant="primary" disabled={connect.isPending} onClick={() => connect.mutate()}>
|
||||
{connect.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <ExternalLink data-icon="inline-start" aria-hidden />}
|
||||
Connect Google
|
||||
</Button>
|
||||
{connect.isError ? <ErrorText error={connect.error} /> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSheet = metadata.data?.sheets.find((sheet) => String(sheet.sheetId) === sheetId);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border bg-surface-2 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><p className="text-sm font-medium">Google Sheets connected</p><p className="text-xs text-muted">{status.connectedAt ? `Connected ${relativeTime(status.connectedAt)}` : 'Encrypted server-side connection'}</p></div>
|
||||
<Button variant="outline" disabled={disconnect.isPending} onClick={() => disconnect.mutate()}><Unplug data-icon="inline-start" aria-hidden />Disconnect</Button>
|
||||
</div>
|
||||
{disconnect.isError ? <ErrorText error={disconnect.error} /> : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Choose a spreadsheet</CardTitle></CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<form className="flex gap-2" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setSearch(searchDraft.trim());
|
||||
setPageToken(null);
|
||||
setPreviousTokens([]);
|
||||
}}>
|
||||
<Input value={searchDraft} onChange={(event) => setSearchDraft(event.target.value)} placeholder="Search spreadsheet names" />
|
||||
<Button type="submit" variant="outline"><Search data-icon="inline-start" aria-hidden />Search</Button>
|
||||
</form>
|
||||
{files.isLoading ? <Skeleton className="h-40" /> : files.isError ? <ErrorText error={files.error} /> : files.data?.files.length === 0 ? <EmptyState title="No spreadsheets found" description="Try another name or confirm this Google account can see the spreadsheet." /> : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{files.data?.files.map((file) => (
|
||||
<button key={file.id} type="button" onClick={() => { setSpreadsheetId(file.id); setSheetId(''); }} className={spreadsheetId === file.id ? 'tap min-w-0 rounded-lg border border-accent bg-accent-subtle p-3 text-left' : 'tap min-w-0 rounded-lg border border-border p-3 text-left hover:bg-surface-2'}>
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
<p className="mt-1 text-xs text-muted">{file.modifiedTime ? `Modified ${relativeTime(file.modifiedTime)}` : 'Modified time unavailable'}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{files.data?.incomplete ? <p role="alert" className="text-xs text-warning">Google reported an incomplete Drive search. Narrow the spreadsheet name and search again.</p> : null}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" disabled={previousTokens.length === 0} onClick={() => {
|
||||
setPreviousTokens((tokens) => {
|
||||
const next = [...tokens];
|
||||
setPageToken(next.pop() ?? null);
|
||||
return next;
|
||||
});
|
||||
}}><ChevronLeft data-icon="inline-start" aria-hidden />Previous</Button>
|
||||
<Badge tone="neutral">Up to 50 per page</Badge>
|
||||
<Button variant="outline" disabled={!files.data?.nextPageToken} onClick={() => {
|
||||
if (!files.data?.nextPageToken) return;
|
||||
setPreviousTokens((tokens) => [...tokens, pageToken]);
|
||||
setPageToken(files.data.nextPageToken);
|
||||
}}>Next<ChevronRight data-icon="inline-end" aria-hidden /></Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{spreadsheetId ? (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Choose a sheet and bounded range</CardTitle><p className="text-xs text-muted">A rectangular A1 range must include one header row and at most 2,000 data rows by 100 columns.</p></CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5 text-sm font-medium">Sheet
|
||||
<Select value={sheetId} onValueChange={setSheetId} disabled={metadata.isLoading}>
|
||||
<SelectTrigger className="h-11"><SelectValue placeholder={metadata.isLoading ? 'Loading sheets…' : 'Select visible sheet'} /></SelectTrigger>
|
||||
<SelectContent><SelectGroup>{metadata.data?.sheets.map((sheet) => <SelectItem key={sheet.sheetId} value={String(sheet.sheetId)}>{sheet.title} · {sheet.rowCount}×{sheet.columnCount}</SelectItem>)}</SelectGroup></SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm font-medium">A1 range
|
||||
<Input value={range} onChange={(event) => setRange(event.target.value)} placeholder="A1:H500" autoCapitalize="off" autoCorrect="off" spellCheck={false} />
|
||||
</label>
|
||||
{metadata.isError ? <div className="sm:col-span-2"><ErrorText error={metadata.error} /></div> : null}
|
||||
{selectedSheet ? <p className="text-xs text-muted sm:col-span-2">Selected grid: {selectedSheet.rowCount} rows × {selectedSheet.columnCount} columns. Range limits are enforced again by the server.</p> : null}
|
||||
<Button className="sm:col-span-2" variant="primary" disabled={!sheetId || !range.trim() || load.isPending} onClick={() => load.mutate()}>
|
||||
{load.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <FileSpreadsheet data-icon="inline-start" aria-hidden />}
|
||||
{load.isPending ? 'Reading bounded cells…' : 'Use this range'}
|
||||
</Button>
|
||||
{load.isError ? <div className="sm:col-span-2"><ErrorText error={load.error} /></div> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorText({ error }: { error: unknown }) {
|
||||
return <p role="alert" className="text-sm text-danger">{error instanceof ApiError || error instanceof Error ? error.message : 'Google Sheets request failed.'}</p>;
|
||||
}
|
||||
Reference in New Issue
Block a user