From 377f21125cc23139cd048ae55ccee2e462cf48b1 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sat, 13 Apr 2024 16:30:42 -0500 Subject: [PATCH 1/5] Begin supporting mapping file --- .../AttachmentsBulkImport/Datasets.tsx | 14 ++++- .../AttachmentsBulkImport/Import.tsx | 54 +++++++++++++------ .../ImportFromMappingFile.tsx | 40 ++++++++++++++ .../ViewAttachmentFiles.tsx | 5 +- .../components/AttachmentsBulkImport/types.ts | 4 +- .../components/AttachmentsBulkImport/utils.ts | 25 +++++---- .../components/Molecules/CsvFilePicker.tsx | 3 ++ .../js_src/lib/components/Router/Routes.tsx | 7 +++ .../js_src/lib/components/WbImport/index.tsx | 1 + .../js_src/lib/localization/attachments.ts | 3 ++ 10 files changed, 127 insertions(+), 29 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ImportFromMappingFile.tsx 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..d0cccf50499 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,34 @@ 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): PartialUploadableFileSpec => { + if (eagerDataSet.uploadplan.staticPathKey === undefined) + return { uploadFile: file }; + let parsedName = undefined; + + if (file.parsedName !== undefined) + // 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] ); @@ -176,7 +191,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 +204,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..fa820381659 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ImportFromMappingFile.tsx @@ -0,0 +1,40 @@ +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]) => ({ + uploadFile: { + file: { + name: fileName, + }, + parsedName, + }, + }) + ); + 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/types.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts index b39a0addb72..7a6e4acb340 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts @@ -9,6 +9,7 @@ import type { DatasetBase, DatasetBriefBase } from '../WbPlanView/Wrapped'; import type { PartialAttachmentUploadSpec } from './Import'; import type { staticAttachmentImportPaths } from './importPaths'; import type { keyLocalizationMapAttachment } from './utils'; +import { Optional } from 'typedoc/dist/lib/utils/validation'; export type UploadAttachmentSpec = { readonly token: string; @@ -57,7 +58,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/Molecules/CsvFilePicker.tsx b/specifyweb/frontend/js_src/lib/components/Molecules/CsvFilePicker.tsx index 7d574550fe2..e46a8bc9344 100644 --- a/specifyweb/frontend/js_src/lib/components/Molecules/CsvFilePicker.tsx +++ b/specifyweb/frontend/js_src/lib/components/Molecules/CsvFilePicker.tsx @@ -79,11 +79,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 +106,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 e758cb3a07a..fc3a3124318 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/components/WbImport/index.tsx b/specifyweb/frontend/js_src/lib/components/WbImport/index.tsx index e76aacf3107..ec8f2f1a9b0 100644 --- a/specifyweb/frontend/js_src/lib/components/WbImport/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbImport/index.tsx @@ -29,6 +29,7 @@ import { parseXls, wbImportPreviewSize, } from './helpers'; +import { stripFileExtension } from '../../utils/utils'; export function WbImportView(): JSX.Element { useMenuItem('workBench'); diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index c28f9e9585e..31ac7a70bb7 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -848,4 +848,7 @@ export const attachmentsText = createDictionary({ 'hr-hr': '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.', }, + importFromMappingFile: { + 'en-us': 'Import from Mapping File', + }, } as const); From 49d82b0833e5371f95f12de6b4f792774a747c86 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sat, 13 Apr 2024 16:43:43 -0500 Subject: [PATCH 2/5] typecheck fix --- .../js_src/lib/components/AttachmentsBulkImport/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts index 7a6e4acb340..6426f160684 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts @@ -9,7 +9,6 @@ import type { DatasetBase, DatasetBriefBase } from '../WbPlanView/Wrapped'; import type { PartialAttachmentUploadSpec } from './Import'; import type { staticAttachmentImportPaths } from './importPaths'; import type { keyLocalizationMapAttachment } from './utils'; -import { Optional } from 'typedoc/dist/lib/utils/validation'; export type UploadAttachmentSpec = { readonly token: string; From 8115038cb95b190ec3976be5f343ab07e711e303 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sat, 13 Apr 2024 17:03:00 -0500 Subject: [PATCH 3/5] Fix an edge case when preserving parsed names --- .../components/AttachmentsBulkImport/Import.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx index d0cccf50499..10a8d2818b8 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx @@ -132,12 +132,15 @@ function AttachmentsImport({ ); const applyFileNames = React.useCallback( - (file: UnBoundFile): PartialUploadableFileSpec => { + ( + file: UnBoundFile, + preserveParseName = true + ): PartialUploadableFileSpec => { if (eagerDataSet.uploadplan.staticPathKey === undefined) return { uploadFile: file }; let parsedName = undefined; - if (file.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. @@ -169,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]); From 44e43a563dbff7de9b35169bd7a5c3b9d7b9de73 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:11:27 -0500 Subject: [PATCH 4/5] feat(attachments): add tests Now this trims filename and parsed name values when importing mapping CSVs to avoid leading/trailing whitespace and undefined values. Adds a bunch of tests! --- .../ImportFromMappingFile.tsx | 21 ++- .../__tests__/ImportFromMappingFile.test.tsx | 155 ++++++++++++++++++ .../lib/components/LocalityUpdate/index.tsx | 2 +- .../components/Molecules/CsvFilePicker.tsx | 15 +- .../js_src/lib/components/WbImport/index.tsx | 1 - .../attachmentsImport/fake-image-1.jpg | Bin 0 -> 854 bytes .../attachmentsImport/fake-image-2.png | Bin 0 -> 81 bytes .../mapping-file-pipe-delimited.csv | 19 +++ .../attachmentsImport/mapping-file-valid.csv | 6 + 9 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/ImportFromMappingFile.test.tsx create mode 100644 specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-1.jpg create mode 100644 specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-2.png create mode 100644 specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-pipe-delimited.csv create mode 100644 specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-valid.csv diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ImportFromMappingFile.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ImportFromMappingFile.tsx index fa820381659..7bc882442b9 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ImportFromMappingFile.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ImportFromMappingFile.tsx @@ -20,14 +20,21 @@ export function ImportFromMappingFile(): JSX.Element { // 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]) => ({ - uploadFile: { - file: { - name: fileName, + ([fileName, parsedName]) => { + const trimmedFileName = + fileName === undefined ? '' : String(fileName).trim(); + const trimmedParsedName = + parsedName === undefined ? undefined : String(parsedName).trim(); + + return { + uploadFile: { + file: { + name: trimmedFileName, + }, + parsedName: trimmedParsedName, }, - parsedName, - }, - }) + }; + } ); createEmptyAttachmentDataset( localized(fileName), 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/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 e46a8bc9344..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, + }); }) ); }} diff --git a/specifyweb/frontend/js_src/lib/components/WbImport/index.tsx b/specifyweb/frontend/js_src/lib/components/WbImport/index.tsx index ec8f2f1a9b0..e76aacf3107 100644 --- a/specifyweb/frontend/js_src/lib/components/WbImport/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbImport/index.tsx @@ -29,7 +29,6 @@ import { parseXls, wbImportPreviewSize, } from './helpers'; -import { stripFileExtension } from '../../utils/utils'; export function WbImportView(): JSX.Element { useMenuItem('workBench'); 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 0000000000000000000000000000000000000000..8c17e314a86017e8e5622562a80461ed41a22be5 GIT binary patch literal 854 zcmaFAfA4!3Vi53h^K@fiWMp7q1VRRg1cNh_0arIf899tR>_gP)XwGI}6ab1a2rvj3 z2q`cq3J4eq$O8o&7y}pt1Q<|3K3KvLs2COSLo^7WYf!L^X1>^uACL9uwAaca&F<{V9B_yB@1OPK)o+bbQ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3ae65e62643f6ab04b8964fa709f517c770e5732 GIT binary patch literal 81 zcmaFAe{X=FJ1>_MFBby?1231Shf5HU!3e?}EI{)8{=G6hOh5*=r;B4q1(2JZkO1T| WGcabTMS^uPc)I$z041CffCK=@_Y_9} literal 0 HcmV?d00001 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" From 0de1d9d46c5b45ba8e00e612201f5faefe37643d Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:19:07 -0500 Subject: [PATCH 5/5] feat(attachments): add mapping file button --- .../frontend/js_src/lib/components/Attachments/index.tsx | 7 ++++++- specifyweb/frontend/js_src/lib/localization/attachments.ts | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) 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/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index 31ac7a70bb7..522cf1911e9 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -29,6 +29,12 @@ export const attachmentsText = createDictionary({ 'pt-br': 'Escala', 'hr-hr': 'Skala', }, + importFromMMappingFile: { + 'en-us': 'Import from Mapping File', + }, + importMatchingAttachments: { + 'en-us': 'Import Matching Attachments', + }, attachmentServerUnavailable: { 'en-us': 'Attachment server unavailable', 'ru-ru': 'Сервер прикрепленных файлов недоступен',