116 lines
6.2 KiB
TypeScript
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.';
|
|
}
|