diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/utils.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/utils.test.ts.snap
index a60a2eb0d03..9e942dcbf2a 100644
--- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/utils.test.ts.snap
+++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/utils.test.ts.snap
@@ -1,7 +1,8 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`allTablesWithAttachments 1`] = `
[
+ "[table AbsoluteAge]",
"[table Accession]",
"[table Agent]",
"[table Borrow]",
@@ -10,10 +11,10 @@ exports[`allTablesWithAttachments 1`] = `
"[table CollectionObject]",
"[table ConservDescription]",
"[table ConservEvent]",
- "[table DNASequence]",
- "[table DNASequencingRun]",
"[table Deaccession]",
"[table Disposal]",
+ "[table DNASequence]",
+ "[table DNASequencingRun]",
"[table ExchangeIn]",
"[table ExchangeOut]",
"[table FieldNotebook]",
@@ -25,18 +26,18 @@ exports[`allTablesWithAttachments 1`] = `
"[table Permit]",
"[table Preparation]",
"[table ReferenceWork]",
+ "[table RelativeAge]",
"[table RepositoryAgreement]",
+ "[table Spdataset]",
"[table Storage]",
"[table Taxon]",
"[table TreatmentEvent]",
- "[table AbsoluteAge]",
- "[table RelativeAge]",
- "[table Spdataset]",
]
`;
exports[`attachmentRelatedTables 1`] = `
[
+ "AbsoluteAgeAttachment",
"AccessionAttachment",
"AgentAttachment",
"Attachment",
@@ -46,10 +47,10 @@ exports[`attachmentRelatedTables 1`] = `
"CollectionObjectAttachment",
"ConservDescriptionAttachment",
"ConservEventAttachment",
- "DNASequenceAttachment",
- "DNASequencingRunAttachment",
"DeaccessionAttachment",
"DisposalAttachment",
+ "DNASequenceAttachment",
+ "DNASequencingRunAttachment",
"ExchangeInAttachment",
"ExchangeOutAttachment",
"FieldNotebookAttachment",
@@ -61,12 +62,11 @@ exports[`attachmentRelatedTables 1`] = `
"PermitAttachment",
"PreparationAttachment",
"ReferenceWorkAttachment",
+ "RelativeAgeAttachment",
"RepositoryAgreementAttachment",
+ "SpDataSetAttachment",
"StorageAttachment",
"TaxonAttachment",
"TreatmentEventAttachment",
- "AbsoluteAgeAttachment",
- "RelativeAgeAttachment",
- "SpDataSetAttachment",
]
`;
diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/uploadFile.test.ts b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/uploadFile.test.ts
index 8eddbdf7907..7439350e268 100644
--- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/uploadFile.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/uploadFile.test.ts
@@ -31,12 +31,15 @@ describe('uploadFile', () => {
return {
open: jest.fn(),
send: jest.fn((..._args: readonly unknown[]) => listeners[nextEvent]?.()),
- addEventListener: jest.fn((eventName: EventName, callback: () => void) => {
- listeners[eventName] = callback;
- }),
+ addEventListener: jest.fn(
+ (eventName: EventName, callback: () => void) => {
+ listeners[eventName] = callback;
+ }
+ ),
removeEventListener: jest.fn(
(eventName: EventName, callback: () => void) => {
- if (listeners[eventName] === callback) listeners[eventName] = undefined;
+ if (listeners[eventName] === callback)
+ listeners[eventName] = undefined;
}
),
upload: {
diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts
index 849fda5e0f8..8b30bfff2b8 100644
--- a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts
+++ b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts
@@ -67,6 +67,8 @@ const thumbnailable = new Set([
'image/gif',
'image/png',
'image/tiff',
+ 'image/heic',
+ 'image/heif',
'application/pdf',
]);
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx
index fda1cfda79d..cb774fb52e2 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx
@@ -208,6 +208,11 @@ export function AttachmentsImportOverlay(): JSX.Element | null {
/>
{attachmentDataSet.name}
+ {attachmentDataSet.matchingmode === 'mappingFile' && (
+
+ {attachmentsText.matchByMappingFile()}
+
+ )}
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx
index 7bd0afe0fbd..6d90361c11d 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx
@@ -11,7 +11,7 @@ import { userText } from '../../localization/user';
import { wbText } from '../../localization/workbench';
import { ajax } from '../../utils/ajax';
import { f } from '../../utils/functools';
-import type { RA, WritableArray } from '../../utils/types';
+import type { RA } from '../../utils/types';
import type { IR } from '../../utils/types';
import { removeKey, sortFunction } from '../../utils/utils';
import { Container } from '../Atoms';
@@ -30,19 +30,26 @@ import { SelectUploadPath } from './SelectUploadPath';
import type {
AttachmentDataSet,
FetchedDataSet,
+ MappingFileColumns,
+ MappingFileRow,
+ MatchingMode,
PartialUploadableFileSpec,
UnBoundFile,
} from './types';
import { AttachmentUpload } from './Upload';
import { useEagerDataSet } from './useEagerDataset';
import {
+ crossReferenceMappingFiles,
+ isMappingFilePlaceholder,
matchSelectedFiles,
+ prepareMappingFileSelection,
reconstructDeletingAttachment,
reconstructUploadingAttachmentSpec,
resolveFileNames,
} from './utils';
import { AttachmentsValidationDialog } from './ValidationDialog';
import { ViewAttachmentFiles } from './ViewAttachmentFiles';
+import { MatchingModeDialog } from './MatchingModeDialog';
export type AttachmentUploadSpec = {
readonly staticPathKey: keyof typeof staticAttachmentImportPaths;
@@ -52,6 +59,10 @@ export type AttachmentUploadSpec = {
};
export type PartialAttachmentUploadSpec = {
readonly fieldFormatter?: UiFormatter;
+ readonly formatQueryResults?: AttachmentUploadSpec['formatQueryResults'];
+ readonly matchingMode?: MatchingMode | null;
+ readonly mappingFileColumns?: MappingFileColumns;
+ readonly mappingFileData?: RA;
} & (AttachmentUploadSpec | { readonly staticPathKey: undefined });
export type EagerDataSet = Omit & {
@@ -132,34 +143,95 @@ 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 => {
+ // In mapping-file mode, look up the match value from the CSV data
+ // (works even before a field path is selected)
+ if (
+ eagerDataSet.uploadplan.matchingMode === 'mappingFile' &&
+ eagerDataSet.uploadplan.mappingFileData !== undefined
+ ) {
+ const mappingRow = eagerDataSet.uploadplan.mappingFileData.find(
+ (row) => row.fileName === file.file.name
+ );
+ return {
+ uploadFile: {
+ ...file,
+ parsedName: mappingRow?.matchValue,
+ mappingMatchValue: mappingRow?.matchValue,
+ mappingFileName: mappingRow?.fileName,
},
- [eagerDataSet.uploadplan.staticPathKey]
+ };
+ }
+
+ if (eagerDataSet.uploadplan.staticPathKey === undefined)
+ return { uploadFile: file };
+
+ // Default: filename pattern matching
+ return {
+ uploadFile: {
+ ...file,
+ parsedName: resolveFileNames(
+ file.file.name,
+ eagerDataSet.uploadplan.formatQueryResults,
+ eagerDataSet.uploadplan.fieldFormatter
+ ),
+ },
+ };
+ },
+ [
+ eagerDataSet.uploadplan.staticPathKey,
+ eagerDataSet.uploadplan.matchingMode,
+ eagerDataSet.uploadplan.mappingFileData,
+ eagerDataSet.uploadplan.formatQueryResults,
+ eagerDataSet.uploadplan.fieldFormatter,
+ ]
+ );
+
+ // Single source of truth: dataset-level matchingmode (DB field) or uploadplan-level
+ const isMappingMode = React.useMemo(
+ () =>
+ (eagerDataSet.matchingmode ?? eagerDataSet.uploadplan.matchingMode) ===
+ 'mappingFile',
+ [eagerDataSet.matchingmode, eagerDataSet.uploadplan.matchingMode]
);
- const previousKeyRef = React.useRef(
+ const previousPathKeyRef = React.useRef(
attachmentDataSetResource.uploadplan.staticPathKey
);
React.useEffect(() => {
- // Reset all parsed names if matching path is changed
- if (previousKeyRef.current !== eagerDataSet.uploadplan.staticPathKey) {
- previousKeyRef.current = eagerDataSet.uploadplan.staticPathKey;
+ if (previousPathKeyRef.current !== eagerDataSet.uploadplan.staticPathKey) {
+ previousPathKeyRef.current = eagerDataSet.uploadplan.staticPathKey;
commitFileChange((files) =>
- files.map(({ uploadFile }) => applyFileNames(uploadFile))
+ files.map(({ uploadFile, ...rest }) => ({
+ ...rest,
+ ...applyFileNames(uploadFile),
+ }))
);
}
- }, [applyFileNames, commitFileChange]);
+ }, [applyFileNames, commitFileChange, eagerDataSet.uploadplan.staticPathKey]);
+
+ // In mapping mode, seed the table with CSV rows so the user sees what to upload
+ React.useEffect(() => {
+ if (
+ isMappingMode &&
+ eagerDataSet.uploadplan.mappingFileData !== undefined &&
+ eagerDataSet.uploadplan.mappingFileData.length > 0 &&
+ eagerDataSet.rows.length === 0
+ ) {
+ commitFileChange((oldRows) =>
+ crossReferenceMappingFiles(
+ oldRows,
+ eagerDataSet.uploadplan.mappingFileData!
+ )
+ );
+ }
+ }, [
+ eagerDataSet.uploadplan.matchingMode,
+ eagerDataSet.uploadplan.mappingFileData,
+ eagerDataSet.rows.length,
+ commitFileChange,
+ isMappingMode,
+ ]);
const currentBaseTable =
eagerDataSet.uploadplan.staticPathKey === undefined
@@ -177,20 +249,38 @@ function AttachmentsImport({
const handleFilesSelected = (files: FileList) => {
const filesList = Array.from(files, (file) => applyFileNames({ file }));
- const oldRows = eagerDataSet.rows;
- const { resolvedFiles, duplicateFiles } = matchSelectedFiles(
- oldRows,
- filesList
- );
- (resolvedFiles as WritableArray).sort(
- sortFunction((file) => file.uploadFile.file.name)
- );
+ const { resolvedFiles, duplicateFiles } =
+ isMappingMode && eagerDataSet.uploadplan.mappingFileData !== undefined
+ ? prepareMappingFileSelection(
+ eagerDataSet.rows,
+ filesList,
+ eagerDataSet.uploadplan.mappingFileData
+ )
+ : matchSelectedFiles(eagerDataSet.rows, filesList);
+
+ // Safety net: guarantee no duplicate filenames in the final list
+ const bestByName = new Map();
+ for (const f of resolvedFiles) {
+ const name = f.uploadFile.file.name;
+ const existing = bestByName.get(name);
+ if (
+ existing === undefined ||
+ (!(existing.uploadFile.file instanceof File) &&
+ f.uploadFile.file instanceof File)
+ ) {
+ bestByName.set(name, f);
+ }
+ }
+ const deduped = [...bestByName.values()];
+
+ deduped.sort(sortFunction((file) => file.uploadFile.file.name));
commitChange((oldState) => ({
...oldState,
uploaderstatus: 'main',
- rows: resolvedFiles,
+ rows: deduped,
}));
setDuplicatedFiles(duplicateFiles);
+ setMissingFiles([]);
};
const [isRenaming, openRenaming, closeRenaming] = useBooleanState(false);
@@ -199,34 +289,72 @@ function AttachmentsImport({
RA
>([]);
+ /*
+ * Placeholder rows from the mapping file whose files have not been selected yet.
+ * Filled in after validation so the user is warned about missing files.
+ */
+ const [missingFiles, setMissingFiles] = React.useState<
+ RA
+ >([]);
+
const mainHeaders = React.useMemo(() => {
- let headers: IR = {
- selectedFileName: commonText.selectedFileName(),
- fileSize: attachmentsText.fileSize(),
- record: (
-
- {currentBaseTable === undefined ? (
- userText.resource()
- ) : (
- <>
-
- {eagerDataSet.uploadplan.staticPathKey === undefined
- ? ''
- : strictGetTable(currentBaseTable).strictGetField(
- staticAttachmentImportPaths[
- eagerDataSet.uploadplan.staticPathKey
- ].path
- ).label}
- >
- )}
-
- ),
- progress: attachmentsText.progress(),
- };
+ const isMapping = isMappingMode;
+ const baseHeaders: IR = isMapping
+ ? {
+ selectedFileName: commonText.selectedFileName(),
+ matchValue: attachmentsText.matchValue(),
+ fileSize: attachmentsText.fileSize(),
+ record: (
+
+ {currentBaseTable === undefined ? (
+ userText.resource()
+ ) : (
+ <>
+
+ {eagerDataSet.uploadplan.staticPathKey === undefined
+ ? ''
+ : strictGetTable(currentBaseTable).strictGetField(
+ staticAttachmentImportPaths[
+ eagerDataSet.uploadplan.staticPathKey
+ ].path
+ ).label}
+ >
+ )}
+
+ ),
+ progress: attachmentsText.progress(),
+ }
+ : {
+ selectedFileName: commonText.selectedFileName(),
+ fileSize: attachmentsText.fileSize(),
+ record: (
+
+ {currentBaseTable === undefined ? (
+ userText.resource()
+ ) : (
+ <>
+
+ {eagerDataSet.uploadplan.staticPathKey === undefined
+ ? ''
+ : strictGetTable(currentBaseTable).strictGetField(
+ staticAttachmentImportPaths[
+ eagerDataSet.uploadplan.staticPathKey
+ ].path
+ ).label}
+ >
+ )}
+
+ ),
+ progress: attachmentsText.progress(),
+ };
+ let headers = baseHeaders;
if (process.env.NODE_ENV === 'development')
headers = { ...headers, attachmentId: attachmentsText.attachmentId() };
return headers;
- }, [eagerDataSet.uploadplan.staticPathKey]);
+ }, [
+ eagerDataSet.uploadplan.staticPathKey,
+ eagerDataSet.uploadplan.matchingMode,
+ ]);
const errorContextData = React.useMemo(
() => ({
@@ -238,6 +366,15 @@ function AttachmentsImport({
useErrorContext('bulkAttachmentImport', errorContextData);
+ const [modeDialogDismissed, setModeDialogDismissed] = React.useState(false);
+
+ const showModeDialog =
+ !modeDialogDismissed &&
+ (eagerDataSet.matchingmode === undefined ||
+ eagerDataSet.matchingmode === null) &&
+ eagerDataSet.uploadplan.staticPathKey === undefined &&
+ eagerDataSet.rows.length === 0;
+
return (
@@ -269,7 +406,13 @@ function AttachmentsImport({
: (uploadSpec) => {
commitChange((oldState) => ({
...oldState,
- uploadplan: uploadSpec,
+ uploadplan: {
+ ...uploadSpec,
+ matchingMode: oldState.uploadplan.matchingMode,
+ mappingFileColumns:
+ oldState.uploadplan.mappingFileColumns,
+ mappingFileData: oldState.uploadplan.mappingFileData,
+ },
}));
}
}
@@ -330,6 +473,7 @@ function AttachmentsImport({
@@ -361,18 +505,18 @@ function AttachmentsImport({
- validatedFiles === undefined
- ? undefined
- : commitChange(
- (oldState) => ({
- ...oldState,
- uploaderstatus: 'main',
- rows: validatedFiles,
- }),
- true
- )
- }
+ onValidated={(validatedFiles) => {
+ if (validatedFiles === undefined) return undefined;
+ setMissingFiles(validatedFiles.filter(isMappingFilePlaceholder));
+ return commitChange(
+ (oldState) => ({
+ ...oldState,
+ uploaderstatus: 'main',
+ rows: validatedFiles,
+ }),
+ true
+ );
+ }}
/>
) : null}
{isRenaming && (
@@ -422,6 +566,7 @@ function AttachmentsImport({
)}
+ {missingFiles.length > 0 && (
+ setMissingFiles([])}
+ >
+
+
{attachmentsText.missingFilesDescription()}
+
+ {missingFiles.map(({ uploadFile }) => (
+ {uploadFile.file.name}
+ ))}
+
+
+
+ )}
+ {showModeDialog && (
+ {
+ // Default to filename mode if user closes without choosing
+ setModeDialogDismissed(true);
+ commitChange((oldState) => ({
+ ...oldState,
+ matchingmode: null,
+ uploadplan: {
+ ...oldState.uploadplan,
+ matchingMode: null,
+ },
+ }));
+ }}
+ onContinue={(mode, columns, data) => {
+ setModeDialogDismissed(true);
+ commitChange((oldState) => ({
+ ...oldState,
+ matchingmode: mode,
+ uploadplan: {
+ ...oldState.uploadplan,
+ matchingMode: mode,
+ mappingFileColumns: columns,
+ mappingFileData: data,
+ },
+ }));
+ }}
+ />
+ )}
);
}
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MappingFileSetup.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MappingFileSetup.tsx
new file mode 100644
index 00000000000..66d7cd8ebd2
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MappingFileSetup.tsx
@@ -0,0 +1,326 @@
+import React from 'react';
+import { parse } from 'csv-parse/browser/esm';
+
+import { attachmentsText } from '../../localization/attachments';
+import { commonText } from '../../localization/common';
+import type { RA } from '../../utils/types';
+import { filterArray } from '../../utils/types';
+import { escapeRegExp } from '../../utils/utils';
+import { Select } from '../Atoms/Form';
+import { FilePicker } from '../Molecules/FilePicker';
+import { strictGetTable } from '../DataModel/tables';
+import { staticAttachmentImportPaths } from './importPaths';
+import type { MappingFileColumns, MappingFileRow } from './types';
+
+/**
+ * Parse a CSV file and return headers + data rows
+ */
+const parseMappingCsv = async (
+ file: File
+): Promise<{ headers: RA; rows: RA> }> => {
+ const text = await file.text();
+ return new Promise((resolve, reject) => {
+ parse(
+ text,
+ {
+ delimiter: [',', '\t', '|'],
+ relaxColumnCount: true,
+ skipEmptyLines: true,
+ trim: true,
+ },
+ (error, data: RA>) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ if (data.length === 0) {
+ reject(new Error(attachmentsText.csvEmptyFile().toString()));
+ return;
+ }
+ const headers = data[0];
+ const rows = data
+ .slice(1)
+ .filter((row) => row.some((cell) => cell.trim() !== ''));
+ resolve({ headers, rows });
+ }
+ );
+ });
+};
+
+/**
+ * Build case-insensitive regex matchers for attachment field names.
+ * Matches against both the raw field name (e.g. "catalogNumber") and
+ * the schema-localized caption (e.g. "Specimen #", "Catalog Number").
+ */
+let _fieldMatchersCache: RA | undefined;
+function buildFieldMatchers(): RA {
+ if (_fieldMatchersCache !== undefined) return _fieldMatchersCache;
+ const matchers: RegExp[] = [];
+ const seen = new Set();
+
+ for (const { baseTable, path } of Object.values(
+ staticAttachmentImportPaths
+ )) {
+ const escaped = escapeRegExp(String(path));
+ const key = escaped.toLowerCase();
+ if (!seen.has(key)) {
+ seen.add(key);
+ matchers.push(new RegExp(`^${escaped}$`, 'i'));
+ }
+
+ // Also match against the schema-localized field caption
+ try {
+ const field = strictGetTable(baseTable).strictGetField(path);
+ const label = String(field.label);
+ const escapedLabel = escapeRegExp(label);
+ const labelKey = escapedLabel.toLowerCase();
+ if (label !== String(path) && !seen.has(labelKey)) {
+ seen.add(labelKey);
+ matchers.push(new RegExp(`^${escapedLabel}$`, 'i'));
+ }
+ } catch {
+ // Schema not yet loaded — skip localized labels
+ }
+ }
+
+ _fieldMatchersCache = matchers;
+ return matchers;
+}
+
+/**
+ * Attempt to auto-detect which columns are match value and file name.
+ * Match value columns are detected by comparing CSV headers against
+ * data model field names and schema-localized field captions.
+ */
+function autoDetectColumns(headers: RA): {
+ matchValueIndex: number | undefined;
+ fileNameIndex: number | undefined;
+} {
+ const fieldMatchers = buildFieldMatchers();
+
+ const fileNamePatterns = [
+ /^attachment\s*name$/i,
+ /^file\s*name$/i,
+ /^filename$/i,
+ /^attachment$/i,
+ /^file$/i,
+ ];
+
+ let matchValueIndex: number | undefined;
+ let fileNameIndex: number | undefined;
+
+ headers.forEach((header, index) => {
+ if (
+ matchValueIndex === undefined &&
+ fieldMatchers.some((p) => p.test(header))
+ ) {
+ matchValueIndex = index;
+ }
+ if (
+ fileNameIndex === undefined &&
+ fileNamePatterns.some((p) => p.test(header))
+ ) {
+ fileNameIndex = index;
+ }
+ });
+
+ return { matchValueIndex, fileNameIndex };
+}
+
+export function MappingFileSetup({
+ onColumnsSelected: handleColumnsSelected,
+ disabled,
+ initialColumns,
+ initialData,
+}: {
+ readonly onColumnsSelected: (
+ columns: MappingFileColumns,
+ data: RA
+ ) => void;
+ readonly disabled?: boolean;
+ readonly initialColumns?: MappingFileColumns;
+ readonly initialData?: RA;
+}): JSX.Element {
+ const [headers, setHeaders] = React.useState | undefined>(
+ undefined
+ );
+ const [rows, setRows] = React.useState> | undefined>(undefined);
+ const [matchValueIndex, setMatchValueIndex] = React.useState<
+ number | undefined
+ >(initialColumns?.matchValueColumnIndex);
+ const [fileNameIndex, setFileNameIndex] = React.useState(
+ initialColumns?.fileNameColumnIndex
+ );
+ const [error, setError] = React.useState(undefined);
+
+ // If initial data is provided (restoring from saved dataset), use it
+ const isRestored = initialColumns !== undefined && initialData !== undefined;
+ const [hasRestored] = React.useState(isRestored);
+
+ const previousMappingRef = React.useRef(undefined);
+ const selectionGenerationRef = React.useRef(0);
+ const handleFileSelected = React.useCallback(async (selectedFile: File) => {
+ setError(undefined);
+ setMatchValueIndex(undefined);
+ setFileNameIndex(undefined);
+ previousMappingRef.current = undefined;
+ selectionGenerationRef.current += 1;
+ const generation = selectionGenerationRef.current;
+ try {
+ const parsed = await parseMappingCsv(selectedFile);
+ if (generation !== selectionGenerationRef.current) return;
+ setHeaders(parsed.headers);
+ setRows(parsed.rows);
+
+ // Auto-detect columns
+ const detected = autoDetectColumns(parsed.headers);
+ if (detected.matchValueIndex !== undefined)
+ setMatchValueIndex(detected.matchValueIndex);
+ if (detected.fileNameIndex !== undefined)
+ setFileNameIndex(detected.fileNameIndex);
+ } catch (err) {
+ if (generation !== selectionGenerationRef.current) return;
+ setHeaders(undefined);
+ setRows(undefined);
+ setError(
+ err instanceof Error
+ ? err.message
+ : attachmentsText.csvParseError().toString()
+ );
+ }
+ }, []);
+
+ // Notify parent when columns are selected
+ React.useEffect(() => {
+ if (
+ matchValueIndex === undefined ||
+ fileNameIndex === undefined ||
+ rows === undefined
+ )
+ return;
+
+ const mappingData: RA = filterArray(
+ rows.map((row) => {
+ const matchValue = row[matchValueIndex]?.trim();
+ const fileName = row[fileNameIndex]?.trim();
+ if (matchValue === undefined || fileName === undefined)
+ return undefined;
+ if (matchValue === '' || fileName === '') return undefined;
+ return { matchValue, fileName };
+ })
+ );
+
+ const mappingKey = JSON.stringify({ matchValueIndex, fileNameIndex });
+ if (mappingKey !== previousMappingRef.current) {
+ previousMappingRef.current = mappingKey;
+ handleColumnsSelected(
+ {
+ matchValueColumnIndex: matchValueIndex,
+ fileNameColumnIndex: fileNameIndex,
+ },
+ mappingData
+ );
+ }
+ }, [matchValueIndex, fileNameIndex, rows, handleColumnsSelected]);
+
+ return (
+
+ {/* Show restored state */}
+ {hasRestored && initialData !== undefined ? (
+
+
+ {attachmentsText.mappingFileLoaded({
+ count: initialData.length,
+ })}
+
+
+ {attachmentsText.mappingFileColumnsInfo({
+ matchColumn:
+ headers?.[initialColumns?.matchValueColumnIndex ?? 0] ??
+ String(initialColumns?.matchValueColumnIndex ?? 0),
+ fileColumn:
+ headers?.[initialColumns?.fileNameColumnIndex ?? 0] ??
+ String(initialColumns?.fileNameColumnIndex ?? 0),
+ })}
+
+
+ ) : (
+ <>
+
+
+
+ {error !== undefined && (
+
{error}
+ )}
+ >
+ )}
+
+ {/* Column selection */}
+ {headers !== undefined && (
+
+
+
+ {attachmentsText.selectMatchValueColumn()}
+
+
+ setMatchValueIndex(
+ value === '' ? undefined : Number.parseInt(value)
+ )
+ }
+ >
+
+ {commonText.select()}
+
+ {headers.map((header, index) => (
+
+ {header ||
+ attachmentsText.columnPlaceholder({
+ columnNumber: index + 1,
+ })}
+
+ ))}
+
+
+
+
+
+ {attachmentsText.selectFileNameColumn()}
+
+
+ setFileNameIndex(
+ value === '' ? undefined : Number.parseInt(value)
+ )
+ }
+ >
+
+ {commonText.select()}
+
+ {headers.map((header, index) => (
+
+ {header ||
+ attachmentsText.columnPlaceholder({
+ columnNumber: index + 1,
+ })}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MatchingModeDialog.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MatchingModeDialog.tsx
new file mode 100644
index 00000000000..8832046b383
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MatchingModeDialog.tsx
@@ -0,0 +1,128 @@
+import React from 'react';
+
+import { attachmentsText } from '../../localization/attachments';
+import { commonText } from '../../localization/common';
+import type { RA } from '../../utils/types';
+import { icons } from '../Atoms/Icons';
+import { Button } from '../Atoms/Button';
+import { Submit } from '../Atoms/Submit';
+import { Dialog } from '../Molecules/Dialog';
+import { MappingFileSetup } from './MappingFileSetup';
+import type { MappingFileColumns, MappingFileRow, MatchingMode } from './types';
+
+export function MatchingModeDialog({
+ onContinue: handleContinue,
+ onClose: handleClose,
+ initialMode,
+ initialColumns,
+ initialData,
+}: {
+ readonly onContinue: (
+ mode: MatchingMode | null,
+ columns?: MappingFileColumns,
+ data?: RA
+ ) => void;
+ readonly onClose: () => void;
+ readonly initialMode?: MatchingMode | null;
+ readonly initialColumns?: MappingFileColumns;
+ readonly initialData?: RA;
+}): JSX.Element {
+ const [mode, setMode] = React.useState(
+ initialMode ?? null
+ );
+ const [mappingColumns, setMappingColumns] = React.useState<
+ MappingFileColumns | undefined
+ >(initialColumns);
+ const [mappingData, setMappingData] = React.useState<
+ RA | undefined
+ >(initialData);
+
+ const canContinue =
+ mode === null ||
+ mode === undefined ||
+ (mode === 'mappingFile' &&
+ mappingColumns !== undefined &&
+ mappingData !== undefined &&
+ mappingData.length > 0);
+
+ return (
+
+ {commonText.close()}
+ handleContinue(mode, mappingColumns, mappingData)}
+ >
+ {commonText.proceed()}
+
+ >
+ }
+ header={attachmentsText.matchingMode()}
+ onClose={handleClose}
+ >
+
+
{attachmentsText.chooseMatchingMode()}
+
+
+ setMode(null)}
+ />
+
+
+ {attachmentsText.matchByFilename()}
+
+
+ {attachmentsText.matchByFilenameDescription()}
+
+
+
+
+
+ setMode('mappingFile')}
+ />
+
+
+ {attachmentsText.matchByMappingFile()}
+
+
+ {attachmentsText.matchByMappingFileDescription()}
+
+ {mode === 'mappingFile' && (
+
+ {
+ setMappingColumns(columns);
+ setMappingData(data);
+ }}
+ />
+
+ )}
+
+
+
+
+ );
+}
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx
index 276b19add08..b0ad7a7ab6a 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx
@@ -19,6 +19,7 @@ import type { PartialAttachmentUploadSpec } from './Import';
import { ResourceDisambiguationDialog } from './ResourceDisambiguation';
import type { PartialUploadableFileSpec } from './types';
import {
+ isMappingFilePlaceholder,
keyLocalizationMapAttachment,
resolveAttachmentRecord,
resolveAttachmentStatus,
@@ -27,7 +28,8 @@ import {
const resolveAttachmentDatasetData = (
uploadableFiles: RA,
setDisambiguationIndex: (index: number) => void,
- baseTableName: keyof Tables | undefined
+ baseTableName: keyof Tables | undefined,
+ isMappingMode: boolean = false
) =>
uploadableFiles.map(
({ uploadFile, status, matchedId, disambiguated, attachmentId }, index) => {
@@ -46,7 +48,8 @@ const resolveAttachmentDatasetData = (
: resolveAttachmentRecord(
matchedId,
disambiguated,
- uploadFile.parsedName
+ uploadFile.parsedName,
+ isMappingMode
);
const isRuntimeError =
@@ -54,12 +57,24 @@ const resolveAttachmentDatasetData = (
typeof status === 'object' &&
(status.type === 'cancelled' || status.type === 'skipped');
- const statusText = f.maybe(status, resolveAttachmentStatus) ?? '';
- return {
+ const isPlaceholder =
+ isMappingMode &&
+ isMappingFilePlaceholder({
+ uploadFile,
+ status,
+ } as PartialUploadableFileSpec);
+
+ const statusText = isPlaceholder
+ ? attachmentsText.awaitingFile()
+ : (f.maybe(status, resolveAttachmentStatus) ?? '');
+
+ const baseData = {
selectedFileName: [
uploadFile.file.name,
- {uploadFile.file instanceof File ? '' : dialogIcons.warning}
+ {uploadFile.file instanceof File || isPlaceholder
+ ? ''
+ : dialogIcons.warning}
{uploadFile.file.name}
,
],
@@ -100,6 +115,16 @@ const resolveAttachmentDatasetData = (
isNativeError: resolvedRecord?.type === 'invalid',
isRuntimeError,
} as const;
+
+ return isMappingMode
+ ? {
+ ...baseData,
+ matchValue: [
+ uploadFile.mappingMatchValue ?? '',
+ {uploadFile.mappingMatchValue ?? ''} ,
+ ] as const,
+ }
+ : baseData;
}
);
@@ -109,6 +134,7 @@ export function ViewAttachmentFiles({
onDisambiguation: handleDisambiguation,
onFilesDropped: handleFilesDropped,
headers,
+ isMappingMode = false,
}: {
readonly uploadableFiles: RA;
readonly baseTableName: keyof Tables | undefined;
@@ -122,6 +148,7 @@ export function ViewAttachmentFiles({
| undefined;
readonly onFilesDropped?: (file: FileList) => void;
readonly headers: IR;
+ readonly isMappingMode?: boolean;
}): JSX.Element | null {
const [disambiguationIndex, setDisambiguationIndex] = React.useState<
number | undefined
@@ -132,9 +159,10 @@ export function ViewAttachmentFiles({
resolveAttachmentDatasetData(
uploadableFiles,
setDisambiguationIndex,
- baseTableName
+ baseTableName,
+ isMappingMode
),
- [uploadableFiles, setDisambiguationIndex, baseTableName]
+ [uploadableFiles, setDisambiguationIndex, baseTableName, isMappingMode]
);
const fileDropDivRef = React.useRef(null);
@@ -163,7 +191,9 @@ export function ViewAttachmentFiles({
{uploadableFiles.some(
- ({ uploadFile: { file } }) => !(file instanceof File)
+ (row) =>
+ !(row.uploadFile.file instanceof File) &&
+ !(isMappingMode && isMappingFilePlaceholder(row))
) && (
<>
{dialogIcons.warning}
@@ -172,6 +202,12 @@ export function ViewAttachmentFiles({
)}
+ {isMappingMode &&
+ uploadableFiles.some(isMappingFilePlaceholder) && (
+
+ {attachmentsText.mappingAwaitingFiles()}
+
+ )}
`bg-[color:var(--background)] p-2 print:p-1 ${
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/__snapshots__/utils.test.ts.snap b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/__snapshots__/utils.test.ts.snap
index 6d12772ce10..2a4a271fe67 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/__snapshots__/utils.test.ts.snap
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/__snapshots__/utils.test.ts.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`reconstruct deleting attachment spec 1`] = `
[
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/utils.test.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/utils.test.ts
index 48584ad4fea..cd4aa478858 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/utils.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/utils.test.ts
@@ -13,9 +13,13 @@ import {
import { syncFieldFormat } from '../../Formatters/fieldFormat';
import type { PartialUploadableFileSpec, UnBoundFile } from '../types';
import {
+ crossReferenceMappingFiles,
inferDeletedAttachments,
inferUploadedAttachments,
+ isMappingFilePlaceholder,
matchFileSpec,
+ matchSelectedFiles,
+ prepareMappingFileSelection,
resolveFileNames,
} from '../utils';
@@ -354,3 +358,818 @@ test('reconstruct deleting attachment spec', () => {
];
expect(inferDeletedAttachments(queryResults, files)).toMatchSnapshot();
});
+
+describe('crossReferenceMappingFiles', () => {
+ const CSV: RA<{ readonly matchValue: string; readonly fileName: string }> = [
+ { matchValue: '000271806', fileName: '271806.jpg' },
+ { matchValue: '000601146', fileName: '601146.jpg' },
+ { matchValue: '000601146', fileName: '601146_2.jpg' },
+ { matchValue: '000273074', fileName: '273074.jpg' },
+ ];
+
+ const makeReal = (name: string, mv: string) =>
+ ({
+ uploadFile: {
+ file: { name, size: 91, type: 'image/jpeg' },
+ parsedName: mv,
+ mappingMatchValue: mv,
+ },
+ }) as PartialUploadableFileSpec;
+
+ test('initial seeding produces all placeholders', () => {
+ const result = crossReferenceMappingFiles([], CSV);
+ expect(result).toHaveLength(4);
+ expect(
+ result.every((r) => (r.status as any)?.reason === 'fileMissing')
+ ).toBe(true);
+ });
+
+ test('adding one real file replaces corresponding placeholder', () => {
+ const seeded = crossReferenceMappingFiles([], CSV);
+ const result = crossReferenceMappingFiles(
+ [...seeded, makeReal('601146.jpg', '000601146')],
+ CSV
+ );
+ expect(result).toHaveLength(4);
+ expect(
+ result.find((r) => r.uploadFile.file.name === '601146.jpg')?.uploadFile
+ .file.size
+ ).toBe(91);
+ expect(
+ result.filter((r) => (r.status as any)?.reason === 'fileMissing')
+ ).toHaveLength(3);
+ });
+
+ test('adding all files removes all placeholders', () => {
+ const seeded = crossReferenceMappingFiles([], CSV);
+ const result = crossReferenceMappingFiles(
+ [
+ ...seeded,
+ makeReal('271806.jpg', '000271806'),
+ makeReal('601146.jpg', '000601146'),
+ makeReal('601146_2.jpg', '000601146'),
+ makeReal('273074.jpg', '000273074'),
+ ],
+ CSV
+ );
+ expect(result).toHaveLength(4);
+ expect(
+ result.every(
+ (r) => !r.status || (r.status as any).reason !== 'fileMissing'
+ )
+ ).toBe(true);
+ expect(result.every((r) => r.uploadFile.file.size === 91)).toBe(true);
+ });
+
+ test('batch processing produces no duplicates', () => {
+ const seeded = crossReferenceMappingFiles([], CSV);
+ const batch1 = crossReferenceMappingFiles(
+ [
+ ...seeded,
+ makeReal('271806.jpg', '000271806'),
+ makeReal('601146.jpg', '000601146'),
+ ],
+ CSV
+ );
+ expect(batch1).toHaveLength(4);
+
+ const batch2 = crossReferenceMappingFiles(
+ [
+ ...batch1,
+ makeReal('601146_2.jpg', '000601146'),
+ makeReal('273074.jpg', '000273074'),
+ ],
+ CSV
+ );
+ expect(batch2).toHaveLength(4);
+ expect(batch2.every((r) => r.uploadFile.file.size === 91)).toBe(true);
+ });
+
+ test('file not in CSV is flagged', () => {
+ const seeded = crossReferenceMappingFiles([], CSV);
+ const result = crossReferenceMappingFiles(
+ [...seeded, makeReal('unknown.jpg', '?')],
+ CSV
+ );
+ expect(
+ (
+ result.find((r) => r.uploadFile.file.name === 'unknown.jpg')!
+ .status as any
+ ).reason
+ ).toBe('notInMappingFile');
+ });
+
+ test('duplicate filenames in CSV produce exactly one row', () => {
+ const csv2: RA<{ readonly matchValue: string; readonly fileName: string }> =
+ [
+ { matchValue: 'A', fileName: 'dup.jpg' },
+ { matchValue: 'B', fileName: 'dup.jpg' },
+ ];
+ const result = crossReferenceMappingFiles(
+ [...crossReferenceMappingFiles([], csv2), makeReal('dup.jpg', 'A')],
+ csv2
+ );
+ expect(result).toHaveLength(1);
+ expect(result[0].uploadFile.file.size).toBe(91);
+ });
+});
+
+describe('prepareMappingFileSelection', () => {
+ const CSV: RA<{ readonly matchValue: string; readonly fileName: string }> = [
+ { matchValue: '000271806', fileName: '271806.jpg' },
+ { matchValue: '000601146', fileName: '601146.jpg' },
+ { matchValue: '000601146', fileName: '601146_2.jpg' },
+ ];
+
+ const makePlaceholder = (name: string, mv: string) =>
+ ({
+ uploadFile: {
+ file: { name, size: 0, type: '' },
+ parsedName: mv,
+ mappingMatchValue: mv,
+ },
+ status: { type: 'cancelled', reason: 'fileMissing' },
+ }) as PartialUploadableFileSpec;
+
+ const makeReal = (name: string, mv: string) =>
+ ({
+ uploadFile: {
+ file: { name, size: 91, type: 'image/jpeg' },
+ parsedName: mv,
+ mappingMatchValue: mv,
+ },
+ }) as PartialUploadableFileSpec;
+
+ test('replaces seeded placeholders with matching uploaded files', () => {
+ const seeded = crossReferenceMappingFiles([], CSV);
+ const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection(
+ seeded,
+ [makeReal('601146.jpg', '000601146')],
+ CSV
+ );
+
+ expect(duplicateFiles).toHaveLength(0);
+ expect(resolvedFiles).toHaveLength(3);
+ expect(
+ resolvedFiles.find((r) => r.uploadFile.file.name === '601146.jpg')
+ ?.uploadFile.file.size
+ ).toBe(91);
+ expect(
+ resolvedFiles.filter((r) => (r.status as any)?.reason === 'fileMissing')
+ ).toHaveLength(2);
+ });
+
+ test('keeps uploaded files that are already real while preserving missing placeholders', () => {
+ const seeded = crossReferenceMappingFiles([], CSV);
+ const { resolvedFiles } = prepareMappingFileSelection(
+ [
+ ...seeded,
+ makeReal('271806.jpg', '000271806'),
+ makePlaceholder('601146_2.jpg', '000601146'),
+ ],
+ [makeReal('601146.jpg', '000601146')],
+ CSV
+ );
+
+ expect(resolvedFiles).toHaveLength(3);
+ expect(
+ resolvedFiles.every(
+ (r) =>
+ r.uploadFile.file.size === 91 ||
+ (r.status as any)?.reason === 'fileMissing'
+ )
+ ).toBe(true);
+ expect(
+ resolvedFiles.filter((r) => r.uploadFile.file.name === '601146.jpg')
+ ).toHaveLength(1);
+ });
+});
+
+describe('end-to-end: seeding then full file selection', () => {
+ // Simulates the real user flow: CSV uploaded → placeholders seeded → user selects files
+ const FULL_CSV: RA<{
+ readonly matchValue: string;
+ readonly fileName: string;
+ }> = [
+ { matchValue: '000271806', fileName: '271806.jpg' },
+ { matchValue: '000601146', fileName: '601146.jpg' },
+ { matchValue: '000601146', fileName: '601146_2.jpg' },
+ { matchValue: '000273074', fileName: '273074.jpg' },
+ { matchValue: '000687972', fileName: '687972.jpg' },
+ { matchValue: '000601108', fileName: '601108.jpg' },
+ { matchValue: '000728604', fileName: '728604.jpg' },
+ { matchValue: '000466309', fileName: '466309.jpg' },
+ { matchValue: '000475938', fileName: '475938.jpg' },
+ { matchValue: '000855732', fileName: '855732.jpg' },
+ ];
+
+ const EXTRA_FILES = ['601766.jpg', '601766_2.jpg', '601766_3.jpg'];
+
+ const allFileNames = [...FULL_CSV.map((r) => r.fileName), ...EXTRA_FILES];
+
+ const makeRealFile = (
+ name: string,
+ mv: string | undefined
+ ): PartialUploadableFileSpec =>
+ ({
+ uploadFile: {
+ file: { name, size: 91, type: 'image/jpeg' } as File,
+ parsedName: mv,
+ mappingMatchValue: mv,
+ mappingFileName: mv !== undefined ? name : undefined,
+ },
+ }) as PartialUploadableFileSpec;
+
+ const countNames = (
+ files: RA
+ ): Map => {
+ const m = new Map();
+ for (const f of files)
+ m.set(f.uploadFile.file.name, (m.get(f.uploadFile.file.name) ?? 0) + 1);
+ return m;
+ };
+
+ test('1: seeding then selecting all files — every name once, mappingMatchValue set', () => {
+ const seeded = crossReferenceMappingFiles([], FULL_CSV);
+ expect(seeded).toHaveLength(10);
+
+ const filesToResolve = allFileNames.map((name) => {
+ const csvRow = FULL_CSV.find((r) => r.fileName === name);
+ return makeRealFile(name, csvRow?.matchValue);
+ });
+
+ const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection(
+ seeded,
+ filesToResolve,
+ FULL_CSV
+ );
+
+ expect(duplicateFiles).toHaveLength(0);
+ expect(resolvedFiles).toHaveLength(13);
+
+ const counts = countNames(resolvedFiles);
+ for (const [, c] of counts) expect(c).toBe(1);
+
+ const csvNames = new Set(FULL_CSV.map((r) => r.fileName));
+ for (const r of resolvedFiles) {
+ if (csvNames.has(r.uploadFile.file.name)) {
+ expect(r.uploadFile.mappingMatchValue).toBeTruthy();
+ expect(r.uploadFile.parsedName).toBeTruthy();
+ } else {
+ expect((r.status as any)?.reason).toBe('notInMappingFile');
+ }
+ }
+ });
+
+ test('2: selecting all files on empty rows (no prior seeding)', () => {
+ const filesToResolve = allFileNames.map((name) => {
+ const csvRow = FULL_CSV.find((r) => r.fileName === name);
+ return makeRealFile(name, csvRow?.matchValue);
+ });
+
+ const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection(
+ [],
+ filesToResolve,
+ FULL_CSV
+ );
+
+ expect(duplicateFiles).toHaveLength(0);
+ expect(resolvedFiles).toHaveLength(13);
+
+ const counts = countNames(resolvedFiles);
+ for (const [, c] of counts) expect(c).toBe(1);
+ });
+
+ test('3: reload scenario — saved rows without mappingMatchValue + re-select files', () => {
+ // Old saved rows: plain objects, File-like, NO mappingMatchValue
+ const savedRows = FULL_CSV.map(
+ (row) =>
+ ({
+ uploadFile: {
+ file: { name: row.fileName, size: 91, type: 'image/jpeg' },
+ parsedName: row.matchValue,
+ },
+ }) as PartialUploadableFileSpec
+ );
+
+ const newFiles = FULL_CSV.map((row) =>
+ makeRealFile(row.fileName, row.matchValue)
+ );
+
+ const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection(
+ savedRows,
+ newFiles,
+ FULL_CSV
+ );
+
+ expect(duplicateFiles).toHaveLength(0);
+ expect(resolvedFiles).toHaveLength(10);
+
+ for (const r of resolvedFiles) {
+ expect(r.uploadFile.mappingMatchValue).toBeTruthy();
+ }
+
+ const counts = countNames(resolvedFiles);
+ for (const [, c] of counts) expect(c).toBe(1);
+ });
+
+ test('4: crossReferenceMappingFiles with old row (no mappingMatchValue) + new file → mappingMatchValue wins', () => {
+ const oldRow = {
+ uploadFile: {
+ file: { name: '271806.jpg', size: 91, type: 'image/jpeg' },
+ parsedName: '000271806',
+ },
+ } as PartialUploadableFileSpec;
+
+ const newFile = makeRealFile('271806.jpg', '000271806');
+
+ const result = crossReferenceMappingFiles([oldRow, newFile], FULL_CSV);
+
+ const matches = result.filter(
+ (r) => r.uploadFile.file.name === '271806.jpg'
+ );
+ expect(matches).toHaveLength(1);
+ expect(matches[0].uploadFile.mappingMatchValue).toBe('000271806');
+ });
+
+ test('5: every output row has mappingMatchValue after cross-reference with mixed data', () => {
+ const seeded = crossReferenceMappingFiles([], FULL_CSV);
+ const realFiles = FULL_CSV.map((row) =>
+ makeRealFile(row.fileName, row.matchValue)
+ );
+
+ const result = crossReferenceMappingFiles(
+ [...seeded, ...realFiles],
+ FULL_CSV
+ );
+
+ expect(result).toHaveLength(10);
+ for (const r of result) {
+ expect(r.uploadFile.mappingMatchValue).toBeTruthy();
+ }
+ });
+});
+
+describe('crossReferenceMappingFiles CSV always wins matchValue', () => {
+ test('CSV matchValue overrides row with undefined mappingMatchValue', () => {
+ const row = {
+ uploadFile: {
+ file: { name: 'test.jpg', size: 91, type: 'image/jpeg' },
+ parsedName: 'oldValue',
+ },
+ } as PartialUploadableFileSpec;
+
+ const csv = [{ fileName: 'test.jpg', matchValue: 'newValue' }];
+ const result = crossReferenceMappingFiles([row], csv);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].uploadFile.mappingMatchValue).toBe('newValue');
+ expect(result[0].uploadFile.parsedName).toBe('newValue');
+ expect(result[0].uploadFile.mappingFileName).toBe('test.jpg');
+ });
+
+ test('CSV matchValue overrides row with different mappingMatchValue', () => {
+ const row = {
+ uploadFile: {
+ file: { name: 'test.jpg', size: 91, type: 'image/jpeg' },
+ parsedName: 'oldValue',
+ mappingMatchValue: 'oldValue',
+ mappingFileName: 'old.jpg',
+ },
+ } as PartialUploadableFileSpec;
+
+ const csv = [{ fileName: 'test.jpg', matchValue: 'CORRECT_VALUE' }];
+ const result = crossReferenceMappingFiles([row], csv);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].uploadFile.mappingMatchValue).toBe('CORRECT_VALUE');
+ expect(result[0].uploadFile.parsedName).toBe('CORRECT_VALUE');
+ expect(result[0].uploadFile.mappingFileName).toBe('test.jpg');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// isMappingFilePlaceholder
+// ---------------------------------------------------------------------------
+describe('isMappingFilePlaceholder', () => {
+ test('returns true for fileMissing cancelled status', () => {
+ expect(
+ isMappingFilePlaceholder({
+ uploadFile: { file: { name: 'a.jpg', size: 0, type: '' } },
+ status: { type: 'cancelled', reason: 'fileMissing' },
+ } as PartialUploadableFileSpec)
+ ).toBe(true);
+ });
+
+ test('returns false for other cancelled reasons', () => {
+ expect(
+ isMappingFilePlaceholder({
+ uploadFile: { file: { name: 'a.jpg', size: 0, type: '' } },
+ status: { type: 'cancelled', reason: 'noMatch' },
+ } as PartialUploadableFileSpec)
+ ).toBe(false);
+ });
+
+ test('returns false for skipped status', () => {
+ expect(
+ isMappingFilePlaceholder({
+ uploadFile: { file: { name: 'a.jpg', size: 0, type: '' } },
+ status: { type: 'skipped', reason: 'fileMissing' },
+ } as PartialUploadableFileSpec)
+ ).toBe(false);
+ });
+
+ test('returns false for success status', () => {
+ expect(
+ isMappingFilePlaceholder({
+ uploadFile: { file: { name: 'a.jpg', size: 0, type: '' } },
+ status: { type: 'success', successType: 'uploaded' },
+ } as PartialUploadableFileSpec)
+ ).toBe(false);
+ });
+
+ test('returns false when status is undefined', () => {
+ expect(
+ isMappingFilePlaceholder({
+ uploadFile: { file: { name: 'a.jpg', size: 91, type: 'image/jpeg' } },
+ } as PartialUploadableFileSpec)
+ ).toBe(false);
+ });
+
+ test('returns false for matched status', () => {
+ expect(
+ isMappingFilePlaceholder({
+ uploadFile: { file: { name: 'a.jpg', size: 91, type: 'image/jpeg' } },
+ status: { type: 'matched', id: 1 },
+ } as PartialUploadableFileSpec)
+ ).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// matchSelectedFiles – existing function, now tested with mapping scenarios
+// ---------------------------------------------------------------------------
+describe('matchSelectedFiles', () => {
+ const mk = (name: string, size = 91) =>
+ ({
+ uploadFile: {
+ file: { name, size, type: 'image/jpeg' },
+ },
+ }) as PartialUploadableFileSpec;
+
+ test('adds new files to empty list', () => {
+ const { resolvedFiles, duplicateFiles } = matchSelectedFiles(
+ [],
+ [mk('a.jpg'), mk('b.jpg')]
+ );
+ expect(resolvedFiles).toHaveLength(2);
+ expect(duplicateFiles).toHaveLength(0);
+ });
+
+ test('detects exact duplicate when existing entry has File instance', () => {
+ const content = new Uint8Array(91);
+ const file = new File([content], 'a.jpg', { type: 'image/jpeg' });
+ const existing = [
+ { uploadFile: { file } },
+ ] as RA;
+ const { resolvedFiles, duplicateFiles } = matchSelectedFiles(existing, [
+ mk('a.jpg'),
+ ]);
+ expect(resolvedFiles).toHaveLength(1);
+ expect(duplicateFiles).toHaveLength(1);
+ expect(duplicateFiles[0].uploadFile.file.name).toBe('a.jpg');
+ });
+
+ test('placeholder (0 bytes) does NOT match real file (91 bytes) — both kept', () => {
+ const placeholder = mk('a.jpg', 0);
+ const real = mk('a.jpg', 91);
+ const { resolvedFiles } = matchSelectedFiles([placeholder], [real]);
+ expect(resolvedFiles).toHaveLength(2);
+ });
+
+ test('replaces non-File existing entry with new entry', () => {
+ // Simulating a serialized row (plain object, not File instance)
+ const saved = {
+ uploadFile: {
+ file: { name: 'a.jpg', size: 91, type: 'image/jpeg' },
+ },
+ } as PartialUploadableFileSpec;
+ const real = mk('a.jpg', 91);
+ const { resolvedFiles, duplicateFiles } = matchSelectedFiles(
+ [saved],
+ [real]
+ );
+ expect(resolvedFiles).toHaveLength(1);
+ expect(duplicateFiles).toHaveLength(0);
+ // The new entry's uploadFile replaced the old one
+ expect(resolvedFiles[0].uploadFile).toBe(real.uploadFile);
+ });
+
+ test('preserves success status when replacing', () => {
+ const saved = {
+ uploadFile: {
+ file: { name: 'a.jpg', size: 91, type: 'image/jpeg' } as File,
+ },
+ status: { type: 'success', successType: 'uploaded' },
+ } as PartialUploadableFileSpec;
+ const real = mk('a.jpg', 91);
+ const { resolvedFiles } = matchSelectedFiles([saved], [real]);
+ expect(resolvedFiles).toHaveLength(1);
+ expect(resolvedFiles[0].status?.type).toBe('success');
+ });
+
+ test('preserves skipped/alreadyUploaded status when replacing', () => {
+ const saved = {
+ uploadFile: {
+ file: { name: 'a.jpg', size: 91, type: 'image/jpeg' } as File,
+ },
+ status: { type: 'skipped', reason: 'alreadyUploaded' },
+ } as PartialUploadableFileSpec;
+ const real = mk('a.jpg', 91);
+ const { resolvedFiles } = matchSelectedFiles([saved], [real]);
+ expect(resolvedFiles).toHaveLength(1);
+ expect(resolvedFiles[0].status?.type).toBe('skipped');
+ });
+
+ test('takes new cancelled status when replacing non-success entry', () => {
+ const saved = {
+ uploadFile: {
+ file: { name: 'a.jpg', size: 91, type: 'image/jpeg' } as File,
+ },
+ status: { type: 'cancelled', reason: 'noMatch' },
+ } as PartialUploadableFileSpec;
+ const real = {
+ ...mk('a.jpg', 91),
+ status: { type: 'cancelled', reason: 'fileMissing' },
+ } as PartialUploadableFileSpec;
+ const { resolvedFiles } = matchSelectedFiles([saved], [real]);
+ expect(resolvedFiles).toHaveLength(1);
+ expect((resolvedFiles[0].status as any)?.reason).toBe('fileMissing');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// crossReferenceMappingFiles – additional edge cases
+// ---------------------------------------------------------------------------
+describe('crossReferenceMappingFiles – edge cases', () => {
+ const mk = (
+ name: string,
+ size: number,
+ mv?: string
+ ): PartialUploadableFileSpec =>
+ ({
+ uploadFile: {
+ file: { name, size, type: size > 0 ? 'image/jpeg' : '' },
+ parsedName: mv,
+ mappingMatchValue: mv,
+ mappingFileName: mv !== undefined ? name : undefined,
+ },
+ }) as PartialUploadableFileSpec;
+
+ test('empty CSV returns empty result', () => {
+ expect(crossReferenceMappingFiles([], [])).toHaveLength(0);
+ });
+
+ test('empty uploadables with CSV produces all placeholders', () => {
+ const csv = [
+ { fileName: 'a.jpg', matchValue: '1' },
+ { fileName: 'b.jpg', matchValue: '2' },
+ ];
+ const result = crossReferenceMappingFiles([], csv);
+ expect(result).toHaveLength(2);
+ expect(
+ result.every((r) => (r.status as any)?.reason === 'fileMissing')
+ ).toBe(true);
+ expect(result[0].uploadFile.mappingMatchValue).toBe('1');
+ expect(result[1].uploadFile.mappingMatchValue).toBe('2');
+ });
+
+ test('uploadables not in CSV are flagged notInMappingFile', () => {
+ const csv = [{ fileName: 'a.jpg', matchValue: '1' }];
+ const result = crossReferenceMappingFiles(
+ [mk('a.jpg', 91, '1'), mk('b.jpg', 91), mk('c.jpg', 91)],
+ csv
+ );
+ const b = result.find((r) => r.uploadFile.file.name === 'b.jpg');
+ const c = result.find((r) => r.uploadFile.file.name === 'c.jpg');
+ expect((b?.status as any)?.reason).toBe('notInMappingFile');
+ expect((c?.status as any)?.reason).toBe('notInMappingFile');
+ // 'a' should be present and matched
+ expect(
+ result.find((r) => r.uploadFile.file.name === 'a.jpg')?.status
+ ).toBeUndefined();
+ });
+
+ test('already-uploaded file (with attachmentId) is preserved through cross-reference', () => {
+ const csv = [{ fileName: 'a.jpg', matchValue: '1' }];
+ const uploaded = {
+ uploadFile: {
+ file: { name: 'a.jpg', size: 91, type: 'image/jpeg' },
+ parsedName: '1',
+ mappingMatchValue: '1',
+ },
+ attachmentId: 42,
+ status: { type: 'success', successType: 'uploaded' },
+ } as PartialUploadableFileSpec;
+
+ const result = crossReferenceMappingFiles([uploaded], csv);
+ expect(result).toHaveLength(1);
+ expect(result[0].attachmentId).toBe(42);
+ expect(result[0].status?.type).toBe('success');
+ });
+
+ test('byName prefers real File (size 91) over plain object (size 91)', () => {
+ const csv = [{ fileName: 'a.jpg', matchValue: '1' }];
+ // Plain object first (would be first in byName), then real file
+ const plainObj = {
+ uploadFile: {
+ file: { name: 'a.jpg', size: 91, type: 'image/jpeg' },
+ parsedName: '1',
+ mappingMatchValue: 'wrong',
+ },
+ } as PartialUploadableFileSpec;
+ const realFile = mk('a.jpg', 91, '1');
+ // Give realFile a distinguishable property — it has mappingMatchValue set correctly
+ const result = crossReferenceMappingFiles([plainObj, realFile], csv);
+ expect(result).toHaveLength(1);
+ // mappingMatchValue from CSV overrides regardless — both should be '1'
+ expect(result[0].uploadFile.mappingMatchValue).toBe('1');
+ });
+
+ test('multiple CSV rows with same filename produce exactly one output row', () => {
+ const csv = [
+ { fileName: 'a.jpg', matchValue: '1' },
+ { fileName: 'a.jpg', matchValue: '1' },
+ { fileName: 'b.jpg', matchValue: '2' },
+ ];
+ const result = crossReferenceMappingFiles([], csv);
+ expect(result).toHaveLength(2);
+ const a = result.filter((r) => r.uploadFile.file.name === 'a.jpg');
+ expect(a).toHaveLength(1);
+ });
+
+ test('triple duplicate filename in CSV produces one row', () => {
+ const csv = [
+ { fileName: 'x.jpg', matchValue: 'A' },
+ { fileName: 'x.jpg', matchValue: 'A' },
+ { fileName: 'x.jpg', matchValue: 'A' },
+ ];
+ const result = crossReferenceMappingFiles([mk('x.jpg', 91, 'A')], csv);
+ expect(result).toHaveLength(1);
+ });
+
+ test('placeholder entries have correct parsedName and mappingMatchValue from CSV', () => {
+ const csv = [{ fileName: 'test.jpg', matchValue: 'ABC-123' }];
+ const result = crossReferenceMappingFiles([], csv);
+ expect(result).toHaveLength(1);
+ expect(result[0].uploadFile.parsedName).toBe('ABC-123');
+ expect(result[0].uploadFile.mappingMatchValue).toBe('ABC-123');
+ expect(result[0].uploadFile.file.size).toBe(0);
+ });
+
+ test('status is cleared when real file replaces placeholder (no error status)', () => {
+ const csv = [{ fileName: 'a.jpg', matchValue: '1' }];
+ const seeded = crossReferenceMappingFiles([], csv);
+ expect((seeded[0].status as any)?.reason).toBe('fileMissing');
+
+ const result = crossReferenceMappingFiles(
+ [...seeded, mk('a.jpg', 91, '1')],
+ csv
+ );
+ expect(result).toHaveLength(1);
+ expect(result[0].status).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// prepareMappingFileSelection – additional edge cases
+// ---------------------------------------------------------------------------
+describe('prepareMappingFileSelection – edge cases', () => {
+ const CSV = [
+ { matchValue: 'A', fileName: 'a.jpg' },
+ { matchValue: 'B', fileName: 'b.jpg' },
+ ];
+
+ const mk = (name: string, size: number, mv?: string) =>
+ ({
+ uploadFile: {
+ file: { name, size, type: size > 0 ? 'image/jpeg' : '' },
+ parsedName: mv,
+ mappingMatchValue: mv,
+ mappingFileName: mv !== undefined ? name : undefined,
+ },
+ ...(size === 0
+ ? {
+ status: {
+ type: 'cancelled' as const,
+ reason: 'fileMissing' as const,
+ },
+ }
+ : {}),
+ }) as PartialUploadableFileSpec;
+
+ test('empty inputs produce only placeholders from CSV', () => {
+ const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection(
+ [],
+ [],
+ CSV
+ );
+ expect(duplicateFiles).toHaveLength(0);
+ expect(resolvedFiles).toHaveLength(2);
+ expect(
+ resolvedFiles.every((r) => (r.status as any)?.reason === 'fileMissing')
+ ).toBe(true);
+ });
+
+ test('existing real files not in new selection are preserved', () => {
+ const existing = [mk('a.jpg', 91, 'A')];
+ const { resolvedFiles } = prepareMappingFileSelection(
+ existing,
+ [mk('b.jpg', 91, 'B')],
+ CSV
+ );
+ expect(resolvedFiles).toHaveLength(2);
+ expect(
+ resolvedFiles.find((r) => r.uploadFile.file.name === 'a.jpg')
+ ).toBeTruthy();
+ expect(
+ resolvedFiles.find((r) => r.uploadFile.file.name === 'b.jpg')
+ ).toBeTruthy();
+ });
+
+ test('existing placeholders are NOT preserved', () => {
+ const placeholders = [mk('a.jpg', 0, 'A'), mk('b.jpg', 0, 'B')];
+ const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection(
+ placeholders,
+ [mk('a.jpg', 91, 'A')],
+ CSV
+ );
+ expect(duplicateFiles).toHaveLength(0);
+ expect(resolvedFiles).toHaveLength(2);
+ // a.jpg should now be real (91 bytes), b.jpg should be a placeholder
+ const a = resolvedFiles.find((r) => r.uploadFile.file.name === 'a.jpg');
+ const b = resolvedFiles.find((r) => r.uploadFile.file.name === 'b.jpg');
+ expect(a?.uploadFile.file.size).toBe(91);
+ expect((b?.status as any)?.reason).toBe('fileMissing');
+ });
+
+ test('duplicate files (same name/size/type) are detected', () => {
+ // Create a real File with 91 bytes so it matches the new entry
+ const content = new Uint8Array(91);
+ const existingFile = new File([content], 'a.jpg', { type: 'image/jpeg' });
+ const existing = [
+ {
+ uploadFile: {
+ file: existingFile,
+ parsedName: 'A',
+ mappingMatchValue: 'A',
+ },
+ },
+ ] as RA;
+ const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection(
+ existing,
+ [mk('a.jpg', 91, 'A')],
+ CSV
+ );
+ expect(duplicateFiles).toHaveLength(1);
+ expect(resolvedFiles).toHaveLength(2); // a (existing) + b (placeholder)
+ });
+
+ test('files with attachmentId are preserved through selection', () => {
+ const existing = [
+ {
+ ...mk('a.jpg', 91, 'A'),
+ attachmentId: 99,
+ status: { type: 'success' as const, successType: 'uploaded' as const },
+ },
+ ] as RA;
+
+ const { resolvedFiles } = prepareMappingFileSelection(
+ existing,
+ [mk('b.jpg', 91, 'B')],
+ CSV
+ );
+ const a = resolvedFiles.find((r) => r.uploadFile.file.name === 'a.jpg');
+ expect(a?.attachmentId).toBe(99);
+ expect(a?.status?.type).toBe('success');
+ });
+
+ test('old rows without mappingMatchValue get it from CSV after selection', () => {
+ const savedRows = CSV.map(
+ (row) =>
+ ({
+ uploadFile: {
+ file: { name: row.fileName, size: 91, type: 'image/jpeg' },
+ parsedName: row.matchValue,
+ },
+ }) as PartialUploadableFileSpec
+ );
+
+ const { resolvedFiles } = prepareMappingFileSelection(savedRows, [], CSV);
+
+ for (const r of resolvedFiles) {
+ expect(r.uploadFile.mappingMatchValue).toBeTruthy();
+ expect(r.uploadFile.parsedName).toBeTruthy();
+ }
+ });
+});
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/importPaths.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/importPaths.ts
index 0a6a4a90b05..dfc0ac1a2d0 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/importPaths.ts
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/importPaths.ts
@@ -42,4 +42,8 @@ export const staticAttachmentImportPaths: IR> = {
agentGUID: createItem('Agent', 'guid'),
collectingTripName: createItem('CollectingTrip', 'collectingTripName'),
deaccessionNumber: createItem('Deaccession', 'deaccessionNumber'),
+ fieldNotebookName: createItem('FieldNotebook', 'name'),
+ fieldNotebookPageNumber: createItem('FieldNotebookPage', 'pageNumber'),
+ fieldNotebookDescription: createItem('FieldNotebookPage', 'description'),
+ disposalNumber: createItem('Disposal', 'disposalNumber'),
};
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts
index b39a0addb72..6b0f9c3811f 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts
@@ -10,6 +10,18 @@ import type { PartialAttachmentUploadSpec } from './Import';
import type { staticAttachmentImportPaths } from './importPaths';
import type { keyLocalizationMapAttachment } from './utils';
+export type MatchingMode = 'mappingFile';
+
+export type MappingFileColumns = {
+ readonly matchValueColumnIndex: number;
+ readonly fileNameColumnIndex: number;
+};
+
+export type MappingFileRow = {
+ readonly matchValue: string;
+ readonly fileName: string;
+};
+
export type UploadAttachmentSpec = {
readonly token: string;
readonly attachmentLocation: string;
@@ -62,6 +74,10 @@ export type BoundFile = Pick;
export type UnBoundFile = {
readonly file: BoundFile | File;
readonly parsedName?: string;
+ /** Match value from mapping file CSV (used in mapping-file mode) */
+ readonly mappingMatchValue?: string;
+ /** File name from mapping file CSV (used in mapping-file mode) */
+ readonly mappingFileName?: string;
};
export type AttachmentWorkProgress = {
@@ -102,6 +118,7 @@ export type AttachmentDatasetBrief = DatasetBriefBase & {
| 'uploading'
| 'uploadInterrupted'
| 'validating';
+ readonly matchingmode?: MatchingMode | null;
};
export type AttachmentDataSetPlan = AttachmentDatasetBrief & {
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/useEagerDataset.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/useEagerDataset.ts
index 437bb9c25dc..41ce5c7cf1a 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/useEagerDataset.ts
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/useEagerDataset.ts
@@ -89,7 +89,13 @@ export function useEagerDataSet(baseDataSet: AttachmentDataSet): {
needsSaved: baseDataSet.uploaderstatus !== 'main',
rows: baseDataSet.rows ?? [],
save: false,
- uploadplan: generateUploadSpec(baseDataSet.uploadplan.staticPathKey),
+ uploadplan: {
+ ...generateUploadSpec(baseDataSet.uploadplan.staticPathKey),
+ matchingMode:
+ baseDataSet.matchingmode ?? baseDataSet.uploadplan.matchingMode,
+ mappingFileColumns: baseDataSet.uploadplan.mappingFileColumns,
+ mappingFileData: baseDataSet.uploadplan.mappingFileData,
+ },
uploadresult:
baseDataSet.uploadresult === undefined ||
baseDataSet.uploadresult === null
diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts
index 7030ecf7e33..d6569c6dad3 100644
--- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts
+++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts
@@ -35,7 +35,11 @@ import { makeQueryField } from '../QueryBuilder/fromTree';
import type { QueryFieldWithPath } from '../Statistics/types';
import type { AttachmentUploadSpec } from './Import';
import { staticAttachmentImportPaths } from './importPaths';
-import type { AttachmentStatus, PartialUploadableFileSpec } from './types';
+import type {
+ AttachmentStatus,
+ MappingFileRow,
+ PartialUploadableFileSpec,
+} from './types';
export type ResolvedAttachmentRecord =
| State<
@@ -63,10 +67,16 @@ const resolveAttachmentMatch = (
export function resolveAttachmentRecord(
matchedId: RA | undefined,
disambiguated: number | undefined,
- parsedName: string | undefined
+ parsedName: string | undefined,
+ isMappingMode: boolean = false
): ResolvedAttachmentRecord {
- if (parsedName === undefined)
- return { type: 'invalid', reason: 'incorrectFormatter' };
+ if (parsedName === undefined) {
+ // In mapping mode, undefined parsedName means not in mapping file
+ return {
+ type: 'invalid',
+ reason: isMappingMode ? 'notInMappingFile' : 'incorrectFormatter',
+ };
+ }
if (matchedId === undefined)
return { type: 'valid', reason: 'correctlyFormatted' };
return resolveAttachmentMatch(matchedId, disambiguated);
@@ -157,6 +167,13 @@ type MatchSelectedFiles = {
readonly resolvedFiles: RA;
readonly duplicateFiles: RA;
};
+
+export const isMappingFilePlaceholder = (
+ uploadable: PartialUploadableFileSpec
+): boolean =>
+ uploadable.status?.type === 'cancelled' &&
+ uploadable.status.reason === 'fileMissing';
+
export const matchSelectedFiles = (
previousUploadables: RA,
filesToResolve: RA
@@ -222,6 +239,136 @@ export const matchSelectedFiles = (
}
);
+export const prepareMappingFileSelection = (
+ previousUploadables: RA,
+ filesToResolve: RA,
+ mappingData: RA
+): MatchSelectedFiles => {
+ const previousRealFiles = previousUploadables.filter(
+ (uploadable) => !isMappingFilePlaceholder(uploadable)
+ );
+ const { resolvedFiles, duplicateFiles } = matchSelectedFiles(
+ previousRealFiles,
+ filesToResolve
+ );
+
+ return {
+ resolvedFiles: crossReferenceMappingFiles(resolvedFiles, mappingData),
+ duplicateFiles,
+ };
+};
+
+/**
+ * Cross-reference uploaded files against the mapping file CSV data.
+ * Builds the result from scratch: for each CSV row, picks the best
+ * available file (real upload > placeholder). Flags leftover files
+ * not in the mapping. Guarantees no duplicate filenames.
+ */
+export function crossReferenceMappingFiles(
+ uploadableFiles: RA,
+ mappingData: RA
+): RA {
+ // Build a filename → best-row map (prefer real files over placeholders)
+ const byName = new Map();
+ for (const f of uploadableFiles) {
+ const existing = byName.get(f.uploadFile.file.name);
+ if (existing === undefined) {
+ byName.set(f.uploadFile.file.name, f);
+ } else {
+ // Keep the "better" entry: real file > placeholder,
+ // File instance > plain object (serialized from server),
+ // row with mappingMatchValue > row without
+ const existingIsPlaceholder =
+ existing.status?.type === 'cancelled' &&
+ existing.status.reason === 'fileMissing';
+ const newIsPlaceholder =
+ f.status?.type === 'cancelled' && f.status.reason === 'fileMissing';
+ if (
+ (existingIsPlaceholder && !newIsPlaceholder) ||
+ (!(existing.uploadFile.file instanceof File) &&
+ f.uploadFile.file instanceof File)
+ ) {
+ byName.set(f.uploadFile.file.name, f);
+ } else if (!existingIsPlaceholder && !newIsPlaceholder) {
+ // Keep the first entry but flag it unless already uploaded.
+ byName.set(f.uploadFile.file.name, {
+ ...existing,
+ ...(existing.attachmentId === undefined &&
+ existing.status?.type !== 'success'
+ ? {
+ status: {
+ type: 'cancelled' as const,
+ reason: 'duplicateInMappingFile' as const,
+ },
+ }
+ : {}),
+ });
+ }
+ }
+ }
+
+ const result: PartialUploadableFileSpec[] = [];
+ const matchedFileNames = new Set();
+
+ // For each CSV row, emit the best available file or a placeholder
+ for (const row of mappingData) {
+ const existing = byName.get(row.fileName);
+
+ if (existing !== undefined) {
+ // Only emit once per filename even if CSV has duplicate rows
+ if (matchedFileNames.has(row.fileName)) continue;
+ matchedFileNames.add(row.fileName);
+ result.push({
+ ...existing,
+ uploadFile: {
+ ...existing.uploadFile,
+ // Always take the match value from the CSV – it is the source of truth
+ parsedName: row.matchValue,
+ mappingMatchValue: row.matchValue,
+ mappingFileName: row.fileName,
+ },
+ });
+ } else {
+ // File not yet uploaded — add placeholder (once per filename)
+ if (matchedFileNames.has(row.fileName)) continue;
+ matchedFileNames.add(row.fileName);
+ result.push({
+ uploadFile: {
+ file: { name: row.fileName, size: 0, type: '' },
+ parsedName: row.matchValue,
+ mappingMatchValue: row.matchValue,
+ mappingFileName: row.fileName,
+ },
+ status: {
+ type: 'cancelled' as const,
+ reason: 'fileMissing' as const,
+ },
+ });
+ }
+ }
+
+ // Flag any uploaded files not referenced by the CSV
+ for (const [name, file] of byName) {
+ if (!matchedFileNames.has(name)) {
+ const alreadyAttached =
+ file.attachmentId !== undefined || file.status?.type === 'success';
+ result.push({
+ ...file,
+ ...(alreadyAttached
+ ? {}
+ : {
+ status: {
+ type: 'cancelled' as const,
+ reason: 'notInMappingFile' as const,
+ },
+ }),
+ });
+ }
+ }
+
+ return result;
+}
+
export function resolveFileNames(
fileName: string,
getFormatted: (rawName: number | string | undefined) => string | undefined,
@@ -479,6 +626,9 @@ export const keyLocalizationMapAttachment = {
errorFetchingRecord: attachmentsText.errorFetchingRecord(),
saveError: attachmentsText.errorSavingRecord(),
attachmentUploadError: attachmentsText.attachmentUploadError(),
+ fileMissing: attachmentsText.fileMissing(),
+ notInMappingFile: attachmentsText.notInMappingFile(),
+ duplicateInMappingFile: attachmentsText.duplicateInMappingFile(),
} as const;
export function resolveAttachmentStatus(
diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/__tests__/parseCatalogNumbers.test.ts b/specifyweb/frontend/js_src/lib/components/BatchIdentify/__tests__/parseCatalogNumbers.test.ts
new file mode 100644
index 00000000000..2a5208abb23
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/__tests__/parseCatalogNumbers.test.ts
@@ -0,0 +1,45 @@
+import {
+ parseCatalogNumberEntries,
+ parseCatalogNumberRanges,
+ tokenizeCatalogEntry,
+} from '../parseCatalogNumbers';
+
+describe('parseCatalogNumberEntries', () => {
+ test('trims lines and removes empty lines', () => {
+ expect(parseCatalogNumberEntries('\n 0001 \n\n0002\n')).toEqual([
+ '0001',
+ '0002',
+ ]);
+ });
+});
+
+describe('tokenizeCatalogEntry', () => {
+ test('treats non-numeric characters as delimiters except dash', () => {
+ expect(
+ tokenizeCatalogEntry('SEMC000271806,SEMC000687972;000601108')
+ ).toEqual([271_806, 687_972, 601_108]);
+ });
+
+ test('retains dash as a range token', () => {
+ expect(tokenizeCatalogEntry('0001 - 0150')).toEqual([1, '-', 150]);
+ });
+});
+
+describe('parseCatalogNumberRanges', () => {
+ test('parses single catalog numbers split by non-numeric delimiters', () => {
+ expect(
+ parseCatalogNumberRanges(['SEMC000271806 SEMC000687972 SEMC000601108'])
+ ).toEqual([
+ [271_806, 271_806],
+ [687_972, 687_972],
+ [601_108, 601_108],
+ ]);
+ });
+
+ test('parses ranges and normalizes reversed ranges', () => {
+ expect(parseCatalogNumberRanges(['0001 - 0150', '0150-0001'])).toEqual([
+ [1, 150],
+ [1, 150],
+ ]);
+ });
+});
diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx
new file mode 100644
index 00000000000..e5ba16b7a28
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx
@@ -0,0 +1,1058 @@
+import React from 'react';
+
+import { useValidation } from '../../hooks/useValidation';
+import { batchIdentifyText } from '../../localization/batchIdentify';
+import { commonText } from '../../localization/common';
+import { queryText } from '../../localization/query';
+import { resourcesText } from '../../localization/resources';
+import { ajax } from '../../utils/ajax';
+import { f } from '../../utils/functools';
+import type { RA, WritableArray } from '../../utils/types';
+import { localized } from '../../utils/types';
+import { H3 } from '../Atoms';
+import { Button } from '../Atoms/Button';
+import { DataEntry } from '../Atoms/DataEntry';
+import { icons } from '../Atoms/Icons';
+import { Link } from '../Atoms/Link';
+import { LoadingContext, ReadOnlyContext } from '../Core/Contexts';
+import { fetchCollection } from '../DataModel/collection';
+import type { SerializedResource } from '../DataModel/helperTypes';
+import {
+ createResource,
+ getResourceApiUrl,
+ strictIdFromUrl,
+} from '../DataModel/resource';
+import { serializeResource } from '../DataModel/serializers';
+import { tables } from '../DataModel/tables';
+import type { RecordSet } from '../DataModel/types';
+import { ResourceView } from '../Forms/ResourceView';
+import { RecordSelectorFromIds } from '../FormSliders/RecordSelectorFromIds';
+import { userInformation } from '../InitialContext/userInformation';
+import { AutoGrowTextArea } from '../Molecules/AutoGrowTextArea';
+import { Dialog, dialogClassNames } from '../Molecules/Dialog';
+import { hasToolPermission } from '../Permissions/helpers';
+import { ProtectedTable } from '../Permissions/PermissionDenied';
+import type { QueryField } from '../QueryBuilder/helpers';
+import { QueryResultsWrapper } from '../QueryBuilder/ResultsWrapper';
+import { TreeDefinitionContext } from '../QueryComboBox/useTreeData';
+import { OverlayContext } from '../Router/Router';
+import { useSearchDialog } from '../SearchDialog';
+import { RecordSetsDialog } from '../Toolbar/RecordSets';
+import {
+ parseCatalogNumberEntries,
+ parseCatalogNumberRanges,
+} from './parseCatalogNumbers';
+
+type BatchIdentifyResolveResponse = {
+ readonly collectionObjectIds: RA;
+ readonly currentDeterminationIds: RA;
+ readonly unmatchedCatalogNumbers: RA;
+ readonly hasMixedTaxonTrees: boolean;
+ readonly taxonTreeGroups: RA<{
+ readonly taxonTreeDefId: number | null;
+ readonly taxonTreeName: string | null;
+ readonly collectionObjectIds: RA;
+ readonly catalogNumbers: RA;
+ readonly collectionObjectTypeNames: RA;
+ }>;
+};
+
+type BatchIdentifyCollectionObjectValidationResponse = Pick<
+ BatchIdentifyResolveResponse,
+ 'collectionObjectIds' | 'hasMixedTaxonTrees' | 'taxonTreeGroups'
+>;
+
+type BatchIdentifySaveResponse = {
+ readonly createdCount: number;
+ readonly collectionObjectIds: RA;
+ readonly determinationIds: RA;
+};
+
+type Step = 'catalogNumbers' | 'determination';
+
+const liveValidationDebounceMs = 1000;
+const collectionObjectViewPathRe =
+ /\/specify\/view\/collectionobject\/(\d+)\/?$/i;
+
+const queryFilterDefaults = {
+ isNot: false,
+ isStrict: false,
+} as const;
+
+const anyFilter = {
+ ...queryFilterDefaults,
+ type: 'any',
+ startValue: '',
+} as const;
+
+const buildPreviewFields = (
+ collectionObjectIds: RA
+): RA => [
+ {
+ id: 0,
+ mappingPath: ['collectionObjectId'],
+ sortType: undefined,
+ isDisplay: false,
+ filters: [
+ {
+ ...queryFilterDefaults,
+ type: 'in',
+ startValue: collectionObjectIds.join(', '),
+ },
+ ],
+ },
+ {
+ id: 1,
+ mappingPath: ['catalogNumber'],
+ sortType: 'ascending',
+ isDisplay: true,
+ filters: [anyFilter],
+ },
+ {
+ id: 2,
+ mappingPath: ['determinations', '#1', 'determinedDate'],
+ sortType: undefined,
+ isDisplay: true,
+ filters: [anyFilter],
+ },
+ {
+ id: 3,
+ mappingPath: ['determinations', '#1', 'typeStatusName'],
+ sortType: undefined,
+ isDisplay: true,
+ filters: [anyFilter],
+ },
+ {
+ id: 4,
+ mappingPath: ['determinations', '#1', 'preferredTaxon', 'fullname'],
+ sortType: undefined,
+ isDisplay: true,
+ filters: [anyFilter],
+ },
+ {
+ id: 5,
+ mappingPath: ['determinations', '#1', 'taxon', 'fullname'],
+ sortType: undefined,
+ isDisplay: true,
+ filters: [anyFilter],
+ },
+ {
+ id: 6,
+ mappingPath: ['determinations', '#1', 'isCurrent'],
+ sortType: undefined,
+ isDisplay: false,
+ filters: [
+ {
+ ...queryFilterDefaults,
+ type: 'trueOrNull',
+ startValue: '',
+ },
+ ],
+ },
+];
+
+const createBatchIdentifyPreviewQuery = () =>
+ new tables.SpQuery.Resource()
+ .set('name', batchIdentifyText.previewQueryName())
+ .set('contextName', tables.CollectionObject.name)
+ .set('contextTableId', tables.CollectionObject.tableId)
+ .set('selectDistinct', false)
+ .set('smushed', false)
+ .set('countOnly', false)
+ .set('formatAuditRecIds', false)
+ .set('specifyUser', userInformation.resource_uri)
+ .set('isFavorite', true)
+ .set('ordinal', 32_767);
+
+const fetchRecordSetCollectionObjectIds = async (
+ recordSetId: number
+): Promise> => {
+ const limit = 2000;
+ let offset = 0;
+ let totalCount = 0;
+ const collectionObjectIds: WritableArray = [];
+
+ do {
+ const { records, totalCount: fetchedTotalCount } = await fetchCollection(
+ 'RecordSetItem',
+ {
+ recordSet: recordSetId,
+ domainFilter: false,
+ limit,
+ offset,
+ orderBy: 'id',
+ }
+ );
+ totalCount = fetchedTotalCount;
+ collectionObjectIds.push(...records.map(({ recordId }) => recordId));
+ offset += records.length;
+ if (records.length === 0) break;
+ } while (offset < totalCount);
+
+ return f.unique(collectionObjectIds);
+};
+
+const createBatchIdentifyRecordSet = async (
+ collectionObjectIds: RA
+): Promise | undefined> => {
+ if (
+ collectionObjectIds.length === 0 ||
+ !hasToolPermission('recordSets', 'create')
+ )
+ return undefined;
+
+ const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' ');
+ return createResource('RecordSet', {
+ name: `${batchIdentifyText.updatedRecordSet()} ${timestamp}`,
+ version: 1,
+ type: 0,
+ dbTableId: tables.CollectionObject.tableId,
+ // @ts-expect-error Inline RecordSetItem creation is supported by the API
+ recordSetItems: f.unique(collectionObjectIds).map((recordId) => ({
+ recordId,
+ })),
+ });
+};
+
+const createDetermination = () =>
+ new tables.Determination.Resource().set('isCurrent', true);
+
+export function BatchIdentifyOverlay(): JSX.Element {
+ const handleClose = React.useContext(OverlayContext);
+ return (
+
+
+
+
+
+ );
+}
+
+function BatchIdentifyDialog({
+ onClose: handleClose,
+}: {
+ readonly onClose: () => void;
+}): JSX.Element {
+ const loading = React.useContext(LoadingContext);
+ const { validationRef, setValidation } = useValidation();
+ const recordSetLabel = tables.RecordSet.label;
+ const canCreateRecordSet = hasToolPermission('recordSets', 'create');
+
+ const [step, setStep] = React.useState('catalogNumbers');
+ const [catalogNumbers, setCatalogNumbers] = React.useState('');
+ const [isIdentifying, setIsIdentifying] = React.useState(false);
+ const [isLiveValidating, setIsLiveValidating] = React.useState(false);
+ const [isRecordSetDialogOpen, setIsRecordSetDialogOpen] =
+ React.useState(false);
+ const [isVerificationDialogOpen, setIsVerificationDialogOpen] =
+ React.useState();
+ const [collectionObjectIds, setCollectionObjectIds] = React.useState<
+ RA
+ >([]);
+ const [createdRecordSet, setCreatedRecordSet] = React.useState<
+ SerializedResource | undefined
+ >(undefined);
+ const [identifiedCollectionObjectIds, setIdentifiedCollectionObjectIds] =
+ React.useState>([]);
+ const [isCreatingRecordSet, setIsCreatingRecordSet] = React.useState(false);
+ const [isBrowseAfterIdentifyOpen, setIsBrowseAfterIdentifyOpen] =
+ React.useState(false);
+ const [showSuccessDialog, setShowSuccessDialog] = React.useState(false);
+ const [resolvedCollectionObjectIds, setResolvedCollectionObjectIds] =
+ React.useState>([]);
+ const [validatedCatalogNumbersKey, setValidatedCatalogNumbersKey] =
+ React.useState('');
+ const [unmatchedCatalogNumbers, setUnmatchedCatalogNumbers] = React.useState<
+ RA
+ >([]);
+ const [hasMixedTaxonTrees, setHasMixedTaxonTrees] = React.useState(false);
+ const [taxonTreeGroups, setTaxonTreeGroups] = React.useState<
+ BatchIdentifyResolveResponse['taxonTreeGroups']
+ >([]);
+ const [recordSetMixedTreeGroups, setRecordSetMixedTreeGroups] =
+ React.useState([]);
+ const [selectedTaxonTreeDefUri, setSelectedTaxonTreeDefUri] = React.useState<
+ string | undefined
+ >(undefined);
+ const [
+ searchTreeCollectionObjectTypeIds,
+ setSearchTreeCollectionObjectTypeIds,
+ ] = React.useState | undefined>(undefined);
+ const [previewRunCount, setPreviewRunCount] = React.useState(0);
+ const [selectedPreviewRows, setSelectedPreviewRows] = React.useState<
+ ReadonlySet
+ >(new Set());
+ const [determination, setDetermination] = React.useState(createDetermination);
+ const [previewQuery] = React.useState(createBatchIdentifyPreviewQuery);
+ const liveValidationRequestTokenRef = React.useRef(0);
+ const liveValidationTimeoutRef = React.useRef<
+ ReturnType | undefined
+ >(undefined);
+ const stepRef = React.useRef(step);
+
+ const catalogNumberEntries = React.useMemo(
+ () => parseCatalogNumberEntries(catalogNumbers),
+ [catalogNumbers]
+ );
+ const catalogNumberRanges = React.useMemo(
+ () => parseCatalogNumberRanges(catalogNumberEntries),
+ [catalogNumberEntries]
+ );
+ const previewFields = React.useMemo(
+ () => buildPreviewFields(collectionObjectIds),
+ [collectionObjectIds]
+ );
+ const selectedPreviewRowsCount = selectedPreviewRows.size;
+ const catalogNumbersKey = React.useMemo(
+ () => catalogNumberEntries.join('\n'),
+ [catalogNumberEntries]
+ );
+ const isCatalogNumbersVerified =
+ validatedCatalogNumbersKey === catalogNumbersKey;
+
+ React.useEffect(() => {
+ if (selectedTaxonTreeDefUri === undefined) {
+ setSearchTreeCollectionObjectTypeIds(undefined);
+ return;
+ }
+ const selectedTaxonTreeDefId = strictIdFromUrl(selectedTaxonTreeDefUri);
+ let isCancelled = false;
+ setSearchTreeCollectionObjectTypeIds(undefined);
+ void fetchCollection('CollectionObjectType', {
+ domainFilter: true,
+ limit: 0,
+ orderBy: 'id',
+ taxonTreeDef: selectedTaxonTreeDefId,
+ })
+ .then(({ records }) => {
+ if (isCancelled) return;
+ setSearchTreeCollectionObjectTypeIds(records.map(({ id }) => id));
+ })
+ .catch(() => {
+ if (isCancelled) return;
+ setSearchTreeCollectionObjectTypeIds([]);
+ });
+ return () => {
+ isCancelled = true;
+ };
+ }, [selectedTaxonTreeDefUri]);
+
+ const searchDialogExtraFilters = React.useMemo(() => {
+ if (selectedTaxonTreeDefUri === undefined) return undefined;
+ if (searchTreeCollectionObjectTypeIds === undefined) return undefined;
+ return [
+ {
+ field: 'collectionObjectType',
+ queryBuilderFieldPath: ['collectionObjectType', 'id'],
+ isRelationship: true,
+ isNot: false,
+ operation: 'in',
+ value:
+ searchTreeCollectionObjectTypeIds.length === 0
+ ? '-1'
+ : searchTreeCollectionObjectTypeIds.join(','),
+ },
+ ] as const;
+ }, [selectedTaxonTreeDefUri, searchTreeCollectionObjectTypeIds]);
+
+ const isSearchTreeFilterLoading =
+ selectedTaxonTreeDefUri !== undefined &&
+ searchTreeCollectionObjectTypeIds === undefined;
+
+ const handleAddCollectionObjects = React.useCallback(
+ (resources: RA<{ readonly id: number }>): void => {
+ const selectedIds = resources.map(({ id }) => id);
+ if (selectedIds.length === 0) return;
+
+ const applyIds = (candidateIds: RA): void => {
+ const mergedCollectionObjectIds = f.unique([
+ ...collectionObjectIds,
+ ...candidateIds,
+ ]);
+ if (mergedCollectionObjectIds.length === collectionObjectIds.length)
+ return;
+ setCollectionObjectIds(mergedCollectionObjectIds);
+ setSelectedPreviewRows(new Set());
+ setPreviewRunCount((count) => count + 1);
+ };
+
+ if (
+ selectedTaxonTreeDefUri === undefined ||
+ searchTreeCollectionObjectTypeIds === undefined
+ ) {
+ applyIds(selectedIds);
+ return;
+ }
+
+ const allowedTypeIds = new Set(searchTreeCollectionObjectTypeIds);
+ loading(
+ fetchCollection(
+ 'CollectionObject',
+ {
+ domainFilter: true,
+ limit: 0,
+ },
+ {
+ id__in: selectedIds.join(','),
+ }
+ ).then(({ records }) => {
+ const allowedIds = records
+ .filter(({ collectionObjectType }) => {
+ if (typeof collectionObjectType !== 'string') return false;
+ const typeId = strictIdFromUrl(collectionObjectType);
+ return allowedTypeIds.has(typeId);
+ })
+ .map(({ id }) => id);
+ applyIds(allowedIds);
+ })
+ );
+ },
+ [
+ collectionObjectIds,
+ loading,
+ searchTreeCollectionObjectTypeIds,
+ selectedTaxonTreeDefUri,
+ ]
+ );
+
+ const { searchDialog, showSearchDialog } = useSearchDialog({
+ extraFilters: searchDialogExtraFilters as never,
+ forceCollection: undefined,
+ multiple: true,
+ table: tables.CollectionObject,
+ onSelected: handleAddCollectionObjects,
+ });
+
+ const handleRemoveSelectedCollectionObjects = React.useCallback((): void => {
+ if (selectedPreviewRowsCount === 0) return;
+ setCollectionObjectIds(
+ collectionObjectIds.filter((id) => !selectedPreviewRows.has(id))
+ );
+ setSelectedPreviewRows(new Set());
+ setPreviewRunCount((count) => count + 1);
+ }, [collectionObjectIds, selectedPreviewRows, selectedPreviewRowsCount]);
+
+ const handlePreviewPopOutClick = React.useCallback(
+ (event: React.MouseEvent): void => {
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+ const link = target.closest(
+ 'a.print\\:hidden[target="_blank"]'
+ );
+ if (link === null) return;
+ const match = collectionObjectViewPathRe.exec(link.href);
+ if (match === null) return;
+ const recordId = Number(match[1]);
+ if (!Number.isInteger(recordId) || recordId <= 0) return;
+ event.preventDefault();
+ setIsVerificationDialogOpen(recordId);
+ },
+ []
+ );
+
+ React.useEffect(() => {
+ stepRef.current = step;
+ }, [step]);
+
+ const resolveCatalogNumbers = React.useCallback(
+ async (
+ entries: RA,
+ options: {
+ readonly validateOnly?: boolean;
+ readonly errorMode?: 'dismissible' | 'silent';
+ } = {}
+ ): Promise =>
+ ajax(
+ '/api/specify/batch_identify/resolve/',
+ {
+ method: 'POST',
+ headers: { Accept: 'application/json' },
+ body: {
+ catalogNumbers: entries,
+ validateOnly: options.validateOnly === true,
+ },
+ errorMode: options.errorMode ?? 'dismissible',
+ }
+ ).then(({ data }) => data),
+ []
+ );
+
+ const validateCollectionObjects = React.useCallback(
+ async (
+ collectionObjectIds: RA
+ ): Promise =>
+ ajax(
+ '/api/specify/batch_identify/validate_record_set/',
+ {
+ method: 'POST',
+ headers: { Accept: 'application/json' },
+ body: {
+ collectionObjectIds,
+ },
+ errorMode: 'dismissible',
+ }
+ ).then(({ data }) => data),
+ []
+ );
+
+ const proceedWithCollectionObjects = React.useCallback(
+ (resolvedIds: RA, taxonTreeDefId?: number | null): void => {
+ setDetermination(createDetermination());
+ setSelectedTaxonTreeDefUri(
+ typeof taxonTreeDefId === 'number'
+ ? getResourceApiUrl('TaxonTreeDef', taxonTreeDefId)
+ : undefined
+ );
+ setCollectionObjectIds(resolvedIds);
+ setSelectedPreviewRows(new Set());
+ if (resolvedIds.length === 0) {
+ setStep('catalogNumbers');
+ return;
+ }
+ setPreviewRunCount((count) => count + 1);
+ setStep('determination');
+ },
+ []
+ );
+
+ const handleBackToCatalogNumbers = React.useCallback((): void => {
+ setDetermination(createDetermination());
+ setSelectedTaxonTreeDefUri(undefined);
+ setStep('catalogNumbers');
+ }, []);
+
+ const runLiveValidation = React.useCallback(
+ (entries: RA, entriesKey: string): void => {
+ const requestToken = liveValidationRequestTokenRef.current + 1;
+ liveValidationRequestTokenRef.current = requestToken;
+ setIsLiveValidating(true);
+ void resolveCatalogNumbers(entries, {
+ validateOnly: true,
+ errorMode: 'silent',
+ })
+ .then((data) => {
+ if (
+ requestToken !== liveValidationRequestTokenRef.current ||
+ stepRef.current !== 'catalogNumbers'
+ )
+ return;
+ setValidatedCatalogNumbersKey(entriesKey);
+ setResolvedCollectionObjectIds(data.collectionObjectIds);
+ setUnmatchedCatalogNumbers(data.unmatchedCatalogNumbers);
+ setHasMixedTaxonTrees(data.hasMixedTaxonTrees);
+ setTaxonTreeGroups(data.taxonTreeGroups);
+ })
+ .catch(() => undefined)
+ .finally(() => {
+ if (requestToken !== liveValidationRequestTokenRef.current) return;
+ setIsLiveValidating(false);
+ });
+ },
+ [resolveCatalogNumbers]
+ );
+
+ const scheduleLiveValidation = React.useCallback(
+ (immediate: boolean): void => {
+ if (step !== 'catalogNumbers' || catalogNumberRanges.length === 0) return;
+
+ if (liveValidationTimeoutRef.current !== undefined) {
+ globalThis.clearTimeout(liveValidationTimeoutRef.current);
+ liveValidationTimeoutRef.current = undefined;
+ }
+ if (validatedCatalogNumbersKey === catalogNumbersKey) return;
+
+ if (immediate) {
+ runLiveValidation(catalogNumberEntries, catalogNumbersKey);
+ return;
+ }
+
+ liveValidationTimeoutRef.current = globalThis.setTimeout(() => {
+ liveValidationTimeoutRef.current = undefined;
+ runLiveValidation(catalogNumberEntries, catalogNumbersKey);
+ }, liveValidationDebounceMs);
+ },
+ [
+ step,
+ catalogNumberRanges,
+ validatedCatalogNumbersKey,
+ catalogNumbersKey,
+ runLiveValidation,
+ catalogNumberEntries,
+ ]
+ );
+
+ React.useEffect(() => {
+ if (step !== 'catalogNumbers') {
+ if (liveValidationTimeoutRef.current !== undefined) {
+ globalThis.clearTimeout(liveValidationTimeoutRef.current);
+ liveValidationTimeoutRef.current = undefined;
+ }
+ liveValidationRequestTokenRef.current += 1;
+ setIsLiveValidating(false);
+ return;
+ }
+
+ if (catalogNumberRanges.length === 0) {
+ setUnmatchedCatalogNumbers([]);
+ setResolvedCollectionObjectIds([]);
+ setHasMixedTaxonTrees(false);
+ setTaxonTreeGroups([]);
+ setValidatedCatalogNumbersKey(catalogNumbersKey);
+ return;
+ }
+ scheduleLiveValidation(false);
+ }, [step, catalogNumberRanges, scheduleLiveValidation, catalogNumbersKey]);
+
+ React.useEffect(() => {
+ if (step !== 'catalogNumbers') return;
+ if (catalogNumbers.trim().length === 0 || catalogNumberRanges.length > 0)
+ setValidation([]);
+ else setValidation(batchIdentifyText.noCatalogNumbersParsed());
+ }, [step, catalogNumbers, catalogNumberRanges, setValidation]);
+
+ React.useEffect(
+ () => (): void => {
+ if (liveValidationTimeoutRef.current !== undefined)
+ globalThis.clearTimeout(liveValidationTimeoutRef.current);
+ liveValidationRequestTokenRef.current += 1;
+ },
+ []
+ );
+
+ const handleRecordSetSelected = React.useCallback(
+ (recordSet: SerializedResource): void => {
+ setIsRecordSetDialogOpen(false);
+ loading(
+ fetchRecordSetCollectionObjectIds(recordSet.id).then(
+ async (recordSetCollectionObjectIds) => {
+ if (recordSetCollectionObjectIds.length === 0) {
+ setRecordSetMixedTreeGroups([]);
+ setIsRecordSetDialogOpen(true);
+ return;
+ }
+ return validateCollectionObjects(recordSetCollectionObjectIds).then(
+ (validationData) => {
+ if (validationData.hasMixedTaxonTrees) {
+ setRecordSetMixedTreeGroups(validationData.taxonTreeGroups);
+ return;
+ }
+ setRecordSetMixedTreeGroups([]);
+ setUnmatchedCatalogNumbers([]);
+ setResolvedCollectionObjectIds([]);
+ setHasMixedTaxonTrees(false);
+ setTaxonTreeGroups([]);
+ setValidatedCatalogNumbersKey('');
+ proceedWithCollectionObjects(
+ validationData.collectionObjectIds,
+ validationData.taxonTreeGroups[0]?.taxonTreeDefId
+ );
+ }
+ );
+ }
+ )
+ );
+ },
+ [loading, proceedWithCollectionObjects, validateCollectionObjects]
+ );
+
+ const handleCloseRecordSetTreeError = React.useCallback((): void => {
+ setRecordSetMixedTreeGroups([]);
+ setIsRecordSetDialogOpen(true);
+ }, []);
+
+ const handleNext = React.useCallback((): void => {
+ if (
+ catalogNumberRanges.length === 0 ||
+ isLiveValidating ||
+ !isCatalogNumbersVerified
+ )
+ return;
+ if (unmatchedCatalogNumbers.length > 0) return;
+ if (hasMixedTaxonTrees) return;
+ proceedWithCollectionObjects(
+ resolvedCollectionObjectIds,
+ taxonTreeGroups[0]?.taxonTreeDefId
+ );
+ }, [
+ catalogNumberRanges,
+ isLiveValidating,
+ isCatalogNumbersVerified,
+ unmatchedCatalogNumbers,
+ hasMixedTaxonTrees,
+ taxonTreeGroups,
+ proceedWithCollectionObjects,
+ resolvedCollectionObjectIds,
+ ]);
+
+ const handleIdentify = React.useCallback((): void => {
+ if (collectionObjectIds.length === 0 || isIdentifying) return;
+ setIsIdentifying(true);
+ loading(
+ ajax('/api/specify/batch_identify/', {
+ method: 'POST',
+ headers: { Accept: 'application/json' },
+ body: {
+ collectionObjectIds,
+ determination: serializeResource(determination),
+ },
+ errorMode: 'dismissible',
+ })
+ .then(({ data }) => {
+ setCreatedRecordSet(undefined);
+ setIdentifiedCollectionObjectIds(f.unique(data.collectionObjectIds));
+ setShowSuccessDialog(true);
+ })
+ .finally(() => setIsIdentifying(false))
+ );
+ }, [collectionObjectIds, isIdentifying, loading, determination]);
+
+ const handleCreateRecordSetAfterIdentify = React.useCallback((): void => {
+ if (
+ isCreatingRecordSet ||
+ identifiedCollectionObjectIds.length === 0 ||
+ typeof createdRecordSet === 'object'
+ )
+ return;
+ setIsCreatingRecordSet(true);
+ loading(
+ createBatchIdentifyRecordSet(identifiedCollectionObjectIds)
+ .then((recordSet) => {
+ if (typeof recordSet === 'object') setCreatedRecordSet(recordSet);
+ })
+ .finally(() => setIsCreatingRecordSet(false))
+ );
+ }, [
+ isCreatingRecordSet,
+ identifiedCollectionObjectIds,
+ createdRecordSet,
+ loading,
+ ]);
+
+ const previewExtraButtons = React.useMemo(
+ () => (
+ <>
+
+
+
+ >
+ ),
+ [
+ isIdentifying,
+ isSearchTreeFilterLoading,
+ showSearchDialog,
+ selectedPreviewRowsCount,
+ handleRemoveSelectedCollectionObjects,
+ ]
+ );
+
+ if (showSuccessDialog)
+ return (
+ <>
+
+ {canCreateRecordSet && (
+
+ {queryText.createRecordSet({
+ recordSetTable: recordSetLabel,
+ })}
+
+ )}
+ setIsBrowseAfterIdentifyOpen(true)}
+ >
+ {queryText.browseInForms()}
+
+ {commonText.close()}
+ >
+ }
+ className={{
+ container: dialogClassNames.narrowContainer,
+ }}
+ dimensionsKey={false}
+ header={batchIdentifyText.batchIdentify()}
+ icon={icons.clipboardCopy}
+ onClose={handleClose}
+ >
+
+
{batchIdentifyText.successMessage()}
+ {typeof createdRecordSet === 'object' ? (
+
+ {localized(createdRecordSet.name)}
+
+ ) : undefined}
+
+
+ {isBrowseAfterIdentifyOpen && (
+
+ setIsBrowseAfterIdentifyOpen(false)}
+ onDelete={undefined}
+ onSaved={f.void}
+ onSlide={undefined}
+ />
+
+ )}
+ >
+ );
+
+ return (
+ <>
+
+ {commonText.cancel()}
+
+ setIsRecordSetDialogOpen(true)}
+ >
+ {recordSetLabel}
+
+ 0
+ }
+ onClick={handleNext}
+ >
+ {commonText.next()}
+
+ >
+ ) : (
+ <>
+ {commonText.close()}
+
+
+ {commonText.back()}
+
+
+ {batchIdentifyText.identify()}
+
+ >
+ )
+ }
+ className={{
+ container:
+ step === 'catalogNumbers'
+ ? dialogClassNames.narrowContainer
+ : dialogClassNames.extraWideContainer,
+ }}
+ dimensionsKey={`batch-identify-${step}`}
+ header={batchIdentifyText.batchIdentify()}
+ icon={icons.clipboardCopy}
+ onClose={handleClose}
+ >
+ {step === 'catalogNumbers' ? (
+
+
{batchIdentifyText.instructions()}
+
scheduleLiveValidation(true)}
+ onValueChange={(value): void => {
+ setCatalogNumbers(value);
+ setUnmatchedCatalogNumbers([]);
+ setResolvedCollectionObjectIds([]);
+ setHasMixedTaxonTrees(false);
+ setTaxonTreeGroups([]);
+ setValidatedCatalogNumbersKey('');
+ }}
+ />
+ {isLiveValidating && (
+ {batchIdentifyText.validatingCatalogNumbers()}
+ )}
+ {hasMixedTaxonTrees && (
+
+
{resourcesText.selectDeterminationTaxon()}
+ {taxonTreeGroups.map((group) => (
+
+
+ {`${
+ group.taxonTreeName ??
+ batchIdentifyText.unknownTaxonTree()
+ } (${group.collectionObjectIds.length})`}
+
+
+ {commonText.colonLine({
+ label: batchIdentifyText.collectionObjectTypes(),
+ value: group.collectionObjectTypeNames.join(', '),
+ })}
+
+
+ proceedWithCollectionObjects(
+ group.collectionObjectIds,
+ group.taxonTreeDefId
+ )
+ }
+ >
+ {commonText.select()}
+
+
+ ))}
+
+ )}
+ {unmatchedCatalogNumbers.length > 0 && (
+
+
{batchIdentifyText.catalogNumbersNotFound()}
+ {unmatchedCatalogNumbers.map((catalogNumber, index) => (
+
{catalogNumber}
+ ))}
+
+ )}
+
+ ) : (
+
+ {unmatchedCatalogNumbers.length > 0 && (
+
+
{batchIdentifyText.catalogNumbersNotFound()}
+ {unmatchedCatalogNumbers.map((catalogNumber, index) => (
+
{catalogNumber}
+ ))}
+
+ )}
+
+
+ )}
+
+ {isRecordSetDialogOpen && (
+
+ setIsRecordSetDialogOpen(false)}
+ onSelect={handleRecordSetSelected}
+ />
+
+ )}
+ {recordSetMixedTreeGroups.length > 0 && (
+
+ {commonText.close()}
+
+ }
+ header={batchIdentifyText.invalidRecordSetTitle()}
+ onClose={handleCloseRecordSetTreeError}
+ >
+
+
{batchIdentifyText.invalidRecordSetMessage()}
+
+ {recordSetMixedTreeGroups.map((group) => (
+
+ {commonText.colonLine({
+ label:
+ group.taxonTreeName ??
+ batchIdentifyText.unknownTaxonTree(),
+ value: String(group.collectionObjectIds.length),
+ })}
+
+ ))}
+
+
{batchIdentifyText.invalidRecordSetInstructions()}
+
+
+ )}
+ {searchDialog}
+ {typeof isVerificationDialogOpen === 'number' && (
+
+ setIsVerificationDialogOpen(undefined)}
+ onDelete={undefined}
+ onSaved={f.void}
+ onSlide={undefined}
+ />
+
+ )}
+ >
+ );
+}
diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts b/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts
new file mode 100644
index 00000000000..2495eb21c12
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts
@@ -0,0 +1,116 @@
+import type { RA, WritableArray } from '../../utils/types';
+
+type CatalogToken = number | '-';
+const yearCatalogNumberDelimiters = '-/|._:; *$%#@';
+const yearCatalogNumberDelimiterClass = yearCatalogNumberDelimiters.replaceAll(
+ /[$()*+\-./?[\\\]^{|}]/g,
+ '\\$&'
+);
+const entryYearCatalogNumberRe = new RegExp(
+ `(?\\d{4})[${yearCatalogNumberDelimiterClass}]+(?\\d+)(?!\\d)`,
+ 'g'
+);
+const isPossibleCatalogYear = (year: number): boolean =>
+ year >= 1000 && year <= 2999;
+
+export const parseCatalogNumberEntries = (rawEntries: string): RA =>
+ rawEntries
+ .split('\n')
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+
+export const tokenizeCatalogEntry = (entry: string): RA => {
+ const tokens: WritableArray = [];
+ let currentNumber = '';
+
+ for (const character of entry) {
+ if (character >= '0' && character <= '9') {
+ currentNumber += character;
+ continue;
+ }
+
+ if (currentNumber.length > 0) {
+ tokens.push(Number(currentNumber));
+ currentNumber = '';
+ }
+
+ if (character === '-') tokens.push('-');
+ }
+
+ if (currentNumber.length > 0) tokens.push(Number(currentNumber));
+ return tokens;
+};
+
+const stripYearCatalogNumberMatches = (entry: string): string => {
+ let segments = '';
+ let cursor = 0;
+ const yearMatches = Array.from(
+ entry.matchAll(entryYearCatalogNumberRe)
+ ).filter((match) => isPossibleCatalogYear(Number(match.groups?.year ?? '')));
+ for (const match of yearMatches) {
+ const start = match.index ?? 0;
+ const matchedText = match[0];
+ segments += entry.slice(cursor, start);
+ segments += ' '.repeat(matchedText.length);
+ cursor = start + matchedText.length;
+ }
+ segments += entry.slice(cursor);
+ return segments;
+};
+
+export const parseCatalogNumberRanges = (
+ entries: RA
+): RA =>
+ entries.flatMap((entry) => {
+ const ranges: WritableArray = [];
+ const yearMatches = Array.from(
+ entry.matchAll(entryYearCatalogNumberRe)
+ ).filter((match) =>
+ isPossibleCatalogYear(Number(match.groups?.year ?? ''))
+ );
+
+ let yearMatchIndex = 0;
+ while (yearMatchIndex < yearMatches.length) {
+ const match = yearMatches[yearMatchIndex];
+ let start = Number(match.groups?.number ?? '');
+ let end = start;
+ const nextMatch = yearMatches[yearMatchIndex + 1];
+ if (
+ typeof nextMatch?.index === 'number' &&
+ Number(nextMatch.groups?.year ?? '') ===
+ Number(match.groups?.year ?? '') &&
+ entry
+ .slice((match.index ?? 0) + match[0].length, nextMatch.index)
+ .includes('-')
+ ) {
+ end = Number(nextMatch.groups?.number ?? '');
+ yearMatchIndex += 1;
+ }
+
+ if (start > end) [start, end] = [end, start];
+ ranges.push([start, end]);
+ yearMatchIndex += 1;
+ }
+
+ const tokens = tokenizeCatalogEntry(stripYearCatalogNumberMatches(entry));
+ let index = 0;
+ while (index < tokens.length) {
+ const token = tokens[index];
+ if (token === '-') {
+ index += 1;
+ continue;
+ }
+
+ let start = token;
+ let end = start;
+ const rangeEndToken = tokens[index + 2];
+ if (tokens[index + 1] === '-' && typeof rangeEndToken === 'number') {
+ end = rangeEndToken;
+ index += 3;
+ } else index += 1;
+
+ if (start > end) [start, end] = [end, start];
+ ranges.push([start, end]);
+ }
+ return ranges;
+ });
diff --git a/specifyweb/frontend/js_src/lib/components/Core/Main.tsx b/specifyweb/frontend/js_src/lib/components/Core/Main.tsx
index 9f9383537b4..881fd55ba00 100644
--- a/specifyweb/frontend/js_src/lib/components/Core/Main.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Core/Main.tsx
@@ -38,7 +38,9 @@ export function Main({
}: {
readonly menuItems: RA;
}): JSX.Element {
- const [hasAgent] = React.useState(userInformation.agent !== null);
+ const [hasAgent] = React.useState(
+ userInformation.currentCollectionAgent !== null
+ );
const mainRef = React.useRef(null);
React.useEffect(() => {
diff --git a/specifyweb/frontend/js_src/lib/components/Core/VersionMismatch.tsx b/specifyweb/frontend/js_src/lib/components/Core/VersionMismatch.tsx
index 1e215f1e1de..eee50ce3396 100644
--- a/specifyweb/frontend/js_src/lib/components/Core/VersionMismatch.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Core/VersionMismatch.tsx
@@ -35,7 +35,7 @@ export function VersionMismatch(): JSX.Element | null {
{mainText.versionMismatchSecondDescription()}
-
+
{mainText.versionMismatchInstructions()}
diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/__snapshots__/specifyTable.test.ts.snap b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/__snapshots__/specifyTable.test.ts.snap
index fd73e1fe5c9..4425fd98366 100644
--- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/__snapshots__/specifyTable.test.ts.snap
+++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/__snapshots__/specifyTable.test.ts.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`fields are loaded 1`] = `
[
diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts
index 26226f396f9..a919fc6ac30 100644
--- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts
@@ -497,6 +497,19 @@ describe.skip('Dependent Collections isPrimary', () => {
});
});
+describe('Collector business rule', () => {
+ test('isPrimary being automatically set', () => {
+ const collectingEvent = new tables.CollectingEvent.Resource();
+ const collector = new tables.Collector.Resource();
+
+ //This initializes the dependent collection
+ collectingEvent.set('collectors', []);
+ collectingEvent.getDependentResource('collectors')?.add(collector);
+
+ expect(collector.get('isPrimary')).toBe(true);
+ });
+});
+
describe('Collecting Event', () => {
test('Removing Collector sets first Collector as primary', () => {
const collectingEvent = new tables.CollectingEvent.Resource({
@@ -893,9 +906,8 @@ describe('treeBusinessRules', () => {
expect(fieldChangeResult.current[0]).toStrictEqual(['Bad tree structure.']);
});
test('saveBlocker not on synonymized parent w/preference', async () => {
- const { collectionPreferences } = await import(
- '../../Preferences/collectionPreferences'
- );
+ const { collectionPreferences } =
+ await import('../../Preferences/collectionPreferences');
const originalRaw = collectionPreferences.getRaw();
collectionPreferences.setRaw({
...originalRaw,
diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/domain.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/domain.test.ts
index 33e16102bc2..4b2a4b89576 100644
--- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/domain.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/domain.test.ts
@@ -148,6 +148,33 @@ describe('Resource initialization preferences', () => {
expect(determinations?.models.at(0)?.get('isCurrent')).toBe(true);
});
+ test('Cloning a loan does not carry over loan preparations', async () => {
+ const loan = new tables.Loan.Resource(
+ addMissingFields('Loan', {
+ loanPreparations: [
+ {
+ _tableName: 'LoanPreparation',
+ },
+ ],
+ })
+ );
+
+ await expect(
+ loan
+ .rgetCollection('loanPreparations')
+ .then((collection) => collection.models.length)
+ ).resolves.toBe(1);
+
+ jest.spyOn(console, 'warn').mockImplementation();
+ const cloned = await loan.clone(true);
+
+ await expect(
+ cloned
+ .rgetCollection('loanPreparations')
+ .then((collection) => collection.models.length)
+ ).resolves.toBe(0);
+ });
+
test('Cloning resource does not create duplicates', async () => {
// See Issue #3278
diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/helpers.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/helpers.test.ts
index 1a50b7cf21e..1afb3a1bc4b 100644
--- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/helpers.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/helpers.test.ts
@@ -106,6 +106,7 @@ describe('serializeResource', () => {
operStart: 0,
position: 0,
query: null,
+ resource_uri: undefined,
sortType: 0,
startValue: '',
stringId: '',
@@ -121,6 +122,7 @@ describe('serializeResource', () => {
name: '',
ordinal: null,
remarks: null,
+ resource_uri: undefined,
searchSynonymy: null,
selectDistinct: null,
smushed: null,
diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resourceApi.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resourceApi.test.ts
index 9b4fda0164d..9fea1b20136 100644
--- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resourceApi.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resourceApi.test.ts
@@ -469,6 +469,125 @@ test('save', async () => {
expect(newDetermination.get('number1')).toBe(2);
});
+describe('independent resource change propagation', () => {
+ overrideAjax(
+ '/api/specify/collectionobject/?domainfilter=false&accession=11&offset=0',
+ emptyCollection
+ );
+ overrideAjax(accessionUrl, accessionResponse, { method: 'PUT' });
+
+ test('needsSaved propagates from independent collection to parent', async () => {
+ const parentResource = new tables.Accession.Resource({ id: accessionId });
+ expect(parentResource.needsSaved).toBe(false);
+
+ const collectionObjectRel =
+ tables.CollectionObject.strictGetRelationship('accession')!;
+
+ const independentCollection =
+ new tables.CollectionObject.IndependentCollection({
+ related: parentResource,
+ field: collectionObjectRel,
+ }) as Collection;
+
+ await independentCollection.fetch();
+
+ // Connect the collection to the parent's event system, as rgetCollection would do internally.
+ // storeIndependent is not in the public TS types so cast to any.
+ (parentResource as any).storeIndependent(
+ collectionObjectRel.getReverse(),
+ independentCollection
+ );
+
+ const newCollectionObject = new tables.CollectionObject.Resource({
+ id: 998,
+ });
+ independentCollection.add(newCollectionObject);
+
+ // Adding an existing resource to an independent collection does not mark the resource itself needsSaved;
+ // only the parent is notified via saverequired
+ expect(newCollectionObject.needsSaved).toBe(false);
+ expect(parentResource.needsSaved).toBe(true);
+
+ await parentResource.save();
+
+ expect(parentResource.needsSaved).toBe(false);
+ });
+});
+
+describe('save base record which has independent subviews', () => {
+ overrideAjax(
+ '/api/specify/collectionobject/?domainfilter=false&accession=11&offset=0',
+ {
+ objects: [collectionObjectResponse],
+ meta: { limit: 20, offset: 0, total_count: 1 },
+ }
+ );
+ overrideAjax(accessionUrl, accessionResponse, { method: 'PUT' });
+
+ async function setupParentWithIndependentCollection() {
+ const parentResource = new tables.Accession.Resource({ id: accessionId });
+ const collectionObjectRel =
+ tables.CollectionObject.strictGetRelationship('accession')!;
+ const independentCollection =
+ new tables.CollectionObject.IndependentCollection({
+ related: parentResource,
+ field: collectionObjectRel,
+ }) as Collection;
+ await independentCollection.fetch();
+ (parentResource as any).storeIndependent(
+ collectionObjectRel.getReverse(),
+ independentCollection
+ );
+ return { parentResource, independentCollection };
+ }
+
+ test('modifying a field on an independent resource marks base record as needsSaved and save applies the change', async () => {
+ const { parentResource, independentCollection } =
+ await setupParentWithIndependentCollection();
+
+ expect(parentResource.needsSaved).toBe(false);
+
+ const existingCollectionObject = independentCollection.models[0];
+ existingCollectionObject.set('text1', 'changed-value');
+
+ expect(parentResource.needsSaved).toBe(true);
+
+ await parentResource.save();
+
+ expect(parentResource.needsSaved).toBe(false);
+ // Change is preserved in memory after save
+ expect(existingCollectionObject.get('text1')).toBe('changed-value');
+ });
+
+ test('modifying a sub-record of an independent resource propagates needsSaved to base record and save completes', async () => {
+ const { parentResource, independentCollection } =
+ await setupParentWithIndependentCollection();
+
+ expect(parentResource.needsSaved).toBe(false);
+
+ const collectionObject = independentCollection.models[0];
+
+ const determinations =
+ collectionObject.getDependentResource('determinations');
+
+ const determination = determinations!.models[0];
+
+ determination.set('number1', 99);
+
+ // Change to sub-record propagates needsSaved all the way up to base record
+
+ expect(parentResource.needsSaved).toBe(true);
+
+ await parentResource.save();
+
+ expect(parentResource.needsSaved).toBe(false);
+
+ // Change to sub-record is preserved in memory after save
+
+ expect(determination.get('number1')).toBe(99);
+ });
+});
+
describe('resource initialization', () => {
test('Initialization with dependent resources does not trigger saveRequired', () => {
const resource = new tables.CollectionObject.Resource({
diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/addMissingFields.ts b/specifyweb/frontend/js_src/lib/components/DataModel/addMissingFields.ts
index 982ae6f4ec4..5785eeed593 100644
--- a/specifyweb/frontend/js_src/lib/components/DataModel/addMissingFields.ts
+++ b/specifyweb/frontend/js_src/lib/components/DataModel/addMissingFields.ts
@@ -61,13 +61,15 @@ export function addMissingFields(
: (record[field.name as keyof typeof record] ??
(field.name === 'version'
? 1
- : (
- field.isRequired
- ? requiredFields === 'set'
- : optionalFields === 'set'
- )
- ? parserFromType(field.type).value
- : null)),
+ : field.type === 'java.lang.Boolean' && field.isRequired
+ ? false
+ : (
+ field.isRequired
+ ? requiredFields === 'set'
+ : optionalFields === 'set'
+ )
+ ? parserFromType(field.type).value
+ : null)),
]
: undefined
)
diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts
index 000fbe21d1b..73aceef4328 100644
--- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts
+++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts
@@ -201,9 +201,10 @@ export const businessRuleDefs: MappedBusinessRuleDefs = {
return undefined;
},
catalogNumber: async (resource): Promise => {
- const preferences = await import(
- '../Preferences/collectionPreferences'
- ).then(({ collectionPreferences }) => collectionPreferences);
+ const preferences =
+ await import('../Preferences/collectionPreferences').then(
+ ({ collectionPreferences }) => collectionPreferences
+ );
const uniqueCatalogNumberAccrossComponentAndCOPref = preferences.get(
'uniqueCatalogNumberAccrossComponentAndCO',
@@ -452,9 +453,10 @@ export const businessRuleDefs: MappedBusinessRuleDefs = {
return undefined;
},
catalogNumber: async (resource): Promise => {
- const preferences = await import(
- '../Preferences/collectionPreferences'
- ).then(({ collectionPreferences }) => collectionPreferences);
+ const preferences =
+ await import('../Preferences/collectionPreferences').then(
+ ({ collectionPreferences }) => collectionPreferences
+ );
const uniqueCatalogNumberAccrossComponentAndCOPref = preferences.get(
'uniqueCatalogNumberAccrossComponentAndCO',
@@ -850,11 +852,15 @@ export const businessRuleDefs: MappedBusinessRuleDefs = {
countAmt: async (prep): Promise => {
const loanPrep = await prep.rgetCollection('loanPreparations');
const totalPrep = prep.get('countAmt') ?? 0;
- let totalPrepLoaned = 0;
+ let totalPrepOnLoan = 0;
loanPrep.models.forEach((loan) => {
const quantity = loan.get('quantity') ?? 0;
- totalPrepLoaned += quantity;
+ const quantityResolved = loan.get('quantityResolved') ?? 0;
+ const unresolvedQuantity = loan.get('isResolved')
+ ? 0
+ : Math.max(quantity - quantityResolved, 0);
+ totalPrepOnLoan += unresolvedQuantity;
});
if (totalPrep < 0) {
@@ -864,7 +870,7 @@ export const businessRuleDefs: MappedBusinessRuleDefs = {
[resourcesText.preparationIsNegative()],
PREPARATION_NEGATIVE_KEY
);
- } else if (totalPrep < totalPrepLoaned) {
+ } else if (totalPrep < totalPrepOnLoan) {
setSaveBlockers(
prep,
prep.specifyTable.field.countAmt,
diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/saveBlockers.tsx b/specifyweb/frontend/js_src/lib/components/DataModel/saveBlockers.tsx
index 4d690b0a028..5e18d0895d8 100644
--- a/specifyweb/frontend/js_src/lib/components/DataModel/saveBlockers.tsx
+++ b/specifyweb/frontend/js_src/lib/components/DataModel/saveBlockers.tsx
@@ -38,6 +38,9 @@ const blockerEvents = eventListener<{
readonly change: SpecifyResource;
}>();
+const hasSameBlockers = (left: RA, right: RA): boolean =>
+ JSON.stringify(left) === JSON.stringify(right);
+
type Blocker = {
readonly key: string;
readonly field: LiteralField | Relationship;
@@ -70,6 +73,11 @@ export function useSaveBlockers(
field: LiteralField | Relationship | undefined
): readonly [RA, (value: RA, blockerKey: string) => void] {
const [blockers, setBlockers] = React.useState>([]);
+ const blockersRef = React.useRef(blockers);
+
+ React.useEffect(() => {
+ blockersRef.current = blockers;
+ }, [blockers]);
React.useEffect(
() =>
@@ -82,7 +90,10 @@ export function useSaveBlockers(
(changedResource !== resource && changedResource !== undefined)
)
return;
- setBlockers(getFieldBlockers(resource, field));
+ const newBlockers = getFieldBlockers(resource, field);
+ if (hasSameBlockers(blockersRef.current, newBlockers)) return;
+ blockersRef.current = newBlockers;
+ setBlockers(newBlockers);
},
true
),
@@ -102,7 +113,11 @@ export function useSaveBlockers(
field,
}
);
- } else setSaveBlockers(resource, field, errors, blockerKey);
+ } else {
+ blockersRef.current = f.unique(errors);
+ setBlockers(blockersRef.current);
+ setSaveBlockers(resource, field, blockersRef.current, blockerKey);
+ }
},
[resource, field]
),
diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/tables.ts b/specifyweb/frontend/js_src/lib/components/DataModel/tables.ts
index 30a40cbadc6..32d4bfbafb4 100644
--- a/specifyweb/frontend/js_src/lib/components/DataModel/tables.ts
+++ b/specifyweb/frontend/js_src/lib/components/DataModel/tables.ts
@@ -94,63 +94,72 @@ export const fetchContext = f
})
.then(({ dataModel, localization }) => {
schemaLocalization = localization;
- dataModel
+
+ const tablePairs = dataModel
+ .slice()
.map((tableDefinition) => {
const table = new SpecifyTable(tableDefinition);
- overwriteReadOnly(genericTables, table.name, table);
return [tableDefinition, table] as const;
})
- .forEach(([tableDefinition, table]) => {
- const [frontEndFields, callback] = (
- schemaExtras[table.name] as (typeof schemaExtras)['Agent'] | undefined
- )?.(table as SpecifyTable) ?? [[]];
- const [literalFields, relationships] = split(
- frontEndFields.map((field) => {
- field.isReadOnly = true;
- field.overrides.isReadOnly = true;
- return field;
- }),
- (field) => field.isRelationship
- );
-
- overwriteReadOnly(
- table,
- 'literalFields',
- processFields(
- tableDefinition.fields.map(
- (fieldDefinition) => new LiteralField(table, fieldDefinition)
- ),
- literalFields
- )
- );
- overwriteReadOnly(
- table,
- 'relationships',
- processFields(
- tableDefinition.relationships.map(
- (relationshipDefinition) =>
- new Relationship(table, relationshipDefinition)
- ),
- relationships
- )
- );
- overwriteReadOnly(table, 'fields', [
- ...table.literalFields,
- ...table.relationships,
- ]);
- overwriteReadOnly(
- table,
- 'field',
- Object.fromEntries(table.fields.map((field) => [field.name, field]))
- );
-
- frontEndOnlyFields[table.name] = [
- ...literalFields,
- ...relationships,
- ].map(({ name }) => name);
-
- callback?.();
- });
+ .sort(([, a], [, b]) => a.name.localeCompare(b.name));
+
+ // First, register all tables so lookups from Relationship constructors
+ // (which call `strictGetTable`) can find any related table.
+ tablePairs.forEach(([, table]) => {
+ overwriteReadOnly(genericTables, table.name, table);
+ });
+
+ // Then process fields and relationships for each table.
+ tablePairs.forEach(([tableDefinition, table]) => {
+ const [frontEndFields, callback] = (
+ schemaExtras[table.name] as (typeof schemaExtras)['Agent'] | undefined
+ )?.(table as SpecifyTable) ?? [[]];
+ const [literalFields, relationships] = split(
+ frontEndFields.map((field) => {
+ field.isReadOnly = true;
+ field.overrides.isReadOnly = true;
+ return field;
+ }),
+ (field) => field.isRelationship
+ );
+
+ overwriteReadOnly(
+ table,
+ 'literalFields',
+ processFields(
+ tableDefinition.fields.map(
+ (fieldDefinition) => new LiteralField(table, fieldDefinition)
+ ),
+ literalFields
+ )
+ );
+ overwriteReadOnly(
+ table,
+ 'relationships',
+ processFields(
+ tableDefinition.relationships.map(
+ (relationshipDefinition) =>
+ new Relationship(table, relationshipDefinition)
+ ),
+ relationships
+ )
+ );
+ overwriteReadOnly(table, 'fields', [
+ ...table.literalFields,
+ ...table.relationships,
+ ]);
+ overwriteReadOnly(
+ table,
+ 'field',
+ Object.fromEntries(table.fields.map((field) => [field.name, field]))
+ );
+
+ frontEndOnlyFields[table.name] = [...literalFields, ...relationships].map(
+ ({ name }) => name
+ );
+
+ callback?.();
+ });
return tables;
});
diff --git a/specifyweb/frontend/js_src/lib/components/Errors/ErrorDialog.tsx b/specifyweb/frontend/js_src/lib/components/Errors/ErrorDialog.tsx
index 68b87baec16..788a316f603 100644
--- a/specifyweb/frontend/js_src/lib/components/Errors/ErrorDialog.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Errors/ErrorDialog.tsx
@@ -48,9 +48,7 @@ const errorBody = (
),
discourseLink: (label): JSX.Element => (
-
- {label}
-
+ {label}
),
}}
string={mainText.errorResolutionSecondDescription()}
diff --git a/specifyweb/frontend/js_src/lib/components/Errors/FormatError.tsx b/specifyweb/frontend/js_src/lib/components/Errors/FormatError.tsx
index 03fb7df0fb9..a2b0e6623b2 100644
--- a/specifyweb/frontend/js_src/lib/components/Errors/FormatError.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Errors/FormatError.tsx
@@ -147,7 +147,7 @@ export function handleAjaxError(
* If exceptions occur because user has no agent, don't display the error
* message, so as not to spawn a new dialog on top of the "No Agent" dialog
*/
- if (userInformation.agent === null) throw error;
+ if (userInformation.currentCollectionAgent === null) throw error;
if (errorMode !== 'silent') {
const isNotFoundError =
diff --git a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ExpressSearchConfigDialog.tsx b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ExpressSearchConfigDialog.tsx
new file mode 100644
index 00000000000..adc8ce465c4
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ExpressSearchConfigDialog.tsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import type { LocalizedString } from 'typesafe-i18n';
+
+import { commonText } from '../../localization/common';
+import { expressSearchConfigText } from '../../localization/expressSearchConfig';
+import { ajax } from '../../utils/ajax';
+import { Button } from '../Atoms/Button';
+import { icons } from '../Atoms/Icons';
+import { LoadingContext } from '../Core/Contexts';
+import { Dialog, dialogClassNames } from '../Molecules/Dialog';
+import { ExpressSearchConfigEditor } from './ExpressSearchConfigEditor';
+
+type ExpressSearchConfigDialogProps = {
+ readonly isOpen: boolean;
+ readonly onClose: () => void;
+ readonly onSave?: () => void;
+};
+
+export function ExpressSearchConfigDialog({
+ isOpen,
+ onClose,
+ onSave,
+}: ExpressSearchConfigDialogProps) {
+ const loading = React.useContext(LoadingContext);
+ const [activeConfig, setActiveConfig] = React.useState(null);
+ const [isDirty, setIsDirty] = React.useState(false);
+ const hasLoadedConfig = React.useRef(false);
+
+ React.useEffect(() => {
+ if (!isOpen) {
+ setActiveConfig(null);
+ setIsDirty(false);
+ hasLoadedConfig.current = false;
+ }
+ }, [isOpen]);
+
+ const handleSave = React.useCallback(() => {
+ if (!activeConfig) return;
+ loading(
+ ajax('/express_search/config/', {
+ method: 'PUT',
+ headers: {},
+ body: activeConfig,
+ }).then(() => {
+ setIsDirty(false);
+ onClose();
+ onSave?.();
+ })
+ );
+ }, [activeConfig, loading, onClose, onSave]);
+
+ const handleChangeJSON = React.useCallback((next: any) => {
+ setActiveConfig(next);
+ if (hasLoadedConfig.current) setIsDirty(true);
+ else hasLoadedConfig.current = true;
+ }, []);
+
+ const cancelLabel = (commonText.cancel as unknown as () => LocalizedString)();
+ const saveLabel = (commonText.save as unknown as () => LocalizedString)();
+
+ const buttons = (
+
+ {cancelLabel}
+
+ {saveLabel}
+
+
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ExpressSearchConfigEditor.tsx b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ExpressSearchConfigEditor.tsx
new file mode 100644
index 00000000000..c47fc6fc0f6
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ExpressSearchConfigEditor.tsx
@@ -0,0 +1,302 @@
+import React from 'react';
+
+import { useAsyncState } from '../../hooks/useAsyncState';
+import { expressSearchConfigText } from '../../localization/expressSearchConfig';
+import { ajax } from '../../utils/ajax';
+import type { AppResourceTabProps } from '../AppResources/TabDefinitions';
+import { Tabs } from '../AppResources/Tabs';
+import { Container } from '../Atoms';
+import { RelatedTablesTab } from './RelatedTablesTab';
+import { ResultsOrderingTab } from './ResultsOrderingTab';
+import { SearchFieldsTab } from './SearchFieldsTab';
+
+const UNINITIALIZED_RESOURCE_KEY = '__uninitialized_resource_key__';
+
+function xmlToConfig(xml: string | null | undefined): any {
+ if (typeof xml !== 'string' || xml.trim().length === 0)
+ return { tables: [], relatedQueries: [] };
+
+ try {
+ const parsed = new DOMParser().parseFromString(xml, 'application/xml');
+ if (parsed.getElementsByTagName('parsererror').length > 0)
+ return { tables: [], relatedQueries: [] };
+
+ const getChildText = (
+ parent: Element,
+ tagName: string
+ ): string | undefined => {
+ const child = Array.from(parent.children).find(
+ (element) => element.tagName === tagName
+ );
+ return child?.textContent ?? undefined;
+ };
+
+ const tableElements = Array.from(
+ parsed.getElementsByTagName('searchtable')
+ );
+ const tables = tableElements.map((tableElement) => {
+ const searchFields = Array.from(
+ tableElement.getElementsByTagName('searchfield'),
+ (fieldElement) => {
+ const fieldName = getChildText(fieldElement, 'fieldName') ?? '';
+ const orderValue = getChildText(fieldElement, 'order');
+ const sortDirection =
+ getChildText(fieldElement, 'sortDirection') ?? 'None';
+ const order = Number.parseInt(orderValue ?? '0', 10);
+
+ return {
+ fieldName,
+ order: Number.isFinite(order) ? order : 0,
+ sortDirection,
+ };
+ }
+ );
+
+ const displayFields = Array.from(
+ tableElement.getElementsByTagName('displayfield'),
+ (fieldElement) => ({
+ fieldName: getChildText(fieldElement, 'fieldName') ?? '',
+ })
+ );
+
+ const tableName = getChildText(tableElement, 'tableName') ?? '';
+ const displayOrderValue = getChildText(tableElement, 'displayOrder');
+ const displayOrder = Number.parseInt(displayOrderValue ?? '0', 10);
+
+ return {
+ tableName,
+ displayOrder: Number.isFinite(displayOrder) ? displayOrder : 0,
+ searchFields,
+ displayFields,
+ };
+ });
+
+ const relatedQueries = Array.from(
+ parsed.getElementsByTagName('relatedquery'),
+ (queryElement) => {
+ const id = getChildText(queryElement, 'id') ?? '';
+ const displayOrderValue = getChildText(queryElement, 'displayOrder');
+ const displayOrder = Number.parseInt(displayOrderValue ?? '0', 10);
+ const isActiveText =
+ getChildText(queryElement, 'isActive') ??
+ queryElement.getAttribute('isactive') ??
+ 'false';
+ const isSystemText =
+ getChildText(queryElement, 'isSystem') ??
+ queryElement.getAttribute('issystem') ??
+ 'false';
+
+ return {
+ id,
+ displayOrder: Number.isFinite(displayOrder) ? displayOrder : 0,
+ isActive: isActiveText.toLowerCase() === 'true',
+ isSystem: isSystemText.toLowerCase() === 'true',
+ };
+ }
+ );
+
+ return { tables, relatedQueries };
+ } catch {
+ return { tables: [], relatedQueries: [] };
+ }
+}
+
+/** Serialize the in-memory config JSON back to minimal valid XML. */
+function configToXml(config: any): string {
+ if (!config)
+ return ' ';
+ const escape = (s: string | null | undefined) =>
+ (s ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>');
+
+ const tables = (config.tables ?? [])
+ .map((table: any) => {
+ const activeSearchFields = (table.searchFields ?? [])
+ .filter((sf: any) => sf.inUse !== false)
+ .sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0));
+
+ const sfs = activeSearchFields
+ .map(
+ (sf: any, index: number) =>
+ `${escape(sf.fieldName)} ${index} ${escape(sf.sortDirection ?? 'None')} `
+ )
+ .join('');
+ const dfs = (table.displayFields ?? [])
+ .filter((df: any) => df.inUse !== false)
+ .map(
+ (df: any) =>
+ `${escape(df.fieldName)} `
+ )
+ .join('');
+ return `${escape(table.tableName)} ${table.displayOrder ?? 0} ${sfs} ${dfs} `;
+ })
+ .join('');
+
+ const rqs = (config.relatedQueries ?? [])
+ .map(
+ (rq: any) =>
+ `${escape(rq.id)} ${rq.displayOrder ?? 0} `
+ )
+ .join('');
+
+ return `${tables} ${rqs} `;
+}
+
+function normalizeConfigForEditing(config: any): any {
+ return {
+ ...config,
+ tables: (config?.tables ?? []).map((table: any) => ({
+ ...table,
+ searchFields: (table?.searchFields ?? []).map((field: any) => ({
+ ...field,
+ inUse: field?.inUse ?? true,
+ })),
+ displayFields: (table?.displayFields ?? []).map((field: any) => ({
+ ...field,
+ inUse: field?.inUse ?? true,
+ })),
+ })),
+ relatedQueries: (config?.relatedQueries ?? []).map((query: any) => ({
+ ...query,
+ isActive: query?.isActive ?? false,
+ isSystem: query?.isSystem ?? true,
+ })),
+ };
+}
+
+export function ExpressSearchConfigEditor({
+ onChange,
+ onChangeJSON,
+ initialXmlData,
+ useResolvedConfig = true,
+ resourceKey,
+}: {
+ readonly onChange?: AppResourceTabProps['onChange'];
+ readonly onChangeJSON?: (json: any) => void;
+ readonly initialXmlData?: string | null;
+ readonly useResolvedConfig?: boolean;
+ readonly resourceKey?: string;
+ readonly onSetCleanup?: AppResourceTabProps['onSetCleanup'];
+}): JSX.Element {
+ /*
+ * Keep a stable ref to `onChange` so effects that use it don't need to list
+ * it as a dependency (the parent recreates it on every render, which would
+ * cause an infinite loop if it were in a dep array).
+ */
+ const onChangeRef = React.useRef(onChange);
+ React.useLayoutEffect(() => {
+ onChangeRef.current = onChange;
+ });
+
+ const [initialDataResult] = useAsyncState(
+ React.useCallback(
+ async () =>
+ ajax('/express_search/config/', {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ cache: 'no-store',
+ }).then((res) => res.data as any),
+ []
+ ),
+ false
+ );
+
+ const [activeConfig, setActiveConfig] = React.useState(null);
+ const lastInitializedResourceKeyRef = React.useRef(
+ UNINITIALIZED_RESOURCE_KEY
+ );
+
+ React.useEffect(() => {
+ if (!initialDataResult) return;
+
+ /*
+ * In app-resource mode, parent updates `data` on every edit.
+ * Re-initializing from that prop causes confusing state resets.
+ */
+ if (!useResolvedConfig) {
+ const normalizedResourceKey = resourceKey ?? '__default_resource__';
+ if (lastInitializedResourceKeyRef.current === normalizedResourceKey)
+ return;
+ lastInitializedResourceKeyRef.current = normalizedResourceKey;
+ }
+
+ const baseConfig = useResolvedConfig
+ ? initialDataResult.config
+ : xmlToConfig(initialXmlData);
+ const normalizedConfig = normalizeConfigForEditing(baseConfig);
+ setActiveConfig(normalizedConfig);
+ onChangeJSON?.(normalizedConfig);
+ }, [
+ initialDataResult,
+ onChangeJSON,
+ initialXmlData,
+ useResolvedConfig,
+ resourceKey,
+ ]);
+
+ // Stable handler — uses the ref so it never needs to be recreated.
+ const handleChangeConfig = React.useCallback(
+ (newConfig: any) => {
+ setActiveConfig(newConfig);
+ onChangeRef.current?.(() => configToXml(newConfig));
+ onChangeJSON?.(newConfig);
+ },
+ [onChangeJSON]
+ ); // Stable
+
+ const [tabIndex, setTabIndex] = React.useState(0);
+
+ if (!activeConfig || !initialDataResult) {
+ return {expressSearchConfigText.loadingConfig()}
;
+ }
+
+ const tabs = {
+ [expressSearchConfigText.searchFieldsTab()]: (
+
+ ),
+ [expressSearchConfigText.relatedTablesTab()]: (
+
+ ),
+ [expressSearchConfigText.resultsOrderingTab()]: (
+
+ ),
+ };
+
+ return (
+
+
+
+ );
+}
+
+export function ExpressSearchConfigResourceEditor(
+ props: AppResourceTabProps
+): JSX.Element {
+ return (
+
+ );
+}
diff --git a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/RelatedTablesTab.tsx b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/RelatedTablesTab.tsx
new file mode 100644
index 00000000000..d534b3c8dab
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/RelatedTablesTab.tsx
@@ -0,0 +1,139 @@
+import React from 'react';
+
+import {
+ expressSearchConfigText,
+ getExpressSearchQueryDescription,
+ getExpressSearchQueryTitle,
+} from '../../localization/expressSearchConfig';
+import { Button } from '../Atoms/Button';
+import { Input } from '../Atoms/Form';
+
+export function RelatedTablesTab({
+ config,
+ relatedQueriesDefinitions,
+ onChangeConfig,
+}: any) {
+ const [selectedRqId, setSelectedRqId] = React.useState(null);
+
+ const inUseQueries = relatedQueriesDefinitions;
+
+ const handleToggle = (id: string, isActive: boolean) => {
+ const newConfig = JSON.parse(JSON.stringify(config));
+ let rq = newConfig.relatedQueries.find((r: any) => r.id === id);
+ if (rq) {
+ rq.isActive = isActive;
+ } else {
+ rq = {
+ id,
+ isActive,
+ isSystem: true,
+ displayOrder: newConfig.relatedQueries.length,
+ };
+ newConfig.relatedQueries.push(rq);
+ }
+ onChangeConfig(newConfig);
+ };
+
+ const setAll = (isActive: boolean) => {
+ const newConfig = JSON.parse(JSON.stringify(config));
+ inUseQueries.forEach((def: any) => {
+ let rq = newConfig.relatedQueries.find((r: any) => r.id === def.id);
+ if (rq) {
+ rq.isActive = isActive;
+ } else {
+ rq = {
+ id: def.id,
+ isActive,
+ isSystem: true,
+ displayOrder: newConfig.relatedQueries.length,
+ };
+ newConfig.relatedQueries.push(rq);
+ }
+ });
+ onChangeConfig(newConfig);
+ };
+
+ const selectedQuery =
+ selectedRqId === null
+ ? undefined
+ : inUseQueries.find((q: any) => q.id === selectedRqId);
+
+ const selectedDescription =
+ selectedQuery == null
+ ? expressSearchConfigText.selectRelatedQueryDescription()
+ : (getExpressSearchQueryDescription(selectedQuery.name) ??
+ selectedQuery.description ??
+ expressSearchConfigText.noDescriptionAvailable());
+
+ const selectedName =
+ selectedQuery == null
+ ? null
+ : getExpressSearchQueryTitle(selectedQuery.name) ||
+ selectedQuery.name ||
+ String(selectedRqId);
+
+ return (
+
+
+
+ setAll(true)}>
+ {expressSearchConfigText.selectAllQueries()}
+
+ setAll(false)}>
+ {expressSearchConfigText.deselectAllQueries()}
+
+
+
+ {inUseQueries.map((def: any) => {
+ const rq = config.relatedQueries.find((r: any) => r.id === def.id);
+ const isActive = rq?.isActive ?? false;
+ const isSelected = selectedRqId === def.id;
+ const title =
+ getExpressSearchQueryTitle(def.name) ||
+ def.name ||
+ String(def.id);
+ return (
+ setSelectedRqId(def.id)}
+ >
+ {title}
+
+ handleToggle(def.id, (e.target as HTMLInputElement).checked)
+ }
+ />
+
+ );
+ })}
+ {inUseQueries.length === 0 && (
+
+ {expressSearchConfigText.noRelatedQueriesAvailable()}
+
+ )}
+
+
+
+
+ {selectedName ? (
+ <>
+
{selectedName}
+
+ {selectedDescription}
+
+ >
+ ) : (
+
+ {expressSearchConfigText.selectRelatedQueryDescription()}
+
+ )}
+
+
+ );
+}
diff --git a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ResultsOrderingTab.tsx b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ResultsOrderingTab.tsx
new file mode 100644
index 00000000000..437e55abc1b
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ResultsOrderingTab.tsx
@@ -0,0 +1,136 @@
+import React from 'react';
+
+import {
+ expressSearchConfigText,
+ getExpressSearchQueryTitle,
+} from '../../localization/expressSearchConfig';
+import { camelToHuman } from '../../utils/utils';
+import { Button } from '../Atoms/Button';
+import { icons } from '../Atoms/Icons';
+import { genericTables } from '../DataModel/tables';
+
+function tableLabel(tableName: string): string {
+ return (
+ (genericTables[tableName as keyof typeof genericTables]?.label as
+ | string
+ | undefined) ?? camelToHuman(tableName)
+ );
+}
+
+export function ResultsOrderingTab({
+ config,
+ relatedQueriesDefinitions = [],
+ onChangeConfig,
+}: any) {
+ const baseTables = config.tables
+ .filter((t: any) => t.searchFields.some((sf: any) => sf.inUse !== false))
+ .map((t: any) => ({
+ type: 'table',
+ id: t.tableName,
+ label: tableLabel(t.tableName),
+ displayOrder: t.displayOrder ?? 1000,
+ }));
+
+ const activeQueries = config.relatedQueries
+ .filter((rq: any) => rq.isActive)
+ .map((rq: any) => {
+ const def = relatedQueriesDefinitions.find(
+ (def: any) => def.id === rq.id
+ );
+ const title = def?.name
+ ? getExpressSearchQueryTitle(def.name)
+ : undefined;
+
+ if (!def || !title || title === String(def.name)) {
+ return undefined;
+ }
+
+ return {
+ type: 'query',
+ id: rq.id,
+ label: expressSearchConfigText.relatedQueryLabel({
+ query: title,
+ }),
+ displayOrder: rq.displayOrder ?? 1000,
+ };
+ })
+ .filter(Boolean);
+
+ const allItems = [...baseTables, ...activeQueries].sort(
+ (a, b) => a.displayOrder - b.displayOrder
+ );
+
+ const moveItem = (index: number, direction: 'down' | 'up') => {
+ if (direction === 'up' && index === 0) return;
+ if (direction === 'down' && index === allItems.length - 1) return;
+
+ const newItems = Array.from(allItems);
+ const targetIndex = direction === 'up' ? index - 1 : index + 1;
+
+ const temporary = newItems[index];
+ newItems[index] = newItems[targetIndex];
+ newItems[targetIndex] = temporary;
+
+ const newConfig = JSON.parse(JSON.stringify(config));
+
+ newItems.forEach((item, newOrder) => {
+ if (item.type === 'table') {
+ const t = newConfig.tables.find((t: any) => t.tableName === item.id);
+ if (t) t.displayOrder = newOrder;
+ } else {
+ const rq = newConfig.relatedQueries.find((r: any) => r.id === item.id);
+ if (rq) rq.displayOrder = newOrder;
+ }
+ });
+
+ newConfig.tables.sort(
+ (a: any, b: any) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)
+ );
+ newConfig.relatedQueries.sort(
+ (a: any, b: any) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)
+ );
+
+ onChangeConfig(newConfig);
+ };
+
+ return (
+
+
+ {expressSearchConfigText.configureResultsOrdering()}
+
+
+ {expressSearchConfigText.reorderResultsOrderingDescription()}
+
+
+
+
+ );
+}
diff --git a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/SearchFieldsTab.tsx b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/SearchFieldsTab.tsx
new file mode 100644
index 00000000000..263b81957ea
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/SearchFieldsTab.tsx
@@ -0,0 +1,482 @@
+import React from 'react';
+
+import { expressSearchConfigText } from '../../localization/expressSearchConfig';
+import { isSchemaFieldVisible } from '../../utils/schemaVisibility';
+import { camelToHuman } from '../../utils/utils';
+import { Button } from '../Atoms/Button';
+import { Input, Select } from '../Atoms/Form';
+import { icons } from '../Atoms/Icons';
+import { genericTables } from '../DataModel/tables';
+import { relationshipIsToMany } from '../WbPlanView/mappingHelpers';
+import { TableIcon } from '../Molecules/TableIcon';
+
+/** Return the localized label for a field, falling back to camelToHuman. */
+function fieldLabel(tableName: string, fieldName: string): string {
+ return (
+ (genericTables[tableName as keyof typeof genericTables]?.getField(fieldName)
+ ?.label as string | undefined) ?? camelToHuman(fieldName)
+ );
+}
+
+/** Return the localized label for a table, falling back to camelToHuman. */
+function tableLabel(tableName: string): string {
+ return (
+ (genericTables[tableName as keyof typeof genericTables]?.label as
+ | string
+ | undefined) ?? camelToHuman(tableName)
+ );
+}
+
+function isSearchFieldVisible(field: any): boolean {
+ return field.isIndexed === true && field.name !== 'collectionMemberId';
+}
+
+function hasVisibleSearchFields(table: any): boolean {
+ return table.fields.some(isSearchFieldVisible);
+}
+
+export function SearchFieldsTab({
+ config,
+ schemaMetadata,
+ onChangeConfig,
+}: any) {
+ const [selectedTable, setSelectedTable] = React.useState(null);
+
+ const visibleTables = React.useMemo(
+ () => (schemaMetadata ?? []).filter(hasVisibleSearchFields),
+ [schemaMetadata]
+ );
+
+ // Default selection
+ React.useEffect(() => {
+ if (!selectedTable && visibleTables.length > 0) {
+ setSelectedTable(visibleTables[0].name);
+ } else if (
+ selectedTable &&
+ !visibleTables.some((table: any) => table.name === selectedTable)
+ ) {
+ setSelectedTable(visibleTables[0]?.name ?? null);
+ }
+ }, [visibleTables, selectedTable]);
+
+ if (!schemaMetadata || !config)
+ return {expressSearchConfigText.loadingMetadata()}
;
+
+ const currentTableSchema = visibleTables.find(
+ (t: any) => t.name === selectedTable
+ );
+ const currentTable = selectedTable
+ ? genericTables[selectedTable as keyof typeof genericTables]
+ : undefined;
+
+ // Ensure table exists in config for rendering purposes
+ let tableConfig = config.tables.find(
+ (t: any) => t.tableName === selectedTable
+ );
+ if (!tableConfig && selectedTable) {
+ tableConfig = {
+ tableName: selectedTable,
+ displayOrder: config.tables.length,
+ searchFields: [],
+ displayFields: [],
+ };
+ }
+
+ const visibleDisplayFields = currentTable?.fields.filter((field: any) =>
+ (() => {
+ const schemaField = currentTable?.getField(field.name);
+ const isToManyRelationship =
+ schemaField?.isRelationship === true &&
+ relationshipIsToMany(
+ schemaField as Parameters[0]
+ );
+ const isVisibleInXml = tableConfig?.displayFields.some(
+ (displayField: any) =>
+ displayField.fieldName === field.name && displayField.inUse
+ );
+ const fieldExistsInSchema = currentTableSchema?.fields.some(
+ (schemaField: any) => schemaField.name === field.name
+ );
+ return (
+ fieldExistsInSchema && // Only show fields that actually exist in the schema
+ field.name !== 'collectionMemberId' && // Hide system collection field that isn't useful to display
+ (isVisibleInXml || // Make sure fields already in displayFields are always visible in the UI regardless of schema visibility settings
+ (isSchemaFieldVisible(
+ false,
+ schemaField?.isHidden ?? false,
+ field.name
+ ) &&
+ !isToManyRelationship))
+ );
+ })()
+ );
+
+ const checkedDisplayFields =
+ tableConfig?.displayFields.filter((field: any) => field.inUse !== false) ??
+ [];
+ const uncheckedDisplayFields =
+ tableConfig?.displayFields.filter((field: any) => field.inUse === false) ??
+ [];
+ const configuredDisplayFieldNames = new Set(
+ (tableConfig?.displayFields ?? []).map((field: any) => field.fieldName)
+ );
+ const unconfiguredDisplayFields =
+ visibleDisplayFields?.filter(
+ (field: any) => !configuredDisplayFieldNames.has(field.name)
+ ) ?? [];
+
+ const updateDisplayFields = (
+ fieldName: string,
+ isChecked: boolean,
+ direction?: 'down' | 'up'
+ ): void => {
+ const newConfig = JSON.parse(JSON.stringify(config));
+ const tConfig = newConfig.tables.find(
+ (table: any) => table.tableName === selectedTable
+ );
+ if (!tConfig) return;
+
+ const currentDisplayFields = tConfig.displayFields ?? [];
+ const existingField = currentDisplayFields.find(
+ (field: any) => field.fieldName === fieldName
+ );
+
+ if (direction !== undefined) {
+ const checkedFields = currentDisplayFields.filter(
+ (field: any) => field.inUse !== false
+ );
+ const uncheckedFields = currentDisplayFields.filter(
+ (field: any) => field.inUse === false
+ );
+ const currentIndex = checkedFields.findIndex(
+ (field: any) => field.fieldName === fieldName
+ );
+ if (currentIndex < 0) return;
+
+ const targetIndex =
+ direction === 'up' ? currentIndex - 1 : currentIndex + 1;
+ if (targetIndex < 0 || targetIndex >= checkedFields.length) return;
+
+ [checkedFields[currentIndex], checkedFields[targetIndex]] = [
+ checkedFields[targetIndex],
+ checkedFields[currentIndex],
+ ];
+
+ tConfig.displayFields = [...checkedFields, ...uncheckedFields];
+ } else if (isChecked) {
+ const checkedFields = currentDisplayFields.filter(
+ (field: any) => field.inUse !== false && field.fieldName !== fieldName
+ );
+ const uncheckedFields = currentDisplayFields.filter(
+ (field: any) => field.inUse === false && field.fieldName !== fieldName
+ );
+ const checkedEntry =
+ existingField === undefined
+ ? { fieldName, inUse: true }
+ : { ...existingField, inUse: true };
+ tConfig.displayFields = [
+ ...checkedFields,
+ checkedEntry,
+ ...uncheckedFields,
+ ];
+ } else {
+ const checkedFields = currentDisplayFields.filter(
+ (field: any) => field.inUse !== false && field.fieldName !== fieldName
+ );
+ const uncheckedFields = currentDisplayFields.filter(
+ (field: any) => field.inUse === false && field.fieldName !== fieldName
+ );
+ const uncheckedEntry =
+ existingField === undefined
+ ? { fieldName, inUse: false }
+ : { ...existingField, inUse: false };
+ tConfig.displayFields = [
+ ...checkedFields,
+ uncheckedEntry,
+ ...uncheckedFields,
+ ];
+ }
+
+ onChangeConfig(newConfig);
+ };
+
+ const handleSearchToggle = (fieldName: string, isChecked: boolean) => {
+ const newConfig = JSON.parse(JSON.stringify(config));
+ let tConfig = newConfig.tables.find(
+ (t: any) => t.tableName === selectedTable
+ );
+ if (!tConfig) {
+ tConfig = {
+ tableName: selectedTable,
+ displayOrder: newConfig.tables.length,
+ searchFields: [],
+ displayFields: [],
+ };
+ newConfig.tables.push(tConfig);
+ }
+
+ const sf = tConfig.searchFields.find((f: any) => f.fieldName === fieldName);
+ if (isChecked) {
+ if (sf) {
+ sf.inUse = true;
+ } else {
+ tConfig.searchFields.push({
+ fieldName,
+ inUse: true,
+ order: tConfig.searchFields.length,
+ sortDirection: 'None',
+ });
+ }
+ // Auto-select corresponding display field
+ const df = tConfig.displayFields.find(
+ (f: any) => f.fieldName === fieldName
+ );
+ if (df) {
+ df.inUse = true;
+ } else {
+ tConfig.displayFields.push({ fieldName, inUse: true });
+ }
+ } else {
+ if (sf) {
+ sf.inUse = false;
+ }
+ const df = tConfig.displayFields.find(
+ (f: any) => f.fieldName === fieldName
+ );
+ if (df) {
+ df.inUse = false;
+ }
+ }
+
+ onChangeConfig(newConfig);
+ };
+
+ const handleDisplayToggle = (fieldName: string, isChecked: boolean) => {
+ const sf = tableConfig?.searchFields.find(
+ (f: any) => f.fieldName === fieldName
+ );
+ // Can't uncheck display if search is checked
+ if (!isChecked && sf?.inUse) return;
+ updateDisplayFields(fieldName, isChecked);
+ };
+
+ const moveDisplayField = (
+ fieldName: string,
+ direction: 'down' | 'up'
+ ): void => {
+ updateDisplayFields(fieldName, true, direction);
+ };
+
+ const handleSortChange = (fieldName: string, sortDirection: string) => {
+ const newConfig = JSON.parse(JSON.stringify(config));
+ const tConfig = newConfig.tables.find(
+ (t: any) => t.tableName === selectedTable
+ );
+ const sf = tConfig?.searchFields.find(
+ (f: any) => f.fieldName === fieldName
+ );
+ if (sf) {
+ sf.sortDirection = sortDirection;
+ }
+ onChangeConfig(newConfig);
+ };
+
+ return (
+
+ {/* Left panel: tables */}
+
+
+ {expressSearchConfigText.availableTables()}
+
+
+ {visibleTables.map((t: any) => (
+ setSelectedTable(t.name)}
+ >
+
+
+
{tableLabel(t.name)}
+
+
+ ))}
+
+
+
+ {/* Middle panel: search fields */}
+
+
+ {expressSearchConfigText.searchableFields()}
+
+ {currentTableSchema && (
+
+
+
+ {expressSearchConfigText.searchLabel()}
+ {expressSearchConfigText.fieldName()}
+ {expressSearchConfigText.sortMode()}
+
+
+
+ {currentTableSchema.fields
+ .filter(isSearchFieldVisible)
+ .map((f: any) => {
+ const sf = tableConfig?.searchFields.find(
+ (sf: any) => sf.fieldName === f.name
+ );
+ const inUse = sf?.inUse ?? sf !== undefined;
+ return (
+
+
+
+ handleSearchToggle(
+ f.name,
+ (e.target as HTMLInputElement).checked
+ )
+ }
+ />
+
+
+ {fieldLabel(selectedTable!, f.name)}
+
+
+
+ handleSortChange(f.name, e.target.value)
+ }
+ >
+
+ {expressSearchConfigText.searchNone()}
+
+
+ {expressSearchConfigText.ascendingOrder()}
+
+
+ {expressSearchConfigText.descendingOrder()}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+
+ {/* Right panel: display fields */}
+
+
+ {expressSearchConfigText.displayFields()}
+
+ {currentTable && (
+
+
+ {checkedDisplayFields.map((f: any) => {
+ const sf = tableConfig?.searchFields.find(
+ (sf: any) => sf.fieldName === f.fieldName
+ );
+ const isSearchInUse = sf?.inUse ?? sf !== undefined;
+ const currentIndex = checkedDisplayFields.findIndex(
+ (field: any) => field.fieldName === f.fieldName
+ );
+ return (
+
+
+ handleDisplayToggle(
+ f.fieldName,
+ (e.target as HTMLInputElement).checked
+ )
+ }
+ />
+
+ {fieldLabel(selectedTable!, f.fieldName)}
+
+
+
+ moveDisplayField(f.fieldName, 'up')
+ }
+ >
+ {icons.chevronUp}
+
+
+ moveDisplayField(f.fieldName, 'down')
+ }
+ >
+ {icons.chevronDown}
+
+
+
+ );
+ })}
+
+
+
+ {uncheckedDisplayFields.map((f: any) => {
+ const sf = tableConfig?.searchFields.find(
+ (sf: any) => sf.fieldName === f.fieldName
+ );
+ const isSearchInUse = sf?.inUse ?? sf !== undefined;
+ return (
+
+
+ handleDisplayToggle(
+ f.fieldName,
+ (e.target as HTMLInputElement).checked
+ )
+ }
+ />
+ {fieldLabel(selectedTable!, f.fieldName)}
+
+ );
+ })}
+ {unconfiguredDisplayFields.map((f: any) => {
+ const sf = tableConfig?.searchFields.find(
+ (sf: any) => sf.fieldName === f.name
+ );
+ const isSearchInUse = sf?.inUse ?? sf !== undefined;
+ return (
+
+
+ handleDisplayToggle(
+ f.name,
+ (e.target as HTMLInputElement).checked
+ )
+ }
+ />
+ {fieldLabel(selectedTable!, f.name)}
+
+ );
+ })}
+
+
+ )}
+
+
+ );
+}
diff --git a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/ExpressSearchConfigEditor.test.tsx b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/ExpressSearchConfigEditor.test.tsx
new file mode 100644
index 00000000000..94d454bb3f4
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/ExpressSearchConfigEditor.test.tsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import { clearIdStore } from '../../../hooks/useId';
+import { mount } from '../../../tests/reactUtils';
+import { overrideAjax } from '../../../tests/ajax';
+import { ExpressSearchConfigEditor } from '../ExpressSearchConfigEditor';
+import { act } from '@testing-library/react';
+
+// Mock child components to simplify testing the main editor logic
+jest.mock('../SearchFieldsTab', () => ({
+ SearchFieldsTab: () => Search Fields Tab
,
+}));
+jest.mock('../RelatedTablesTab', () => ({
+ RelatedTablesTab: () => Related Tables Tab
,
+}));
+jest.mock('../ResultsOrderingTab', () => ({
+ ResultsOrderingTab: () => Results Ordering Tab
,
+}));
+
+beforeEach(() => {
+ clearIdStore();
+});
+
+const mockConfigResponse = {
+ config: {
+ tables: [
+ {
+ tableName: 'CollectionObject',
+ displayOrder: 0,
+ searchFields: [],
+ displayFields: [],
+ },
+ ],
+ relatedQueries: [],
+ },
+ related_queries_definitions: [],
+ schema_metadata: [
+ {
+ name: 'CollectionObject',
+ title: 'Collection Object',
+ fields: [],
+ },
+ ],
+};
+
+describe('ExpressSearchConfigEditor', () => {
+ overrideAjax('/express_search/config/', mockConfigResponse);
+
+ test('uses provided XML data in app-resource mode', async () => {
+ const onChangeJSON = jest.fn();
+ const xml =
+ 'Agent 0 firstName 0 None lastName ';
+
+ mount(
+
+ );
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ expect(onChangeJSON).toHaveBeenCalled();
+ const latestConfig =
+ onChangeJSON.mock.calls[onChangeJSON.mock.calls.length - 1][0];
+ expect(latestConfig.tables[0].tableName).toBe('Agent');
+ expect(latestConfig.tables[0].searchFields[0].fieldName).toBe('firstName');
+ });
+
+ test('renders loading state initially', async () => {
+ const { getByText } = mount(
+
+ );
+ expect(getByText('Loading...')).toBeInTheDocument();
+
+ // Wait for it to finish loading to avoid act warnings
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ });
+
+ test('renders tabs after data load', async () => {
+ const { findByRole } = mount(
+
+ );
+
+ expect(await findByRole('tablist')).toBeInTheDocument();
+ });
+
+ test('switches tabs correctly', async () => {
+ const { findByText, getByRole, user } = mount(
+
+ );
+
+ // Initial tab
+ expect(await findByText('Search Fields Tab')).toBeInTheDocument();
+
+ // Click Related Tables
+ const relatedTab = getByRole('tab', { name: /Related Tables/i });
+ await act(async () => {
+ await user.click(relatedTab);
+ });
+
+ expect(await findByText('Related Tables Tab')).toBeInTheDocument();
+
+ // Click Results Ordering
+ const resultsTab = getByRole('tab', { name: /Results Ordering/i });
+ await act(async () => {
+ await user.click(resultsTab);
+ });
+ expect(await findByText('Results Ordering Tab')).toBeInTheDocument();
+ });
+});
diff --git a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/RelatedTablesTab.test.tsx b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/RelatedTablesTab.test.tsx
new file mode 100644
index 00000000000..e096a045a54
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/RelatedTablesTab.test.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+
+import { mount } from '../../../tests/reactUtils';
+import { RelatedTablesTab } from '../RelatedTablesTab';
+
+describe('RelatedTablesTab', () => {
+ test('calls onChangeConfig when a related query is enabled or disabled', async () => {
+ const config = {
+ tables: [],
+ relatedQueries: [
+ { id: '1', isActive: true, isSystem: true, displayOrder: 0 },
+ { id: '2', isActive: false, isSystem: true, displayOrder: 1 },
+ ],
+ };
+
+ const relatedQueriesDefinitions = [
+ { id: '1', name: 'CollObjToDeterminer', description: 'Determiner' },
+ { id: '2', name: 'CollObjToLocality', description: 'Locality' },
+ ];
+
+ const onChangeConfig = jest.fn();
+ const { container, user } = mount(
+
+ );
+
+ const rows = Array.from(container.querySelectorAll('li'));
+ expect(rows).toHaveLength(2);
+
+ const inactiveRow = rows.find((row) =>
+ row.textContent?.includes('Locality')
+ );
+ expect(inactiveRow).toBeDefined();
+
+ const checkbox = inactiveRow?.querySelector('input[type="checkbox"]');
+ expect(checkbox).toBeInstanceOf(HTMLInputElement);
+ expect((checkbox as HTMLInputElement).checked).toBe(false);
+
+ await user.click(checkbox as HTMLInputElement);
+
+ expect(onChangeConfig).toHaveBeenCalledTimes(1);
+ const newConfig = onChangeConfig.mock.calls[0][0];
+ expect(
+ newConfig.relatedQueries.find((rq: any) => rq.id === '2').isActive
+ ).toBe(true);
+
+ const activeRow = rows[0];
+ const activeCheckbox = activeRow.querySelector('input[type="checkbox"]');
+ expect(activeCheckbox).toBeInstanceOf(HTMLInputElement);
+ expect((activeCheckbox as HTMLInputElement).checked).toBe(true);
+
+ await user.click(activeCheckbox as HTMLInputElement);
+
+ expect(onChangeConfig).toHaveBeenCalledTimes(2);
+ const secondConfig = onChangeConfig.mock.calls[1][0];
+ expect(
+ secondConfig.relatedQueries.find((rq: any) => rq.id === '1').isActive
+ ).toBe(false);
+ });
+});
diff --git a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/ResultsOrderingTab.test.tsx b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/ResultsOrderingTab.test.tsx
new file mode 100644
index 00000000000..4b15f595931
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/ResultsOrderingTab.test.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+
+import { mount } from '../../../tests/reactUtils';
+import { ResultsOrderingTab } from '../ResultsOrderingTab';
+
+describe('ResultsOrderingTab', () => {
+ test('hides related queries that do not have a usable title', () => {
+ const config = {
+ tables: [],
+ relatedQueries: [
+ { id: '8', isActive: true, displayOrder: 0 },
+ { id: '5', isActive: true, displayOrder: 1 },
+ ],
+ };
+
+ const relatedQueriesDefinitions = [
+ { id: '5', name: 'CollObjToDeterminer' },
+ ];
+
+ const { queryByText, container } = mount(
+
+ );
+
+ expect(queryByText('Related Query: 8')).not.toBeInTheDocument();
+ expect(container.querySelectorAll('li')).toHaveLength(1);
+ });
+
+ test('updates config display order when a related query is moved above a table', async () => {
+ const config = {
+ tables: [
+ {
+ tableName: 'CollectionObject',
+ displayOrder: 0,
+ searchFields: [{ fieldName: 'CatalogNumber', inUse: true }],
+ displayFields: [],
+ },
+ ],
+ relatedQueries: [{ id: '8', isActive: true, displayOrder: 1 }],
+ };
+
+ const onChangeConfig = jest.fn();
+ const relatedQueriesDefinitions = [
+ { id: '8', name: 'CollObjToDeterminer' },
+ ];
+
+ const { container, user } = mount(
+
+ );
+
+ const rows = Array.from(container.querySelectorAll('ul > li'));
+ expect(rows).toHaveLength(2);
+
+ const secondRowButtons = rows[1].querySelectorAll('button');
+ expect(secondRowButtons).toHaveLength(2);
+
+ await user.click(secondRowButtons[0]);
+
+ expect(onChangeConfig).toHaveBeenCalledTimes(1);
+ const updatedConfig = onChangeConfig.mock.calls[0][0];
+ expect(updatedConfig.relatedQueries[0].displayOrder).toBe(0);
+ expect(updatedConfig.tables[0].displayOrder).toBe(1);
+ });
+});
diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap
index c823d7dba47..13662ae86ec 100644
--- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap
+++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`field formatters are fetched and parsed correctly 1`] = `
{
diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/index.test.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/index.test.ts
index 3dfb93f5c28..e88f0b9db1b 100644
--- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/index.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/index.test.ts
@@ -1,7 +1,12 @@
import { mockTime, requireContext } from '../../../tests/helpers';
import { getField } from '../../DataModel/helpers';
import { tables } from '../../DataModel/tables';
-import { UiFormatter, fetchContext, fieldFormatterTypeMapper, getUiFormatters } from '..';
+import {
+ UiFormatter,
+ fetchContext,
+ fieldFormatterTypeMapper,
+ getUiFormatters,
+} from '..';
import { localized } from '../../../utils/types';
mockTime();
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index e9ed2afadc0..baf13a48768 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -24,7 +24,7 @@ import type { Collection, SpecifyTable } from '../DataModel/specifyTable';
import type { CollectionObjectGroup } from '../DataModel/types';
import { FormMeta } from '../FormMeta';
import type { FormCellDefinition, SubViewSortField } from '../FormParse/cells';
-import { attachmentView } from '../FormParse/webOnlyViews';
+import { DeleteButton } from '../Forms/DeleteButton';
import { SpecifyForm } from '../Forms/SpecifyForm';
import { SubViewContext } from '../Forms/SubView';
import { propsToFormMode, useViewDefinition } from '../Forms/useViewDefinition';
@@ -147,6 +147,8 @@ export function FormTable({
const rowsRef = React.useRef(null);
+ const isTreeTable = collection!.table.specifyTable.name.includes('Tree');
+
React.useEffect(() => {
if (addedResource.current === undefined) return;
const resourceIndex = resources.indexOf(addedResource.current);
@@ -212,8 +214,8 @@ export function FormTable({
resource.cid,
Boolean(
resource.specifyTable.name === 'Preparation' &&
- collectionPreparationPref &&
- resource.isNew()
+ collectionPreparationPref &&
+ resource.isNew()
),
])
)
@@ -440,7 +442,7 @@ export function FormTable({
collapsedViewDefinition.mode === 'search'
}
>
- {collapsedViewDefinition.name === attachmentView ? (
+ {collapsedViewDefinition.isAttachmentPlugin ? (
@@ -502,25 +504,52 @@ export function FormTable({
hasTablePermission(
relationship.relatedTable.name,
isDependent ? 'delete' : 'update'
- )) ? (
- handleDelete(resource)}
- >
- {icons.trash}
-
+ )) &&
+ !disableRemove &&
+ (renderedResourceId === undefined ||
+ renderedResourceId === resource.id) ? (
+ /*
+ * Check condition for tree table delete button, since new resources do not have id yet
+ * alternates between DeleteButton logic with save blcokers and simple remove button for new, and unsaved resources
+ */
+ resource.id !== undefined &&
+ resource.id !== null &&
+ isTreeTable ? (
+ {
+ if (typeof handleDelete === 'function') {
+ handleDelete(resource);
+ }
+ }}
+ >
+ {(onClick, disabled): JSX.Element => (
+
+ {icons.trash}
+
+ )}
+
+ ) : (
+ {
+ handleDelete(resource);
+ }}
+ >
+ {icons.trash}
+
+ )
) : undefined}
{isExpanded[resource.cid] === true && (
>;
- readonly fieldName: string;
- readonly displayFieldName: string;
-}): JSX.Element {
- const [entries] = useAsyncState(
- React.useCallback(async () => {
- const interactions: RA> = await Promise.all(
- resources.map(async (resource) => resource.rgetPromise(fieldName))
- );
- return interactions
- .map((resource) => ({
- label: resource.get(displayFieldName),
- resource,
- }))
- .sort(sortFunction(({ label }) => label));
- }, [resources, fieldName, displayFieldName]),
- false
- );
-
- return resources.length === 0 ? (
- <>{commonText.noResults()}>
- ) : Array.isArray(entries) ? (
-
- {entries.map(({ label, resource }, index) => (
-
-
- {label}
-
-
- ))}
-
- ) : (
- <>{commonText.loading()}>
- );
-}
-
export function ShowLoansCommand({
preparation,
onClose: handleClose,
@@ -73,101 +37,155 @@ export function ShowLoansCommand({
readonly preparation: SpecifyResource;
readonly onClose: () => void;
}): JSX.Element | null {
- const [data] = useAsyncState(
- React.useCallback(
- async () =>
- f.all({
- openLoans: hasTablePermission('LoanPreparation', 'read')
- ? fetchCollection('LoanPreparation', {
- isResolved: false,
- limit: DEFAULT_FETCH_LIMIT,
- preparation: preparation.get('id'),
- domainFilter: false,
- }).then(({ records }) => records.map(deserializeResource))
- : undefined,
- resolvedLoans: hasTablePermission('LoanPreparation', 'read')
- ? fetchCollection('LoanPreparation', {
- isResolved: true,
- limit: DEFAULT_FETCH_LIMIT,
- preparation: preparation.get('id'),
- domainFilter: false,
- }).then(({ records }) => records.map(deserializeResource))
- : undefined,
- gifts: hasTablePermission('GiftPreparation', 'read')
- ? fetchCollection('GiftPreparation', {
- limit: DEFAULT_FETCH_LIMIT,
- preparation: preparation.get('id'),
- domainFilter: false,
- }).then(({ records }) => records.map(deserializeResource))
- : undefined,
- exchanges: hasTablePermission('ExchangeOutPrep', 'read')
- ? fetchCollection('ExchangeOutPrep', {
- limit: DEFAULT_FETCH_LIMIT,
- preparation: preparation.get('id'),
- domainFilter: false,
- }).then(({ records }) => records.map(deserializeResource))
- : undefined,
- }),
- [preparation]
+ const accessibleInteractionTables = React.useMemo(
+ () =>
+ interactionsWithPrepTables.filter((interactionTable) =>
+ hasTablePermission(interactionTable, 'read')
+ ),
+ []
+ );
+
+ const [relatedInteractions] = useMultipleAsyncState<
+ RR>
+ >(
+ React.useMemo(
+ () =>
+ Object.fromEntries(
+ accessibleInteractionTables.map((interactionTable) => [
+ interactionTable,
+ async () =>
+ fetchRelatedInterations(preparation, interactionTable).then(
+ (records) => records.map(({ id }) => id)
+ ),
+ ])
+ ),
+ [preparation, accessibleInteractionTables]
),
- true
+ false
);
- return typeof data === 'object' ? (
+ return (
-
-
- {interactionsText.openLoans({
- loanTable: tables.Loan.label,
- })}
-
-
-
-
- {interactionsText.resolvedLoans({
- loanTable: tables.Loan.label,
- })}
-
-
-
-
- {interactionsText.gifts({
- giftTable: tables.Gift.label,
- })}
-
-
- {Array.isArray(data.exchanges) && data.exchanges.length > 0 && (
+ {relatedInteractions === undefined
+ ? commonText.loading()
+ : accessibleInteractionTables.length ===
+ Object.keys(relatedInteractions).length &&
+ Object.values(relatedInteractions).every(
+ (relatedIds) =>
+ Array.isArray(relatedIds) && relatedIds.length === 0
+ )
+ ? interactionsText.noInteractions()
+ : accessibleInteractionTables
+ .map(
+ (interactionTable) =>
+ [
+ interactionTable,
+ relatedInteractions[interactionTable],
+ ] as const
+ )
+ .map(([interactionTable, relatedIds], index) => (
+
+ ))}
+
+ );
+}
+function InterationWithPreps({
+ tableName,
+ relatedInteractionIds,
+}: {
+ readonly tableName: InteractionWithPreps['tableName'];
+ readonly relatedInteractionIds: RA | undefined;
+}): JSX.Element | null {
+ const [isOpen, handleOpen, handleClose, _] = useBooleanState(false);
+
+ return (
+ <>
+ {relatedInteractionIds === undefined ? (
<>
- {interactionsText.exchanges({
- exhangeInTable: tables.ExchangeIn.label,
- exhangeOutTable: tables.ExchangeOut.label,
- })}
+
+
+
+ {interactionsText.tableLabelRecords({
+ tableLabel: tables[tableName].label,
+ })}
+
-
+ {commonText.loading()}
>
+ ) : relatedInteractionIds.length === 0 ? null : (
+
+
+
+
+ {`${interactionsText.tableLabelRecords({
+ tableLabel: tables[tableName].label,
+ })} (${formatNumber(relatedInteractionIds.length)})`}
+
+
)}
-
- ) : null;
+ {isOpen && Array.isArray(relatedInteractionIds) ? (
+ undefined}
+ onSlide={undefined}
+ />
+ ) : null}
+ >
+ );
+}
+
+async function fetchRelatedInterations<
+ INTERACTION_TABLE extends InteractionWithPreps['tableName'],
+>(
+ preparation: SpecifyResource,
+ interactionTable: INTERACTION_TABLE
+): Promise>> {
+ const preparationField = tables[interactionTable].relationships.find(
+ (relationship) =>
+ interactionPrepTables.includes(
+ relationship.relatedTable.name as AnyInteractionPreparation['tableName']
+ )
+ );
+
+ return fetchCollection(interactionTable, {
+ ...backendFilter(
+ formatRelationshipPath(preparationField!.name, 'preparation')
+ ).equals(preparation.get('id')),
+ domainFilter: false,
+ limit: 0,
+ } as CollectionFetchFilters).then(
+ ({ records }) => {
+ /**
+ * If there are multiple InteractionPreparations in an Interaction that
+ * reference the same Preparation, remove the duplicated Interaction
+ * records from response
+ */
+ const recordIds: Record = {};
+ return records.filter(({ id }) => {
+ if (recordIds[id]) return false;
+ recordIds[id] = true;
+ return true;
+ });
+ }
+ );
}
diff --git a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/createView.test.ts.snap b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/createView.test.ts.snap
index 4ced007cfc9..e09d65fa939 100644
--- a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/createView.test.ts.snap
+++ b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/createView.test.ts.snap
@@ -1,7 +1,10 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Tables with form tables computed correctly 1`] = `
[
+ "[table AbsoluteAge]",
+ "[table AbsoluteAgeAttachment]",
+ "[table AbsoluteAgeCitation]",
"[table Accession]",
"[table AccessionAgent]",
"[table AccessionAttachment]",
@@ -36,9 +39,13 @@ exports[`Tables with form tables computed correctly 1`] = `
"[table CollectionObjectAttr]",
"[table CollectionObjectAttribute]",
"[table CollectionObjectCitation]",
+ "[table CollectionObjectGroup]",
+ "[table CollectionObjectGroupJoin]",
+ "[table CollectionObjectGroupType]",
"[table CollectionObjectProperty]",
- "[table CollectionRelType]",
+ "[table CollectionObjectType]",
"[table CollectionRelationship]",
+ "[table CollectionRelType]",
"[table Collector]",
"[table CommonNameTx]",
"[table CommonNameTxCitation]",
@@ -48,12 +55,6 @@ exports[`Tables with form tables computed correctly 1`] = `
"[table ConservEvent]",
"[table ConservEventAttachment]",
"[table Container]",
- "[table DNAPrimer]",
- "[table DNASequence]",
- "[table DNASequenceAttachment]",
- "[table DNASequencingRun]",
- "[table DNASequencingRunAttachment]",
- "[table DNASequencingRunCitation]",
"[table Deaccession]",
"[table DeaccessionAgent]",
"[table DeaccessionAttachment]",
@@ -66,6 +67,12 @@ exports[`Tables with form tables computed correctly 1`] = `
"[table DisposalAttachment]",
"[table DisposalPreparation]",
"[table Division]",
+ "[table DNAPrimer]",
+ "[table DNASequence]",
+ "[table DNASequenceAttachment]",
+ "[table DNASequencingRun]",
+ "[table DNASequencingRunAttachment]",
+ "[table DNASequencingRunCitation]",
"[table ExchangeIn]",
"[table ExchangeInAttachment]",
"[table ExchangeInPrep]",
@@ -116,19 +123,23 @@ exports[`Tables with form tables computed correctly 1`] = `
"[table PcrPerson]",
"[table Permit]",
"[table PermitAttachment]",
- "[table PrepType]",
"[table Preparation]",
"[table PreparationAttachment]",
"[table PreparationAttr]",
"[table PreparationAttribute]",
"[table PreparationProperty]",
+ "[table PrepType]",
"[table Project]",
"[table ReferenceWork]",
"[table ReferenceWorkAttachment]",
+ "[table RelativeAge]",
+ "[table RelativeAgeAttachment]",
+ "[table RelativeAgeCitation]",
"[table RepositoryAgreement]",
"[table RepositoryAgreementAttachment]",
"[table Shipment]",
"[table SpAuditLog]",
+ "[table SpDataSetAttachment]",
"[table Storage]",
"[table StorageAttachment]",
"[table StorageTreeDef]",
@@ -139,22 +150,11 @@ exports[`Tables with form tables computed correctly 1`] = `
"[table TaxonCitation]",
"[table TaxonTreeDef]",
"[table TaxonTreeDefItem]",
+ "[table TectonicUnit]",
+ "[table TectonicUnitTreeDef]",
+ "[table TectonicUnitTreeDefItem]",
"[table TreatmentEvent]",
"[table TreatmentEventAttachment]",
"[table VoucherRelationship]",
- "[table CollectionObjectType]",
- "[table CollectionObjectGroup]",
- "[table CollectionObjectGroupJoin]",
- "[table CollectionObjectGroupType]",
- "[table AbsoluteAge]",
- "[table RelativeAge]",
- "[table AbsoluteAgeAttachment]",
- "[table RelativeAgeAttachment]",
- "[table AbsoluteAgeCitation]",
- "[table RelativeAgeCitation]",
- "[table TectonicUnitTreeDef]",
- "[table TectonicUnitTreeDefItem]",
- "[table TectonicUnit]",
- "[table SpDataSetAttachment]",
]
`;
diff --git a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/fetchAllViews.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/fetchAllViews.test.tsx.snap
index ab1ce534d9b..98208a49a69 100644
--- a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/fetchAllViews.test.tsx.snap
+++ b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/fetchAllViews.test.tsx.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`fetchAllViews 1`] = `
{
diff --git a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/index.test.ts.snap b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/index.test.ts.snap
index 1e7ca28896a..7f1f0bba827 100644
--- a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/index.test.ts.snap
+++ b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/index.test.ts.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Can edit a form definition 1`] = `
{
@@ -43,44 +43,44 @@ exports[`Can edit a form definition 1`] = `
`;
exports[`Can edit a form definition 2`] = `
-"
-
+"
+
-
+
The Accession form.
-
-
-
+
+
+
-
+
The Accession form.
100px,2px,175px,5px,140px,2px,161px,5px,75px,2px,160px,0px,15px,p:g
- test
+ test
100px,2px,175px,5px,140px,2px,161px,5px,75px,2px,160px,0px,15px,p:g
- 115px,2px,185px,5px,160px,2px,180px,5px,105px,2px,156px,0px,15px,p:g
- 130px,2px,205px,5px,180px,2px,200px,5px,125px,2px,176px,0px,15px,p:g
- p,2px,p:g,5px:g,p,2px,p:g,5px:g,p,2px,p:g,0px,p,p:g
-
+ 115px,2px,185px,5px,160px,2px,180px,5px,105px,2px,156px,0px,15px,p:g
+ 130px,2px,205px,5px,180px,2px,200px,5px,125px,2px,176px,0px,15px,p:g
+ p,2px,p:g,5px:g,p,2px,p:g,5px:g,p,2px,p:g,0px,p,p:g
+
-
-
-
-
-
-
+
+ |
+ |
+ |
+ |
+ |
| | | | |
-
-
-
-
+ |
+ |
+ |
+ |
| | | |
diff --git a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/viewSpec.test.ts.snap b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/viewSpec.test.ts.snap
index 02fdefd49aa..56159e20d5c 100644
--- a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/viewSpec.test.ts.snap
+++ b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/viewSpec.test.ts.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Can edit form definition 1`] = `
"
diff --git a/specifyweb/frontend/js_src/lib/components/FormFields/README.md b/specifyweb/frontend/js_src/lib/components/FormFields/README.md
index e4e6e537e86..24b078488ff 100644
--- a/specifyweb/frontend/js_src/lib/components/FormFields/README.md
+++ b/specifyweb/frontend/js_src/lib/components/FormFields/README.md
@@ -1,5 +1,5 @@
# Form Fields
-[Documentation](https://discourse.specifysoftware.org/t/editing-forms-in-specify/1557#p-2936-field-31)
+[Documentation](https://speciforum.org/t/editing-forms-in-specify/1557#p-2936-field-31)
On any modifications, please check if documentation needs to be updated.
diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/AutoNumbering.test.ts b/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/AutoNumbering.test.ts
index ed46f0851b4..48f385cea9e 100644
--- a/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/AutoNumbering.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/AutoNumbering.test.ts
@@ -8,3 +8,8 @@ test('getAutoNumberingFields', () =>
expect(
getAutoNumberingFields(tables.CollectionObject).map(({ name }) => name)
).toEqual(['catalogNumber']));
+
+test('getAutoNumberingFields returns empty array for table without auto-numberable fields', () =>
+ expect(
+ getAutoNumberingFields(tables.Locality).map(({ name }) => name)
+ ).toEqual([]));
diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/CarryForward.test.ts b/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/CarryForward.test.ts
index 24820a7f90e..c75539e5011 100644
--- a/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/CarryForward.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/CarryForward.test.ts
@@ -1,8 +1,59 @@
import { requireContext } from '../../../tests/helpers';
-import { dependentFields, strictDependentFields } from '../CarryForward';
+import {
+ dependentFields,
+ strictDependentFields,
+ tableValidForBulkClone,
+} from '../CarryForward';
+import { tables } from '../../DataModel/tables';
requireContext();
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+test('tableValidForBulkClone returns true for CollectionObject', () =>
+ expect(tableValidForBulkClone(tables.CollectionObject)).toBe(true));
+
+test('tableValidForBulkClone returns false for non-CollectionObject table', () =>
+ expect(tableValidForBulkClone(tables.Locality)).toBe(false));
+
+test('tableValidForBulkClone returns false when formatter has regex part', () => {
+ const catalogNumber =
+ tables.CollectionObject.strictGetLiteralField('catalogNumber');
+ jest.spyOn(catalogNumber, 'getUiFormatter').mockReturnValue({
+ parts: [{ type: 'regex', canAutonumber: () => false } as any],
+ } as any);
+ expect(tableValidForBulkClone(tables.CollectionObject)).toBe(false);
+});
+
+test('tableValidForBulkClone returns false when formatter has alpha part', () => {
+ const catalogNumber =
+ tables.CollectionObject.strictGetLiteralField('catalogNumber');
+ jest.spyOn(catalogNumber, 'getUiFormatter').mockReturnValue({
+ parts: [{ type: 'alpha', canAutonumber: () => false } as any],
+ } as any);
+ expect(tableValidForBulkClone(tables.CollectionObject)).toBe(false);
+});
+
+test('tableValidForBulkClone returns false when formatter has alphanumeric part', () => {
+ const catalogNumber =
+ tables.CollectionObject.strictGetLiteralField('catalogNumber');
+ jest.spyOn(catalogNumber, 'getUiFormatter').mockReturnValue({
+ parts: [{ type: 'alphanumeric', canAutonumber: () => false } as any],
+ } as any);
+ expect(tableValidForBulkClone(tables.CollectionObject)).toBe(false);
+});
+
+test('tableValidForBulkClone returns false when numeric part cannot auto-number', () => {
+ const catalogNumber =
+ tables.CollectionObject.strictGetLiteralField('catalogNumber');
+ jest.spyOn(catalogNumber, 'getUiFormatter').mockReturnValue({
+ parts: [{ type: 'numeric', canAutonumber: () => false } as any],
+ } as any);
+ expect(tableValidForBulkClone(tables.CollectionObject)).toBe(false);
+});
+
test('strictDependentFields', () =>
expect(strictDependentFields()).toMatchSnapshot());
diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/__snapshots__/CarryForward.test.ts.snap b/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/__snapshots__/CarryForward.test.ts.snap
index 5e4e6f21c14..ee37d431ac5 100644
--- a/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/__snapshots__/CarryForward.test.ts.snap
+++ b/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/__snapshots__/CarryForward.test.ts.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`dependentFields 1`] = `
{
diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/README.md b/specifyweb/frontend/js_src/lib/components/FormParse/README.md
index 80f54d565b8..e034a92cedd 100644
--- a/specifyweb/frontend/js_src/lib/components/FormParse/README.md
+++ b/specifyweb/frontend/js_src/lib/components/FormParse/README.md
@@ -2,6 +2,6 @@
Parse loose XML form definition into a strict and type-safe JSON structure.
-[Documentation](https://discourse.specifysoftware.org/t/editing-forms-in-specify/1557)
+[Documentation](https://speciforum.org/t/editing-forms-in-specify/1557)
On any modifications, please check if documentation needs to be updated.
diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/cells.ts b/specifyweb/frontend/js_src/lib/components/FormParse/cells.ts
index 9b815635d6a..8733e6e96e0 100644
--- a/specifyweb/frontend/js_src/lib/components/FormParse/cells.ts
+++ b/specifyweb/frontend/js_src/lib/components/FormParse/cells.ts
@@ -1,7 +1,7 @@
/**
* Parse form cell XML into a JSON structure
*
- * Documentation - https://discourse.specifysoftware.org/t/editing-forms-in-specify/1557#p-2936-cell-definition-22
+ * Documentation - https://speciforum.org/t/editing-forms-in-specify/1557#p-2936-cell-definition-22
* On any modifications, please check if documentation needs to be updated.
*/
@@ -118,6 +118,17 @@ export type SubViewSortField = {
export const cellAlign = ['left', 'center', 'right'] as const;
export const cellVerticalAlign = ['stretch', 'center', 'start', 'end'] as const;
+const specialFieldNames = new Set([
+ /*
+ * Legacy synthetic form controls that are not schema-backed fields.
+ * These are still parsed intentionally by some field renderers.
+ */
+ 'this',
+ 'generateLabelChk',
+ 'generateInvoice',
+ 'sendEMail',
+]);
+
const processCellType: {
readonly [KEY in keyof CellTypes]: (props: {
readonly cell: SimpleXmlNode;
@@ -156,6 +167,7 @@ const processCellType: {
if (
resolvedFields === undefined &&
+ !specialFieldNames.has(rawFieldName ?? '') &&
(fieldDefinition.type !== 'Plugin' ||
fieldDefinition.pluginDefinition.type === 'PartialDateUI')
)
diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/commands.ts b/specifyweb/frontend/js_src/lib/components/FormParse/commands.ts
index f34965f8a32..b642eb47bf5 100644
--- a/specifyweb/frontend/js_src/lib/components/FormParse/commands.ts
+++ b/specifyweb/frontend/js_src/lib/components/FormParse/commands.ts
@@ -1,7 +1,7 @@
/**
* Parse XML cell with a command definition into a JSON structure
*
- * Documentation - https://discourse.specifysoftware.org/t/editing-forms-in-specify/1557#p-2936-command-29
+ * Documentation - https://speciforum.org/t/editing-forms-in-specify/1557#p-2936-command-29
* On any modifications, please check if documentation needs to be updated.
*/
diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts b/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts
index ebe8fa93f99..ed67aeed4b2 100644
--- a/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts
+++ b/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts
@@ -1,7 +1,7 @@
/**
* Parse XML cell with a field definition into JSON
*
- * Documentation - https://discourse.specifysoftware.org/t/editing-forms-in-specify/1557#p-2936-field-31
+ * Documentation - https://speciforum.org/t/editing-forms-in-specify/1557#p-2936-field-31
* On any modifications, please check if documentation needs to be updated.
*/
diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/index.ts b/specifyweb/frontend/js_src/lib/components/FormParse/index.ts
index 0e310ca3fa7..86e5325b50f 100644
--- a/specifyweb/frontend/js_src/lib/components/FormParse/index.ts
+++ b/specifyweb/frontend/js_src/lib/components/FormParse/index.ts
@@ -50,6 +50,7 @@ export type ViewDescription = ParsedFormDefinition & {
readonly errors?: RA;
readonly viewSetId?: number;
readonly name: string;
+ readonly isAttachmentPlugin?: true;
};
type AltView = {
diff --git a/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap
index d2930538113..fb4094c85f1 100644
--- a/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap
+++ b/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`PartialDateUi renders without errors 1`] = `
diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx
index 18ba02e6180..8b6da91d914 100644
--- a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx
@@ -7,6 +7,7 @@ import { commonText } from '../../localization/common';
import { f } from '../../utils/functools';
import type { RA } from '../../utils/types';
import { Button } from '../Atoms/Button';
+import { className } from '../Atoms/className';
import { DataEntry } from '../Atoms/DataEntry';
import { ReadOnlyContext } from '../Core/Contexts';
import type { CollectionFetchFilters } from '../DataModel/collection';
@@ -23,6 +24,7 @@ import { COJODialog } from '../FormCells/COJODialog';
import { FormTableCollection } from '../FormCells/FormTableCollection';
import type { FormType } from '../FormParse';
import type { SubViewSortField } from '../FormParse/cells';
+import { DeleteButton } from '../Forms/DeleteButton';
import { augmentMode, ResourceView } from '../Forms/ResourceView';
import { useFirstFocus } from '../Forms/SpecifyForm';
import { SubViewContext } from '../Forms/SubView';
@@ -137,6 +139,8 @@ export function IntegratedRecordSelector({
const isAttachmentTable =
collection.table.specifyTable.name.includes('Attachment');
+ const isTreeTable = collection.table.specifyTable.name.includes('Tree');
+
const subviewContext = React.useContext(SubViewContext);
const parentContext = React.useMemo(
() => subviewContext?.parentContext ?? [],
@@ -316,25 +320,50 @@ export function IntegratedRecordSelector({
{hasTablePermission(
relationship.relatedTable.name,
isDependent ? 'delete' : 'read'
- ) && typeof handleRemove === 'function' ? (
- {
- if (isAttachmentTable) {
- handleWarning();
- } else {
+ ) &&
+ typeof handleRemove === 'function' &&
+ resource !== undefined ? (
+ isTreeTable &&
+ resource.id !== undefined &&
+ resource.id !== null ? (
+ {
handleRemove('minusButton');
+ }}
+ >
+ {(onClick, disabled) => (
+
+ )}
+
+ ) : (
+
+ onClick={(): void => {
+ if (isAttachmentTable) {
+ handleWarning();
+ } else {
+ handleRemove('minusButton');
+ }
+ }}
+ />
+ )
) : undefined}
fetch("url", {offset, limit: 20});
+ * const {
+ * results: [results, setResults],
+ * onFetchMore: handleFetchMore,
+ * totalCount: [totalCount, setTotalCount],
+ * canFetchMore
+ * } = usePaginatedRecords({
+ * initialData: initialIds,
+ * totalCount,
+ * fetchSize: 3,
+ * fetchResults: fetchMoreIds
+ * });
+ *
+ * // We can call handleFetchMore to automatically grab the next fetchSize
+ * // records
+ * const fetchedNext = await handleFetchMore();
+ * console.log(fetchedNext); // [4, 5, 6]
+ * // Now results will have the next fetchSize results
+ * console.log(results); // [1, 2, 3, 4, 5, 6]
+ * // We can pass in a specific index to fetch only from that index
+ * const fetchFromFar = await handleFetchMore(99);
+ * console.log(fetchFromFar); // [100, 101, 102]
+ * // Note that the results array can be sparse, with holes at the indexes
+ * // where results have not been fetched
+ * console.log(results);
+ * // [1, 2, 3, 4, 5, 6, , 100, 101, 102]
+ * ```
+ */
+export function usePaginatedRecords<
+ PAGINATED_TYPE,
+ FETCH_ARGS extends RA,
+>({
initialData,
- fetchResults,
totalCount: initialTotalCount,
- fetchSize,
-}: Pick<
- QueryResultsProps,
- 'fetchResults' | 'fetchSize' | 'initialData' | 'totalCount'
->): {
- readonly results: GetOrSet | undefined>;
- readonly fetchersRef: {
- readonly current: IR | void>>;
- };
- readonly onFetchMore: (index?: number) => Promise | void>;
+ fetchSize = DEFAULT_FETCH_LIMIT,
+ fetchResults,
+}: {
+ readonly initialData: RA | undefined;
+ readonly totalCount: number | undefined;
+ readonly fetchSize?: number;
+ readonly fetchResults:
+ | ((offset: number, ...args: FETCH_ARGS) => Promise>)
+ | undefined;
+}): {
+ readonly results: GetOrSet | undefined>;
+ readonly onFetchMore: (
+ index?: number,
+ ...args: FETCH_ARGS
+ ) => Promise | void>;
readonly totalCount: GetOrSet;
readonly canFetchMore: boolean;
} {
@@ -30,34 +75,27 @@ export function useFetchQueryResults({
* hundreds of thousands of results.
*/
const getSetResults = useTriggerState<
- RA | undefined
+ RA | undefined
>(initialData);
const [results, setResults] = getSetResults;
const resultsRef = React.useRef(results);
- const handleSetResults: GetOrSet<
- RA | undefined
- >[1] = React.useCallback(
- (results) => {
- const resolved =
- typeof results === 'function' ? results(resultsRef.current) : results;
- setResults(resolved);
- resultsRef.current = resolved;
- },
- [setResults]
- );
// Queue for fetching
- const fetchersRef = React.useRef | void>>>({});
+ const fetchersRef = React.useRef | void>>>({});
const getSetTotalCount = useTriggerState(initialTotalCount);
const [totalCount] = getSetTotalCount;
+
const canFetchMore =
!Array.isArray(results) ||
totalCount === undefined ||
results.length < totalCount;
const handleFetchMore = React.useCallback(
- async (index?: number): Promise | void> => {
+ async (
+ index?: number,
+ ...args: FETCH_ARGS
+ ): Promise | void> => {
const currentResults = resultsRef.current;
const canFetch = Array.isArray(currentResults);
@@ -87,7 +125,7 @@ export function useFetchQueryResults({
: naiveFetchIndex;
// Prevent concurrent fetching in different places
- fetchersRef.current[fetchIndex] ??= fetchResults(fetchIndex)
+ fetchersRef.current[fetchIndex] ??= fetchResults(fetchIndex, ...args)
.then(async (newResults) => {
if (
process.env.NODE_ENV === 'development' &&
@@ -111,7 +149,7 @@ export function useFetchQueryResults({
combinedResults[fetchIndex] ??= undefined;
combinedResults.splice(fetchIndex, newResults.length, ...newResults);
- handleSetResults(combinedResults);
+ setResults(combinedResults);
fetchersRef.current = removeKey(
fetchersRef.current,
@@ -119,7 +157,7 @@ export function useFetchQueryResults({
);
if (typeof index === 'number' && index >= combinedResults.length)
- return handleFetchMore(index);
+ return handleFetchMore(index, ...args);
return newResults;
})
.catch(raise);
@@ -130,8 +168,7 @@ export function useFetchQueryResults({
);
return {
- fetchersRef,
- results: [results, handleSetResults],
+ results: [results, setResults],
onFetchMore: handleFetchMore,
totalCount: getSetTotalCount,
canFetchMore,
diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap
index 33999118207..dea1513e2c1 100644
--- a/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap
+++ b/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Formatters are fetched and parsed correctly 1`] = `
{
diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts b/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts
index 6bac7a53769..356b993e3de 100644
--- a/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts
+++ b/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts
@@ -196,10 +196,15 @@ async function formatField(
? naiveFormatter(parentResource.specifyTable.name, parentResource.id)
: userText.noPermission();
- if (trimZeros)
- formatted = Number.isNaN(Number(formatted))
- ? formatted
- : Number(formatted).toString();
+ if (trimZeros) {
+ const num = Number(formatted);
+ formatted =
+ Number.isNaN(num) ||
+ (formatted ?? '').trim() === '' ||
+ !Number.isSafeInteger(num)
+ ? formatted
+ : num.toString();
+ }
return {
formatted: formatted?.toString() ?? '',
diff --git a/specifyweb/frontend/js_src/lib/components/Forms/DeleteButton.tsx b/specifyweb/frontend/js_src/lib/components/Forms/DeleteButton.tsx
index 79bec086528..9d5e89f6b3f 100644
--- a/specifyweb/frontend/js_src/lib/components/Forms/DeleteButton.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Forms/DeleteButton.tsx
@@ -56,11 +56,14 @@ export function DeleteButton({
component: ButtonComponent = Button.Secondary,
onDeleted: handleDeleted,
isIcon = false,
+ children,
}: DeleteButtonProps & {
readonly deletionMessage?: React.ReactNode;
readonly component?: (typeof Button)['Secondary'];
readonly onDeleted?: () => void;
readonly isIcon?: boolean;
+ // A render prop to render custom children inside the delete dialog
+ readonly children?: (onClick: () => void, disabled: boolean) => JSX.Element;
}): JSX.Element {
const { blockers, setBlockers, fetchBlockers } = useDeleteBlockers(
resource,
@@ -74,9 +77,20 @@ export function DeleteButton({
const iconName = resource.specifyTable.name;
+ // Callback for button click
+ const handleClick = (): void => {
+ handleOpen();
+ fetchBlockers();
+ };
+
+ const isDisabled = blockers === undefined || isBlocked;
+
return (
<>
- {isIcon ? (
+ {/* Use children as render prop if provided */}
+ {typeof children === 'function' ? (
+ children(handleClick, isDisabled)
+ ) : isIcon ? (
({
? undefined
: error(error_)
)
+ .then(handleSaved)
.finally(() => {
unsetUnloadProtect();
- handleSaved?.();
setIsSaving(false);
});
}
@@ -407,7 +407,12 @@ function useEnabledButtons(
!NO_CLONE.has(tableName) &&
!isDisabledCloneAppResource;
const showAdd =
- !disableAdd.includes(tableName) && !FORBID_ADDING.has(tableName);
+ !disableAdd.includes(tableName) &&
+ !FORBID_ADDING.has(tableName) &&
+ !(
+ tableName === 'SpAppResource' &&
+ appResourceName === 'CollectionPreferences'
+ );
return {
showClone,
diff --git a/specifyweb/frontend/js_src/lib/components/Forms/SpecifyForm.tsx b/specifyweb/frontend/js_src/lib/components/Forms/SpecifyForm.tsx
index ac1840ef1e2..d1812f5e702 100644
--- a/specifyweb/frontend/js_src/lib/components/Forms/SpecifyForm.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Forms/SpecifyForm.tsx
@@ -18,7 +18,6 @@ import type { AnySchema } from '../DataModel/helperTypes';
import type { SpecifyResource } from '../DataModel/legacyTypes';
import { FormCell } from '../FormCells';
import type { ViewDescription } from '../FormParse';
-import { attachmentView } from '../FormParse/webOnlyViews';
import { loadingGif } from '../Molecules';
import { userPreferences } from '../Preferences/userPreferences';
import { unsafeTriggerNotFound } from '../Router/Router';
@@ -107,7 +106,7 @@ export function SpecifyForm({
React.useContext(SearchDialogContext) || viewDefinition?.mode === 'search';
const [language] = userPreferences.use('form', 'schema', 'language');
- return viewDefinition?.name === attachmentView ? (
+ return viewDefinition?.isAttachmentPlugin ? (
) : (
diff --git a/specifyweb/frontend/js_src/lib/components/Forms/__tests__/DeleteButton.test.tsx b/specifyweb/frontend/js_src/lib/components/Forms/__tests__/DeleteButton.test.tsx
index 56b3870334b..eda37d3d8cc 100644
--- a/specifyweb/frontend/js_src/lib/components/Forms/__tests__/DeleteButton.test.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Forms/__tests__/DeleteButton.test.tsx
@@ -103,7 +103,7 @@ overrideAjax(
ordinal: 32_767,
remarks: null,
resource_uri: undefined,
- searchsynonymy: null,
+ searchsynonymy: false,
selectdistinct: false,
smushed: false,
specifyuser: '/api/specify/specifyuser/2/',
diff --git a/specifyweb/frontend/js_src/lib/components/Forms/useViewDefinition.tsx b/specifyweb/frontend/js_src/lib/components/Forms/useViewDefinition.tsx
index 98e7dc0fffc..88500252d06 100644
--- a/specifyweb/frontend/js_src/lib/components/Forms/useViewDefinition.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Forms/useViewDefinition.tsx
@@ -52,15 +52,33 @@ export function useViewDefinition({
const [viewDefinition] = useAsyncState(
React.useCallback(async () => {
if (table === undefined) return undefined;
- else if (viewName === attachmentView)
+ else if (viewName === attachmentView) {
+ /*
+ * Try the specific attachment table form first (e.g.
+ * 'CollectionObjectAttachment'), then fall back to the generic
+ * 'ObjectAttachment' form. Whichever has a viewSetId (i.e. is
+ * user-defined) wins so the "Edit Form Definition" link can appear.
+ */
+ const [specificView, genericView] = await Promise.all([
+ fetchView(table.name),
+ fetchView(attachmentView),
+ ]);
+ const viewWithId =
+ specificView?.viewsetId == null
+ ? genericView?.viewsetId == null
+ ? undefined
+ : genericView
+ : specificView;
return {
...webOnlyViews()[attachmentView],
table,
- name: attachmentView,
+ name: viewWithId?.name ?? attachmentView,
formType,
mode,
+ viewSetId: viewWithId?.viewsetId ?? undefined,
+ isAttachmentPlugin: true,
};
- else if (useGeneratedForm)
+ } else if (useGeneratedForm)
return autoGenerateViewDefinition(table, formType, mode);
const resolvedViewName = viewName ?? table.view;
return fetchViewDefinition(resolvedViewName, table, formType, mode)
@@ -94,7 +112,7 @@ const fetchViewDefinition = async (
fetchView(
viewName === originalAttachmentsView ? 'ObjectAttachment' : viewName
)
- .then((viewDefinition) =>
+ .then(async (viewDefinition) =>
typeof viewDefinition === 'object'
? parseViewDefinition(viewDefinition, formType, mode, table)
: undefined
@@ -106,10 +124,7 @@ const fetchViewDefinition = async (
new Error('View definition table does not match resource table')
);
return viewName === originalAttachmentsView
- ? {
- ...viewDefinition,
- name: originalAttachmentsView,
- }
+ ? { ...viewDefinition, name: originalAttachmentsView }
: viewDefinition;
} else
return f.maybe(
diff --git a/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchHooks.tsx b/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchHooks.tsx
index d083ec276f8..62177d62253 100644
--- a/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchHooks.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchHooks.tsx
@@ -19,7 +19,8 @@ export type RawExpressSearchResult = {
};
export function usePrimarySearch(
- query: string
+ query: string,
+ refreshTrigger?: number
): RA | false | undefined {
const [primaryResults] = useAsyncState | false>(
React.useCallback(async () => {
@@ -31,6 +32,7 @@ export function usePrimarySearch(
return ajax>(ajaxUrl, {
headers: { Accept: 'application/json' },
expectedErrors: [Http.FORBIDDEN],
+ cache: 'no-store',
}).then(({ data, status }) =>
status === Http.FORBIDDEN
? false
@@ -43,20 +45,23 @@ export function usePrimarySearch(
ajaxUrl,
}))
);
- }, [query]),
+ }, [query, refreshTrigger]),
false
);
return primaryResults;
}
-const relatedSearchesPromise = contextUnlockedPromise.then(
- async (entrypoint) =>
+async function fetchRelatedSearches(): Promise> {
+ return contextUnlockedPromise.then(async (entrypoint) =>
entrypoint === 'main'
? ajax>('/context/available_related_searches.json', {
headers: { Accept: 'application/json' },
+ cache: 'no-store',
}).then(({ data }) => data)
: foreverFetch>()
-);
+ );
+}
+
export const expressSearchFetchSize = 40;
type FieldSpec = {
@@ -83,12 +88,13 @@ type RelatedTableResult = {
};
export function useSecondarySearch(
- query: string
+ query: string,
+ refreshTrigger?: number
): RA | false | undefined {
const [secondaryResults] = useAsyncState | false>(
React.useCallback(async () => {
if (query === '') return false;
- const relatedSearches = await relatedSearchesPromise;
+ const relatedSearches = await fetchRelatedSearches();
const results = await Promise.all(
relatedSearches.map(async (name) => {
const ajaxUrl = formatUrl('/express_search/related/', {
@@ -99,6 +105,7 @@ export function useSecondarySearch(
return ajax(ajaxUrl, {
headers: { Accept: 'application/json' },
expectedErrors: [Http.FORBIDDEN],
+ cache: 'no-store',
}).then(({ data, status }) =>
status === Http.FORBIDDEN ? undefined : ([ajaxUrl, data] as const)
);
@@ -148,7 +155,7 @@ export function useSecondarySearch(
ajaxUrl,
};
});
- }, [query]),
+ }, [query, refreshTrigger]),
false
);
return secondaryResults;
diff --git a/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchTask.tsx b/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchTask.tsx
index d6ded3dd0a0..4094c57a5a7 100644
--- a/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchTask.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchTask.tsx
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
import { useSearchParameter } from '../../hooks/navigation';
import { useId } from '../../hooks/useId';
import { useTriggerState } from '../../hooks/useTriggerState';
+import { useCachedState } from '../../hooks/useCachedState';
import { commonText } from '../../localization/common';
import { headerText } from '../../localization/header';
import { ajax } from '../../utils/ajax';
@@ -16,10 +17,12 @@ import type { GetSet, IR, RA } from '../../utils/types';
import { Container, H2, H3 } from '../Atoms';
import { Button } from '../Atoms/Button';
import { Form, Input } from '../Atoms/Form';
+import { Link } from '../Atoms/Link';
import { icons } from '../Atoms/Icons';
import { Submit } from '../Atoms/Submit';
import { serializeResource } from '../DataModel/serializers';
import { ErrorBoundary } from '../Errors/ErrorBoundary';
+import { ExpressSearchConfigDialog } from '../ExpressSearchConfig/ExpressSearchConfigDialog';
import { WelcomeView } from '../HomePage';
import { Dialog } from '../Molecules/Dialog';
import { QueryFieldSpec } from '../QueryBuilder/fieldSpec';
@@ -27,6 +30,7 @@ import { parseQueryFields } from '../QueryBuilder/helpers';
import { QueryResults } from '../QueryBuilder/Results';
import { formatUrl, parseUrl } from '../Router/queryString';
import { OverlayContext } from '../Router/Router';
+import { hasToolPermission } from '../Permissions/helpers';
import type {
QueryTableResult,
RawExpressSearchResult,
@@ -38,31 +42,62 @@ import {
} from './ExpressSearchHooks';
import { useMenuItem } from './MenuContext';
+const expressSearchDocumentationUrl =
+ 'https://speciforum.org/t/simple-search/185';
+
export function ExpressSearchOverlay(): JSX.Element {
useMenuItem('search');
const formId = useId('express-search')('form');
const handleClose = React.useContext(OverlayContext);
+ const [isConfigOpen, setIsConfigOpen] = React.useState(false);
+ const [, setConfigRefreshTrigger] = React.useState(0);
+ const canEditExpressSearchConfig =
+ hasToolPermission('resources', 'read') &&
+ hasToolPermission('resources', 'create') &&
+ hasToolPermission('resources', 'update');
+
return (
-
- {commonText.cancel()}
- {commonText.search()}
- >
- }
- header={headerText.simpleSearch()}
- icon={icons.search}
- onClose={handleClose}
- >
-
-
+ <>
+
+ {commonText.cancel()}
+ {commonText.search()}
+ >
+ }
+ header={headerText.simpleSearch()}
+ icon={icons.search}
+ onClose={handleClose}
+ >
+
+ {canEditExpressSearchConfig && (
+ setIsConfigOpen(true)}
+ >
+ {icons.cog}
+
+ )}
+
+
+ {canEditExpressSearchConfig && (
+ setIsConfigOpen(false)}
+ onSave={() => setConfigRefreshTrigger((value) => value + 1)}
+ />
+ )}
+ >
);
}
export function SearchForm({
formId,
+ children,
}: {
readonly formId: string;
+ readonly children?: React.ReactNode;
}): JSX.Element {
const navigate = useNavigate();
const [query = ''] = useSearchParameter('q');
@@ -75,7 +110,10 @@ export function SearchForm({
navigate(formatUrl('/specify/express-search/', { q: pendingQuery }))
}
>
-
+
+
+ {children}
+
);
}
@@ -89,7 +127,7 @@ function SearchField({
void;
+}): JSX.Element {
+ return (
+
+
+
+ {commonText.expressSearchInstructionsTitle()}
+
+
+ {typeof expressSearchDocumentationUrl === 'string' && (
+
+ {headerText.documentation()}
+
+ )}
+
+
+
+
+ {commonText.expressSearchInstructions()}
+ {commonText.expressSearchPhraseExample()}
+ {commonText.expressSearchDateFormats()}
+
+
+ );
+}
+
export function ExpressSearchView(): JSX.Element {
const [query = '', setQuery] = useSearchParameter('q');
const value = useTriggerState(query);
const [pendingQuery] = value;
+ const [isConfigOpen, setIsConfigOpen] = React.useState(false);
+ const [configRefreshTrigger, setConfigRefreshTrigger] = React.useState(0);
+ const [showInstructions = true, setShowExpressSearchInstructions] =
+ useCachedState('expressSearch', 'showSearchTips');
+ const canEditExpressSearchConfig =
+ hasToolPermission('resources', 'read') &&
+ hasToolPermission('resources', 'create') &&
+ hasToolPermission('resources', 'update');
- const primaryResults = usePrimarySearch(query);
- const secondaryResults = useSecondarySearch(query);
+ const primaryResults = usePrimarySearch(query, configRefreshTrigger);
+ const secondaryResults = useSecondarySearch(query, configRefreshTrigger);
return (
-
{headerText.simpleSearch()}
+
+
{headerText.simpleSearch()}
+
+ setShowExpressSearchInstructions((value) => !value)
+ }
+ />
+
+ {showInstructions && (
+
setShowExpressSearchInstructions(false)}
+ />
+ )}
@@ -144,10 +244,17 @@ export function ExpressSearchView(): JSX.Element {
) : (
<>
-
+
>
)}
+ {canEditExpressSearchConfig && (
+ setIsConfigOpen(false)}
+ onSave={() => setConfigRefreshTrigger((value) => value + 1)}
+ />
+ )}
);
}
@@ -229,8 +336,8 @@ function TableResult({
{commonText.countLine({
diff --git a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts
index cfb1bbf64ab..e91854c92f7 100644
--- a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts
+++ b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts
@@ -1,3 +1,4 @@
+import { batchIdentifyText } from '../../localization/batchIdentify';
import { commonText } from '../../localization/common';
import { headerText } from '../../localization/header';
import { preferencesText } from '../../localization/preferences';
@@ -132,13 +133,21 @@ const rawUserTools = ensure>>>()({
icon: icons.rss,
},
},
- [commonText.import()]: {
+ [commonText.tools()]: {
localityUpdate: {
title: headerText.localityUpdateTool(),
enabled: () => userInformation.isadmin,
url: '/specify/import/locality-dataset/',
icon: icons.globe,
},
+ batchIdentify: {
+ title: batchIdentifyText.batchIdentify(),
+ url: '/specify/overlay/batch-identify/',
+ icon: icons.clipboardCopy,
+ enabled: () =>
+ hasTablePermission('CollectionObject', 'read') &&
+ hasTablePermission('Determination', 'create'),
+ },
},
[headerText.documentation()]: {
aboutSpecify: {
@@ -148,12 +157,12 @@ const rawUserTools = ensure>>>()({
},
forum: {
title: headerText.forum(),
- url: 'https://discourse.specifysoftware.org/',
+ url: 'https://speciforum.org/',
icon: icons.annotation,
},
technicalDocumentation: {
title: headerText.technicalDocumentation(),
- url: 'https://discourse.specifysoftware.org/c/docs/',
+ url: 'https://speciforum.org/c/docs/',
icon: icons.bookOpen,
},
},
diff --git a/specifyweb/frontend/js_src/lib/components/HomePage/index.tsx b/specifyweb/frontend/js_src/lib/components/HomePage/index.tsx
index 4f478c6fca6..f636fd45d9d 100644
--- a/specifyweb/frontend/js_src/lib/components/HomePage/index.tsx
+++ b/specifyweb/frontend/js_src/lib/components/HomePage/index.tsx
@@ -9,13 +9,21 @@ import { SearchForm } from '../Header/ExpressSearchTask';
import { useDarkMode } from '../Preferences/Hooks';
import { getDefaultWelcomePageImage } from '../Preferences/Renderers';
import { userPreferences } from '../Preferences/userPreferences';
+import { icons } from '../Atoms/Icons';
+import { Button } from '../Atoms/Button';
import { ReactLazy } from '../Router/ReactLazy';
+import { ExpressSearchConfigDialog } from '../ExpressSearchConfig/ExpressSearchConfigDialog';
+import { hasToolPermission } from '../Permissions/helpers';
const TaxonTiles = ReactLazy(async () =>
import('./TaxonTiles').then(({ TaxonTiles }) => TaxonTiles)
);
-export function WelcomeView(): JSX.Element {
+export function WelcomeView({
+ hideSearchBar = false,
+}: {
+ readonly hideSearchBar?: boolean;
+}): JSX.Element {
const [mode] = userPreferences.use('welcomePage', 'general', 'mode');
const formId = useId('express-search')('form');
@@ -24,17 +32,37 @@ export function WelcomeView(): JSX.Element {
'general',
'addSearchBar'
);
+ const [isConfigOpen, setIsConfigOpen] = React.useState(false);
+ const canEditExpressSearchConfig =
+ hasToolPermission('resources', 'read') &&
+ hasToolPermission('resources', 'create') &&
+ hasToolPermission('resources', 'update');
return (
- {displaySearchBar && (
-
+ {!hideSearchBar && displaySearchBar && (
+
+ {canEditExpressSearchConfig && (
+ setIsConfigOpen(true)}
+ className="!px-2"
+ >
+ {icons.cog}
+
+ )}
{commonText.search()}
)}
+ {canEditExpressSearchConfig && (
+
setIsConfigOpen(false)}
+ />
+ )}
(rawRoot)
.sum(({ count }) => count)
.sort(sortFunction(({ data }) => data.id));
const svg = select(container);
- treemap()
+ treemap
()
.tile(treemapBinary)
.size([container.clientWidth, container.clientHeight])
.round(true)(root);
diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/remotePrefs.test.ts.snap b/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/remotePrefs.test.ts.snap
index 8e56db1c7c4..261c3aa88fe 100644
--- a/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/remotePrefs.test.ts.snap
+++ b/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/remotePrefs.test.ts.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`fetches and parses remotePrefs correctly 1`] = `
{
@@ -13,7 +13,7 @@ exports[`fetches and parses remotePrefs correctly 1`] = `
"CO_CREATE_PREP_32768": "false",
"CO_CREATE_PREP_65536": "false",
"CleanupToolsTask.OnTaskbar.fish": "false",
- "DOF_SEP": "\\\\:",
+ "DOF_SEP": "\\:",
"ExportTask.OnTaskbar.fish": "true",
"Exporttask.OnTaskbar": "true",
"FILLED_PREP_GUIDS": "true",
@@ -26,8 +26,8 @@ exports[`fetches and parses remotePrefs correctly 1`] = `
"IPAD_CURATOR_NAME_4": "William Leo Smith",
"IPAD_PICTURE_LOCATION_32768": "Specifyworkspace_tissue.png",
"IPAD_PICTURE_LOCATION_4": "Specifyworkspace_voucher.png",
- "IPAD_REMOTE_IMAGE_URL_32768": "http\\\\://biimages.biodiversity.ku.edu",
- "IPAD_REMOTE_IMAGE_URL_4": "http\\\\://biimages.biodiversity.ku.edu/web_asset_store.xml",
+ "IPAD_REMOTE_IMAGE_URL_32768": "http\\://biimages.biodiversity.ku.edu",
+ "IPAD_REMOTE_IMAGE_URL_4": "http\\://biimages.biodiversity.ku.edu/web_asset_store.xml",
"IPAD_REMOTE_IMAGE_URL_TYPE_32768": "attmgr",
"IPAD_REMOTE_IMAGE_URL_TYPE_4": "attmgr",
"IPAD_USERNAME_2_2": "tester@ku.edu",
@@ -48,14 +48,14 @@ exports[`fetches and parses remotePrefs correctly 1`] = `
"StartupTask.OnTaskbar": "true",
"StartupTask.OnTaskbar.fish": "true",
"SubPaneMgr.MaxPanes": "12",
- "SymbiotaTask.BaseUrlPref": "http\\\\://pinkava.asu.edu/symbiota/sandbox/webservices/dwc/dwcaingesthandler.php",
+ "SymbiotaTask.BaseUrlPref": "http\\://pinkava.asu.edu/symbiota/sandbox/webservices/dwc/dwcaingesthandler.php",
"SymbiotaTask.SHOW_TASK_PREF.fish": "false",
"Testing2": "",
"TreeEditor.RestoreTreeExpansionState": "true",
"attachment.key": "c3wNpDBTLMedXWSb8w2TeSwHWVFLvBwiYmtU0CdOzLQtelcibV9sTXW7NxZlX68",
"attachment.path": "",
"attachment.preview_size": "123.3",
- "attachment.url": "http\\\\://biwebdb.nhm.ku.edu/web_asset_store.xml",
+ "attachment.url": "http\\://biwebdb.nhm.ku.edu/web_asset_store.xml",
"auditing.audit_field_updates": "true",
"auditing.do_audits": "false",
"bnrIconSizeCBX": "20 x 20 pixels",
@@ -85,7 +85,7 @@ exports[`fetches and parses remotePrefs correctly 1`] = `
"recent_collection_id.testuser.kui_fish_dbo_6": "32768",
"recent_collection_id.testuser.tishfish": "4",
"recent_collection_id.vdelnavaz.KU_Fish_Tissue": "4",
- "selected_node\\\\:TaxonTreeDef\\\\:1": "21",
+ "selected_node\\:TaxonTreeDef\\:1": "21",
"settings.email.accounttype": "In Use",
"settings.email.email": "abentley@ku.edu",
"settings.email.port": "25",
diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/treeRanks.test.ts.snap b/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/treeRanks.test.ts.snap
index def76761ae1..0088f4403af 100644
--- a/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/treeRanks.test.ts.snap
+++ b/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/treeRanks.test.ts.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Get tree definition for the Geography tree 1`] = `
[
diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/userInformation.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/userInformation.ts
index e9a4c9c9bbd..2fc9b8527ff 100644
--- a/specifyweb/frontend/js_src/lib/components/InitialContext/userInformation.ts
+++ b/specifyweb/frontend/js_src/lib/components/InitialContext/userInformation.ts
@@ -18,22 +18,29 @@ export type UserInformation = SerializedRecord & {
readonly name: LocalizedString;
readonly isauthenticated: boolean;
readonly availableCollections: RA>;
- readonly agent: SerializedRecord;
+ readonly currentCollectionAgent: SerializedRecord | null;
+ readonly agent: SerializedRecord | null;
};
const userInfo: Writable = {} as UserInformation;
export const fetchContext = load<
- Omit & {
+ Omit & {
// eslint-disable-next-line @typescript-eslint/naming-convention
readonly available_collections: RA>;
+ readonly current_agent?: SerializedRecord | null;
}
>('/context/user.json', 'application/json').then(
async ({ available_collections: availableCollections, ...data }) => {
- Object.entries(data).forEach(([key, value]) => {
+ const currentCollectionAgent = data.current_agent ?? data.agent ?? null;
+ const rest = { ...data };
+ delete rest.current_agent;
+ Object.entries(rest).forEach(([key, value]) => {
// @ts-expect-error
userInfo[key as keyof UserInformation] = value;
});
+ userInfo.currentCollectionAgent = currentCollectionAgent;
+ userInfo.agent = currentCollectionAgent;
await import('../DataModel/tables').then(
async ({ fetchContext }) => fetchContext
);
diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx
index c08a1e144b9..892230db16f 100644
--- a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx
@@ -574,20 +574,21 @@ export function InteractionDialog({
{state.type === 'MissingState' && (
{state.missing.length > 0 && (
-
+ <>
{interactionsText.preparationsNotFoundFor()}
{state.missing.map((problem, index) => (
{problem}
))}
-
+ >
)}
+
{state.unavailableBis.length > 0 && (
-
+ <>
{interactionsText.preparationsNotAvailableFor()}
{state.unavailableBis.map((problem, index) => (
{problem}
))}
-
+ >
)}
)}
diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/LoanReturn.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/LoanReturn.tsx
index df0ec7b2e49..70701aafa1c 100644
--- a/specifyweb/frontend/js_src/lib/components/Interactions/LoanReturn.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Interactions/LoanReturn.tsx
@@ -96,7 +96,7 @@ function PreparationReturn({
const loanReturnPreparation = React.useRef(
new tables.LoanReturnPreparation.Resource({
returneddate: getDateInputValue(new Date()),
- receivedby: userInformation.agent.resource_uri,
+ receivedby: userInformation.currentCollectionAgent?.resource_uri,
})
);
const [state, setState] = React.useState>(() =>
diff --git a/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts b/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts
index c6be6d2bc10..02721c80224 100644
--- a/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts
+++ b/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts
@@ -51,7 +51,7 @@ export const preferredOverlay = 'Labels and boundaries';
* leafletLayersEndpoint
*
* Documentation:
- * https://discourse.specifysoftware.org/t/adding-custom-tile-servers-geomap/2593
+ * https://speciforum.org/t/adding-custom-tile-servers-geomap/2593
*
* Adding "dark:invert-leaflet-layer' smartly inverts the layer colors when in
* dark mode
diff --git a/specifyweb/frontend/js_src/lib/components/LocalityUpdate/Status.tsx b/specifyweb/frontend/js_src/lib/components/LocalityUpdate/Status.tsx
index 9aafa3625eb..40fca8f08d6 100644
--- a/specifyweb/frontend/js_src/lib/components/LocalityUpdate/Status.tsx
+++ b/specifyweb/frontend/js_src/lib/components/LocalityUpdate/Status.tsx
@@ -18,6 +18,7 @@ import { localized } from '../../utils/types';
import { H2, Progress } from '../Atoms';
import { Button } from '../Atoms/Button';
import { Label } from '../Atoms/Form';
+import { dialogIcons } from '../Atoms/Icons';
import { Link } from '../Atoms/Link';
import { SECOND } from '../Atoms/timeUnits';
import { LoadingContext } from '../Core/Contexts';
@@ -157,6 +158,7 @@ export function LocalityUpdateStatus({
children={null}
dimensionsKey={statusDimensionKey}
header={title}
+ icon={dialogIcons.error}
modal={false}
onClose={handleClose}
/>
@@ -234,6 +236,7 @@ function LocalityUpdateParsed({
>
}
header={localityUpdateStatusLocalization.PARSED}
+ icon={dialogIcons.question}
modal={false}
onClose={handleClose}
>
@@ -284,6 +287,7 @@ export function LocalityUpdateSuccess({
{commonText.close()}}
header={localityUpdateStatusLocalization.SUCCEEDED}
+ icon={dialogIcons.success}
modal={false}
onClose={handleClose}
>
diff --git a/specifyweb/frontend/js_src/lib/components/Merging/__tests__/__snapshots__/autoMerge.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Merging/__tests__/__snapshots__/autoMerge.test.ts.snap
index 6c6d6979017..f14891e48d1 100644
--- a/specifyweb/frontend/js_src/lib/components/Merging/__tests__/__snapshots__/autoMerge.test.ts.snap
+++ b/specifyweb/frontend/js_src/lib/components/Merging/__tests__/__snapshots__/autoMerge.test.ts.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`autoMerge cautious 1`] = `
{
diff --git a/specifyweb/frontend/js_src/lib/components/Molecules/AutoComplete.tsx b/specifyweb/frontend/js_src/lib/components/Molecules/AutoComplete.tsx
index cf3efbe00d3..c4554cff812 100644
--- a/specifyweb/frontend/js_src/lib/components/Molecules/AutoComplete.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Molecules/AutoComplete.tsx
@@ -31,6 +31,8 @@ export type AutoCompleteItem = {
readonly data: T;
};
+type AutoCompleteValue = AutoCompleteItem | string | null;
+
/**
* Get the nearest scrollable parent.
* Adapted from https://stackoverflow.com/a/35940276/8584605
@@ -341,6 +343,7 @@ export function AutoComplete({
);
const inputRef = React.useRef(null);
+ const shouldBlurAfterMouseSelection = React.useRef(false);
const forwardChildRef: React.RefCallback =
React.useCallback(
(input): void => {
@@ -372,23 +375,36 @@ export function AutoComplete({
);
}, [currentValue]);
+ const handleOptionMouseDown = React.useCallback(
+ (e: React.MouseEvent): void => {
+ e.preventDefault();
+ e.stopPropagation();
+ shouldBlurAfterMouseSelection.current = true;
+ },
+ []
+ );
+
return (
- | string | null | undefined>
+ | string | null | undefined
- ): void => {
- if (value === null || value === undefined) handleCleared?.();
+ onChange={(value): void => {
+ const blurAfterSelection = shouldBlurAfterMouseSelection.current;
+ shouldBlurAfterMouseSelection.current = false;
+ if (value === null) handleCleared?.();
else if (typeof value === 'string') handleNewValue?.(value);
- else handleChanged(value);
+ else {
+ handleChanged(value);
+ if (blurAfterSelection)
+ globalThis.setTimeout(() => inputRef.current?.blur(), 0);
+ }
}}
>
-
+ {
const value = (target as HTMLInputElement).value;
@@ -398,13 +414,14 @@ export function AutoComplete({
pendingValueRef.current = value;
}}
{...inputProps}
- displayValue={(item: AutoCompleteItem | null): string =>
- typeof item === 'string'
- ? item
- : typeof item?.label === 'string'
- ? item.label
- : (item?.searchValue ?? '')
- }
+ displayValue={(item: unknown): string => {
+ const currentItem = item as AutoCompleteValue;
+ return typeof currentItem === 'string'
+ ? currentItem
+ : typeof currentItem?.label === 'string'
+ ? currentItem.label
+ : (currentItem?.searchValue ?? '');
+ }}
ref={forwardChildRef}
onBlur={withHandleBlur(inputProps?.onBlur).onBlur}
/*
@@ -423,7 +440,7 @@ export function AutoComplete({
* of parents with overflow:hidden
*/}