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 f8cba708515..e501815e4a1 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 @@ -166,6 +166,43 @@ describe('Collection Object business rules', () => { expect(result.current[0]).toStrictEqual([]); }); + // Uniqueness rule check + overrideAjax( + '/api/specify/collectionobject/?domainfilter=false&catalognumber=2022-%23%23%23%23%23%23&collection=4&offset=0', + { + objects: [], + meta: { + limit: 20, + offset: 0, + total_count: 0, + }, + } + ); + + test('CollectionObject -> catalogNumber is reset whenever new CollectionObject -> collectionObjectType changes', async () => { + const collectionObject = new tables.CollectionObject.Resource(); + expect(collectionObject.get('catalogNumber')).toBeUndefined(); + collectionObject.set( + 'collectionObjectType', + getResourceApiUrl('CollectionObjectType', 2) + ); + expect(collectionObject.get('catalogNumber')).toBe('2022-######'); + // Wait for any pending promise to complete before test finishes + await collectionObject.businessRuleManager?.pendingPromise; + }); + + test('CollectionObject -> catalogNumber is reset whenever existing CollectionObject -> collectionObjectType changes', async () => { + const collectionObject = getBaseCollectionObject(); + expect(collectionObject.get('catalogNumber')).toBe('123'); + collectionObject.set( + 'collectionObjectType', + getResourceApiUrl('CollectionObjectType', 2) + ); + expect(collectionObject.get('catalogNumber')).toBe('2022-######'); + // Wait for any pending promise to complete before test finishes + await collectionObject.businessRuleManager?.pendingPromise; + }); + test('CollectionObject -> determinations: New determinations are current by default', async () => { const collectionObject = getBaseCollectionObject(); const determinations = 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 0adee723905..5a5a3bc4970 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 @@ -65,6 +65,26 @@ overrideAjax( } ); +const firstCollectionObjectUrl = getResourceApiUrl('CollectionObject', 1); +const firstCollectionObject = { + id: 1, + resource_uri: firstCollectionObjectUrl, + catalognumber: '000011111', + collection: getResourceApiUrl('Collection', 4), +}; +overrideAjax(firstCollectionObjectUrl, firstCollectionObject); +overrideAjax( + '/api/specify/collectionobject/?domainfilter=false&catalognumber=000011111&collection=4&offset=0', + { + objects: [], + meta: { + limit: 20, + offset: 0, + total_count: 1, + }, + } +); + const accessionNumber = '2011-IC-116'; const accessionResponse = { resource_uri: accessionUrl, @@ -251,7 +271,7 @@ describe('eventHandlerForToMany', () => { expect(resource.needsSaved).toBe(true); // Change to 2 in issue-6214 - expect(testFunction).toHaveBeenCalledTimes(2); + expect(testFunction).toHaveBeenCalledTimes(3); }); test('changing collection propagates to related', () => { const resource = new tables.CollectionObject.Resource( @@ -300,14 +320,14 @@ describe('eventHandlerForToMany', () => { { index: 1 } ); - expect(onResourceChange).toHaveBeenCalledTimes(3); + expect(onResourceChange).toHaveBeenCalledTimes(4); resource.set('determinations', [ addMissingFields('Determination', { taxon: getResourceApiUrl('Taxon', 1), }), ]); - expect(onResourceChange).toHaveBeenCalledTimes(4); + expect(onResourceChange).toHaveBeenCalledTimes(5); expect(onPrepChange).toHaveBeenCalledTimes(1); expect(onPrepAdd).toHaveBeenCalledTimes(1); expect(onPrepRemoval).toHaveBeenCalledTimes(1); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index a89856b6e9a..9a941e44358 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -1,4 +1,6 @@ import { resourcesText } from '../../localization/resources'; +import { resolveParser } from '../../utils/parser/definitions'; +import type { ValueOf } from '../../utils/types'; import type { BusinessRuleResult } from './businessRules'; import { COG_PRIMARY_KEY, @@ -9,7 +11,7 @@ import { hasNoCurrentDetermination, } from './businessRuleUtils'; import { cogTypes } from './helpers'; -import type { AnySchema, TableFields } from './helperTypes'; +import type { AnySchema, CommonFields, TableFields } from './helperTypes'; import { checkPrepAvailability, getTotalLoaned, @@ -21,6 +23,7 @@ import { import type { SpecifyResource } from './legacyTypes'; import { setSaveBlockers } from './saveBlockers'; import { schema } from './schema'; +import type { LiteralField, Relationship } from './specifyField'; import type { Collection } from './specifyTable'; import { tables } from './tables'; import type { @@ -49,7 +52,17 @@ export type BusinessRuleDefs = { readonly customInit?: (resource: SpecifyResource) => void; readonly fieldChecks?: { readonly [FIELD_NAME in TableFields]?: ( - resource: SpecifyResource + resource: SpecifyResource, + field: (CommonFields & + SCHEMA['fields'] & + SCHEMA['toManyDependent'] & + SCHEMA['toManyIndependent'] & + SCHEMA['toOneDependent'] & + SCHEMA['toOneIndependent'])[FIELD_NAME] extends ValueOf< + AnySchema['fields'] + > + ? LiteralField + : Relationship ) => Promise | void; }; }; @@ -169,6 +182,16 @@ export const businessRuleDefs: MappedBusinessRuleDefs = { }, fieldChecks: { collectionObjectType: async (resource): Promise => { + const parser = resolveParser( + resource.specifyTable.strictGetLiteralField('catalogNumber'), + undefined, + resource + ); + // REFACTOR: non-silent set causes infinite loop and silent set still triggers save blocker when parser value is empty string + resource.set('catalogNumber', parser.value as never, { + silent: (parser.value ?? '') === '', + }); + const determinations = resource.getDependentResource('determinations'); if (determinations === undefined || determinations.models.length === 0) return; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index 462bf2bd323..22cd084c518 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -80,7 +80,10 @@ export class BusinessRuleManager { const checks: RA | undefined>> = [ ...this.checkUnique(processedFieldName), - this.invokeRule('fieldChecks', processedFieldName, [this.resource]), + this.invokeRule('fieldChecks', processedFieldName, [ + this.resource, + field, + ]), isTreeResource(this.resource as SpecifyResource) ? treeBusinessRules( this.resource as SpecifyResource, diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx b/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx index af44ccd7033..d1a2d31fb15 100644 --- a/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx @@ -133,7 +133,7 @@ export function CarryForwardConfig({ /> )} {isCarryForwardEnabled ? ( - + ) : null} {isOpen && ( @@ -202,7 +201,10 @@ function BulkCloneConfig({ ) : null; } -export const tableValidForBulkClone = (table: SpecifyTable, resource?: SpecifyResource): boolean => +export const tableValidForBulkClone = ( + table: SpecifyTable, + resource?: SpecifyResource +): boolean => table === tables.CollectionObject && !( tables.CollectionObject.strictGetLiteralField('catalogNumber') diff --git a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx index 345362e6edf..381e672b4c1 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx @@ -233,12 +233,14 @@ export function SaveButton({ resource.specifyTable.name === 'CollectionObjectGroup' || resource.specifyTable.name === 'CollectionObjectGroupJoin'; - // Disable bulk carry forward for COType cat num format that are undefined or one of types listed in tableValidForBulkClone() + // Disable bulk carry forward for COType cat num format that are undefined or one of types listed in tableValidForBulkClone() const formatter = tables.CollectionObject.strictGetLiteralField( 'catalogNumber' ).getUiFormatter(resource)!; - const disableBulk = !tableValidForBulkClone(resource.specifyTable, resource) || formatter === undefined + const disableBulk = + !tableValidForBulkClone(resource.specifyTable, resource) || + formatter === undefined; return ( <> @@ -249,7 +251,8 @@ export function SaveButton({ isSaveDisabled && showCarry && showBulkCarry && - !isCOGorCOJO && !disableBulk? ( + !isCOGorCOJO && + !disableBulk ? ( ({ resource.specifyTable.name === 'CollectionObject' && carryForwardAmount > 1 ? async (): Promise>> => { - const wildCard = formatter.valueOrWild(); const clonePromises = Array.from( diff --git a/specifyweb/frontend/js_src/lib/hooks/__tests__/useParserDefaultValue.test.ts b/specifyweb/frontend/js_src/lib/hooks/__tests__/useParserDefaultValue.test.ts new file mode 100644 index 00000000000..5ec511018fc --- /dev/null +++ b/specifyweb/frontend/js_src/lib/hooks/__tests__/useParserDefaultValue.test.ts @@ -0,0 +1,70 @@ +import { renderHook } from '@testing-library/react'; +import { tables } from '../../components/DataModel/tables'; +import { requireContext } from '../../tests/helpers'; +import { Parser } from '../../utils/parser/definitions'; +import { useParser } from '../resource'; +import { useParserDefaultValue } from '../useParserDefaultValue'; + +requireContext(); + +test('Simple parser', () => { + const resource = new tables.ExchangeOut.Resource(); + const field = tables.ExchangeOut.strictGetLiteralField('number1'); + expect(resource.get(field.name as never)).toBeUndefined(); + const parser: Parser = { + type: 'number', + value: '2', + }; + renderHook(() => useParserDefaultValue(resource, field, parser)); + + expect(resource.get(field.name as never)).toBe('2'); +}); + +test('Only overwrites when needed', () => { + const resource = new tables.ExchangeOut.Resource({ + number1: 42, + }); + const field = tables.ExchangeOut.strictGetLiteralField('number1'); + expect(resource.get(field.name as never)).toBe(42); + const parser: Parser = { + type: 'number', + value: '2', + }; + renderHook(() => useParserDefaultValue(resource, field, parser)); + + expect(resource.get(field.name as never)).toBe(42); +}); + +test("Doesn't override an existing resource's values", () => { + const resource = new tables.ExchangeOut.Resource({ id: 1 }); + const field = tables.ExchangeOut.strictGetLiteralField('number1'); + expect(resource.get(field.name as never)).toBeUndefined(); + const parser: Parser = { + type: 'number', + value: '2', + }; + renderHook(() => useParserDefaultValue(resource, field, parser)); + + expect(resource.get(field.name as never)).toBe(undefined); +}); + +test("Doesn't assume default value", () => { + const resource = new tables.Accession.Resource(); + const field = tables.Accession.strictGetLiteralField('integer1'); + expect(resource.get(field.name as never)).toBeUndefined(); + const { result } = renderHook(() => useParser(field, resource)); + + renderHook(() => useParserDefaultValue(resource, field, result.current)); + + expect(resource.get(field.name as never)).toBeUndefined(); +}); + +test('CatalogNumber parser', () => { + const resource = new tables.CollectionObject.Resource(); + const field = tables.CollectionObject.strictGetLiteralField('catalogNumber'); + expect(resource.get('catalogNumber')).toBeUndefined(); + const { result } = renderHook(() => useParser(field, resource)); + renderHook(() => useParserDefaultValue(resource, field, result.current)); + + expect(resource.get(field.name as never)).toBe('#########'); +}); diff --git a/specifyweb/frontend/js_src/lib/hooks/useFieldParser.tsx b/specifyweb/frontend/js_src/lib/hooks/useFieldParser.tsx new file mode 100644 index 00000000000..119e9918b16 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/hooks/useFieldParser.tsx @@ -0,0 +1,143 @@ +import React from 'react'; + +import { className } from '../components/Atoms/className'; +import type { AnySchema } from '../components/DataModel/helperTypes'; +import type { SpecifyResource } from '../components/DataModel/legacyTypes'; +import type { + LiteralField, + Relationship, +} from '../components/DataModel/specifyField'; +import type { Input } from '../components/Forms/validationHelpers'; +import { f } from '../utils/functools'; +import type { Parser } from '../utils/parser/definitions'; +import type { + InvalidParseResult, + ValidParseResult, +} from '../utils/parser/parse'; +import { parseValue } from '../utils/parser/parse'; + +export function useFieldParser< + T extends boolean | number | string | null, + INPUT extends Input = HTMLInputElement, +>({ + resource, + field, + inputRef, + parser, + trim, + onParse: handleParse, +}: { + readonly resource: SpecifyResource | undefined; + readonly field: LiteralField | Relationship | undefined; + readonly inputRef: React.MutableRefObject; + readonly parser: Parser; + readonly trim?: boolean; + readonly onParse: ( + parseResult: InvalidParseResult | ValidParseResult + ) => void; +}): readonly [ + value: T | undefined, + updateValue: (newValue: T, reportErrors?: boolean) => void, +] { + /* + * Display saveBlocker validation errors only after field lost focus, not + * during typing + */ + const [input, setInput] = React.useState(null); + const [value, setValue] = React.useState(undefined); + + /* + * Updating field value changes data model value, which triggers a field + * update, which updated field value, and so on. This ref helps break + * this cycle. + */ + const ignoreChangeRef = React.useRef(false); + + // Parse value and update saveBlockers + const updateValue = React.useCallback( + /* + * REFACTOR: disable @typescript-eslint/no-inferrable-types and set + * type explicitly as @typescript-eslint/strict-boolean-expressions can't + * infer implicit types + */ + (newValue: T, reportErrors = true) => { + if (ignoreChangeRef.current || resource === undefined) return; + + /* + * Converting ref to state so that React.useEffect can be triggered + * when needed + */ + setInput(inputRef.current); + + if (parser.type === undefined) return; + + /* + * If updateValue is called from the onChange event handler and field is + * required and field did not have a value when onChange occurred, then + * parseValue() is going to report "Value missing" error. This fixes that + * issue. See https://github.com/specify/specify7/issues/1427 + */ + if ( + inputRef.current !== null && + inputRef.current.value === '' && + newValue !== '' + ) + inputRef.current.value = newValue?.toString() ?? inputRef.current.value; + + const parseResults = parseValue( + parser, + inputRef.current ?? undefined, + newValue?.toString() ?? '', + trim + ); + + const parsedValue = parseResults.isValid ? parseResults.parsed : newValue; + const formattedValue = + field?.isRelationship === true && newValue === '' + ? null + : ['checkbox', 'date'].includes(parser.type ?? '') || reportErrors + ? parsedValue + : newValue; + setValue( + (parser.type === 'number' && reportErrors + ? (f.parseFloat( + parser?.printFormatter?.(parsedValue, parser) ?? '' + ) ?? parsedValue) + : formattedValue) as T + ); + if (field === undefined) return; + handleParse(parseResults); + + ignoreChangeRef.current = true; + /* + * If value changed as a result of being formatted, don't trigger + * unload protect + */ + const formattedOnly = resource.get(field.name) === newValue; + resource.set(field.name, formattedValue as never, { + /* + * Don't trigger the save blocker for this trivial change + * REFACTOR: move this logic into ResourceBase.set + */ + silent: + (formattedValue === null && resource.get(field.name) === '') || + formattedOnly, + }); + ignoreChangeRef.current = false; + }, + [resource, field, parser, inputRef] + ); + + // REFACTOR: move this? + /* + * Resource changes when sliding in a record selector, but react reuses + * the DOM component, thus need to manually add back the "notTouchedInput" + * class name + */ + React.useEffect( + () => input?.classList.add(className.notTouchedInput), + [input, resource] + ); + + return [value, updateValue] as const; +} diff --git a/specifyweb/frontend/js_src/lib/hooks/useFieldValidation.tsx b/specifyweb/frontend/js_src/lib/hooks/useFieldValidation.tsx new file mode 100644 index 00000000000..e60763a1a5e --- /dev/null +++ b/specifyweb/frontend/js_src/lib/hooks/useFieldValidation.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import type { AnySchema } from '../components/DataModel/helperTypes'; +import type { SpecifyResource } from '../components/DataModel/legacyTypes'; +import { + getFieldBlockerKey, + useSaveBlockers, +} from '../components/DataModel/saveBlockers'; +import type { + LiteralField, + Relationship, +} from '../components/DataModel/specifyField'; +import type { Input } from '../components/Forms/validationHelpers'; +import type { RA } from '../utils/types'; +import { useValidation } from './useValidation'; + +export function useFieldValidation( + resource: SpecifyResource | undefined, + field: LiteralField | Relationship | undefined +): { + // See useValidation for documentation of these props: + readonly inputRef: React.MutableRefObject; + readonly validationRef: React.RefCallback; + readonly setValidation: ( + message: RA | string, + blockerKey?: string + ) => void; +} { + const [blockers, setBlockers] = useSaveBlockers(resource, field); + const { inputRef, validationRef } = useValidation(blockers); + + return { + inputRef, + validationRef, + setValidation: React.useCallback( + (message, blockerKey) => { + const blockers = typeof message === 'string' ? [message] : message; + if (field !== undefined) + setBlockers( + blockers, + blockerKey ?? getFieldBlockerKey(field, 'validation') + ); + }, + [setBlockers, field] + ), + }; +} diff --git a/specifyweb/frontend/js_src/lib/hooks/useParserDefaultValue.tsx b/specifyweb/frontend/js_src/lib/hooks/useParserDefaultValue.tsx new file mode 100644 index 00000000000..6a344be433f --- /dev/null +++ b/specifyweb/frontend/js_src/lib/hooks/useParserDefaultValue.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +import type { AnySchema } from '../components/DataModel/helperTypes'; +import type { SpecifyResource } from '../components/DataModel/legacyTypes'; +import type { + LiteralField, + Relationship, +} from '../components/DataModel/specifyField'; +import { getDateInputValue } from '../utils/dayJs'; +import type { Parser } from '../utils/parser/definitions'; +import { parseAnyDate } from '../utils/relativeDate'; + +/** + * Handles setting the default value of a field if needed on a resource + * according to some parser. + * + * If you don't have a parser, you can use `resolveParser` or `useParser` to + * generate one given the field and resource + * + * Example: + * ``` + * const parser = useParser(field, resource); + * useParserDefaultValue(resource, field, parser); + * ``` + * + */ +export function useParserDefaultValue( + resource: SpecifyResource | undefined, + field: LiteralField | Relationship | undefined, + parser: Parser +) { + React.useLayoutEffect(() => { + if (field === undefined || resource === undefined) return; + /* + * Don't auto set numeric to "0" or boolean fields to false, unless it is the default value + * in the form definition + */ + // REFACTOR: resolveParser() should probably not make up the default value like false/0 out of the blue as it's not safe to assume that it's always desired (vs null) + const hasDefault = + parser.value !== undefined && + (parser.type !== 'number' || parser.value !== 0) && + (parser.type !== 'checkbox' || parser.value !== false); + + const fieldValue = resource.get(field.name) as + | boolean + | number + | string + | null + | undefined; + + if ( + hasDefault && + /* + * Even if resource is new, some values may be prepopulated (i.e, by + * PrepDialog). This is a crude check to see if form's default value + * should overwrite that of the resource + */ + resource.isNew() && + (parser.type !== 'number' || + typeof fieldValue !== 'number' || + fieldValue === 0) && + ((parser.type !== 'text' && parser.type !== 'date') || + typeof fieldValue !== 'string' || + fieldValue === '') && + (parser.type !== 'checkbox' || typeof fieldValue !== 'boolean') + ) + resource.set( + field.name, + (parser.type === 'date' + ? (getDateInputValue( + parseAnyDate(parser.value?.toString() ?? '') ?? new Date() + ) ?? new Date()) + : parser.value) as never, + { silent: true } + ); + }, [parser, resource, field]); +} diff --git a/specifyweb/frontend/js_src/lib/hooks/useResourceValue.tsx b/specifyweb/frontend/js_src/lib/hooks/useResourceValue.tsx index f94bc41b48b..6f09ec07c02 100644 --- a/specifyweb/frontend/js_src/lib/hooks/useResourceValue.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/useResourceValue.tsx @@ -1,26 +1,20 @@ import React from 'react'; -import { className } from '../components/Atoms/className'; import type { AnySchema } from '../components/DataModel/helperTypes'; import type { SpecifyResource } from '../components/DataModel/legacyTypes'; import { resourceOn } from '../components/DataModel/resource'; -import { - getFieldBlockerKey, - useSaveBlockers, -} from '../components/DataModel/saveBlockers'; +import { getFieldBlockerKey } from '../components/DataModel/saveBlockers'; import type { LiteralField, Relationship, } from '../components/DataModel/specifyField'; import type { Input } from '../components/Forms/validationHelpers'; -import { getDateInputValue } from '../utils/dayJs'; -import { f } from '../utils/functools'; import type { Parser } from '../utils/parser/definitions'; -import { parseValue } from '../utils/parser/parse'; -import { parseAnyDate } from '../utils/relativeDate'; import type { RA } from '../utils/types'; import { useParser } from './resource'; -import { useValidation } from './useValidation'; +import { useParserDefaultValue } from './useParserDefaultValue'; +import { useFieldParser } from './useFieldParser'; +import { useFieldValidation } from './useFieldValidation'; /** * A hook to integrate an Input with a field on a Backbone resource @@ -45,7 +39,7 @@ import { useValidation } from './useValidation'; */ export function useResourceValue< T extends boolean | number | string | null, - INPUT extends Input = HTMLInputElement, + INPUT extends Input = Input, >( resource: SpecifyResource | undefined, // If field is undefined, this hook behaves pretty much like useValidation() @@ -53,7 +47,7 @@ export function useResourceValue< // Default parser is usually coming from the form definition defaultParser: Parser | undefined, trim?: boolean -): ReturnType & { +): { readonly value: T | undefined; readonly updateValue: (newValue: T, reportErrors?: boolean) => void; // See useValidation for documentation of these props: @@ -64,173 +58,31 @@ export function useResourceValue< } { const parser = useParser(field, resource, defaultParser); - const [value, setValue] = React.useState(undefined); + useParserDefaultValue(resource, field, parser); - /* - * Display saveBlocker validation errors only after field lost focus, not - * during typing - */ - const [input, setInput] = React.useState(null); - const [blockers, setBlockers] = useSaveBlockers(resource, field); - const { inputRef, validationRef, setValidation } = - useValidation(blockers); - - /* - * Updating field value changes data model value, which triggers a field - * update, which updated field value, and so on. This ref helps break - * this cycle. - */ - const ignoreChangeRef = React.useRef(false); - - // Parse value and update saveBlockers - const updateValue = React.useCallback( - /* - * REFACTOR: disable @typescript-eslint/no-inferrable-types and set - * type explicitly as @typescript-eslint/strict-boolean-expressions can't - * infer implicit types - */ - (newValue: T, reportErrors = true) => { - if (ignoreChangeRef.current || resource === undefined) return; - - /* - * Converting ref to state so that React.useEffect can be triggered - * when needed - */ - setInput(inputRef.current); - - if (parser.type === undefined) return; - - /* - * If updateValue is called from the onChange event handler and field is - * required and field did not have a value when onChange occurred, then - * parseValue() is going to report "Value missing" error. This fixes that - * issue. See https://github.com/specify/specify7/issues/1427 - */ - if ( - inputRef.current !== null && - inputRef.current.value === '' && - newValue !== '' - ) - inputRef.current.value = newValue?.toString() ?? inputRef.current.value; - - const parseResults = parseValue( - parser, - inputRef.current ?? undefined, - newValue?.toString() ?? '', - trim - ); + const { inputRef, validationRef, setValidation } = useFieldValidation( + resource, + field + ); - const parsedValue = parseResults.isValid ? parseResults.parsed : newValue; - const formattedValue = - field?.isRelationship === true && newValue === '' - ? null - : ['checkbox', 'date'].includes(parser.type ?? '') || reportErrors - ? parsedValue - : newValue; - setValue( - (parser.type === 'number' && reportErrors - ? (f.parseFloat( - parser?.printFormatter?.(parsedValue, parser) ?? '' - ) ?? parsedValue) - : formattedValue) as T - ); + const [value, updateValue] = useFieldParser({ + resource, + field, + inputRef, + parser, + trim, + onParse: (parseResult) => { if (field === undefined) return; - if (parseResults.isValid) - setBlockers([], getFieldBlockerKey(field, 'parseResult')); + if (parseResult.isValid) + setValidation([], getFieldBlockerKey(field, 'parseResult')); else - setBlockers( - [parseResults.reason], + setValidation( + [parseResult.reason], getFieldBlockerKey(field, 'parseResult') ); - - ignoreChangeRef.current = true; - /* - * If value changed as a result of being formatted, don't trigger - * unload protect - */ - const formattedOnly = resource.get(field.name) === newValue; - resource.set(field.name, formattedValue as never, { - /* - * Don't trigger the save blocker for this trivial change - * REFACTOR: move this logic into ResourceBase.set - */ - silent: - (formattedValue === null && resource.get(field.name) === '') || - formattedOnly, - }); - ignoreChangeRef.current = false; }, - [resource, field, parser, inputRef, setValidation] - ); - - /* - * Resource changes when sliding in a record selector, but react reuses - * the DOM component, thus need to manually add back the "notTouchedInput" - * class name - */ - React.useEffect( - () => input?.classList.add(className.notTouchedInput), - [input, resource] - ); - - // Set default value - React.useLayoutEffect(() => { - if (field === undefined || resource === undefined) return; - - /* - * Don't auto set numeric to "0" or boolean fields to false, unless it is the default value - * in the form definition - */ - // REFACTOR: resolveParser() should probably not make up the default value like false/0 out of the blue as it's not safe to assume that it's always desired (vs null) - const hasDefault = - parser.value !== undefined && - (parser.type !== 'number' || - parser.value !== 0 || - defaultParser?.value === 0) && - (parser.type !== 'checkbox' || - parser.value !== false || - defaultParser?.value === false); - - const fieldValue = resource.get(field.name) as - | boolean - | number - | string - | null - | undefined; - const parsedValue = parseValue( - parser, - inputRef.current ?? undefined, - fieldValue?.toString() ?? '', - trim - ); - if ( - hasDefault && - /* - * Even if resource is new, some values may be prepopulated (i.e, by - * PrepDialog). This is a crude check to see if form's default value - * should overwrite that of the resource - */ - resource.isNew() && - ((!parsedValue.isValid && field.name === 'catalogNumber') || - ((parser.type !== 'number' || - typeof fieldValue !== 'number' || - fieldValue === 0) && - ((parser.type !== 'text' && parser.type !== 'date') || - typeof fieldValue !== 'string' || - fieldValue === '') && - (parser.type !== 'checkbox' || typeof fieldValue !== 'boolean'))) - ) - resource.set( - field.name, - (parser.type === 'date' - ? (getDateInputValue( - parseAnyDate(parser.value?.toString() ?? '') ?? new Date() - ) ?? new Date()) - : parser.value) as never, - { silent: true } - ); - }, [parser, resource, field, defaultParser]); + }); // Listen for resource update React.useLayoutEffect( @@ -251,14 +103,7 @@ export function useResourceValue< updateValue, inputRef, validationRef, - setValidation: React.useCallback( - (message) => { - const blockers = typeof message === 'string' ? [message] : message; - if (field !== undefined) - setBlockers(blockers, getFieldBlockerKey(field, 'validation')); - }, - [setBlockers, field] - ), + setValidation, parser, } as const; } diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/collectionobject/domainfilter=false&catalognumber=%23%23%23%23%23%23%23%23%23&collection=4&offset=0.json b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/collectionobject/domainfilter=false&catalognumber=%23%23%23%23%23%23%23%23%23&collection=4&offset=0.json new file mode 100644 index 00000000000..ba6974a6032 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/collectionobject/domainfilter=false&catalognumber=%23%23%23%23%23%23%23%23%23&collection=4&offset=0.json @@ -0,0 +1,8 @@ +{ + "objects": [], + "meta": { + "limit": 20, + "offset": 0, + "total_count": 0 + } +} diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/collectionobject/domainfilter=false&catalognumber=&collection=4&offset=0.json b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/collectionobject/domainfilter=false&catalognumber=&collection=4&offset=0.json new file mode 100644 index 00000000000..ba6974a6032 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/collectionobject/domainfilter=false&catalognumber=&collection=4&offset=0.json @@ -0,0 +1,8 @@ +{ + "objects": [], + "meta": { + "limit": 20, + "offset": 0, + "total_count": 0 + } +}