evOo-local/apps/web/src/components/media/library/CsvImportDialog.tsx

355 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Plural, Trans, useLingui } from "@lingui/react/macro";
import type { MetricCsvMapping } from "@rakazo/contracts";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Field,
FieldLabel,
Input,
NativeSelect,
NativeSelectOption,
} from "@rakazo/ui-web";
import { useEffect, useId, useMemo, useState } from "react";
import type { CsvDelimiter, MappingColumn } from "../../../lib/media-library";
import {
csvHeaders,
firstCsvLine,
guessColumns,
guessDelimiter,
guessPublicationKey,
MAPPING_COLUMNS,
} from "../../../lib/media-library";
import { rpcErrorMessage } from "../../../lib/media-studio";
import { rpc } from "../../../lib/rpc";
const MAX_CSV_CHARS = 5_000_000;
const NEW = "";
type Draft = Omit<MetricCsvMapping, "columns"> & {
columns: Partial<Record<MappingColumn, string>>;
};
function draftFrom(mapping: MetricCsvMapping): Draft {
return { ...mapping, columns: { ...mapping.columns } };
}
function toMapping(draft: Draft): MetricCsvMapping | null {
const { publication, observedAt } = draft.columns;
if (!publication || !observedAt || !draft.name.trim()) return null;
if (!/^[A-Za-z]{3}$/.test(draft.defaultCurrency)) return null;
const columns: MetricCsvMapping["columns"] = { publication, observedAt };
for (const key of MAPPING_COLUMNS) {
const value = draft.columns[key];
if (value) columns[key] = value;
}
return {
...draft,
name: draft.name.trim(),
defaultCurrency: draft.defaultCurrency.toUpperCase(),
columns,
};
}
export function CsvImportDialog({
open,
onOpenChange,
onImported,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onImported: () => void;
}) {
const { t } = useLingui();
const fileId = useId();
const mappingId = useId();
const nameId = useId();
const [mappings, setMappings] = useState<MetricCsvMapping[]>([]);
const [csv, setCsv] = useState<string | null>(null);
const [fileName, setFileName] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{
imported: number;
skipped: Array<{ row: number; reason: string }>;
} | null>(null);
useEffect(() => {
if (!open) return;
setCsv(null);
setFileName("");
setDraft(null);
setError(null);
setResult(null);
rpc.media.metrics
.mappings({})
.then(setMappings)
.catch(() => setMappings([]));
}, [open]);
const headers = useMemo(
() => (csv && draft ? csvHeaders(csv, draft.delimiter) : []),
[csv, draft?.delimiter, draft],
);
const labels: Record<MappingColumn, string> = {
publication: t`Publication`,
observedAt: t`Date`,
views: t`Views`,
clicks: t`Clicks`,
conversions: t`Purchases`,
revenue: t`Revenue`,
currency: t`Currency`,
};
const keyLabels: Record<MetricCsvMapping["publicationKey"], string> = {
utm_content: t`UTM content`,
tracking_url: t`Tracking link`,
external_url: t`Post link`,
id: t`Publication ID`,
};
const readFile = async (file: File) => {
setError(null);
setResult(null);
if (file.size > MAX_CSV_CHARS) {
setError(t`This file is too large.`);
return;
}
const text = await file.text();
setCsv(text);
setFileName(file.name);
const delimiter: CsvDelimiter = guessDelimiter(firstCsvLine(text));
const found = csvHeaders(text, delimiter);
const matching = mappings.find((mapping) =>
Object.values(mapping.columns).every((column) => !column || found.includes(column)),
);
if (matching) {
setDraft(draftFrom(matching));
return;
}
const columns = guessColumns(found);
setDraft({
name: file.name.replace(/\.[^.]+$/, "").slice(0, 60) || t`Import`,
delimiter,
columns,
publicationKey: guessPublicationKey(columns.publication),
revenueUnit: "major",
defaultCurrency: "EUR",
});
};
const mapping = draft ? toMapping(draft) : null;
const runImport = async () => {
if (!csv || !mapping) return;
setBusy(true);
setError(null);
try {
const saved = await rpc.media.metrics.saveMapping(mapping);
if (!saved.id) throw new Error(t`Could not save the mapping.`);
const outcome = await rpc.media.metrics.importCsv({ mappingId: saved.id, csv });
setResult(outcome);
onImported();
} catch (cause) {
setError(rpcErrorMessage(cause, t`Import failed.`));
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="media-csv-import" className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
<Trans>Import results</Trans>
</DialogTitle>
</DialogHeader>
{result ? (
<div className="flex flex-col gap-2 text-ui-body-sm">
<p data-testid="media-csv-imported">
<Plural value={result.imported} one="# row imported" other="# rows imported" />
</p>
{result.skipped.length > 0 ? (
<ul className="max-h-40 overflow-y-auto text-muted-foreground">
{result.skipped.slice(0, 20).map((skip) => (
<li key={skip.row}>
<Trans>
Row {skip.row}: {skip.reason}
</Trans>
</li>
))}
</ul>
) : null}
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>
<Trans>Done</Trans>
</Button>
</DialogFooter>
</div>
) : (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void runImport();
}}
>
<Field>
<FieldLabel htmlFor={fileId}>
<Trans>CSV file</Trans>
</FieldLabel>
<Input
id={fileId}
type="file"
accept=".csv,text/csv,text/plain"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void readFile(file);
}}
/>
</Field>
{draft && csv ? (
<>
{mappings.length > 0 ? (
<Field>
<FieldLabel htmlFor={mappingId}>
<Trans>Mapping</Trans>
</FieldLabel>
<NativeSelect
id={mappingId}
className="w-full"
value={draft.id ?? NEW}
onChange={(event) => {
const chosen = mappings.find((row) => row.id === event.target.value);
setDraft(chosen ? draftFrom(chosen) : { ...draft, id: undefined });
}}
>
<NativeSelectOption value={NEW}>{t`New mapping`}</NativeSelectOption>
{mappings.map((row) => (
<NativeSelectOption key={row.id} value={row.id ?? NEW}>
{row.name}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
) : null}
<Field>
<FieldLabel htmlFor={nameId}>
<Trans>Platform</Trans>
</FieldLabel>
<Input
id={nameId}
value={draft.name}
maxLength={60}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
{MAPPING_COLUMNS.map((column) => (
<Field key={column}>
<FieldLabel htmlFor={`${mappingId}-${column}`}>{labels[column]}</FieldLabel>
<NativeSelect
id={`${mappingId}-${column}`}
size="sm"
className="w-full"
value={draft.columns[column] ?? ""}
onChange={(event) =>
setDraft({
...draft,
columns: {
...draft.columns,
[column]: event.target.value || undefined,
},
})
}
>
<NativeSelectOption value="">–</NativeSelectOption>
{headers.map((header) => (
<NativeSelectOption key={header} value={header}>
{header}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
))}
<Field>
<FieldLabel htmlFor={`${mappingId}-key`}>
<Trans>Publication is</Trans>
</FieldLabel>
<NativeSelect
id={`${mappingId}-key`}
size="sm"
className="w-full"
value={draft.publicationKey}
onChange={(event) =>
setDraft({
...draft,
publicationKey: event.target.value as MetricCsvMapping["publicationKey"],
})
}
>
{(Object.keys(keyLabels) as MetricCsvMapping["publicationKey"][]).map(
(key) => (
<NativeSelectOption key={key} value={key}>
{keyLabels[key]}
</NativeSelectOption>
),
)}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor={`${mappingId}-unit`}>
<Trans>Revenue in</Trans>
</FieldLabel>
<div className="flex gap-2">
<NativeSelect
id={`${mappingId}-unit`}
size="sm"
className="w-full"
value={draft.revenueUnit}
onChange={(event) =>
setDraft({
...draft,
revenueUnit: event.target.value as MetricCsvMapping["revenueUnit"],
})
}
>
<NativeSelectOption value="major">1.00</NativeSelectOption>
<NativeSelectOption value="cents">100</NativeSelectOption>
</NativeSelect>
<Input
aria-label={t`Currency`}
className="h-7! w-16 uppercase"
maxLength={3}
value={draft.defaultCurrency}
onChange={(event) =>
setDraft({ ...draft, defaultCurrency: event.target.value.toUpperCase() })
}
/>
</div>
</Field>
</div>
</>
) : null}
{error ? (
<p role="alert" className="text-ui-body-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" disabled={busy || !mapping || !csv}>
{fileName ? <Trans>Import {fileName}</Trans> : <Trans>Import</Trans>}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
}