1040 lines
40 KiB
TypeScript
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>
|
|
);
|
|
}
|