Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/rows/components/RowsImportRoute.tsx
T

1040 lines
40 KiB
TypeScript

import { Form, useActionData, useLoaderData, useNavigation, useSubmit } from "react-router";
import type { TFunction } from "i18next";
import clsx from "clsx";
import { useState, useEffect, useMemo } from "react";
import type { FormEvent, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import type { RowHeaderDisplayDto } from "~/application/dtos/data/RowHeaderDisplayDto";
import { PropertyType } from "~/application/enums/entities/PropertyType";
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import ExclamationTriangleIcon from "~/components/ui/icons/ExclamationTriangleIcon";
import { MinusIcon } from "~/components/ui/icons/vendor";
import InputCheckboxInline from "~/components/ui/input/InputCheckboxInline";
import InputSelect from "~/components/ui/input/InputSelect";
import InputSelector from "~/components/ui/input/InputSelector";
import InputText from "~/components/ui/input/InputText";
import Loading from "~/components/ui/loaders/Loading";
import Modal from "~/components/ui/modals/Modal";
import ProgressBar from "~/components/ui/steps/ProgressBar";
import type { StepDto } from "~/components/ui/steps/StepDto";
import TableSimple from "~/components/ui/tables/TableSimple";
import UploadDocuments from "~/components/ui/uploaders/UploadDocument";
import type { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
import type { Rows_Import } from "../routes/Rows_Import.server";
import { appPath } from "~/utils/app/CanonicalUrlUtils";
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
import { useLocaleFormatters } from "~/locale/format";
import { translateEntityTitlePlural, translatePropertyTitle } from "~/utils/i18n/translateLabel";
type RawDataDto = {
rows: RawRow[];
columns: RawColumn[];
};
type RawRow = { column: string; value: string }[];
type RawColumn = { column: string; name?: string };
function dryRunSourceKey(tenantId: string, entityName: string) {
return `tenants/${tenantId}/imports/dry-run/${entityName.replace(/[^a-zA-Z0-9._-]+/g, "-") || "rows"}-import.csv`;
}
export function getImportSampleValue(propertyType: PropertyType, index: number, t: (key: string, options?: Record<string, unknown>) => string) {
switch (propertyType) {
case PropertyType.TEXT:
return `"${t("models.row.import.sampleValues.text", { number: index })}"`;
case PropertyType.NUMBER:
return `"123"`;
case PropertyType.DATE:
return `"2022-12-31"`;
case PropertyType.BOOLEAN:
return "true";
case PropertyType.SELECT:
return `"${t("models.row.import.sampleValues.option", { number: 1 })}"`;
case PropertyType.MEDIA:
return `"https://via.placeholder.com/600x400"`;
default:
return "";
}
}
function createInitialSteps(t: TFunction): StepDto[] {
return [
{
name: "upload",
title: t("models.row.import.steps.uploadCsvFile"),
status: "current",
},
{
name: "map",
title: t("models.row.import.steps.mapColumns"),
status: "pending",
},
{
name: "confirm",
title: t("models.row.import.steps.confirm"),
status: "pending",
},
];
}
export default function RowsImportRoute() {
const data = useLoaderData<Rows_Import.LoaderData>();
const { t } = useTranslation();
const [entity] = useState(data.entity);
const translatedInitialSteps = useMemo(() => createInitialSteps(t), [t]);
const [steps, setSteps] = useState<StepDto[]>(translatedInitialSteps);
const progressSteps = useMemo(
() =>
steps.map((step) => ({
...step,
title: translatedInitialSteps.find((translatedStep) => translatedStep.name === step.name)?.title ?? step.title,
})),
[steps, translatedInitialSteps]
);
const [currentStep, setCurrentStep] = useState<string>();
const [rawData, setRawData] = useState<RawDataDto>();
const [selectedTenant, setSelectedTenant] = useState<{ id: string; name: string; slug: string } | null>(null);
useEffect(() => {
setCurrentStep(steps.find((s) => s.status === "current")?.name);
}, [steps]);
function onImport(data: RawDataDto) {
const updatedSteps = steps.map((step) => {
switch (step.name) {
case "upload":
return { ...step, status: "completed" };
case "map":
return { ...step, status: "current" };
default:
return { ...step, status: "pending" };
}
});
setRawData(data);
setSteps(updatedSteps as StepDto[]);
}
function onMapped(data: RawDataDto) {
const updatedSteps = steps.map((step) => ({
...step,
status: step.name === "confirm" ? "current" : "completed",
}));
setRawData(data);
setSteps(updatedSteps as StepDto[]);
}
return (
<div className="p-4">
<ProgressBar steps={progressSteps} />
<div className="border-border bg-secondary overflow-x-auto rounded-b-md border border-t-0 p-4">
{currentStep === "upload" && (
<ImportCsv
entity={entity}
onImport={onImport}
allTenants={data.allTenants}
selectedTenant={selectedTenant}
onChangeSelectedTenant={setSelectedTenant}
/>
)}
{currentStep === "map" && rawData && <MapFields entity={entity} data={rawData} onConfirm={onMapped} selectedTenant={selectedTenant} />}
{currentStep === "confirm" && rawData && <Confirm entity={entity} data={rawData} onBack={() => onImport(rawData)} selectedTenant={selectedTenant} />}
</div>
</div>
);
}
function ImportCsv({
entity,
onImport,
allTenants,
selectedTenant,
onChangeSelectedTenant,
}: {
entity: EntityWithDetails;
onImport: (rawData: RawDataDto) => void;
allTenants: { id: string; name: string; slug: string }[];
selectedTenant: { id: string; name: string; slug: string } | null;
onChangeSelectedTenant: (e: { id: string; name: string; slug: string } | null) => void;
}) {
const { t, i18n } = useTranslation();
const appOrAdminData = useAppOrAdminData();
const [delimiter, setDelimiter] = useState("|");
const [firstRowHasHeaders, setFirstRowHasHeaders] = useState("firstRowHasHeaders");
function onDropped(base64: string, file: File) {
const reader = new FileReader();
reader.onload = (e: any) => {
const rawData: RawDataDto = {
columns: [],
rows: [],
};
const results = csvToArray(e.target.result);
for (let idx = 0; idx < results.length; idx++) {
const result = results[idx];
// if (idx === 0 && firstRowHasHeaders === "firstRowHasHeaders") {
// continue;
// }
const rowToImport: RawRow = [];
const item: string[] = Object.values(result);
item.forEach((value, index) => {
let column = t("models.row.import.defaultColumnName", { number: index + 1 });
if (!rawData.columns.find((c) => c.column === column)) {
rawData.columns.push({ column });
}
if (true) {
// replace all double quotes with single quotes
value = value.replace(/"/g, "");
}
rowToImport.push({ column, value });
});
rawData.rows.push(rowToImport);
}
onImport(rawData);
};
reader.readAsText(file);
}
function csvToArray(str: string) {
let headers: string[] = str.slice(0, str.indexOf("\n")).split(delimiter);
let rows = str.slice(str.indexOf("\n") + 1).split("\n");
if (firstRowHasHeaders === "noHeaders") {
rows = str.split("\n");
headers = headers.map((_header, index) => t("models.row.import.defaultColumnName", { number: index + 1 }));
}
const arr = rows.map((row) => {
const values = row.split(delimiter);
const el = headers.reduce((object: any, header, index) => {
object[index] = values[index];
return object;
}, {});
return el;
});
return arr;
}
function downloadSample() {
const rows = [];
// Add header row if first row has headers
if (firstRowHasHeaders === "firstRowHasHeaders") {
const headerRow = entity.properties.filter((property) => !property.isDefault).map((property) => `"${property.name}"`);
rows.push(headerRow);
}
// Add sample data rows
for (let i = 1; i <= 5; i++) {
const dataRow = entity.properties.filter((property) => !property.isDefault).map((property) => getImportSampleValue(property.type, i, t));
rows.push(dataRow);
}
const csvContent = "data:text/csv;charset=utf-8," + rows.map((row) => row.join(delimiter?.toString())).join("\n");
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", `${entity.name}-import-sample.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
return (
<div className="space-y-2">
<div className="space-y-2 md:flex md:space-y-0 md:space-x-2">
<div className="md:w-64">
<InputSelect
name="delimiter"
title={t("models.row.import.delimiter")}
value={delimiter}
setValue={(i) => setDelimiter(i?.toString() ?? "|")}
options={[
{ name: t("models.row.import.delimiterOptions.comma"), value: "," },
{ name: t("models.row.import.delimiterOptions.pipe"), value: "|" },
{ name: t("models.row.import.delimiterOptions.semicolon"), value: ";" },
{ name: t("models.row.import.delimiterOptions.tab"), value: "\t" },
]}
/>
</div>
<div className="md:w-64">
<InputSelect
name="headers"
title={t("models.row.import.headers")}
value={firstRowHasHeaders}
setValue={(i) => setFirstRowHasHeaders(i?.toString() ?? "|")}
options={[
{ name: t("models.row.import.headerOptions.firstRowHasHeaders"), value: "firstRowHasHeaders" },
{ name: t("models.row.import.headerOptions.noHeaders"), value: "noHeaders" },
]}
/>
</div>
<div className="md:w-64">
{!appOrAdminData.currentTenant && (
<InputSelect
name="tenantId"
title={t("models.tenant.object")}
value={selectedTenant?.id ?? "{null}"}
setValue={(i) => {
const tenant = allTenants.find((f) => f.id === i?.toString()) ?? null;
onChangeSelectedTenant(tenant);
}}
options={[
{
name: t("models.row.import.adminTenantOption"),
value: "{null}",
},
...allTenants.map((tenant) => {
return {
name: tenant.name,
value: tenant.id,
};
}),
]}
/>
)}
</div>
</div>
<Form method="post">
<input type="hidden" name="action" value="upload" hidden readOnly />
<div className="space-y-2">
<div className="flex items-center justify-between space-x-2">
<h4 className="text-foreground text-sm font-medium">
{t("models.row.import.importingEntity", { entity: translateEntityTitlePlural(t, i18n, entity) })}
</h4>
<button
type="button"
onClick={downloadSample}
className="bg-theme-100 text-theme-700 hover:bg-theme-200 focus:ring-ring inline-flex items-center rounded border border-transparent px-2.5 py-1.5 text-xs font-medium focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
>
{t("models.row.import.downloadSample")}
</button>
</div>
<UploadDocuments className="bg-background" name="file" accept=".csv" multiple={false} onDropped={onDropped} />
{/* <div className="flex justify-end">
<ButtonPrimary disabled={disabled} type="submit">{t("shared.submit")}</ButtonPrimary>
</div> */}
</div>
</Form>
</div>
);
}
type DryRunDiagnostic = {
code?: string;
message: string;
row?: number;
rowNumber?: number;
column?: string;
severity?: string;
valuePreview?: string;
};
type DryRunPreviewResponse = {
status?: string;
summary?: {
totalRows?: number;
acceptedRows?: number;
rejectedRows?: number;
diagnostics?: DryRunDiagnostic[];
};
rows?: { ok?: boolean; rowNumber?: number; diagnostics?: DryRunDiagnostic[]; warnings?: DryRunDiagnostic[] }[];
diagnostics?: DryRunDiagnostic[];
error?: string;
};
type DryRunState =
| { status: "idle" }
| { status: "pending" }
| { status: "success"; signature: string; response: DryRunPreviewResponse }
| { status: "error"; signature: string; message: string; diagnostics: DryRunDiagnostic[] };
function redactDryRunMessage(message: string) {
return message
.replace(/\bauthorization\s*[:=]?\s*(?:bearer\s+)?[^,;\n]+/gi, (match) => {
const [label] = match.split(/[:=]/, 1);
return `${label.trim()}: [REDACTED]`;
})
.replace(/\b(api[_-]?key|bearer|password|secret|token|rawBody)\s*[:=]\s*(?:\{[^}]*\}|[^\s,;]+)/gi, (match) => {
const [label] = match.split(/[:=]/, 1);
return `${label.trim()}: [REDACTED]`;
});
}
function translateDryRunDiagnostic(t: ReturnType<typeof useTranslation>["t"], diagnostic: DryRunDiagnostic) {
const code = getKnownDryRunDiagnosticCode(diagnostic.code);
return t(`models.row.import.diagnostics.${code}`, {
column: diagnostic.column ?? "",
value: diagnostic.valuePreview ?? "",
});
}
function getKnownDryRunDiagnosticCode(code: unknown) {
switch (code) {
case "blank_row":
case "dry_run_failed":
case "duplicate_key":
case "invalid_dry_run_request":
case "missing_required_field":
case "required":
return code;
default:
return "unknown";
}
}
function normalizeDryRunDiagnostic(diagnostic: DryRunDiagnostic, fallbackRowNumber?: number): DryRunDiagnostic {
return {
...diagnostic,
rowNumber: diagnostic.rowNumber ?? diagnostic.row ?? fallbackRowNumber,
message: redactDryRunMessage(diagnostic.message),
valuePreview: diagnostic.valuePreview ? redactDryRunMessage(diagnostic.valuePreview) : diagnostic.valuePreview,
};
}
function collectDryRunDiagnostics(response: DryRunPreviewResponse): DryRunDiagnostic[] {
const responseDiagnostics = response.diagnostics ?? [];
const summaryDiagnostics = response.summary?.diagnostics ?? [];
const rowDiagnostics =
response.rows?.flatMap((row) => [...(row.diagnostics ?? []), ...(row.warnings ?? [])].map((diagnostic) => normalizeDryRunDiagnostic(diagnostic, row.rowNumber))) ?? [];
return [...responseDiagnostics, ...summaryDiagnostics].map((diagnostic) => normalizeDryRunDiagnostic(diagnostic)).concat(rowDiagnostics);
}
function MapFields({
entity,
data,
onConfirm,
selectedTenant,
}: {
entity: EntityWithDetails;
data: RawDataDto;
onConfirm: (data: RawDataDto) => void;
selectedTenant: { id: string; name: string; slug: string } | null;
}) {
const { t, i18n } = useTranslation();
const formatters = useLocaleFormatters();
const appOrAdminData = useAppOrAdminData();
const [rawData, setRawData] = useState<RawDataDto>(data);
const [primaryProperty, setPrimaryProperty] = useState<string>();
const [preview, setPreview] = useState(false);
const [dryRun, setDryRun] = useState<DryRunState>({ status: "idle" });
const uniqueRows = useMemo(() => {
if (!primaryProperty) {
return rawData.rows;
}
const seenPrimaryValues = new Set<string | undefined>();
const deduplicatedRows: RawRow[] = [];
for (const row of rawData.rows) {
const primaryValue = row.find((field) => field.column === primaryProperty)?.value;
if (!seenPrimaryValues.has(primaryValue)) {
seenPrimaryValues.add(primaryValue);
deduplicatedRows.push(row);
}
}
return deduplicatedRows;
}, [primaryProperty, rawData.rows]);
const mappedColumns = useMemo(() => rawData.columns.filter((f) => f.name), [rawData.columns]);
const mappedColumnNames = useMemo(() => new Set(mappedColumns.map((column) => column.name).filter((name): name is string => Boolean(name))), [mappedColumns]);
const sampleRows = useMemo(() => rawData.rows.slice(0, 5), [rawData.rows]);
const dryRunSignature = useMemo(() => JSON.stringify({ columns: rawData.columns, rows: uniqueRows, primaryProperty }), [primaryProperty, rawData.columns, uniqueRows]);
const dryRunSummary = dryRun.status === "success" ? dryRun.response.summary : undefined;
const dryRunRows = dryRun.status === "success" ? (dryRun.response.rows ?? []) : [];
const dryRunDiagnostics =
dryRun.status === "success"
? collectDryRunDiagnostics(dryRun.response)
: dryRun.status === "error"
? dryRun.diagnostics
: [];
const dryRunIsStale = dryRun.status === "success" || dryRun.status === "error" ? dryRun.signature !== dryRunSignature : false;
const acceptedRows = dryRunSummary?.acceptedRows ?? dryRunRows.filter((row) => row.ok).length;
const continueDisabled = mappedColumns.length === 0 || uniqueRows.length === 0 || dryRun.status !== "success" || dryRunIsStale || acceptedRows < 1;
async function requestDryRun() {
const signature = dryRunSignature;
const dryRunEndpoint = appOrAdminData.currentTenant?.slug
? appPath({ tenantSlug: appOrAdminData.currentTenant.slug, path: "internal/imports/dry-run", mode: "host-aware" })
: "/internal/imports/dry-run";
setPreview(true);
setDryRun({ status: "pending" });
try {
const sourceTenantId = appOrAdminData.currentTenant?.id ?? selectedTenant?.id ?? null;
const response = await fetch(dryRunEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
format: "csv",
importType: entity.name,
source: {
tenantId: sourceTenantId,
sourceKey: sourceTenantId ? dryRunSourceKey(sourceTenantId, entity.name) : null,
fileName: `${entity.name}-import.csv`,
contentType: "text/csv",
},
fields: entity.properties
.filter((property) => !property.isDefault)
.map((property) => ({ targetField: property.name, label: translatePropertyTitle(t, i18n, entity.name, property), required: false })),
mappings: mappedColumns.map((column) => ({
sourceColumn: column.column,
targetField: column.name ?? "",
label: column.name ?? column.column,
required: false,
})),
rows: uniqueRows.map((row, index) => ({
rowNumber: index + 1,
values: Object.fromEntries(row.map((cell) => [cell.column, cell.value])),
})),
}),
});
const payload = (await response.json().catch(() => ({}))) as DryRunPreviewResponse;
if (!response.ok) {
setDryRun({
status: "error",
signature,
message: t("models.row.import.errors.previewFailed"),
diagnostics: (payload.diagnostics ?? []).map((diagnostic) => ({ ...diagnostic, message: redactDryRunMessage(diagnostic.message) })),
});
return false;
}
setDryRun({ status: "success", signature, response: payload });
return (payload.summary?.acceptedRows ?? payload.rows?.filter((row) => row.ok).length ?? 0) > 0;
} catch {
setDryRun({
status: "error",
signature,
message: t("models.row.import.errors.networkPreview"),
diagnostics: [],
});
return false;
}
}
async function removeDuplicatesAndConfirm() {
if (continueDisabled) {
const canContinue = await requestDryRun();
if (!canContinue) {
return;
}
}
onConfirm({ ...rawData, rows: uniqueRows });
}
function hasBeenMapped(name: string) {
return mappedColumnNames.has(name);
}
return (
<div className="space-y-2">
<div className="flex justify-between space-x-2 pb-2">
<div className="text-foreground flex items-baseline space-x-1 text-lg font-bold">
<div>{t("models.row.import.mapFieldsFor")}</div>
<div>{t("models.row.import.rowsCount", { count: uniqueRows.length, formattedCount: formatters.number(uniqueRows.length) })}</div>
<button type="button" onClick={requestDryRun} className="text-muted-foreground text-sm underline">
({t("models.row.import.previewCta")})
</button>
</div>
</div>
<div className="grid gap-3 xl:grid-cols-3">
{rawData.columns.map((column, idx) => {
return (
<div
key={idx}
className={clsx(
"border-border bg-background group relative space-y-2 rounded-md border-2 border-dashed p-2",
column.name && "border-emerald-500 bg-emerald-50"
)}
>
<button
onClick={() => {
// remove column
const newRawData = { ...rawData };
newRawData.columns.splice(idx, 1);
setDryRun({ status: "idle" });
setRawData(newRawData);
}}
type="button"
aria-label={t("models.row.import.removeColumn", { column: column.column })}
className={clsx(
"text-muted-foreground bg-background absolute top-0 right-0 -mt-3 hidden origin-top-right justify-center rounded-full group-hover:flex"
)}
>
<MinusIcon className="h-6 w-6" />
</button>
{/* <div className="flex items-center justify-between space-x-2 pr-2">
<div className="text-sm font-bold">{column.column}</div>
<InputCheckboxInline
name={"primary-" + idx}
title={t("models.row.import.primary")}
// description={t("models.row.import.identifyExistingRecords")}
value={primaryProperties.filter((f) => f === column.column).length > 0}
setValue={(i) => {
if (i) {
setPrimaryProperties([...primaryProperties, column.column]);
} else {
setPrimaryProperties(primaryProperties.filter((f) => f !== column.column));
}
}}
/>
</div> */}
<div className="">
<div className="flex justify-between space-x-1 pb-1.5">
<div className="grow text-sm font-medium">{column.column}</div>
<InputCheckboxInline
className=""
name={"primary-" + idx}
title={t("models.row.import.primary")}
value={column.column === primaryProperty}
disabled={!!primaryProperty && column.column !== primaryProperty}
setValue={(i) => {
if (i) {
setDryRun({ status: "idle" });
setPrimaryProperty(column.column);
} else {
setDryRun({ status: "idle" });
setPrimaryProperty(undefined);
}
}}
/>
</div>
<InputSelector
className="grow"
name={"map-to-" + idx}
value={column.name}
withSearch={false}
setValue={(i) => {
setDryRun({ status: "idle" });
setRawData({
...rawData,
columns: rawData.columns.map((c, cIdx) => {
if (cIdx === idx) {
return { ...c, name: i?.toString() };
}
return c;
}),
});
}}
options={[
{
name: t("models.row.import.notMapped"),
value: undefined,
},
...entity.properties
.filter((f) => !f.isDefault)
.map((prop) => {
return {
name: `${translatePropertyTitle(t, i18n, entity.name, prop)} (${prop.name})`,
value: prop.type === PropertyType.MEDIA ? prop.name + "[]" : prop.name,
disabled: hasBeenMapped(prop.name),
};
}),
]}
/>
</div>
<div className="space-y-1">
<label className="cursor-pointer select-none">
<div className="text-foreground/80 text-sm font-medium">{t("models.row.import.values")}</div>
</label>
<div className="bg-secondary truncate">
{sampleRows.map((row, idxRow) => {
return (
<div className="border-border truncate border" key={idxRow}>
<span className="text-muted-foreground truncate px-1 text-sm">{row.find((r) => r.column === column.column)?.value ?? t("shared.notSet")}</span>
</div>
);
})}
</div>
</div>
</div>
);
})}
</div>
<div className="flex justify-end">
<ButtonPrimary className="flex space-x-1" disabled={continueDisabled} onClick={removeDuplicatesAndConfirm}>
{uniqueRows.length === 0 ? (
<div>
<div className="">{t("models.row.import.noRows")}</div>
</div>
) : (
<>{t("shared.continue")}</>
)}
</ButtonPrimary>
</div>
<Modal open={preview} setOpen={setPreview}>
<div className="flex justify-between space-x-2 pb-2">
<div className="text-foreground text-xl font-bold">
{t("models.row.import.rowsCount", { count: uniqueRows.length, formattedCount: formatters.number(uniqueRows.length) })}
</div>
<div className="flex space-x-2">
<ButtonSecondary onClick={() => setPreview(false)}>{t("shared.back")}</ButtonSecondary>
<ButtonPrimary className="flex space-x-1" disabled={continueDisabled} onClick={removeDuplicatesAndConfirm}>
{uniqueRows.length === 0 ? (
<div>
<div className="">{t("models.row.import.noRows")}</div>
</div>
) : (
<>{t("shared.continue")}</>
)}
</ButtonPrimary>
</div>
</div>
<div className="space-y-2 pb-3 text-sm">
{dryRun.status === "pending" && <div className="text-muted-foreground">{t("models.row.import.previewingRows")}</div>}
{dryRunIsStale && <div className="text-amber-700">{t("models.row.import.mappingsChanged")}</div>}
{dryRun.status === "error" && (
<div className="rounded-md border border-red-200 bg-red-50 p-2 text-red-700">
<div>{dryRun.message}</div>
<button type="button" className="underline" onClick={requestDryRun}>
{t("models.row.import.retryPreview")}
</button>
</div>
)}
{dryRun.status === "success" && dryRunSummary && (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-2 text-emerald-800">
{t("models.row.import.dryRunSummary", {
accepted: dryRunSummary.acceptedRows ?? acceptedRows,
rejected: dryRunSummary.rejectedRows ?? 0,
total: dryRunSummary.totalRows ?? uniqueRows.length,
formattedAccepted: formatters.number(dryRunSummary.acceptedRows ?? acceptedRows),
formattedRejected: formatters.number(dryRunSummary.rejectedRows ?? 0),
formattedTotal: formatters.number(dryRunSummary.totalRows ?? uniqueRows.length),
})}
</div>
)}
{dryRunDiagnostics.length > 0 && (
<ul className="space-y-1 rounded-md border border-amber-200 bg-amber-50 p-2 text-amber-800">
{dryRunDiagnostics.map((diagnostic, index) => (
<li key={index}>
{(() => {
const row = diagnostic.rowNumber ?? diagnostic.row;
const formattedRow = typeof row === "number" ? formatters.number(row) : row;
return row ? t("models.row.import.diagnosticRow", { formattedRow, row }) : "";
})()}
{diagnostic.column ? t("models.row.import.diagnosticColumn", { column: diagnostic.column }) : ""}
{translateDryRunDiagnostic(t, diagnostic)}
</li>
))}
</ul>
)}
{dryRun.status === "success" && acceptedRows < 1 && <div className="text-red-700">{t("models.row.import.allPreviewRowsRejected")}</div>}
</div>
<div className="border-border bg-secondary h-64 overflow-y-auto rounded-lg border-2 border-dashed">
{mappedColumns.length === 0 ? (
<div className="text-muted-foreground mx-auto flex flex-col space-y-2 p-12 text-center">
<ExclamationTriangleIcon className="mx-auto h-6 w-6 text-red-600" />
<div className="text-medium">{t("models.row.import.mapAtLeastOneColumn")}</div>
</div>
) : uniqueRows.length === 0 ? (
<div className="text-muted-foreground mx-auto flex flex-col space-y-2 p-12 text-center">
<ExclamationTriangleIcon className="mx-auto h-6 w-6 text-red-600" />
<div className="text-medium">{t("models.row.import.noRows")}</div>
</div>
) : (
<TableSimple
items={uniqueRows}
headers={rawData.columns
.filter((f) => f.name)
.map((c) => {
return {
name: c.column,
title: c.column,
value: (item: RawRow) => item?.find((f) => f.column === c.column)?.value ?? t("shared.notSet"),
};
})}
/>
)}
</div>
</Modal>
</div>
);
}
type MediaType = "image" | "video" | "application/pdf" | "application/msword" | "application/zip";
interface MediaDto {
title: string;
name: string;
type: MediaType;
file: string;
publicUrl: string;
}
function getMediaType(extension: string): MediaType {
switch (extension) {
case "jpg":
case "jpeg":
case "png":
case "gif":
return "image";
case "mp4":
case "mov":
case "avi":
return "video";
case "pdf":
return "application/pdf";
case "doc":
case "docx":
return "application/msword";
case "zip":
return "application/zip";
default:
return "image";
}
}
function getMediaFromProperty(property: { name: string; value: string }): MediaDto | null {
if (property.name.includes("[]")) {
const extension = property.value.split(".").pop();
if (extension) {
const name = property.value.split("/").pop() ?? property.value;
const type = getMediaType(extension);
const media: MediaDto = {
title: name,
name,
type,
file: "",
publicUrl: property.value,
};
return media;
}
}
return null;
}
function getImportRowStringValue(item: Rows_Import.ImportRow) {
item.properties.forEach((property) => {
const media = getMediaFromProperty(property);
if (media) {
property.value = JSON.stringify(media);
}
});
return JSON.stringify(item);
}
export function translateRowImportError(t: TFunction, error?: string | null) {
const normalized = error?.trim();
if (!normalized) {
return t("models.row.import.errors.rowFailed");
}
const requiredMatch = normalized.match(/^(.+):\s*required$/i);
if (requiredMatch) {
return t("models.row.import.errors.requiredField", { field: requiredMatch[1] });
}
if (/^Entity not found\b/i.test(normalized)) {
return t("models.row.import.errors.entityNotFound");
}
if (/^Row not found$|^Not found$/i.test(normalized) || /\bnot found:\s*/i.test(normalized)) {
return t("models.row.import.errors.rowNotFound");
}
if (/Row exists, but not in entity/i.test(normalized)) {
return t("models.row.import.errors.rowEntityMismatch");
}
if (/Row exists, but not in tenant/i.test(normalized)) {
return t("models.row.import.errors.rowTenantMismatch");
}
if (/don't have access|do not have access|missing permission|unknown permission/i.test(normalized)) {
return t("models.row.import.errors.rowAccessDenied");
}
if (/Invalid number range/i.test(normalized)) {
return t("models.row.import.errors.invalidNumberRange");
}
if (/Invalid date range/i.test(normalized)) {
return t("models.row.import.errors.invalidDateRange");
}
return t("models.row.import.errors.rowFailed");
}
function Confirm({
entity,
data,
onBack,
selectedTenant,
}: {
entity: EntityWithDetails;
data: RawDataDto;
onBack: () => void;
selectedTenant: { id: string; name: string; slug: string } | null;
}) {
const { t, i18n } = useTranslation();
const formatters = useLocaleFormatters();
const appOrAdminData = useAppOrAdminData();
const [items, setItems] = useState<Rows_Import.ImportRow[]>([]);
const [headers, setHeaders] = useState<RowHeaderDisplayDto<Rows_Import.ImportRow>[]>([]);
const [importTagDialogOpen, setImportTagDialogOpen] = useState(false);
const [pendingImportFormData, setPendingImportFormData] = useState<FormData | null>(null);
const [importTag, setImportTag] = useState("");
const navigation = useNavigation();
const submit = useSubmit();
const actionData = useActionData<Rows_Import.ActionData>();
useEffect(() => {
const newItems: Rows_Import.ImportRow[] = [];
data.rows.forEach((row) => {
const newRow: Rows_Import.ImportRow = {
properties: [],
};
row.forEach((r) => {
const column = data.columns.find((f) => f.column === r.column);
if (column?.name) {
newRow.properties.push({
name: column.name,
value: r.value,
});
}
});
newItems.push(newRow);
});
setItems(newItems);
}, [data.columns, data.rows]);
useEffect(() => {
if (actionData?.rows) {
setItems(actionData.rows);
}
}, [actionData]);
useEffect(() => {
const newHeaders: RowHeaderDisplayDto<Rows_Import.ImportRow>[] = [
{
name: "tenant",
title: t("models.tenant.object"),
hidden: !!appOrAdminData.currentTenant,
value: () => {
if (selectedTenant) {
return (
<div>
{selectedTenant.name} ({selectedTenant.slug})
</div>
);
}
return <div className="text-muted-foreground italic">{t("models.row.import.adminTenantOption")}</div>;
},
},
];
data.columns.forEach((column) => {
if (column.name) {
const property = entity.properties.find((f) => f.name === column.name);
newHeaders.push({
name: property?.name ?? "",
title: property ? translatePropertyTitle(t, i18n, entity.name, property) : "",
value: (i) => i.properties?.find((f) => f.name === column.name)?.value ?? t("shared.notSet"),
});
}
});
newHeaders.push({
name: "status",
title: t("shared.status"),
value: (i) => (
<div>
{!i.row && navigation.state === "submitting" ? (
<div className="text-muted-foreground text-sm italic">{t("models.row.import.importing")}</div>
) : i.error ? (
<div className="text-sm text-red-500">{t("models.row.import.errorStatus", { error: translateRowImportError(t, i.error) })}</div>
) : i.row ? (
<div className="text-sm text-emerald-500">{t("models.row.import.importedStatus", { id: i.row?.id })}</div>
) : null}
</div>
),
});
setHeaders(newHeaders);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.columns, entity.properties, t, navigation.state]);
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.stopPropagation();
e.preventDefault();
const formData = new FormData(e.currentTarget);
setPendingImportFormData(formData);
setImportTag(t("models.row.import.defaultTag"));
setImportTagDialogOpen(true);
}
function closeImportTagDialog() {
setImportTagDialogOpen(false);
setPendingImportFormData(null);
}
function setImportTagDialogState(open: SetStateAction<boolean>) {
const nextOpen = typeof open === "function" ? open(importTagDialogOpen) : open;
if (nextOpen) {
setImportTagDialogOpen(true);
} else {
closeImportTagDialog();
}
}
function confirmImportTag() {
if (!pendingImportFormData) {
return;
}
pendingImportFormData.set("tag", importTag);
submit(pendingImportFormData, { method: "post" });
closeImportTagDialog();
}
const pendingItems = items.filter((f) => !f.row);
const pendingItemCount = pendingItems.length;
return (
<div>
<Form method="post" onSubmit={handleSubmit}>
<input type="hidden" name="action" value="import" hidden readOnly />
<input type="hidden" name="selectedTenantId" value={appOrAdminData.currentTenant?.id ?? selectedTenant?.id ?? "{null}"} hidden readOnly />
{pendingItems.map((item, idx) => {
return <input key={idx} type="hidden" readOnly hidden name="rows[]" value={getImportRowStringValue(item)} />;
})}
<div className="space-y-2">
<div className="text-foreground text-xl font-bold">
<div>
<div>{t("shared.importRecords", { count: items.length, formattedCount: formatters.number(items.length) })}</div>
</div>
</div>
{navigation.state === "submitting" ? (
<Loading />
) : (
<TableSimple
items={items}
headers={headers}
actions={[
{
title: t("shared.remove"),
onClick: (_, item) => {
setItems(items.filter((f) => f !== item));
},
hidden: (item) => !!item.row,
},
]}
/>
)}
<div className="flex justify-end space-x-2">
<ButtonSecondary onClick={onBack}>
<div>{t("shared.back")}</div>
</ButtonSecondary>
<ButtonPrimary type="submit" className="flex space-x-1" disabled={pendingItemCount === 0 || navigation.state === "submitting"}>
{pendingItemCount === 0 ? (
<div>{t("models.row.import.noRows")}</div>
) : (
<div>{t("shared.importRecords", { count: pendingItemCount, formattedCount: formatters.number(pendingItemCount) })}</div>
)}
</ButtonPrimary>
</div>
</div>
</Form>
<Modal open={importTagDialogOpen} setOpen={setImportTagDialogState} size="sm" title={t("models.row.import.tagPromptLabel")}>
<div className="space-y-4">
<p className="text-muted-foreground text-sm">{t("models.row.import.tagPromptDescription")}</p>
<InputText name="tag" title={t("models.row.import.tagPromptLabel")} value={importTag} setValue={setImportTag} autoFocus />
<div className="flex justify-end space-x-2">
<ButtonSecondary onClick={closeImportTagDialog}>{t("shared.cancel")}</ButtonSecondary>
<ButtonPrimary onClick={confirmImportTag}>
{t("shared.importRecords", { count: pendingItemCount, formattedCount: formatters.number(pendingItemCount) })}
</ButtonPrimary>
</div>
</div>
</Modal>
</div>
);
}