Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: update validation logic for csv headers #1625

Merged
merged 3 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions app/components/assets/import-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "../shared/modal";
import { WarningBox } from "../shared/warning-box";
import { Table, Td, Th, Tr } from "../table";
import When from "../when/when";

export const ImportBackup = () => (
<>
Expand Down Expand Up @@ -241,10 +242,11 @@ export const FileForm = ({ intent, url }: { intent: string; url?: string }) => {
</>
) : null}
</AlertDialogHeader>
{data?.error ? (

<When truthy={!!data?.error}>
<div>
<h5 className="text-red-500">{data.error.title}</h5>
<p className="text-red-500">{data.error.message}</p>
<h5 className="text-red-500">{data?.error?.title}</h5>
<p className="text-red-500">{data?.error?.message}</p>
{data?.error?.additionalData?.duplicateCodes ? (
<BrokenQrCodesTable
title="Duplicate codes"
Expand Down Expand Up @@ -282,19 +284,47 @@ export const FileForm = ({ intent, url }: { intent: string; url?: string }) => {
/>
) : null}

{Array.isArray(data?.error?.additionalData?.defectedHeaders) ? (
<table className="mt-4 w-full rounded-md border text-left text-sm">
<thead className="bg-error-100 text-xs">
<tr>
<th scope="col" className="px-2 py-1">
Incorrect Header
</th>
<th scope="col" className="px-2 py-1">
Error
</th>
</tr>
</thead>
<tbody>
{data?.error?.additionalData?.defectedHeaders?.map(
(data: {
incorrectHeader: string;
errorMessage: string;
}) => (
<tr key={data.incorrectHeader}>
<td className="px-2 py-1">{data.incorrectHeader}</td>
<td className="px-2 py-1">{data.errorMessage}</td>
</tr>
)
)}
</tbody>
</table>
) : null}

<p className="mt-2">
Please fix your CSV file and try again. If the issue persists,
don't hesitate to get in touch with us.
</p>
</div>
) : null}
</When>

{isSuccessful ? (
<When truthy={isSuccessful}>
<div>
<b className="text-green-500">Success!</b>
<p>Your assets have been imported.</p>
</div>
) : null}
</When>

<AlertDialogFooter>
{isSuccessful ? (
Expand Down
17 changes: 12 additions & 5 deletions app/modules/asset/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ import type {
Organization,
} from "@prisma/client";
import type { Return } from "@prisma/client/runtime/library";
import type { z } from "zod";
import type { assetIndexFields } from "./fields";
import type { importAssetsSchema } from "./utils.server";

export interface ICustomFieldValueJson {
raw: string | number | boolean;
Expand Down Expand Up @@ -49,9 +47,18 @@ export interface UpdateAssetPayload {
organizationId: Organization["id"];
}

export type CreateAssetFromContentImportPayload = z.infer<
typeof importAssetsSchema
>;
export interface CreateAssetFromContentImportPayload
extends Record<string, any> {
title: string;
description?: string;
category?: string;
kit?: string;
tags: string[];
location?: string;
custodian?: string;
bookable?: "yes" | "no";
imageUrl?: string; // URL of the image to import
}

export interface CreateAssetFromBackupImportPayload
extends Record<string, any> {
Expand Down
26 changes: 13 additions & 13 deletions app/modules/asset/utils.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,16 +237,16 @@ export function formatAssetsRemindersDates({
});
}

export const importAssetsSchema = z
.object({
title: z.string(),
description: z.string().optional(),
category: z.string().optional(),
kit: z.string().optional(),
tags: z.string().array(),
location: z.string().optional(),
custodian: z.string().optional(),
bookable: z.enum(["yes", "no"]).optional().nullable(),
imageUrl: z.string().url().optional(),
})
.and(z.record(z.string(), z.any()));
export const ASSET_CSV_HEADERS = [
"title",
"description",
"category",
"kit",
"tags",
"location",
"custodian",
"bookable",
"imageUrl",
"valuation",
"qrId",
];
1 change: 1 addition & 0 deletions app/modules/invite/utils.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const IMPORT_USERS_CSV_HEADERS = ["role", "email"];
4 changes: 2 additions & 2 deletions app/routes/_layout+/admin-dashboard+/org.$organizationId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import HorizontalTabs from "~/components/layout/horizontal-tabs";
import { Button } from "~/components/shared/button";
import { db } from "~/database/db.server";
import { createAssetsFromContentImport } from "~/modules/asset/service.server";
import { importAssetsSchema } from "~/modules/asset/utils.server";
import { ASSET_CSV_HEADERS } from "~/modules/asset/utils.server";
import { toggleOrganizationSso } from "~/modules/organization/service.server";
import { csvDataFromRequest } from "~/utils/csv.server";
import { ShelfError, makeShelfError } from "~/utils/error";
Expand Down Expand Up @@ -169,7 +169,7 @@ export const action = async ({

const contentData = extractCSVDataFromContentImport(
csvData,
importAssetsSchema.array()
ASSET_CSV_HEADERS
);
await createAssetsFromContentImport({
data: contentData,
Expand Down
4 changes: 2 additions & 2 deletions app/routes/_layout+/assets.import.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
TabsTrigger,
} from "~/components/shared/tabs";
import { createAssetsFromContentImport } from "~/modules/asset/service.server";
import { importAssetsSchema } from "~/modules/asset/utils.server";
import { ASSET_CSV_HEADERS } from "~/modules/asset/utils.server";
import { appendToMetaTitle } from "~/utils/append-to-meta-title";
import { csvDataFromRequest } from "~/utils/csv.server";
import { ShelfError, makeShelfError } from "~/utils/error";
Expand Down Expand Up @@ -65,7 +65,7 @@ export const action = async ({ context, request }: ActionFunctionArgs) => {

const contentData = extractCSVDataFromContentImport(
csvData,
importAssetsSchema.array()
ASSET_CSV_HEADERS
);

await createAssetsFromContentImport({
Expand Down
4 changes: 2 additions & 2 deletions app/routes/api+/settings.import-users.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { InviteUserFormSchema } from "~/components/settings/invite-user-dialog";
import { bulkInviteUsers } from "~/modules/invite/service.server";
import { IMPORT_USERS_CSV_HEADERS } from "~/modules/invite/utils.server";
import { csvDataFromRequest } from "~/utils/csv.server";
import { makeShelfError, ShelfError } from "~/utils/error";
import { assertIsPost, data, error } from "~/utils/http.server";
Expand Down Expand Up @@ -43,7 +43,7 @@ export async function action({ context, request }: ActionFunctionArgs) {

const users = extractCSVDataFromContentImport(
csvData,
InviteUserFormSchema.array()
IMPORT_USERS_CSV_HEADERS
);

const response = await bulkInviteUsers({
Expand Down
52 changes: 38 additions & 14 deletions app/utils/import.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { ZodSchema } from "zod";
import type { CreateAssetFromContentImportPayload } from "~/modules/asset/types";
import { ShelfError } from "./error";

Expand Down Expand Up @@ -27,46 +26,71 @@ export function getUniqueValuesFromArrayOfObjects({
}

/** Takes the CSV data from a `content` import and parses it into an object that we can then use to create the entries */
export function extractCSVDataFromContentImport<Schema extends ZodSchema>(
export function extractCSVDataFromContentImport(
data: string[][],
schema: Schema
csvHeaders: string[]
) {
/**
* The first row of the CSV contains the keys for the data
* We need to trim the keys to remove any whitespace and special characters and Non-printable characters as it already causes issues with in the past
* Non-printable character: The non-printable character you encountered at the beginning of the title property key ('\ufeff') is known as the Unicode BOM (Byte Order Mark).
*/
const keys = data[0].map((key) => key.trim()); // Trim the keys
const headers = data[0].map((key) => key.trim()); // Trim the keys
const values = data.slice(1) as string[][];
const rawData = values.map((entry) =>

const csvData = values.map((entry) =>
Object.fromEntries(
entry.map((value, index) => {
switch (keys[index]) {
switch (headers[index]) {
case "tags":
return [keys[index], value.split(",").map((tag) => tag.trim())];
return [headers[index], value.split(",").map((tag) => tag.trim())];
case "imageUrl":
// Return empty string if URL is empty/undefined, otherwise trim
return [keys[index], value?.trim() || ""];
return [headers[index], value?.trim() || ""];
case "bookable":
return [keys[index], !value ? null : value];
return [headers[index], !value ? null : value];
default:
return [keys[index], value];
return [headers[index], value];
}
})
)
);

const parsedResult = schema.safeParse(rawData);
if (!parsedResult.success) {
/* Validating headers in csv file */
const defectedHeaders: Array<{
incorrectHeader: string;
errorMessage: string;
}> = [];

headers.forEach((header) => {
if (header.startsWith("cf:")) {
if (header.length < 4) {
defectedHeaders.push({
incorrectHeader: header,
errorMessage: "Custom field header name is not provided.",
});
}

return;
} else if (!csvHeaders.includes(header)) {
defectedHeaders.push({
incorrectHeader: header,
errorMessage: "Invalid header provided.",
});
}
});

if (defectedHeaders.length > 0) {
throw new ShelfError({
cause: null,
message:
"Received invalid data, please update the file with proper headers and data.",
"Invalid headers in csv file. Please fix the headers and try again.",
additionalData: { defectedHeaders },
label: "Assets",
});
}

return parsedResult.data as Schema["_output"];
return csvData;
}

/** Takes the CSV data from a `backup` import and parses it into an object that we can then use to create the entries */
Expand Down