diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx
index 32d32acdd1d..bd37c289414 100644
--- a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx
@@ -236,7 +236,12 @@ function Attachments({
navigate('/specify/overlay/attachments/import/')}
>
- {commonText.import()}
+ {attachmentsText.importMatchingAttachments()}
+
+ navigate('/specify/attachments/import//')}
+ >
+ {attachmentsText.importFromMappingFile()}
>
)}
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx
index fda1cfda79d..f733fe586d7 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx
@@ -32,6 +32,7 @@ import type {
AttachmentDataSet,
AttachmentDataSetPlan,
FetchedDataSet,
+ PartialUploadableFileSpec,
} from './types';
import { useEagerDataSet } from './useEagerDataset';
@@ -97,12 +98,21 @@ function ModifyDataset({
);
}
-const createEmpty = async (name: LocalizedString) =>
+const createEmpty = async (
+ name: LocalizedString,
+ rows: RA = []
+) =>
createEmptyDataSet('bulkAttachment', name, {
uploadplan: { staticPathKey: undefined },
uploaderstatus: 'main',
+ rows,
});
+export const createEmptyAttachmentDataset = (
+ name: LocalizedString,
+ rows: RA = []
+) => createEmpty(name, rows);
+
export function AttachmentsImportOverlay(): JSX.Element | null {
const handleClose = React.useContext(OverlayContext);
const attachmentDataSetsPromise = React.useMemo(fetchAttachmentMappings, []);
@@ -274,7 +284,7 @@ function NewDataSet(): JSX.Element | null {
id={id('form')}
onSubmit={async () => {
loading(
- createEmpty(pendingName).then(({ id }) =>
+ createEmptyAttachmentDataset(pendingName).then(({ id }) =>
navigate(`/specify/attachments/import/${id}`)
)
);
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx
index 7bd0afe0fbd..10a8d2818b8 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx
@@ -13,7 +13,7 @@ import { ajax } from '../../utils/ajax';
import { f } from '../../utils/functools';
import type { RA, WritableArray } from '../../utils/types';
import type { IR } from '../../utils/types';
-import { removeKey, sortFunction } from '../../utils/utils';
+import { removeKey, sortFunction, stripFileExtension } from '../../utils/utils';
import { Container } from '../Atoms';
import { Button } from '../Atoms/Button';
import { strictGetTable } from '../DataModel/tables';
@@ -132,19 +132,37 @@ function AttachmentsImport({
);
const applyFileNames = React.useCallback(
- (file: UnBoundFile): PartialUploadableFileSpec =>
- eagerDataSet.uploadplan.staticPathKey === undefined
- ? { uploadFile: file }
- : {
- uploadFile: {
- ...file,
- parsedName: resolveFileNames(
- file.file.name,
- eagerDataSet.uploadplan.formatQueryResults,
- eagerDataSet.uploadplan.fieldFormatter
- ),
- },
- },
+ (
+ file: UnBoundFile,
+ preserveParseName = true
+ ): PartialUploadableFileSpec => {
+ if (eagerDataSet.uploadplan.staticPathKey === undefined)
+ return { uploadFile: file };
+ let parsedName = undefined;
+
+ if (file.parsedName !== undefined && preserveParseName)
+ // Maybe we are being too nice. Users should correctly format this?
+ // This wil also correctly handle the case where formatter is no longer valid
+ // because it was changed.
+ parsedName = resolveFileNames(
+ file.parsedName,
+ eagerDataSet.uploadplan.formatQueryResults,
+ eagerDataSet.uploadplan.fieldFormatter
+ );
+ if (parsedName === undefined)
+ parsedName = resolveFileNames(
+ stripFileExtension(file.file.name),
+ eagerDataSet.uploadplan.formatQueryResults,
+ eagerDataSet.uploadplan.fieldFormatter
+ );
+
+ return {
+ uploadFile: {
+ ...file,
+ parsedName,
+ },
+ };
+ },
[eagerDataSet.uploadplan.staticPathKey]
);
@@ -154,10 +172,15 @@ function AttachmentsImport({
React.useEffect(() => {
// Reset all parsed names if matching path is changed
if (previousKeyRef.current !== eagerDataSet.uploadplan.staticPathKey) {
- previousKeyRef.current = eagerDataSet.uploadplan.staticPathKey;
commitFileChange((files) =>
- files.map(({ uploadFile }) => applyFileNames(uploadFile))
+ files.map(({ uploadFile }) =>
+ // Do not preserve parsed name if previous static key was not undefined.
+ // Otherwise, a numeric formatter can be applied after a text formatter and we will match
+ // records incorrectly
+ applyFileNames(uploadFile, previousKeyRef.current === undefined)
+ )
);
+ previousKeyRef.current = eagerDataSet.uploadplan.staticPathKey;
}
}, [applyFileNames, commitFileChange]);
@@ -176,7 +199,11 @@ function AttachmentsImport({
);
const handleFilesSelected = (files: FileList) => {
- const filesList = Array.from(files, (file) => applyFileNames({ file }));
+ const filesList: RA = Array.from(
+ files,
+ (file) => ({ uploadFile: { file } })
+ );
+
const oldRows = eagerDataSet.rows;
const { resolvedFiles, duplicateFiles } = matchSelectedFiles(
oldRows,
@@ -185,10 +212,13 @@ function AttachmentsImport({
(resolvedFiles as WritableArray).sort(
sortFunction((file) => file.uploadFile.file.name)
);
+ const fileNamesApplied = resolvedFiles.map(({ uploadFile }) =>
+ applyFileNames(uploadFile)
+ );
commitChange((oldState) => ({
...oldState,
uploaderstatus: 'main',
- rows: resolvedFiles,
+ rows: fileNamesApplied,
}));
setDuplicatedFiles(duplicateFiles);
};
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ImportFromMappingFile.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ImportFromMappingFile.tsx
new file mode 100644
index 00000000000..7bc882442b9
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ImportFromMappingFile.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { localized, RA } from '../../utils/types';
+import { CsvFilePicker } from '../Molecules/CsvFilePicker';
+import { attachmentsText } from '../../localization/attachments';
+import { useNavigate } from 'react-router-dom';
+
+import { PartialUploadableFileSpec } from './types';
+import { createEmptyAttachmentDataset } from './Datasets';
+
+// TODO: Is this needed?
+//const requiredHeaders = ['filename', 'identifier'];
+
+export function ImportFromMappingFile(): JSX.Element {
+ const navigate = useNavigate();
+ return (
+ <>
+ {
+ // To support mapping file, we just create a new dataset with files. Matching behaviour
+ // within dataset takes care of rest
+ const attachmentRows: RA = rawRows.map(
+ ([fileName, parsedName]) => {
+ const trimmedFileName =
+ fileName === undefined ? '' : String(fileName).trim();
+ const trimmedParsedName =
+ parsedName === undefined ? undefined : String(parsedName).trim();
+
+ return {
+ uploadFile: {
+ file: {
+ name: trimmedFileName,
+ },
+ parsedName: trimmedParsedName,
+ },
+ };
+ }
+ );
+ createEmptyAttachmentDataset(
+ localized(fileName),
+ attachmentRows
+ ).then(({ id }) => navigate(`/specify/attachments/import/${id}`));
+ }}
+ />
+ >
+ );
+}
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx
index 276b19add08..06b41e19ba2 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx
@@ -63,7 +63,10 @@ const resolveAttachmentDatasetData = (
{uploadFile.file.name}
,
],
- fileSize: formatFileSize(uploadFile.file.size),
+ fileSize:
+ uploadFile.file.size === undefined
+ ? ''
+ : formatFileSize(uploadFile.file.size),
record: [
resolvedRecord?.type === 'matched'
? resolvedRecord.id
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/ImportFromMappingFile.test.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/ImportFromMappingFile.test.tsx
new file mode 100644
index 00000000000..93c0e4fa335
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/ImportFromMappingFile.test.tsx
@@ -0,0 +1,155 @@
+import { waitFor } from '@testing-library/react';
+import React from 'react';
+import fs from 'fs';
+import path from 'path';
+import { jest } from '@jest/globals';
+
+import { mount } from '../../../tests/reactUtils';
+import { LoadingContext } from '../../Core/Contexts';
+import { requireContext } from '../../../tests/helpers';
+import * as Datasets from '../Datasets';
+import { ImportFromMappingFile } from '../ImportFromMappingFile';
+
+requireContext();
+
+const mockNavigate = jest.fn();
+
+jest.mock('react-router-dom', () => ({
+ useNavigate: () => mockNavigate,
+}));
+
+type FileType = 'text/csv' | 'image/jpeg' | 'image/png' | 'text/plain';
+
+const fixturesRoot = path.join(
+ __dirname,
+ '../../../tests/fixtures/attachmentsImport'
+);
+
+function fixtureFile(name: string): File {
+ const fileBuffer = fs.readFileSync(path.join(fixturesRoot, name));
+ const type: FileType = name.endsWith('.png')
+ ? 'image/png'
+ : name.endsWith('.jpg') || name.endsWith('.jpeg')
+ ? 'image/jpeg'
+ : name.endsWith('.csv')
+ ? 'text/csv'
+ : 'text/plain';
+
+ return new File([fileBuffer], name, { type });
+}
+
+describe('ImportFromMappingFile', () => {
+ beforeEach(() => {
+ const portalRoot = document.createElement('div');
+ portalRoot.id = 'portal-root';
+ document.body.appendChild(portalRoot);
+
+ mockNavigate.mockClear();
+ jest
+ .spyOn(Datasets, 'createEmptyAttachmentDataset')
+ .mockResolvedValue({ id: 123 } as any);
+ });
+
+ afterEach(() => {
+ const portalRoot = document.getElementById('portal-root');
+ if (portalRoot !== null) portalRoot.remove();
+ jest.restoreAllMocks();
+ });
+
+ test('creates attachment dataset from a valid CSV mapping file', async () => {
+ const { container, user } = mount(
+ promise as Promise}>
+
+
+ );
+
+ const fileInput = container.querySelector('input[type=file]');
+ expect(fileInput).toBeTruthy();
+
+ const mappingFile = fixtureFile('mapping-file-valid.csv');
+ await user.upload(fileInput!, mappingFile);
+
+ const importButton = container.getElementsByTagName('button')[0];
+ expect(importButton).toBeTruthy();
+
+ await waitFor(() => expect(importButton).not.toBeDisabled());
+ await user.click(importButton!);
+
+ await waitFor(() => {
+ expect(Datasets.createEmptyAttachmentDataset).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.arrayContaining([
+ expect.objectContaining({
+ uploadFile: expect.objectContaining({
+ file: expect.objectContaining({ name: 'fake-image-1.jpg' }),
+ parsedName: 'ID_001',
+ }),
+ }),
+ expect.objectContaining({
+ uploadFile: expect.objectContaining({
+ file: expect.objectContaining({ name: 'fake-image-2.png' }),
+ parsedName: 'ID_002',
+ }),
+ }),
+ ])
+ );
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith('/specify/attachments/import/123');
+ });
+
+ test('parses pipe-delimited CSV mapping files with trimmed fields', async () => {
+ const { container, user } = mount(
+ promise as Promise}>
+
+
+ );
+
+ const fileInput = container.querySelector('input[type=file]');
+ expect(fileInput).toBeTruthy();
+
+ const mappingFile = fixtureFile('mapping-file-pipe-delimited.csv');
+ await user.upload(fileInput!, mappingFile);
+
+ const importButton = container.getElementsByTagName('button')[0];
+ expect(importButton).toBeTruthy();
+
+ await waitFor(() => expect(importButton).not.toBeDisabled());
+ await user.click(importButton!);
+
+ await waitFor(() => {
+ expect(Datasets.createEmptyAttachmentDataset).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.arrayContaining([
+ expect.objectContaining({
+ uploadFile: expect.objectContaining({
+ file: expect.objectContaining({ name: 'fake-image-3.jpg' }),
+ parsedName: 'ID_003',
+ }),
+ }),
+ expect.objectContaining({
+ uploadFile: expect.objectContaining({
+ file: expect.objectContaining({ name: 'fake-image-4.png' }),
+ parsedName: 'ID_004',
+ }),
+ }),
+ ])
+ );
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith('/specify/attachments/import/123');
+ });
+
+ test('fixture image files are readable and expose correct file metadata', () => {
+ const jpegFile = fixtureFile('fake-image-1.jpg');
+ const pngFile = fixtureFile('fake-image-2.png');
+
+ expect(jpegFile.name).toBe('fake-image-1.jpg');
+ expect(jpegFile.type).toBe('image/jpeg');
+ expect(jpegFile.size).toBeGreaterThan(0);
+
+ expect(pngFile.name).toBe('fake-image-2.png');
+ expect(pngFile.type).toBe('image/png');
+ expect(pngFile.size).toBeGreaterThan(0);
+ });
+});
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts
index b39a0addb72..6426f160684 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts
@@ -57,7 +57,8 @@ type UploadableFileSpec = {
* Forcing keys to be primitive because objects would be
* ignored during syncing to backend.
*/
-export type BoundFile = Pick;
+export type BoundFile = Pick &
+ Partial>;
export type UnBoundFile = {
readonly file: BoundFile | File;
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts
index 7030ecf7e33..86174a2263f 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts
@@ -15,7 +15,6 @@ import {
keysToLowerCase,
mappedFind,
replaceItem,
- stripFileExtension,
} from '../../utils/utils';
import { addMissingFields } from '../DataModel/addMissingFields';
import type {
@@ -157,6 +156,7 @@ type MatchSelectedFiles = {
readonly resolvedFiles: RA;
readonly duplicateFiles: RA;
};
+
export const matchSelectedFiles = (
previousUploadables: RA,
filesToResolve: RA
@@ -168,10 +168,14 @@ export const matchSelectedFiles = (
previousUploadable.uploadFile !== undefined &&
previousUploadable.uploadFile.file.name ===
uploadable.uploadFile.file.name &&
- previousUploadable.uploadFile.file.size ===
- uploadable.uploadFile.file.size &&
- previousUploadable.uploadFile.file.type ===
- uploadable.uploadFile.file.type
+ // If previous uploadables do not have size, skip them.
+ // Happens when mapping file is being used
+ (previousUploadable.uploadFile.file.size === undefined ||
+ previousUploadable.uploadFile.file.size ===
+ uploadable.uploadFile.file.size) &&
+ (previousUploadable.uploadFile.file.type === undefined ||
+ previousUploadable.uploadFile.file.type ===
+ uploadable.uploadFile.file.type)
);
if (matchedIndex === -1)
return {
@@ -189,6 +193,7 @@ export const matchSelectedFiles = (
duplicateFiles: [...previousMatchedSpec.duplicateFiles, uploadable],
};
+ // We get here if we are going to preserve the incoming file.
return {
...previousMatchedSpec,
resolvedFiles: replaceItem(
@@ -196,15 +201,17 @@ export const matchSelectedFiles = (
matchedIndex,
{
...previousMatch,
- uploadFile: uploadable.uploadFile,
+ uploadFile: {
+ file: uploadable.uploadFile.file,
+ parsedName: previousMatch.uploadFile.parsedName,
+ },
/*
* Generating tokens again because the file could have been
* uploaded to the asset server but not yet recorded in Specify DB.
*/
uploadTokenSpec: undefined,
/*
- * Take the new status in case of parse failure was reported.
- * But take the previous status it was a success
+ * Take the previous status it was a success
*/
status:
previousMatch.status?.type === 'success' ||
@@ -228,7 +235,7 @@ export function resolveFileNames(
formatter?: UiFormatter
): string | undefined {
// BUG: Won't catch if formatters begin or end with a space
- const splitName = stripFileExtension(fileName).trim();
+ const splitName = fileName.trim();
let nameToParse = splitName;
if (formatter?.parts.every((field) => field.type !== 'regex') === true) {
nameToParse = fileName.trim().slice(0, formatter.size);
diff --git a/specifyweb/frontend/js_src/lib/components/LocalityUpdate/index.tsx b/specifyweb/frontend/js_src/lib/components/LocalityUpdate/index.tsx
index fd16ebd5181..e73b07a0bb8 100644
--- a/specifyweb/frontend/js_src/lib/components/LocalityUpdate/index.tsx
+++ b/specifyweb/frontend/js_src/lib/components/LocalityUpdate/index.tsx
@@ -81,7 +81,7 @@ export function LocalityUpdateFromDataSet(): JSX.Element {
{
+ onFileImport={({ headers, data }): void => {
const foundHeaderErrors = headers.reduce(
(accumulator, currentHeader) => {
const parsedHeader = currentHeader
diff --git a/specifyweb/frontend/js_src/lib/components/Molecules/CsvFilePicker.tsx b/specifyweb/frontend/js_src/lib/components/Molecules/CsvFilePicker.tsx
index 7d574550fe2..27402712294 100644
--- a/specifyweb/frontend/js_src/lib/components/Molecules/CsvFilePicker.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Molecules/CsvFilePicker.tsx
@@ -27,10 +27,11 @@ export function CsvFilePicker({
}: {
readonly header: LocalizedString;
readonly firstRowAlwaysHeader?: boolean;
- readonly onFileImport: (
- headers: RA,
- data: RA>
- ) => void;
+ readonly onFileImport: (payload: {
+ readonly headers: RA;
+ readonly data: RA>;
+ readonly fileName: string;
+ }) => void;
}): JSX.Element {
const [file, setFile] = React.useState();
const getSetHasHeader = useStateForContext(true);
@@ -55,7 +56,11 @@ export function CsvFilePicker({
parseCsv(file, encoding, getSetDelimiter).then((data) => {
const { header, rows } = extractHeader(data, hasHeader);
- return void handleFileImport(header, rows);
+ return void handleFileImport({
+ headers: header,
+ data: rows,
+ fileName: file.name,
+ });
})
);
}}
@@ -79,11 +84,13 @@ export function CsvFilePreview({
hasHeader,
encoding,
getSetDelimiter,
+ fileName,
}: {
readonly data: RA>;
readonly hasHeader: boolean;
readonly encoding: string;
readonly getSetDelimiter: GetOrSet;
+ readonly fileName: string;
}) => void;
}): JSX.Element {
const [encoding, setEncoding] = React.useState('utf-8');
@@ -104,6 +111,7 @@ export function CsvFilePreview({
hasHeader,
encoding,
getSetDelimiter,
+ fileName: file.name,
});
}}
>
diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx
index 4b8ca180f5a..50ae9a66645 100644
--- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx
@@ -200,6 +200,13 @@ export const routes: RA = [
({ AttachmentImportById }) => AttachmentImportById
),
},
+ {
+ path: 'import',
+ element: () =>
+ import('../AttachmentsBulkImport/ImportFromMappingFile').then(
+ ({ ImportFromMappingFile }) => ImportFromMappingFile
+ ),
+ },
],
},
{
diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts
index 730265cdeb5..a6d6bd46338 100644
--- a/specifyweb/frontend/js_src/lib/localization/attachments.ts
+++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts
@@ -31,6 +31,12 @@ export const attachmentsText = createDictionary({
'hr-hr': 'Skala',
nb: 'Skala',
},
+ importFromMMappingFile: {
+ 'en-us': 'Import from Mapping File',
+ },
+ importMatchingAttachments: {
+ 'en-us': 'Import Matching Attachments',
+ },
attachmentServerUnavailable: {
'en-us': 'Attachment server unavailable',
'ru-ru': 'Сервер прикрепленных файлов недоступен',
@@ -920,4 +926,7 @@ export const attachmentsText = createDictionary({
'Ovo kontrolira hoće li se novi privitci dodani u ovu kolekciju prema zadanim postavkama označavati kao "Javni". Javni privitci automatski će biti vidljivi na Navedite web portalu. Ova se postavka može poništiti za svaki pojedinačni privitak i ne utječe na postojeće privitke.',
nb: 'Dette kontrollerer om nye vedlegg som legges til i denne samlingen skal flagges som «Offentlige» som standard. Offentlige vedlegg vil automatisk være synlige på en Specify-nettportal. Denne innstillingen kan overstyres for hvert vedlegg og påvirker ikke eksisterende vedlegg.',
},
+ importFromMappingFile: {
+ 'en-us': 'Import from Mapping File',
+ },
} as const);
diff --git a/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-1.jpg b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-1.jpg
new file mode 100644
index 00000000000..8c17e314a86
Binary files /dev/null and b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-1.jpg differ
diff --git a/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-2.png b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-2.png
new file mode 100644
index 00000000000..3ae65e62643
Binary files /dev/null and b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-2.png differ
diff --git a/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-pipe-delimited.csv b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-pipe-delimited.csv
new file mode 100644
index 00000000000..fac1b77bd3a
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-pipe-delimited.csv
@@ -0,0 +1,19 @@
+filename|parsedName
+fake-image-3.jpg|ID_003
+ fake-image-4.png | ID_004
+"fake-image|5.png"|"ID_005|EXTRA"
+"fake-image-6.jpg"| "ID_006"
+fake-image-7.jpg|ID_007
+fake-image-8.jpg|ID_008
+"fake-image-9.jpg"|"ID_009"
+ fake-image-10.png | ID_010
+"weird|name|11.jpg"|"ID_011"
+"trailing-space.jpg"|"ID_012"
+ "leading-space.png" | "ID_013"
+"quoted,comma.jpg"|ID_014
+fake-image-15.jpg|"ID_015"
+"semi;colon.jpg"|"ID_016"
+"pipe|and,comma.jpg"|"ID_017|EXTRA"
+"quoted-with-\"embedded\".jpg"|"ID_018"
+fake-image-19.jpg|ID_019
+fake-image-20.png|ID_020
diff --git a/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-valid.csv b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-valid.csv
new file mode 100644
index 00000000000..558d6134adb
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-valid.csv
@@ -0,0 +1,6 @@
+filename,parsedName
+fake-image-1.jpg,ID_001
+ "fake-image-2.png" , "ID_002"
+"fake-image,3.jpg",ID_003
+fake-image-4.png,"ID_004"
+"fake-image-5.jpg","ID_005"