From 8bf89e58ce5754b0f413c5d61d5e4cfd7fca4f0d Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 30 Apr 2026 12:16:39 -0500 Subject: [PATCH 01/26] Implement to_failed_business_rule helper function --- .../backend/workbench/upload/upload_result.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/workbench/upload/upload_result.py b/specifyweb/backend/workbench/upload/upload_result.py index ea6e13f0ea5..2b4e305f53b 100644 --- a/specifyweb/backend/workbench/upload/upload_result.py +++ b/specifyweb/backend/workbench/upload/upload_result.py @@ -2,9 +2,21 @@ from typing import Literal +from specifyweb.backend.businessrules.exceptions import BusinessRuleException + from .parsing import WorkBenchParseFailure Failure = Literal["Failure"] +BusinessRulePayloadValue = ( + str + | int + | bool + | None + | list[str] + | list[int] + | dict[str, str | int | bool | None] +) +BusinessRulePayload = dict[str, BusinessRulePayloadValue] class TreeInfo(NamedTuple): @@ -215,7 +227,7 @@ def from_json(json: dict) -> "Deleted": class FailedBusinessRule(NamedTuple): message: str - payload: dict[str, str | int | list[str] | list[int]] + payload: BusinessRulePayload info: ReportInfo def get_id(self) -> Failure: @@ -238,6 +250,18 @@ def from_json(json: dict) -> "FailedBusinessRule": ) +def to_failed_business_rule(exception: Exception, info: ReportInfo) -> FailedBusinessRule: + if ( + isinstance(exception, BusinessRuleException) + and len(exception.args) >= 2 + and isinstance(exception.args[0], str) + and isinstance(exception.args[1], dict) + ): + return FailedBusinessRule(exception.args[0], exception.args[1], info) + + return FailedBusinessRule(str(exception), {}, info) + + class NoMatch(NamedTuple): info: ReportInfo From d6dffa2c95f001ff804a6f23e2836d2a4900a7be Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 30 Apr 2026 12:17:13 -0500 Subject: [PATCH 02/26] Replace FailedBusinessRule with to_failed_business_rule in treerecord.py --- specifyweb/backend/workbench/upload/treerecord.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/workbench/upload/treerecord.py b/specifyweb/backend/workbench/upload/treerecord.py index ce82b1c3644..c3506057809 100644 --- a/specifyweb/backend/workbench/upload/treerecord.py +++ b/specifyweb/backend/workbench/upload/treerecord.py @@ -47,6 +47,7 @@ FailedBusinessRule, ReportInfo, TreeInfo, + to_failed_business_rule, ) from .uploadable import ( Row, @@ -954,7 +955,7 @@ def _upload( obj = self._do_insert(model, **new_attrs) except (BusinessRuleException, IntegrityError) as e: return UploadResult( - FailedBusinessRule(str(e), {}, info), parent_result, {} + to_failed_business_rule(e, info), parent_result, {} ) result = UploadResult(Uploaded(obj.id, info, []), parent_result, {}) From adfbba1e8e3a352170d01d10c189113f7c00495a Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 30 Apr 2026 12:17:38 -0500 Subject: [PATCH 03/26] Replace FailedBusinessRule with to_failed_business_rule in upload_table.py --- specifyweb/backend/workbench/upload/upload_table.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/specifyweb/backend/workbench/upload/upload_table.py b/specifyweb/backend/workbench/upload/upload_table.py index a5ab4cb3f9a..15689e2be34 100644 --- a/specifyweb/backend/workbench/upload/upload_table.py +++ b/specifyweb/backend/workbench/upload/upload_table.py @@ -39,6 +39,7 @@ PicklistAddition, ParseFailures, PropagatedFailure, + to_failed_business_rule, ) from .uploadable import ( NULL_RECORD, @@ -760,7 +761,7 @@ def _do_upload( picklist_additions = self._do_picklist_additions() except (BusinessRuleException, IntegrityError) as e: return UploadResult( - FailedBusinessRule(str(e), {}, info), to_one_results, {} + to_failed_business_rule(e, info), to_one_results, {} ) record = Uploaded(uploaded.id, info, picklist_additions) @@ -865,7 +866,7 @@ def delete_row(self, parent_obj=None) -> UploadResult: reference_record.delete() result = Deleted(self.current_id, info) except (BusinessRuleException, IntegrityError) as e: - result = FailedBusinessRule(str(e), {}, info) + result = to_failed_business_rule(e, info) to_one_deleted: dict[str, UploadResult] = { key: value.delete_row() @@ -1066,7 +1067,7 @@ def _do_upload( picklist_additions = self._do_picklist_additions() except (BusinessRuleException, IntegrityError) as e: return UploadResult( - FailedBusinessRule(str(e), {}, info), to_one_results, {} + to_failed_business_rule(e, info), to_one_results, {} ) record: Updated | NoChange = ( From b5a50dc9f3c09aa8a1f1244f9d79d2b076c527cf Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 30 Apr 2026 12:19:32 -0500 Subject: [PATCH 04/26] Add unit test testBusinessRuleExceptionPayload --- .../upload/tests/test_upload_results_json.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py b/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py index d215ed9d42f..c4d3776b147 100644 --- a/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py +++ b/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py @@ -4,6 +4,8 @@ import unittest from jsonschema import validate, Draft7Validator # type: ignore +from specifyweb.backend.businessrules.exceptions import BusinessRuleException + from ..upload_result import * from ..upload_results_schema import schema @@ -36,6 +38,37 @@ def testFailedBusinessRule(self, failedBusinessRule: FailedBusinessRule): j = json.dumps(failedBusinessRule.to_json()) self.assertEqual(failedBusinessRule, FailedBusinessRule.from_json(json.loads(j))) + def testBusinessRuleExceptionPayload(self): + info = ReportInfo( + tableName="Collectionobject", + columns=["catalogNumber"], + treeInfo=None, + ) + payload = { + "localizationKey": "childFieldNotUnique", + "table": "Collectionobject", + "fieldName": "catalognumber", + "fieldData": {"catalognumber": "0037481"}, + "parentField": "collection", + "parentData": {"collection": "Collection object (360449)"}, + "conflicting": [3347460], + } + + self.assertEqual( + to_failed_business_rule( + BusinessRuleException( + "Collectionobject must have unique catalognumber in collection", + payload, + ), + info, + ), + FailedBusinessRule( + "Collectionobject must have unique catalognumber in collection", + payload, + info, + ), + ) + @given(noMatch=infer) def testNoMatch(self, noMatch: NoMatch): j = json.dumps(noMatch.to_json()) From 9d8e306c2b6c6ba422244a647fde212da02ff8ee Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 30 Apr 2026 12:20:19 -0500 Subject: [PATCH 05/26] Fix BusinessRuleMessage issue --- .../lib/components/WorkBench/resultsParser.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts index 76d187c9ba2..11fa104dde4 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts @@ -256,14 +256,57 @@ export function resolveBackendParsingMessage( else return undefined; } +function withConflictingRecordIds( + message: LocalizedString, + payload: IR +): LocalizedString { + const conflicting = payload.conflicting; + return Array.isArray(conflicting) && conflicting.length > 0 + ? localized( + `${message} (Conflicting record IDs: ${conflicting.join(', ')})` + ) + : message; +} + +function getStringPayload(payload: IR, key: string): string { + const value = payload[key]; + return typeof value === 'string' ? value : ''; +} + +function resolveBackendBusinessRuleMessage( + payload: IR +): LocalizedString | undefined { + if (payload.localizationKey === 'fieldNotUnique') + return withConflictingRecordIds( + backEndText.fieldNotUnique({ + tableName: getStringPayload(payload, 'table'), + fieldName: getStringPayload(payload, 'fieldName'), + }), + payload + ); + else if (payload.localizationKey === 'childFieldNotUnique') + return withConflictingRecordIds( + backEndText.childFieldNotUnique({ + tableName: getStringPayload(payload, 'table'), + fieldName: getStringPayload(payload, 'fieldName'), + parentField: getStringPayload(payload, 'parentField'), + }), + payload + ); + else return undefined; +} + /** Back-end sends a validation key. Front-end translates it */ export function resolveValidationMessage( key: string, payload: IR ): LocalizedString { const baseParsedMessage = resolveBackendParsingMessage(key, payload); + const businessRuleMessage = resolveBackendBusinessRuleMessage(payload); if (baseParsedMessage !== undefined) { return baseParsedMessage; + } else if (businessRuleMessage !== undefined) { + return businessRuleMessage; } else if (key === 'failedParsingPickList') return backEndText.failedParsingPickList({ value: `"${payload.value as string}"`, From 7a4c53851342ea67565dfc46011454f48e56a99f Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 21 May 2026 14:05:19 -0500 Subject: [PATCH 06/26] Use schema labels in Workbench uniqueness errors --- .../lib/components/WorkBench/resultsParser.ts | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts index 11fa104dde4..dce605c97ae 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts @@ -15,7 +15,7 @@ import { formatDisjunction, } from '../Atoms/Internationalization'; import { getField } from '../DataModel/helpers'; -import { tables } from '../DataModel/tables'; +import { getTable, tables } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; /* @@ -273,23 +273,62 @@ function getStringPayload(payload: IR, key: string): string { return typeof value === 'string' ? value : ''; } +function getSchemaTableLabel(tableName: string): LocalizedString { + return getTable(tableName)?.label ?? localized(tableName); +} + +function getSchemaFieldLabel( + tableName: string, + fieldName: string +): LocalizedString { + const lookupFieldName = fieldName.split('__').join('.'); + return ( + getTable(tableName)?.getField(lookupFieldName)?.label ?? + localized(fieldName) + ); +} + +function getSchemaFieldLabels( + tableName: string, + fieldNames: string +): LocalizedString { + const labels = fieldNames + .split(',') + .map((fieldName) => fieldName.trim()) + .filter((fieldName) => fieldName.length > 0) + .map((fieldName) => getSchemaFieldLabel(tableName, fieldName)); + return labels.length === 0 + ? localized(fieldNames) + : formatConjunction(labels); +} + function resolveBackendBusinessRuleMessage( payload: IR ): LocalizedString | undefined { + const tableName = getStringPayload(payload, 'table'); if (payload.localizationKey === 'fieldNotUnique') return withConflictingRecordIds( backEndText.fieldNotUnique({ - tableName: getStringPayload(payload, 'table'), - fieldName: getStringPayload(payload, 'fieldName'), + tableName: getSchemaTableLabel(tableName), + fieldName: getSchemaFieldLabels( + tableName, + getStringPayload(payload, 'fieldName') + ), }), payload ); else if (payload.localizationKey === 'childFieldNotUnique') return withConflictingRecordIds( backEndText.childFieldNotUnique({ - tableName: getStringPayload(payload, 'table'), - fieldName: getStringPayload(payload, 'fieldName'), - parentField: getStringPayload(payload, 'parentField'), + tableName: getSchemaTableLabel(tableName), + fieldName: getSchemaFieldLabels( + tableName, + getStringPayload(payload, 'fieldName') + ), + parentField: getSchemaFieldLabels( + tableName, + getStringPayload(payload, 'parentField') + ), }), payload ); From 3007e568d48decce9cff812f15edf8fbc173e2a8 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 21 May 2026 14:19:10 -0500 Subject: [PATCH 07/26] bussiness rule fix --- .../backend/workbench/upload/upload_result.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/specifyweb/backend/workbench/upload/upload_result.py b/specifyweb/backend/workbench/upload/upload_result.py index 2b4e305f53b..0cfdeb5aabb 100644 --- a/specifyweb/backend/workbench/upload/upload_result.py +++ b/specifyweb/backend/workbench/upload/upload_result.py @@ -2,11 +2,11 @@ from typing import Literal -from specifyweb.backend.businessrules.exceptions import BusinessRuleException - from .parsing import WorkBenchParseFailure Failure = Literal["Failure"] +BUSINESS_RULE_EXCEPTION_MODULE = "specifyweb.backend.businessrules.exceptions" +BUSINESS_RULE_EXCEPTION_NAME = "BusinessRuleException" BusinessRulePayloadValue = ( str | int @@ -250,13 +250,19 @@ def from_json(json: dict) -> "FailedBusinessRule": ) -def to_failed_business_rule(exception: Exception, info: ReportInfo) -> FailedBusinessRule: - if ( - isinstance(exception, BusinessRuleException) +def is_business_rule_exception_with_payload(exception: Exception) -> bool: + exception_class = exception.__class__ + return ( + exception_class.__module__ == BUSINESS_RULE_EXCEPTION_MODULE + and exception_class.__name__ == BUSINESS_RULE_EXCEPTION_NAME and len(exception.args) >= 2 and isinstance(exception.args[0], str) and isinstance(exception.args[1], dict) - ): + ) + + +def to_failed_business_rule(exception: Exception, info: ReportInfo) -> FailedBusinessRule: + if is_business_rule_exception_with_payload(exception): return FailedBusinessRule(exception.args[0], exception.args[1], info) return FailedBusinessRule(str(exception), {}, info) From 28af8a93688b1a75484fefa2cceafa9a3dd8b1f3 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 27 Jul 2026 14:30:24 +0200 Subject: [PATCH 08/26] Fix: Sanitize businessrule payload message --- .../backend/workbench/upload/upload_result.py | 71 +++++++++++++++++-- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/specifyweb/backend/workbench/upload/upload_result.py b/specifyweb/backend/workbench/upload/upload_result.py index 0cfdeb5aabb..3926127e71b 100644 --- a/specifyweb/backend/workbench/upload/upload_result.py +++ b/specifyweb/backend/workbench/upload/upload_result.py @@ -12,8 +12,7 @@ | int | bool | None - | list[str] - | list[int] + | list[str | int | bool | None] | dict[str, str | int | bool | None] ) BusinessRulePayload = dict[str, BusinessRulePayloadValue] @@ -252,18 +251,76 @@ def from_json(json: dict) -> "FailedBusinessRule": def is_business_rule_exception_with_payload(exception: Exception) -> bool: exception_class = exception.__class__ - return ( - exception_class.__module__ == BUSINESS_RULE_EXCEPTION_MODULE - and exception_class.__name__ == BUSINESS_RULE_EXCEPTION_NAME - and len(exception.args) >= 2 + payload_like_exception = ( + len(exception.args) >= 2 and isinstance(exception.args[0], str) and isinstance(exception.args[1], dict) ) + if not payload_like_exception: + return False + + # Some wrapped code paths can preserve the same payload shape without + # preserving the original exception class identity. + has_business_rule_shape = any( + key in exception.args[1] + for key in ( + "localizationKey", + "table", + "fieldName", + "parentField", + "conflicting", + ) + ) + + return ( + ( + exception_class.__module__ == BUSINESS_RULE_EXCEPTION_MODULE + and exception_class.__name__ == BUSINESS_RULE_EXCEPTION_NAME + ) + or has_business_rule_shape + ) + + +def _is_business_rule_scalar(value: Any) -> bool: + return isinstance(value, (str, int, bool)) or value is None + + +def _sanitize_business_rule_payload_value(value: Any) -> BusinessRulePayloadValue | None: + if _is_business_rule_scalar(value): + return value + + if isinstance(value, list): + if all(_is_business_rule_scalar(item) for item in value): + return value + return None + + if isinstance(value, dict): + sanitized: dict[str, str | int | bool | None] = {} + for key, item in value.items(): + if not isinstance(key, str) or not _is_business_rule_scalar(item): + return None + sanitized[key] = item + return sanitized + + return None + + +def _sanitize_business_rule_payload(payload: dict[Any, Any]) -> BusinessRulePayload: + sanitized: BusinessRulePayload = {} + for key, value in payload.items(): + if not isinstance(key, str): + continue + sanitized_value = _sanitize_business_rule_payload_value(value) + if sanitized_value is not None: + sanitized[key] = sanitized_value + return sanitized + def to_failed_business_rule(exception: Exception, info: ReportInfo) -> FailedBusinessRule: if is_business_rule_exception_with_payload(exception): - return FailedBusinessRule(exception.args[0], exception.args[1], info) + payload = _sanitize_business_rule_payload(exception.args[1]) + return FailedBusinessRule(exception.args[0], payload, info) return FailedBusinessRule(str(exception), {}, info) From ec00f27c64d86dbb07c337d29d9e0adfd65f4799 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 27 Jul 2026 14:31:58 +0200 Subject: [PATCH 09/26] Test: Add test for Business Rule Exception PayloadSanitization --- .../upload/tests/test_upload_results_json.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py b/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py index c4d3776b147..aed25d45c06 100644 --- a/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py +++ b/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py @@ -69,6 +69,45 @@ def testBusinessRuleExceptionPayload(self): ), ) + def testBusinessRuleExceptionPayloadSanitization(self): + info = ReportInfo( + tableName="Collectionobject", + columns=["catalogNumber"], + treeInfo=None, + ) + + payload = { + "localizationKey": "childFieldNotUnique", + "table": "Collectionobject", + "fieldName": "catalognumber", + "goodNested": {"a": "b", "n": 1, "ok": True, "null": None}, + "badNested": {"bad": info}, + "goodList": [1, 2, 3], + "badList": [1, info], + } + + failed_business_rule = to_failed_business_rule( + Exception( + "Collectionobject must have unique catalognumber in collection", + payload, + ), + info, + ) + + self.assertEqual( + failed_business_rule.payload, + { + "localizationKey": "childFieldNotUnique", + "table": "Collectionobject", + "fieldName": "catalognumber", + "goodNested": {"a": "b", "n": 1, "ok": True, "null": None}, + "goodList": [1, 2, 3], + }, + ) + + # Ensure sanitized payload always serializes in upload results. + json.dumps(failed_business_rule.to_json()) + @given(noMatch=infer) def testNoMatch(self, noMatch: NoMatch): j = json.dumps(noMatch.to_json()) From 7d664873a54b765510c67f077a897d6f3b8fc4a3 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 27 Jul 2026 14:33:50 +0200 Subject: [PATCH 10/26] Fix: Use failed bus tule def --- specifyweb/backend/workbench/upload/upload_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/backend/workbench/upload/upload_table.py b/specifyweb/backend/workbench/upload/upload_table.py index 15689e2be34..8b91798a582 100644 --- a/specifyweb/backend/workbench/upload/upload_table.py +++ b/specifyweb/backend/workbench/upload/upload_table.py @@ -592,7 +592,7 @@ def _handle_row(self, skip_match: bool, allow_null: bool) -> UploadResult: except ContetRef as e: # Not sure if there is a better way for this. Consider moving this to binding. return UploadResult( - FailedBusinessRule(str(e), {}, info), to_one_results, {} + to_failed_business_rule(e, info), to_one_results, {} ) attrs = { From 14177a65d4a04690f4b268deeef92eec3d07eb57 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 27 Jul 2026 14:35:53 +0200 Subject: [PATCH 11/26] Fix: Improve conflicting record ids message --- .../lib/components/WorkBench/resultsParser.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts index dce605c97ae..a1905cefa48 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts @@ -261,9 +261,19 @@ function withConflictingRecordIds( payload: IR ): LocalizedString { const conflicting = payload.conflicting; - return Array.isArray(conflicting) && conflicting.length > 0 + const conflictingIds = Array.isArray(conflicting) + ? conflicting + .filter( + (value): value is string | number => + typeof value === 'string' || typeof value === 'number' + ) + .map((value) => String(value)) + : []; + return conflictingIds.length > 0 ? localized( - `${message} (Conflicting record IDs: ${conflicting.join(', ')})` + `${message} (${backEndText.conflictingRecordIds({ + ids: conflictingIds.join(', '), + })})` ) : message; } @@ -306,6 +316,7 @@ function resolveBackendBusinessRuleMessage( payload: IR ): LocalizedString | undefined { const tableName = getStringPayload(payload, 'table'); + if (tableName.length === 0) return undefined; if (payload.localizationKey === 'fieldNotUnique') return withConflictingRecordIds( backEndText.fieldNotUnique({ From 481605712247ac058db12e97706ee1282ba8dfd9 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 27 Jul 2026 14:36:34 +0200 Subject: [PATCH 12/26] Chore: add new localization to BE file --- specifyweb/frontend/js_src/lib/localization/backEnd.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/localization/backEnd.ts b/specifyweb/frontend/js_src/lib/localization/backEnd.ts index b340ca7c140..76fc1085f5a 100644 --- a/specifyweb/frontend/js_src/lib/localization/backEnd.ts +++ b/specifyweb/frontend/js_src/lib/localization/backEnd.ts @@ -321,6 +321,9 @@ export const backEndText = createDictionary({ '{tableName:string} mora imati jedinstveni {fieldName:string} u {parentField:string}', nb: '{tableName:string} må ha unik {fieldName:string} i {parentField:string}', }, + conflictingRecordIds: { + 'en-us': 'Conflicting record IDs: {ids:string}', + }, deletingTreeRoot: { 'en-us': 'Can not delete root level tree definition item', 'es-es': From c33d706e92ccbd46181987e4b7ff0c17e114a1ef Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 27 Jul 2026 14:37:27 +0200 Subject: [PATCH 13/26] Test: Add unit tests --- .../WorkBench/__tests__/resultsParser.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts new file mode 100644 index 00000000000..331da889363 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts @@ -0,0 +1,63 @@ +import { backEndText } from '../../../localization/backEnd'; +import { requireContext } from '../../../tests/helpers'; + +import { resolveValidationMessage } from '../resultsParser'; + +requireContext(); + +describe('resolveValidationMessage business-rule handling', () => { + test('formats childFieldNotUnique and appends conflicting record ids', () => { + const message = resolveValidationMessage('notAParsingKey', { + localizationKey: 'childFieldNotUnique', + table: 'Collectionobject', + fieldName: 'catalognumber', + parentField: 'collection', + conflicting: [4, 9], + }); + + const localizedSuffix = backEndText.conflictingRecordIds({ ids: '4, 9' }); + + expect(message).toContain('unique'); + expect(message).toContain(localizedSuffix); + }); + + test('does not append conflicting ids when no valid ids are provided', () => { + const message = resolveValidationMessage('notAParsingKey', { + localizationKey: 'childFieldNotUnique', + table: 'Collectionobject', + fieldName: 'catalognumber', + parentField: 'collection', + conflicting: [{ id: 4 }], + }); + + expect(message).toContain('unique'); + expect(message).not.toContain('Conflicting record IDs:'); + }); + + test('falls back to generic message when business-rule payload has no table', () => { + const payload = { + localizationKey: 'fieldNotUnique', + fieldName: 'catalognumber', + conflicting: [4], + }; + + expect(resolveValidationMessage('unknownKey', payload)).toBe( + `unknownKey ${JSON.stringify(payload)}` + ); + }); + + test('parsing message takes precedence over business-rule message', () => { + const message = resolveValidationMessage('failedParsingBoolean', { + localizationKey: 'childFieldNotUnique', + table: 'Collectionobject', + fieldName: 'catalognumber', + parentField: 'collection', + value: 'not-a-bool', + conflicting: [4], + }); + + expect(message).toBe( + backEndText.failedParsingBoolean({ value: 'not-a-bool' }) + ); + }); +}); From 091fe2256f480311fe0dcbbc2e29f04d39056be5 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 27 Jul 2026 14:55:38 +0200 Subject: [PATCH 14/26] Refactor: result parser FE code refactorization and expension of logic to other BR --- .../WorkBench/__tests__/resultsParser.test.ts | 21 + .../WorkBench/resultMessageResolvers.ts | 329 ++++++++++++++ .../lib/components/WorkBench/resultsParser.ts | 404 +----------------- .../components/WorkBench/uploadResultTypes.ts | 191 +++++++++ 4 files changed, 562 insertions(+), 383 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts create mode 100644 specifyweb/frontend/js_src/lib/components/WorkBench/uploadResultTypes.ts diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts index 331da889363..2a5b5564915 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts @@ -46,6 +46,27 @@ describe('resolveValidationMessage business-rule handling', () => { ); }); + test('resolves datasetAlreadyUploaded via localizationKey payload', () => { + const message = resolveValidationMessage('backend raw business-rule text', { + localizationKey: 'datasetAlreadyUploaded', + }); + + expect(message).toBe(backEndText.datasetAlreadyUploaded()); + }); + + test('resolves non-uniqueness business-rule key with payload arguments', () => { + const message = resolveValidationMessage('backend raw business-rule text', { + localizationKey: 'resourceInPermissionRegistry', + resource: 'my-resource', + }); + + expect(message).toBe( + backEndText.resourceInPermissionRegistry({ + resource: 'my-resource', + }) + ); + }); + test('parsing message takes precedence over business-rule message', () => { const message = resolveValidationMessage('failedParsingBoolean', { localizationKey: 'childFieldNotUnique', diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts new file mode 100644 index 00000000000..3581f57b6e8 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts @@ -0,0 +1,329 @@ +import type { LocalizedString } from 'typesafe-i18n'; + +import { backEndText } from '../../localization/backEnd'; +import type { IR, RA, RR } from '../../utils/types'; +import { localized } from '../../utils/types'; +import { + formatConjunction, + formatDisjunction, +} from '../Atoms/Internationalization'; +import { getField } from '../DataModel/helpers'; +import { getTable, tables } from '../DataModel/tables'; + +type PayloadMessageResolver = (payload: IR) => LocalizedString; + +type BusinessRuleMessageResolver = ( + payload: IR +) => LocalizedString | undefined; + +export const backendParsingMessageResolvers: RR = { + failedParsingBoolean: (payload): LocalizedString => + backEndText.failedParsingBoolean({ value: payload.value as string }), + failedParsingDecimal: (payload): LocalizedString => + backEndText.failedParsingDecimal({ value: payload.value as string }), + failedParsingFloat: (payload): LocalizedString => + backEndText.failedParsingFloat({ value: payload.value as string }), + failedParsingAgentType: (payload): LocalizedString => + backEndText.failedParsingAgentType({ + agentTypeField: getField(tables.Agent, 'agentType').label, + badType: payload.badType as string, + validTypes: formatDisjunction( + (payload.validTypes as RA) ?? [] + ), + }), + valueTooLong: (payload): LocalizedString => + backEndText.valueTooLong({ + maxLength: payload.maxLength as number, + }), + invalidYear: (payload): LocalizedString => + backEndText.invalidYear({ + value: payload.value as string, + }), + badDateFormat: (payload): LocalizedString => + backEndText.badDateFormat({ + value: payload.value as string, + format: payload.format as string, + }), + coordinateBadFormat: (payload): LocalizedString => + backEndText.coordinateBadFormat({ + value: payload.value as string, + }), + latitudeOutOfRange: (payload): LocalizedString => + backEndText.latitudeOutOfRange({ + value: payload.value as string, + }), + longitudeOutOfRange: (payload): LocalizedString => + backEndText.longitudeOutOfRange({ + value: payload.value as string, + }), + formatMismatch: (payload): LocalizedString => + backEndText.formatMismatch({ + value: payload.value as string, + formatter: payload.formatter as string, + }), +}; + +export function resolveBackendParsingMessage( + key: string, + payload: IR +): LocalizedString | undefined { + const resolver = backendParsingMessageResolvers[key]; + return resolver?.(payload); +} + +function withConflictingRecordIds( + message: LocalizedString, + payload: IR +): LocalizedString { + const conflicting = payload.conflicting; + const conflictingIds = Array.isArray(conflicting) + ? conflicting + .filter( + (value): value is string | number => + typeof value === 'string' || typeof value === 'number' + ) + .map((value) => String(value)) + : []; + return conflictingIds.length > 0 + ? localized( + `${message} (${backEndText.conflictingRecordIds({ + ids: conflictingIds.join(', '), + })})` + ) + : message; +} + +function getStringPayload(payload: IR, key: string): string { + const value = payload[key]; + return typeof value === 'string' ? value : ''; +} + +function getObjectPayload(payload: IR, key: string): IR { + const value = payload[key]; + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as IR) + : {}; +} + +function getNestedStringPayload( + payload: IR, + key: string, + nestedKey: string +): string { + return getStringPayload(getObjectPayload(payload, key), nestedKey); +} + +function getSchemaTableLabel(tableName: string): LocalizedString { + return getTable(tableName)?.label ?? localized(tableName); +} + +function getSchemaFieldLabel( + tableName: string, + fieldName: string +): LocalizedString { + const lookupFieldName = fieldName.split('__').join('.'); + return ( + getTable(tableName)?.getField(lookupFieldName)?.label ?? + localized(fieldName) + ); +} + +function getSchemaFieldLabels( + tableName: string, + fieldNames: string +): LocalizedString { + const labels = fieldNames + .split(',') + .map((fieldName) => fieldName.trim()) + .filter((fieldName) => fieldName.length > 0) + .map((fieldName) => getSchemaFieldLabel(tableName, fieldName)); + return labels.length === 0 + ? localized(fieldNames) + : formatConjunction(labels); +} + +export const businessRuleMessageResolvers: RR = { + fieldNotUnique: (payload): LocalizedString | undefined => { + const tableName = getStringPayload(payload, 'table'); + if (tableName.length === 0) return undefined; + return withConflictingRecordIds( + backEndText.fieldNotUnique({ + tableName: getSchemaTableLabel(tableName), + fieldName: getSchemaFieldLabels( + tableName, + getStringPayload(payload, 'fieldName') + ), + }), + payload + ); + }, + childFieldNotUnique: (payload): LocalizedString | undefined => { + const tableName = getStringPayload(payload, 'table'); + if (tableName.length === 0) return undefined; + return withConflictingRecordIds( + backEndText.childFieldNotUnique({ + tableName: getSchemaTableLabel(tableName), + fieldName: getSchemaFieldLabels( + tableName, + getStringPayload(payload, 'fieldName') + ), + parentField: getSchemaFieldLabels( + tableName, + getStringPayload(payload, 'parentField') + ), + }), + payload + ); + }, + badTreeStructureInvalidRanks: (payload): LocalizedString => + backEndText.badTreeStructureInvalidRanks({ + badRanks: Number(payload.badRanks) || 0, + }), + deletingTreeRoot: (): LocalizedString => backEndText.deletingTreeRoot(), + nodeParentInvalidRank: (): LocalizedString => backEndText.nodeParentInvalidRank(), + nodeChildrenInvalidRank: (): LocalizedString => + backEndText.nodeChildrenInvalidRank(), + nodeOperationToSynonymizedParent: (payload): LocalizedString => + backEndText.nodeOperationToSynonymizedParent({ + operation: getStringPayload(payload, 'operation'), + nodeName: getNestedStringPayload(payload, 'node', 'fullName'), + parentName: getNestedStringPayload(payload, 'parent', 'fullName'), + }), + nodeSynonymizeToSynonymized: (payload): LocalizedString => + backEndText.nodeSynonymizeToSynonymized({ + nodeName: getNestedStringPayload(payload, 'node', 'fullName'), + intoName: getNestedStringPayload(payload, 'synonymized', 'fullName'), + }), + nodeSynonimizeWithChildren: (payload): LocalizedString => + backEndText.nodeSynonimizeWithChildren({ + nodeName: getNestedStringPayload(payload, 'parent', 'fullName'), + }), + invalidNodeType: (payload): LocalizedString => + backEndText.invalidNodeType({ + node: `${payload.node ?? ''}`, + operation: getStringPayload(payload, 'operation'), + nodeModel: getStringPayload(payload, 'nodeModel'), + }), + operationAcrossTrees: (payload): LocalizedString => + backEndText.operationAcrossTrees({ + operation: getStringPayload(payload, 'operation'), + }), + limitReachedDeterminingAccepted: (payload): LocalizedString => + backEndText.limitReachedDeterminingAccepted({ + taxonId: Number(payload.taxonId) || 0, + }), + resourceInPermissionRegistry: (payload): LocalizedString => + backEndText.resourceInPermissionRegistry({ + resource: getStringPayload(payload, 'resource'), + }), + actorIsNotSpecifyUser: (payload): LocalizedString => + backEndText.actorIsNotSpecifyUser({ + agentTable: tables.Agent.label, + specifyUserTable: tables.SpecifyUser.label, + actor: getStringPayload(payload, 'actor'), + }), + unexpectedCollectionType: (payload): LocalizedString => + backEndText.unexpectedCollectionType({ + unexpectedTypeName: getStringPayload(payload, 'unexpectedTypeName'), + collectionName: getStringPayload(payload, 'collectionName'), + }), + invalidReportMimetype: (): LocalizedString => + backEndText.invalidReportMimetype({ + mimeTypeField: getField(tables.SpAppResource, 'mimeType').label, + }), + fieldNotRelationship: (payload): LocalizedString => + backEndText.fieldNotRelationship({ + field: getStringPayload(payload, 'field'), + }), + unexpectedTableId: (payload): LocalizedString => + backEndText.unexpectedTableId({ + tableId: `${payload.tableId ?? ''}`, + expectedTableId: `${payload.expectedTableId ?? ''}`, + }), + noCollectionInQuery: (payload): LocalizedString => + backEndText.noCollectionInQuery({ + table: getStringPayload(payload, 'table'), + }), + invalidDatePart: (payload): LocalizedString => + backEndText.invalidDatePart({ + datePart: getStringPayload(payload, 'datePart'), + validDateParts: getStringPayload(payload, 'validDateParts'), + }), + invalidUploadStatus: (payload): LocalizedString => + backEndText.invalidUploadStatus({ + uploadStatus: `${payload.uploadStatus ?? ''}`, + operation: getStringPayload(payload, 'operation'), + expectedUploadStatus: getStringPayload(payload, 'expectedUploadStatus'), + }), + datasetAlreadyUploaded: (): LocalizedString => + backEndText.datasetAlreadyUploaded(), +}; + +export function resolveBackendBusinessRuleMessage( + key: string, + payload: IR +): LocalizedString | undefined { + const localizationKey = getStringPayload(payload, 'localizationKey') || key; + if (localizationKey.length === 0) return undefined; + + const resolver = businessRuleMessageResolvers[localizationKey]; + return resolver?.(payload); +} + +export const validationMessageResolvers: RR = { + failedParsingPickList: (payload): LocalizedString => + backEndText.failedParsingPickList({ + value: `"${payload.value as string}"`, + }), + pickListValueTooLong: (payload): LocalizedString => + backEndText.pickListValueTooLong({ + pickListTable: tables.PickList.label, + pickList: payload.pickList as string, + maxLength: payload.maxLength as number, + }), + invalidPartialRecord: (payload): LocalizedString => + backEndText.invalidPartialRecord({ + column: payload.column as string, + }), + fieldRequiredByUploadPlan: (): LocalizedString => + backEndText.fieldRequiredByUploadPlan(), + invalidTreeStructure: (): LocalizedString => backEndText.invalidTreeStructure(), + scopeChangeError: (): LocalizedString => backEndText.scopeChangeDetected(), + multipleTreeDefsInRow: (): LocalizedString => + backEndText.multipleTreeDefsInRow(), + invalidCotype: (): LocalizedString => backEndText.invalidCotype(), + invalidComponentType: (): LocalizedString => + backEndText.invalidComponentType({ + componentType: tables.Component.field.type.label, + }), + missingRequiredTreeParent: (payload): LocalizedString => + backEndText.missingRequiredTreeParent({ + names: formatConjunction((payload.names as RA) ?? []), + }), +}; + +export function resolveSpecificValidationMessage( + key: string, + payload: IR +): LocalizedString | undefined { + const resolver = validationMessageResolvers[key]; + return resolver?.(payload); +} + +export const attachmentValidationMessageResolvers: RR< + string, + () => LocalizedString +> = { + attachmentNotFound: (): LocalizedString => backEndText.attachmentNotFound(), + tableDoesNotSupportAttachments: (): LocalizedString => + backEndText.tableDoesNotSupportAttachments(), + attachmentAlreadyLinked: (): LocalizedString => + backEndText.attachmentAlreadyLinked(), +}; + +export function resolveAttachmentValidationMessageByKey( + key: string +): LocalizedString { + return attachmentValidationMessageResolvers[key]?.() ?? + backEndText.attachmentNotFound(); +} diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts index a1905cefa48..253e645b737 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts @@ -5,346 +5,16 @@ */ import type { LocalizedString } from 'typesafe-i18n'; -import type { State } from 'typesafe-reducer'; -import { backEndText } from '../../localization/backEnd'; -import type { IR, RA, RR } from '../../utils/types'; +import type { IR } from '../../utils/types'; import { localized } from '../../utils/types'; import { - formatConjunction, - formatDisjunction, -} from '../Atoms/Internationalization'; -import { getField } from '../DataModel/helpers'; -import { getTable, tables } from '../DataModel/tables'; -import type { Tables } from '../DataModel/types'; - -/* - * If an UploadResult involves a tree record, this metadata indicates - * where in the tree the record resides - */ -type TreeInfo = { - // The tree rank a record relates to - readonly rank: string; - // The name of the tree node a record relates to - readonly name: string; -}; - -/* - * Records metadata about an UploadResult indicating the tables, data set - * columns, and any tree information involved - */ -type ReportInfo = { - // The name of the table a record relates to - readonly tableName: keyof Tables; - // The columns from the data set a record relates to - readonly columns: RA; - readonly treeInfo: TreeInfo | null; -}; - -/* - * Indicates that a value had to be added to a picklist during uploading - * a record - */ -type PicklistAddition = { - // The new picklistitem id - readonly id: number; - // The name of the picklist receiving the new item - readonly name: string; - // The value of the new item - readonly value: string; - // The data set column that produced the new item - readonly caption: string; -}; - -// Indicates that a new row was added to the database -type Uploaded = State< - 'Uploaded', - { - // The database id of the added row - readonly id: number; - readonly picklistAdditions: RA; - readonly info: ReportInfo; - } ->; - -// Indicates that an existing record in the database was matched -type Matched = State< - 'Matched', - { - // The id of the matched database row - readonly id: number; - readonly info: ReportInfo; - } ->; - -// Indicates failure due to finding multiple matches to existing records -type MatchedMultiple = State< - 'MatchedMultiple', - { - // List of ids of the matching database records - readonly ids: RA; - readonly key: string; - readonly info: ReportInfo; - } ->; - -/* - * Indicates that no record was uploaded because all relevant columns in - * the data set are empty - */ -type NullRecord = State< - 'NullRecord', - { - readonly info: ReportInfo; - } ->; - -// Indicates a record didn't upload due to a business rule violation -type FailedBusinessRule = State< - 'FailedBusinessRule', - { - // The error message generated by the business rule exception - readonly message: string; - readonly payload?: IR; - readonly info: ReportInfo; - } ->; - -// Indicates failure due to an error associated with a row's attachments -type AttachmentFailure = State< - 'AttachmentFailure', - { - readonly message: string; - readonly info: ReportInfo; - } ->; - -/* - * Indicates failure due to inability to find an expected existing - * matching record - */ -type NoMatch = State< - 'NoMatch', - { - readonly info: ReportInfo; - } ->; - -/* - * Indicates one or more values were invalid, preventing a record - * from uploading - */ -type ParseFailures = State< - 'ParseFailures', - { - readonly failures: RA< - readonly [string, IR, string] | readonly [string, string] - >; - } ->; - -type Updated = State<'Updated', Omit>; - -type NoChange = State< - 'NoChange', - { - readonly id: number; - readonly info: ReportInfo; - } ->; - -type Deleted = State< - 'Deleted', - { readonly id: number; readonly info: ReportInfo } ->; -// Indicates failure due to a failure to upload a related record -type PropagatedFailure = State<'PropagatedFailure'>; - -type MatchedAndChanged = State<'MatchedAndChanged', Omit>; - -type RecordResultTypes = - | AttachmentFailure - | Deleted - | Deleted - | FailedBusinessRule - | Matched - | MatchedAndChanged - | MatchedAndChanged - | MatchedMultiple - | NoChange - | NoChange - | NoMatch - | NullRecord - | ParseFailures - | PropagatedFailure - | Updated - | Uploaded; - -// Records the specific result of attempting to upload a particular record -type WbRecordResult = { - readonly [recordResultType in RecordResultTypes['type']]: Omit< - Extract>, - 'type' - >; -}; - -export type UploadResult = { - readonly UploadResult: { - readonly record_result: WbRecordResult; - /* - * Maps the names of -to-one relationships of the table to upload - * results for each - * 'parent' exists for tree nodes only - */ - readonly toOne: RR; - /* - * Maps the names of -to-many relationships of the table to an - * array of upload results for each - */ - readonly toMany: IR>; - }; -}; - -export function resolveBackendParsingMessage( - key: string, - payload: IR -): LocalizedString | undefined { - if (key === 'failedParsingBoolean') - return backEndText.failedParsingBoolean({ value: payload.value as string }); - else if (key === 'failedParsingDecimal') - return backEndText.failedParsingDecimal({ value: payload.value as string }); - else if (key === 'failedParsingFloat') - return backEndText.failedParsingFloat({ value: payload.value as string }); - else if (key === 'failedParsingAgentType') - return backEndText.failedParsingAgentType({ - agentTypeField: getField(tables.Agent, 'agentType').label, - badType: payload.badType as string, - validTypes: formatDisjunction( - (payload.validTypes as RA) ?? [] - ), - }); - else if (key === 'valueTooLong') - return backEndText.valueTooLong({ - maxLength: payload.maxLength as number, - }); - else if (key === 'invalidYear') - return backEndText.invalidYear({ - value: payload.value as string, - }); - else if (key === 'badDateFormat') - return backEndText.badDateFormat({ - value: payload.value as string, - format: payload.format as string, - }); - else if (key === 'coordinateBadFormat') - return backEndText.coordinateBadFormat({ - value: payload.value as string, - }); - else if (key === 'latitudeOutOfRange') - return backEndText.latitudeOutOfRange({ - value: payload.value as string, - }); - else if (key === 'longitudeOutOfRange') - return backEndText.longitudeOutOfRange({ - value: payload.value as string, - }); - else if (key === 'formatMismatch') - return backEndText.formatMismatch({ - value: payload.value as string, - formatter: payload.formatter as string, - }); - else return undefined; -} - -function withConflictingRecordIds( - message: LocalizedString, - payload: IR -): LocalizedString { - const conflicting = payload.conflicting; - const conflictingIds = Array.isArray(conflicting) - ? conflicting - .filter( - (value): value is string | number => - typeof value === 'string' || typeof value === 'number' - ) - .map((value) => String(value)) - : []; - return conflictingIds.length > 0 - ? localized( - `${message} (${backEndText.conflictingRecordIds({ - ids: conflictingIds.join(', '), - })})` - ) - : message; -} - -function getStringPayload(payload: IR, key: string): string { - const value = payload[key]; - return typeof value === 'string' ? value : ''; -} - -function getSchemaTableLabel(tableName: string): LocalizedString { - return getTable(tableName)?.label ?? localized(tableName); -} - -function getSchemaFieldLabel( - tableName: string, - fieldName: string -): LocalizedString { - const lookupFieldName = fieldName.split('__').join('.'); - return ( - getTable(tableName)?.getField(lookupFieldName)?.label ?? - localized(fieldName) - ); -} - -function getSchemaFieldLabels( - tableName: string, - fieldNames: string -): LocalizedString { - const labels = fieldNames - .split(',') - .map((fieldName) => fieldName.trim()) - .filter((fieldName) => fieldName.length > 0) - .map((fieldName) => getSchemaFieldLabel(tableName, fieldName)); - return labels.length === 0 - ? localized(fieldNames) - : formatConjunction(labels); -} - -function resolveBackendBusinessRuleMessage( - payload: IR -): LocalizedString | undefined { - const tableName = getStringPayload(payload, 'table'); - if (tableName.length === 0) return undefined; - if (payload.localizationKey === 'fieldNotUnique') - return withConflictingRecordIds( - backEndText.fieldNotUnique({ - tableName: getSchemaTableLabel(tableName), - fieldName: getSchemaFieldLabels( - tableName, - getStringPayload(payload, 'fieldName') - ), - }), - payload - ); - else if (payload.localizationKey === 'childFieldNotUnique') - return withConflictingRecordIds( - backEndText.childFieldNotUnique({ - tableName: getSchemaTableLabel(tableName), - fieldName: getSchemaFieldLabels( - tableName, - getStringPayload(payload, 'fieldName') - ), - parentField: getSchemaFieldLabels( - tableName, - getStringPayload(payload, 'parentField') - ), - }), - payload - ); - else return undefined; -} + resolveAttachmentValidationMessageByKey, + resolveBackendBusinessRuleMessage, + resolveBackendParsingMessage, + resolveSpecificValidationMessage, +} from './resultMessageResolvers'; +export type { UploadResult } from './uploadResultTypes'; /** Back-end sends a validation key. Front-end translates it */ export function resolveValidationMessage( @@ -352,60 +22,28 @@ export function resolveValidationMessage( payload: IR ): LocalizedString { const baseParsedMessage = resolveBackendParsingMessage(key, payload); - const businessRuleMessage = resolveBackendBusinessRuleMessage(payload); + const businessRuleMessage = resolveBackendBusinessRuleMessage(key, payload); if (baseParsedMessage !== undefined) { return baseParsedMessage; } else if (businessRuleMessage !== undefined) { return businessRuleMessage; - } else if (key === 'failedParsingPickList') - return backEndText.failedParsingPickList({ - value: `"${payload.value as string}"`, - }); - else if (key === 'pickListValueTooLong') - return backEndText.pickListValueTooLong({ - pickListTable: tables.PickList.label, - pickList: payload.pickList as string, - maxLength: payload.maxLength as number, - }); - else if (key === 'invalidPartialRecord') - return backEndText.invalidPartialRecord({ - column: payload.column as string, - }); - else if (key === 'fieldRequiredByUploadPlan') - return backEndText.fieldRequiredByUploadPlan(); - else if (key === 'invalidTreeStructure') - return backEndText.invalidTreeStructure(); - else if (key === 'scopeChangeError') return backEndText.scopeChangeDetected(); - else if (key === 'multipleTreeDefsInRow') - return backEndText.multipleTreeDefsInRow(); - else if (key === 'invalidCotype') return backEndText.invalidCotype(); - else if (key === 'invalidComponentType') - return backEndText.invalidComponentType({ - componentType: tables.Component.field.type.label, - }); - else if (key === 'missingRequiredTreeParent') - return backEndText.missingRequiredTreeParent({ - names: formatConjunction((payload.names as RA) ?? []), - }); + } + + const specificValidationMessage = resolveSpecificValidationMessage(key, payload); + if (specificValidationMessage !== undefined) { + return specificValidationMessage; + } + // This can happen for data sets created before 7.8.2 - else - return localized( - `${key}${ - Object.keys(payload).length === 0 ? '' : ` ${JSON.stringify(payload)}` - }` - ); + return localized( + `${key}${ + Object.keys(payload).length === 0 ? '' : ` ${JSON.stringify(payload)}` + }` + ); } export function resolveAttachmentValidationMessage( key: string ): LocalizedString { - if (key === 'attachmentNotFound') { - return backEndText.attachmentNotFound(); - } else if (key === 'tableDoesNotSupportAttachments') { - return backEndText.tableDoesNotSupportAttachments(); - } else if (key === 'attachmentAlreadyLinked') { - return backEndText.attachmentAlreadyLinked(); - } else { - return backEndText.attachmentNotFound(); - } + return resolveAttachmentValidationMessageByKey(key); } diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/uploadResultTypes.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/uploadResultTypes.ts new file mode 100644 index 00000000000..e8f3d62562e --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/uploadResultTypes.ts @@ -0,0 +1,191 @@ +import type { State } from 'typesafe-reducer'; + +import type { IR, RA, RR } from '../../utils/types'; +import type { Tables } from '../DataModel/types'; + +/* + * If an UploadResult involves a tree record, this metadata indicates + * where in the tree the record resides + */ +type TreeInfo = { + // The tree rank a record relates to + readonly rank: string; + // The name of the tree node a record relates to + readonly name: string; +}; + +/* + * Records metadata about an UploadResult indicating the tables, data set + * columns, and any tree information involved + */ +type ReportInfo = { + // The name of the table a record relates to + readonly tableName: keyof Tables; + // The columns from the data set a record relates to + readonly columns: RA; + readonly treeInfo: TreeInfo | null; +}; + +/* + * Indicates that a value had to be added to a picklist during uploading + * a record + */ +type PicklistAddition = { + // The new picklistitem id + readonly id: number; + // The name of the picklist receiving the new item + readonly name: string; + // The value of the new item + readonly value: string; + // The data set column that produced the new item + readonly caption: string; +}; + +// Indicates that a new row was added to the database +type Uploaded = State< + 'Uploaded', + { + // The database id of the added row + readonly id: number; + readonly picklistAdditions: RA; + readonly info: ReportInfo; + } +>; + +// Indicates that an existing record in the database was matched +type Matched = State< + 'Matched', + { + // The id of the matched database row + readonly id: number; + readonly info: ReportInfo; + } +>; + +// Indicates failure due to finding multiple matches to existing records +type MatchedMultiple = State< + 'MatchedMultiple', + { + // List of ids of the matching database records + readonly ids: RA; + readonly key: string; + readonly info: ReportInfo; + } +>; + +/* + * Indicates that no record was uploaded because all relevant columns in + * the data set are empty + */ +type NullRecord = State< + 'NullRecord', + { + readonly info: ReportInfo; + } +>; + +// Indicates a record didn't upload due to a business rule violation +type FailedBusinessRule = State< + 'FailedBusinessRule', + { + // The error message generated by the business rule exception + readonly message: string; + readonly payload?: IR; + readonly info: ReportInfo; + } +>; + +// Indicates failure due to an error associated with a row's attachments +type AttachmentFailure = State< + 'AttachmentFailure', + { + readonly message: string; + readonly info: ReportInfo; + } +>; + +/* + * Indicates failure due to inability to find an expected existing + * matching record + */ +type NoMatch = State< + 'NoMatch', + { + readonly info: ReportInfo; + } +>; + +/* + * Indicates one or more values were invalid, preventing a record + * from uploading + */ +type ParseFailures = State< + 'ParseFailures', + { + readonly failures: RA< + readonly [string, IR, string] | readonly [string, string] + >; + } +>; + +type Updated = State<'Updated', Omit>; + +type NoChange = State< + 'NoChange', + { + readonly id: number; + readonly info: ReportInfo; + } +>; + +type Deleted = State< + 'Deleted', + { readonly id: number; readonly info: ReportInfo } +>; +// Indicates failure due to a failure to upload a related record +type PropagatedFailure = State<'PropagatedFailure'>; + +type MatchedAndChanged = State<'MatchedAndChanged', Omit>; + +type RecordResultTypes = + | AttachmentFailure + | Deleted + | Deleted + | FailedBusinessRule + | Matched + | MatchedAndChanged + | MatchedAndChanged + | MatchedMultiple + | NoChange + | NoChange + | NoMatch + | NullRecord + | ParseFailures + | PropagatedFailure + | Updated + | Uploaded; + +// Records the specific result of attempting to upload a particular record +type WbRecordResult = { + readonly [recordResultType in RecordResultTypes['type']]: Omit< + Extract>, + 'type' + >; +}; + +export type UploadResult = { + readonly UploadResult: { + readonly record_result: WbRecordResult; + /* + * Maps the names of -to-one relationships of the table to upload + * results for each + * 'parent' exists for tree nodes only + */ + readonly toOne: RR; + /* + * Maps the names of -to-many relationships of the table to an + * array of upload results for each + */ + readonly toMany: IR>; + }; +}; From 979085fdeda0c7b28dfb23ed5b9444a57c9ebb96 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 27 Jul 2026 15:08:57 +0200 Subject: [PATCH 15/26] Fix: Import --- .../frontend/js_src/lib/components/LocalityUpdate/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/LocalityUpdate/utils.ts b/specifyweb/frontend/js_src/lib/components/LocalityUpdate/utils.ts index a14c686ace2..b50cce6a84d 100644 --- a/specifyweb/frontend/js_src/lib/components/LocalityUpdate/utils.ts +++ b/specifyweb/frontend/js_src/lib/components/LocalityUpdate/utils.ts @@ -6,8 +6,8 @@ import { f } from '../../utils/functools'; import type { IR, RA, RR } from '../../utils/types'; import { tables } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; -import { resolveBackendParsingMessage } from '../WorkBench/resultsParser'; import type { LocalityUpdateHeader, LocalityUpdateTaskStatus } from './types'; +import { resolveBackendParsingMessage } from '../WorkBench/resultMessageResolvers'; const localityUpdateAcceptedLocalityFields: RA< Lowercase From 8e679a73235022181c752f07665f18060840c381 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 27 Jul 2026 15:11:28 +0200 Subject: [PATCH 16/26] Fix: Tighten wrapper fallback --- .../upload/tests/test_upload_results_json.py | 16 ++++++++++++++++ .../backend/workbench/upload/upload_result.py | 13 +++---------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py b/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py index aed25d45c06..3aa8e50eeb3 100644 --- a/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py +++ b/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py @@ -108,6 +108,22 @@ def testBusinessRuleExceptionPayloadSanitization(self): # Ensure sanitized payload always serializes in upload results. json.dumps(failed_business_rule.to_json()) + def testWrapperFallbackDoesNotMatchGenericTwoArgException(self): + info = ReportInfo( + tableName="Collectionobject", + columns=["catalogNumber"], + treeInfo=None, + ) + + exception = Exception( + "connection failed", + {"table": "Collectionobject", "reason": "timeout"}, + ) + failed_business_rule = to_failed_business_rule(exception, info) + + self.assertEqual(failed_business_rule.payload, {}) + self.assertEqual(failed_business_rule.message, str(exception)) + @given(noMatch=infer) def testNoMatch(self, noMatch: NoMatch): j = json.dumps(noMatch.to_json()) diff --git a/specifyweb/backend/workbench/upload/upload_result.py b/specifyweb/backend/workbench/upload/upload_result.py index 3926127e71b..59fe2b89ec8 100644 --- a/specifyweb/backend/workbench/upload/upload_result.py +++ b/specifyweb/backend/workbench/upload/upload_result.py @@ -260,17 +260,10 @@ def is_business_rule_exception_with_payload(exception: Exception) -> bool: if not payload_like_exception: return False - # Some wrapped code paths can preserve the same payload shape without + # Some wrapped code paths can preserve a business-rule payload without # preserving the original exception class identity. - has_business_rule_shape = any( - key in exception.args[1] - for key in ( - "localizationKey", - "table", - "fieldName", - "parentField", - "conflicting", - ) + has_business_rule_shape = isinstance( + exception.args[1].get("localizationKey"), str ) return ( From d45a626a5cd475565266d43cf384d7438a518d97 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 27 Jul 2026 15:13:53 +0200 Subject: [PATCH 17/26] Fix: Add payload value to error --- .../upload/tests/test_upload_results_json.py | 22 +++++++++++++++++++ .../backend/workbench/upload/upload_result.py | 13 ++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py b/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py index 3aa8e50eeb3..406104c1e25 100644 --- a/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py +++ b/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py @@ -124,6 +124,28 @@ def testWrapperFallbackDoesNotMatchGenericTwoArgException(self): self.assertEqual(failed_business_rule.payload, {}) self.assertEqual(failed_business_rule.message, str(exception)) + def testBusinessRulePayloadPreservesTopLevelNone(self): + info = ReportInfo( + tableName="Collectionobject", + columns=["catalogNumber"], + treeInfo=None, + ) + + payload = { + "localizationKey": "childFieldNotUnique", + "table": "Collectionobject", + "fieldName": None, + "parentField": "collection", + } + + failed_business_rule = to_failed_business_rule( + Exception("Business rule failed", payload), + info, + ) + + self.assertIn("fieldName", failed_business_rule.payload) + self.assertIsNone(failed_business_rule.payload["fieldName"]) + @given(noMatch=infer) def testNoMatch(self, noMatch: NoMatch): j = json.dumps(noMatch.to_json()) diff --git a/specifyweb/backend/workbench/upload/upload_result.py b/specifyweb/backend/workbench/upload/upload_result.py index 59fe2b89ec8..f2ba47149be 100644 --- a/specifyweb/backend/workbench/upload/upload_result.py +++ b/specifyweb/backend/workbench/upload/upload_result.py @@ -279,24 +279,27 @@ def _is_business_rule_scalar(value: Any) -> bool: return isinstance(value, (str, int, bool)) or value is None -def _sanitize_business_rule_payload_value(value: Any) -> BusinessRulePayloadValue | None: +_SANITIZE_FAILED = object() + + +def _sanitize_business_rule_payload_value(value: Any) -> BusinessRulePayloadValue | object: if _is_business_rule_scalar(value): return value if isinstance(value, list): if all(_is_business_rule_scalar(item) for item in value): return value - return None + return _SANITIZE_FAILED if isinstance(value, dict): sanitized: dict[str, str | int | bool | None] = {} for key, item in value.items(): if not isinstance(key, str) or not _is_business_rule_scalar(item): - return None + return _SANITIZE_FAILED sanitized[key] = item return sanitized - return None + return _SANITIZE_FAILED def _sanitize_business_rule_payload(payload: dict[Any, Any]) -> BusinessRulePayload: @@ -305,7 +308,7 @@ def _sanitize_business_rule_payload(payload: dict[Any, Any]) -> BusinessRulePayl if not isinstance(key, str): continue sanitized_value = _sanitize_business_rule_payload_value(value) - if sanitized_value is not None: + if sanitized_value is not _SANITIZE_FAILED: sanitized[key] = sanitized_value return sanitized From 46838130e3b1426a636a165f65288c8e4f7ba7c4 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 27 Jul 2026 15:15:31 +0200 Subject: [PATCH 18/26] Fix: Do not stringify unknown business-rule payloads. --- .../WorkBench/__tests__/resultsParser.test.ts | 16 +++++++++++++--- .../lib/components/WorkBench/resultsParser.ts | 2 ++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts index 2a5b5564915..4d3e792d40c 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts @@ -34,15 +34,25 @@ describe('resolveValidationMessage business-rule handling', () => { expect(message).not.toContain('Conflicting record IDs:'); }); - test('falls back to generic message when business-rule payload has no table', () => { + test('falls back to backend key when business-rule payload has no table', () => { const payload = { localizationKey: 'fieldNotUnique', fieldName: 'catalognumber', conflicting: [4], }; - expect(resolveValidationMessage('unknownKey', payload)).toBe( - `unknownKey ${JSON.stringify(payload)}` + expect(resolveValidationMessage('unknownKey', payload)).toBe('unknownKey'); + }); + + test('does not stringify unknown business-rule payload internals', () => { + const payload = { + localizationKey: 'notRegisteredBusinessRule', + parentData: { collection: 'Collection object (1)' }, + conflicting: [4], + }; + + expect(resolveValidationMessage('backend raw business-rule text', payload)).toBe( + 'backend raw business-rule text' ); }); diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts index 253e645b737..fd222fa67e7 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts @@ -21,6 +21,7 @@ export function resolveValidationMessage( key: string, payload: IR ): LocalizedString { + const isBusinessRule = typeof payload.localizationKey === 'string'; const baseParsedMessage = resolveBackendParsingMessage(key, payload); const businessRuleMessage = resolveBackendBusinessRuleMessage(key, payload); if (baseParsedMessage !== undefined) { @@ -33,6 +34,7 @@ export function resolveValidationMessage( if (specificValidationMessage !== undefined) { return specificValidationMessage; } + if (isBusinessRule) return localized(key); // This can happen for data sets created before 7.8.2 return localized( From 3de6ff5a5ef550fb2aefa7b73a79dcd58f91145c Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 27 Jul 2026 15:20:22 +0200 Subject: [PATCH 19/26] Fix: Test import --- specifyweb/backend/workbench/upload/upload_result.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/specifyweb/backend/workbench/upload/upload_result.py b/specifyweb/backend/workbench/upload/upload_result.py index f2ba47149be..06cb7208781 100644 --- a/specifyweb/backend/workbench/upload/upload_result.py +++ b/specifyweb/backend/workbench/upload/upload_result.py @@ -1,4 +1,4 @@ -from typing import Any, NamedTuple +from typing import Any, NamedTuple, cast from typing import Literal @@ -308,8 +308,9 @@ def _sanitize_business_rule_payload(payload: dict[Any, Any]) -> BusinessRulePayl if not isinstance(key, str): continue sanitized_value = _sanitize_business_rule_payload_value(value) - if sanitized_value is not _SANITIZE_FAILED: - sanitized[key] = sanitized_value + if sanitized_value is _SANITIZE_FAILED: + continue + sanitized[key] = cast(BusinessRulePayloadValue, sanitized_value) return sanitized From daa8669ecb3e225a4cc45d1b888b50abbcac6b40 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 28 Jul 2026 13:22:54 +0200 Subject: [PATCH 20/26] Fix: Guard fieldName --- .../WorkBench/resultMessageResolvers.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts index 3581f57b6e8..c2b45ac8262 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts @@ -145,28 +145,24 @@ function getSchemaFieldLabels( export const businessRuleMessageResolvers: RR = { fieldNotUnique: (payload): LocalizedString | undefined => { const tableName = getStringPayload(payload, 'table'); - if (tableName.length === 0) return undefined; + const fieldName = getStringPayload(payload, 'fieldName'); + if (tableName.length === 0 || fieldName.length === 0) return undefined; return withConflictingRecordIds( backEndText.fieldNotUnique({ tableName: getSchemaTableLabel(tableName), - fieldName: getSchemaFieldLabels( - tableName, - getStringPayload(payload, 'fieldName') - ), + fieldName: getSchemaFieldLabels(tableName, fieldName), }), payload ); }, childFieldNotUnique: (payload): LocalizedString | undefined => { const tableName = getStringPayload(payload, 'table'); - if (tableName.length === 0) return undefined; + const fieldName = getStringPayload(payload, 'fieldName'); + if (tableName.length === 0 || fieldName.length === 0) return undefined; return withConflictingRecordIds( backEndText.childFieldNotUnique({ tableName: getSchemaTableLabel(tableName), - fieldName: getSchemaFieldLabels( - tableName, - getStringPayload(payload, 'fieldName') - ), + fieldName: getSchemaFieldLabels(tableName, fieldName), parentField: getSchemaFieldLabels( tableName, getStringPayload(payload, 'parentField') From 3a5c90aae6b5dc07bddd89274083f6fa4ed6ee6f Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 28 Jul 2026 13:25:52 +0200 Subject: [PATCH 21/26] Fix: wb result record type --- .../lib/components/WorkBench/uploadResultTypes.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/uploadResultTypes.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/uploadResultTypes.ts index e8f3d62562e..096446c8280 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/uploadResultTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/uploadResultTypes.ts @@ -167,11 +167,13 @@ type RecordResultTypes = // Records the specific result of attempting to upload a particular record type WbRecordResult = { - readonly [recordResultType in RecordResultTypes['type']]: Omit< - Extract>, - 'type' - >; -}; + readonly [recordResultType in RecordResultTypes['type']]: { + readonly [key in recordResultType]: Omit< + Extract>, + 'type' + >; + }; +}[RecordResultTypes['type']]; export type UploadResult = { readonly UploadResult: { From 287a7fea0220df94559452563047851784ec407c Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 28 Jul 2026 14:58:43 +0200 Subject: [PATCH 22/26] Fix: Typing --- .../lib/components/WorkBench/WbValidation.tsx | 154 +++++++++++++----- 1 file changed, 116 insertions(+), 38 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx index 805344218c9..0c097a5c441 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx @@ -2,7 +2,7 @@ import { whitespaceSensitive } from '../../localization/utils'; import { wbText } from '../../localization/workbench'; import { ajax } from '../../utils/ajax'; import type { RA, Writable, WritableArray } from '../../utils/types'; -import { capitalize, mappedFind, toLowerCase } from '../../utils/utils'; +import { capitalize, mappedFind } from '../../utils/utils'; import type { Tables } from '../DataModel/types'; import { raise } from '../Errors/Crash'; import { pathStartsWith } from '../WbPlanView/helpers'; @@ -35,10 +35,7 @@ type Records = WritableArray< >; // Just to make things manageable -type RecordCountsKey = keyof Pick< - UploadResult['UploadResult']['record_result'], - 'Deleted' | 'MatchedAndChanged' | 'Updated' | 'Uploaded' ->; +type RecordCountsKey = 'Deleted' | 'MatchedAndChanged' | 'Updated' | 'Uploaded'; export type RecordCounts = Partial< Record, number>>> @@ -65,6 +62,40 @@ type UploadResults = { readonly interestingRecords: Records; }; +type UploadStatus = + | 'AttachmentFailure' + | 'Deleted' + | 'FailedBusinessRule' + | 'Matched' + | 'MatchedAndChanged' + | 'MatchedMultiple' + | 'NoChange' + | 'NoMatch' + | 'NullRecord' + | 'ParseFailures' + | 'PropagatedFailure' + | 'Updated' + | 'Uploaded'; + +const uploadStatuses: RA = [ + 'AttachmentFailure', + 'Deleted', + 'FailedBusinessRule', + 'Matched', + 'MatchedAndChanged', + 'MatchedMultiple', + 'NoChange', + 'NoMatch', + 'NullRecord', + 'ParseFailures', + 'PropagatedFailure', + 'Updated', + 'Uploaded', +]; + +const isUploadStatus = (value: string): value is UploadStatus => + (uploadStatuses as RA).includes(value); + /* eslint-disable functional/no-this-expression */ export class WbValidation { // eslint-disable-next-line functional/prefer-readonly-type @@ -293,7 +324,7 @@ export class WbValidation { } private resolveUploadStatus( - uploadStatus: keyof UploadResult['UploadResult']['record_result'], + uploadStatus: UploadStatus, recordResult: UploadResult['UploadResult']['record_result'], physicalRow: number, mappingPath: MappingPath, @@ -315,7 +346,7 @@ export class WbValidation { uploadStatus ) ) { - } else if (uploadStatus === 'ParseFailures') + } else if ('ParseFailures' in recordResult) recordResult.ParseFailures.failures.forEach((line) => { const [issueMessage, payload, column] = line.length === 2 ? [line[0], {}, line[1]] : line; @@ -328,14 +359,14 @@ export class WbValidation { resolveColumns ); }); - else if (uploadStatus === 'NoMatch') + else if ('NoMatch' in recordResult) setMetaCallback( 'issues', wbText.noMatchErrorMessage(), recordResult.NoMatch.info.columns, resolveColumns ); - else if (uploadStatus === 'FailedBusinessRule') + else if ('FailedBusinessRule' in recordResult) setMetaCallback( 'issues', whitespaceSensitive( @@ -347,7 +378,7 @@ export class WbValidation { recordResult.FailedBusinessRule.info.columns, resolveColumns ); - else if (uploadStatus === 'MatchedMultiple') { + else if ('MatchedMultiple' in recordResult) { this.uploadResults.ambiguousMatches[physicalRow] ??= []; this.uploadResults.ambiguousMatches[physicalRow].push({ physicalCols: this.resolveValidationColumns( @@ -364,7 +395,7 @@ export class WbValidation { recordResult.MatchedMultiple.info.columns, resolveColumns ); - } else if (uploadStatus === 'AttachmentFailure') + } else if ('AttachmentFailure' in recordResult) setMetaCallback( 'issues', whitespaceSensitive( @@ -377,46 +408,47 @@ export class WbValidation { ); // TODO: Discuss if MatchedAndChanged needs to shown. or whatever. else if ( - uploadStatus === 'Uploaded' || - uploadStatus === 'Updated' || - uploadStatus === 'MatchedAndChanged' || - uploadStatus === 'Deleted' + 'Uploaded' in recordResult || + 'Updated' in recordResult || + 'MatchedAndChanged' in recordResult || + 'Deleted' in recordResult ) { - // All these meta ones are interesting - const metaKey = - uploadStatus === 'Uploaded' - ? 'isNew' - : uploadStatus === 'Updated' - ? 'isUpdated' - : uploadStatus === 'MatchedAndChanged' - ? 'isMatchedAndChanged' - : 'isDeleted'; + const [statusKey, statusData, metaKey] = 'Uploaded' in recordResult + ? (['Uploaded', recordResult.Uploaded, 'isNew'] as const) + : 'Updated' in recordResult + ? (['Updated', recordResult.Updated, 'isUpdated'] as const) + : 'MatchedAndChanged' in recordResult + ? (['MatchedAndChanged', recordResult.MatchedAndChanged, 'isMatchedAndChanged'] as const) + : (['Deleted', recordResult.Deleted, 'isDeleted'] as const); + setMetaCallback( metaKey, true, - recordResult[uploadStatus].info.columns, + statusData.info.columns, undefined ); - const tableName = toLowerCase(recordResult[uploadStatus].info.tableName); - this.uploadResults.recordCounts[uploadStatus] ??= {}; - this.uploadResults.recordCounts[uploadStatus]![tableName]! ??= 0; - this.uploadResults.recordCounts[uploadStatus]![tableName]! += 1; + const tableName = statusData.info.tableName.toLowerCase() as Lowercase< + keyof Tables + >; + this.uploadResults.recordCounts[statusKey] ??= {}; + this.uploadResults.recordCounts[statusKey]![tableName]! ??= 0; + this.uploadResults.recordCounts[statusKey]![tableName]! += 1; - if (uploadStatus === 'Deleted') return; // Not sure if there is any value in showing deleted id's itself, right? + if (statusKey === 'Deleted') return; // Not sure if there is any value in showing deleted id's itself, right? const writable = this.uploadResults.interestingRecords; writable[physicalRow] ??= []; this.resolveValidationColumns( - recordResult[uploadStatus].info.columns, + statusData.info.columns, undefined ).forEach((physicalCol) => { writable[physicalRow]![physicalCol] ??= []; writable[physicalRow]![physicalCol].push([ tableName, - recordResult[uploadStatus].id, - recordResult[uploadStatus].info?.treeInfo - ? `${recordResult[uploadStatus].info.treeInfo!.name} (${recordResult[uploadStatus].info.treeInfo!.rank})` + statusData.id, + statusData.info?.treeInfo + ? `${statusData.info.treeInfo!.name} (${statusData.info.treeInfo!.rank})` : '', ]); }); @@ -441,12 +473,58 @@ export class WbValidation { initialMappingPath: MappingPath | undefined = [] ): void { const uploadResult = result.UploadResult; - const uploadStatus = Object.keys(uploadResult.record_result)[0]; - const statusData = uploadResult.record_result[uploadStatus]; + const uploadStatusKey = Object.keys(uploadResult.record_result)[0]; + + if (typeof uploadStatusKey !== 'string' || !isUploadStatus(uploadStatusKey)) + return; - const isTree = 'info' in statusData && statusData.info?.treeInfo !== null; + const uploadStatus: UploadStatus = uploadStatusKey; + + const statusData = 'AttachmentFailure' in uploadResult.record_result + ? uploadResult.record_result.AttachmentFailure + : 'Deleted' in uploadResult.record_result + ? uploadResult.record_result.Deleted + : 'FailedBusinessRule' in uploadResult.record_result + ? uploadResult.record_result.FailedBusinessRule + : 'Matched' in uploadResult.record_result + ? uploadResult.record_result.Matched + : 'MatchedAndChanged' in uploadResult.record_result + ? uploadResult.record_result.MatchedAndChanged + : 'MatchedMultiple' in uploadResult.record_result + ? uploadResult.record_result.MatchedMultiple + : 'NoChange' in uploadResult.record_result + ? uploadResult.record_result.NoChange + : 'NoMatch' in uploadResult.record_result + ? uploadResult.record_result.NoMatch + : 'NullRecord' in uploadResult.record_result + ? uploadResult.record_result.NullRecord + : 'ParseFailures' in uploadResult.record_result + ? uploadResult.record_result.ParseFailures + : 'PropagatedFailure' in uploadResult.record_result + ? uploadResult.record_result.PropagatedFailure + : 'Updated' in uploadResult.record_result + ? uploadResult.record_result.Updated + : 'Uploaded' in uploadResult.record_result + ? uploadResult.record_result.Uploaded + : undefined; + + if (statusData === undefined) return; + + const info = + 'info' in statusData + ? (statusData.info as + | { + readonly treeInfo: { + readonly rank: string; + readonly name: string; + } | null; + } + | undefined) + : undefined; + + const isTree = info?.treeInfo !== null && info !== undefined; const mappingPath = isTree - ? [...initialMappingPath, formatTreeRank(statusData.info.treeInfo.rank)] + ? [...initialMappingPath, formatTreeRank(info.treeInfo!.rank)] : initialMappingPath; this.resolveUploadStatus( From b863356a0cd3b763efe291fbf9be8e1d30d376b7 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 28 Jul 2026 15:02:57 +0200 Subject: [PATCH 23/26] Fix: Typing --- .../lib/components/WorkBench/WbValidation.tsx | 64 +++++++------------ 1 file changed, 24 insertions(+), 40 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx index 0c097a5c441..b8b8f0138f9 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx @@ -480,47 +480,31 @@ export class WbValidation { const uploadStatus: UploadStatus = uploadStatusKey; - const statusData = 'AttachmentFailure' in uploadResult.record_result - ? uploadResult.record_result.AttachmentFailure - : 'Deleted' in uploadResult.record_result - ? uploadResult.record_result.Deleted - : 'FailedBusinessRule' in uploadResult.record_result - ? uploadResult.record_result.FailedBusinessRule - : 'Matched' in uploadResult.record_result - ? uploadResult.record_result.Matched - : 'MatchedAndChanged' in uploadResult.record_result - ? uploadResult.record_result.MatchedAndChanged - : 'MatchedMultiple' in uploadResult.record_result - ? uploadResult.record_result.MatchedMultiple - : 'NoChange' in uploadResult.record_result - ? uploadResult.record_result.NoChange - : 'NoMatch' in uploadResult.record_result - ? uploadResult.record_result.NoMatch - : 'NullRecord' in uploadResult.record_result - ? uploadResult.record_result.NullRecord - : 'ParseFailures' in uploadResult.record_result - ? uploadResult.record_result.ParseFailures - : 'PropagatedFailure' in uploadResult.record_result - ? uploadResult.record_result.PropagatedFailure - : 'Updated' in uploadResult.record_result - ? uploadResult.record_result.Updated - : 'Uploaded' in uploadResult.record_result - ? uploadResult.record_result.Uploaded - : undefined; - - if (statusData === undefined) return; - + const recordResult = uploadResult.record_result; const info = - 'info' in statusData - ? (statusData.info as - | { - readonly treeInfo: { - readonly rank: string; - readonly name: string; - } | null; - } - | undefined) - : undefined; + 'AttachmentFailure' in recordResult + ? recordResult.AttachmentFailure.info + : 'Deleted' in recordResult + ? recordResult.Deleted.info + : 'FailedBusinessRule' in recordResult + ? recordResult.FailedBusinessRule.info + : 'Matched' in recordResult + ? recordResult.Matched.info + : 'MatchedAndChanged' in recordResult + ? recordResult.MatchedAndChanged.info + : 'MatchedMultiple' in recordResult + ? recordResult.MatchedMultiple.info + : 'NoChange' in recordResult + ? recordResult.NoChange.info + : 'NoMatch' in recordResult + ? recordResult.NoMatch.info + : 'NullRecord' in recordResult + ? recordResult.NullRecord.info + : 'Updated' in recordResult + ? recordResult.Updated.info + : 'Uploaded' in recordResult + ? recordResult.Uploaded.info + : undefined; const isTree = info?.treeInfo !== null && info !== undefined; const mappingPath = isTree From 5c604afc1a1195b8c56bc266717de3225c11eff5 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 28 Jul 2026 15:08:09 +0200 Subject: [PATCH 24/26] Fix: Guard parentFieldg --- .../components/WorkBench/resultMessageResolvers.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts index c2b45ac8262..c88dd3c9c34 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts @@ -158,15 +158,18 @@ export const businessRuleMessageResolvers: RR { const tableName = getStringPayload(payload, 'table'); const fieldName = getStringPayload(payload, 'fieldName'); - if (tableName.length === 0 || fieldName.length === 0) return undefined; + const parentField = getStringPayload(payload, 'parentField'); + if ( + tableName.length === 0 || + fieldName.length === 0 || + parentField.length === 0 + ) + return undefined; return withConflictingRecordIds( backEndText.childFieldNotUnique({ tableName: getSchemaTableLabel(tableName), fieldName: getSchemaFieldLabels(tableName, fieldName), - parentField: getSchemaFieldLabels( - tableName, - getStringPayload(payload, 'parentField') - ), + parentField: getSchemaFieldLabels(tableName, parentField), }), payload ); From e166e3404d51bacd4cca2651ec296024fa23a294 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 28 Jul 2026 15:22:14 +0200 Subject: [PATCH 25/26] Refactor: Simplify record result typing --- .../lib/components/WorkBench/WbValidation.tsx | 43 ++++++++----------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx index b8b8f0138f9..0991cce81d8 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx @@ -96,6 +96,18 @@ const uploadStatuses: RA = [ const isUploadStatus = (value: string): value is UploadStatus => (uploadStatuses as RA).includes(value); +const hasUploadInfo = ( + value: unknown +): value is { + readonly info: { + readonly treeInfo: { + readonly rank: string; + readonly name: string; + } | null; + }; +} => + typeof value === 'object' && value !== null && 'info' in value; + /* eslint-disable functional/no-this-expression */ export class WbValidation { // eslint-disable-next-line functional/prefer-readonly-type @@ -473,38 +485,17 @@ export class WbValidation { initialMappingPath: MappingPath | undefined = [] ): void { const uploadResult = result.UploadResult; - const uploadStatusKey = Object.keys(uploadResult.record_result)[0]; + const [uploadStatusKey, statusData] = + (Object.entries(uploadResult.record_result)[0] ?? []) as + | [string, unknown] + | []; if (typeof uploadStatusKey !== 'string' || !isUploadStatus(uploadStatusKey)) return; const uploadStatus: UploadStatus = uploadStatusKey; - const recordResult = uploadResult.record_result; - const info = - 'AttachmentFailure' in recordResult - ? recordResult.AttachmentFailure.info - : 'Deleted' in recordResult - ? recordResult.Deleted.info - : 'FailedBusinessRule' in recordResult - ? recordResult.FailedBusinessRule.info - : 'Matched' in recordResult - ? recordResult.Matched.info - : 'MatchedAndChanged' in recordResult - ? recordResult.MatchedAndChanged.info - : 'MatchedMultiple' in recordResult - ? recordResult.MatchedMultiple.info - : 'NoChange' in recordResult - ? recordResult.NoChange.info - : 'NoMatch' in recordResult - ? recordResult.NoMatch.info - : 'NullRecord' in recordResult - ? recordResult.NullRecord.info - : 'Updated' in recordResult - ? recordResult.Updated.info - : 'Uploaded' in recordResult - ? recordResult.Uploaded.info - : undefined; + const info = hasUploadInfo(statusData) ? statusData.info : undefined; const isTree = info?.treeInfo !== null && info !== undefined; const mappingPath = isTree From 03adf12ea06ea0a461124f3aa8abfdcbb5069ffd Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 28 Jul 2026 15:25:01 +0200 Subject: [PATCH 26/26] Refactor: Simplify typing --- .../lib/components/WorkBench/WbValidation.tsx | 53 +++++-------------- 1 file changed, 13 insertions(+), 40 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx index 0991cce81d8..281abdcba31 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx @@ -62,39 +62,17 @@ type UploadResults = { readonly interestingRecords: Records; }; -type UploadStatus = - | 'AttachmentFailure' - | 'Deleted' - | 'FailedBusinessRule' - | 'Matched' - | 'MatchedAndChanged' - | 'MatchedMultiple' - | 'NoChange' - | 'NoMatch' - | 'NullRecord' - | 'ParseFailures' - | 'PropagatedFailure' - | 'Updated' - | 'Uploaded'; - -const uploadStatuses: RA = [ - 'AttachmentFailure', - 'Deleted', - 'FailedBusinessRule', - 'Matched', - 'MatchedAndChanged', - 'MatchedMultiple', - 'NoChange', - 'NoMatch', - 'NullRecord', - 'ParseFailures', - 'PropagatedFailure', - 'Updated', - 'Uploaded', -]; +type KeysOfUnion = T extends unknown ? keyof T : never; -const isUploadStatus = (value: string): value is UploadStatus => - (uploadStatuses as RA).includes(value); +type UploadStatus = Extract< + KeysOfUnion, + string +>; + +const getRecordResultEntry = ( + recordResult: UploadResult['UploadResult']['record_result'] +): readonly [UploadStatus, unknown] | undefined => + Object.entries(recordResult)[0] as [UploadStatus, unknown] | undefined; const hasUploadInfo = ( value: unknown @@ -485,15 +463,10 @@ export class WbValidation { initialMappingPath: MappingPath | undefined = [] ): void { const uploadResult = result.UploadResult; - const [uploadStatusKey, statusData] = - (Object.entries(uploadResult.record_result)[0] ?? []) as - | [string, unknown] - | []; - - if (typeof uploadStatusKey !== 'string' || !isUploadStatus(uploadStatusKey)) - return; + const recordResultEntry = getRecordResultEntry(uploadResult.record_result); + if (recordResultEntry === undefined) return; - const uploadStatus: UploadStatus = uploadStatusKey; + const [uploadStatus, statusData] = recordResultEntry; const info = hasUploadInfo(statusData) ? statusData.info : undefined;