Files
pig/apps/web/src/components/NotionImportSource.tsx
T
2026-08-13 01:39:01 -07:00

116 lines
6.2 KiB
TypeScript

import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Database, Link2, LoaderCircle, Unplug } from 'lucide-react';
import { api, get, post } from '@/lib/api';
import { Badge, Button } from '@/components/ui';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface ImportedTable {
fileName: string;
sheetName: string | null;
headers: string[];
rows: string[][];
warnings: string[];
unsupportedProperties?: { name: string; type: string; reason: string }[];
}
interface Connection {
id: string;
workspaceId: string;
workspaceName: string | null;
workspaceIcon: string | null;
connectedAt: string;
}
interface Status {
configured: boolean;
connected: boolean;
connections: Connection[];
}
interface DataSource {
id: string;
databaseId: string | null;
name: string;
url: string | null;
icon: string | null;
}
export function NotionImportSource({
disabled,
onTable,
}: {
disabled: boolean;
onTable(table: ImportedTable): void;
}) {
const queryClient = useQueryClient();
const [connectionId, setConnectionId] = useState('');
const [dataSourceId, setDataSourceId] = useState('');
const status = useQuery({
queryKey: ['notion-import-status'],
queryFn: () => get<Status>('/api/imports/notion/status'),
enabled: !disabled,
});
const selectedConnection = connectionId || status.data?.connections[0]?.id || '';
const dataSources = useQuery({
queryKey: ['notion-data-sources', selectedConnection],
queryFn: () => get<{ dataSources: DataSource[] }>(
`/api/imports/notion/connections/${selectedConnection}/data-sources`,
),
enabled: Boolean(selectedConnection),
});
const connect = useMutation({
mutationFn: () => post<{ authorizationUrl: string }>('/api/imports/notion/oauth/start', {}),
onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl),
});
const materialize = useMutation({
mutationFn: () => post<ImportedTable>(
`/api/imports/notion/connections/${selectedConnection}/materialize`,
{ dataSourceId },
),
onSuccess: onTable,
});
const disconnect = useMutation({
mutationFn: (id: string) => api(`/api/imports/notion/connections/${id}`, { method: 'DELETE' }),
onSuccess: () => {
setConnectionId('');
setDataSourceId('');
void queryClient.invalidateQueries({ queryKey: ['notion-import-status'] });
},
});
if (status.data && !status.data.configured) {
return <div className="rounded-xl border border-dashed border-border p-4"><p className="text-sm font-medium">Notion is not configured</p><p className="mt-1 text-xs text-muted">An operator must set the Notion OAuth environment variables and encryption key on the API server.</p></div>;
}
return (
<div className="rounded-xl border border-border bg-surface-2/40 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-xl border border-border bg-surface"><Database className="size-5" aria-hidden /></span>
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><p className="font-medium">Notion database</p>{status.data?.connected ? <Badge tone="positive">Connected</Badge> : null}</div><p className="mt-0.5 text-xs text-muted">Choose a shared data source, then map it through the same dry run as a spreadsheet.</p></div>
</div>
{!status.data?.connected ? <Button className="min-h-11" type="button" variant="outline" disabled={disabled || connect.isPending || !status.data?.configured} onClick={() => connect.mutate()}>{connect.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <Link2 data-icon="inline-start" aria-hidden />}Connect Notion</Button> : null}
</div>
{status.data?.connected ? <div className="mt-4 grid gap-3 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1fr)_auto_auto] lg:items-end">
<label className="flex min-w-0 flex-col gap-1.5 text-sm font-medium">Workspace<Select value={selectedConnection} onValueChange={(value) => { setConnectionId(value); setDataSourceId(''); }}><SelectTrigger className="h-11"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{status.data.connections.map((connection) => <SelectItem key={connection.id} value={connection.id}>{connection.workspaceIcon ? `${connection.workspaceIcon} ` : ''}{connection.workspaceName ?? connection.workspaceId}</SelectItem>)}</SelectGroup></SelectContent></Select></label>
<label className="flex min-w-0 flex-col gap-1.5 text-sm font-medium">Database<Select value={dataSourceId} onValueChange={setDataSourceId} disabled={dataSources.isLoading}><SelectTrigger className="h-11"><SelectValue placeholder={dataSources.isLoading ? 'Loading databases…' : 'Choose a database'} /></SelectTrigger><SelectContent><SelectGroup>{(dataSources.data?.dataSources ?? []).map((source) => <SelectItem key={source.id} value={source.id}>{source.icon ? `${source.icon} ` : ''}{source.name}</SelectItem>)}</SelectGroup></SelectContent></Select></label>
<Button className="min-h-11" type="button" variant="primary" disabled={!dataSourceId || materialize.isPending} onClick={() => materialize.mutate()}>{materialize.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <Database data-icon="inline-start" aria-hidden />}{materialize.isPending ? 'Reading…' : 'Use database'}</Button>
<Button className="min-h-11" type="button" variant="ghost" disabled={disconnect.isPending} onClick={() => disconnect.mutate(selectedConnection)}><Unplug data-icon="inline-start" aria-hidden />Disconnect</Button>
</div> : null}
{connect.error || dataSources.error || materialize.error || disconnect.error ? <p role="alert" className="mt-3 text-sm text-danger">{errorMessage(connect.error ?? dataSources.error ?? materialize.error ?? disconnect.error)}</p> : null}
</div>
);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'The Notion request failed.';
}