diff --git a/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx b/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx index 194de1c87c4..0c013c1d69c 100644 --- a/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx @@ -13,6 +13,7 @@ import { resourceOn } from '../DataModel/resource'; import type { LiteralField, Relationship } from '../DataModel/specifyField'; import { raise } from '../Errors/Crash'; import { fetchPathAsString } from '../Formatters/formatters'; +import { SeriesFormContext } from '../Forms/BulkCarryForward'; import { collectionPreferences } from '../Preferences/collectionPreferences'; import { userPreferences } from '../Preferences/userPreferences'; @@ -24,10 +25,13 @@ export function UiField({ readonly name: string | undefined; readonly resource: SpecifyResource | undefined; readonly field: LiteralField | Relationship | undefined; + readonly isSeries: boolean | undefined; readonly parser?: Parser; }): JSX.Element { return field?.isRelationship === true ? ( + ) : props.isSeries === true ? ( + ) : ( ); @@ -90,18 +94,26 @@ function RelationshipField({ ); } +type FieldSeriesInput = (props: { + readonly name: string | undefined; + readonly placeholder: string | undefined; + readonly validationAttributes: ReturnType; +}) => React.ReactNode; + function Field({ resource, id, name, field, parser: defaultParser, + seriesInput, }: { readonly resource: SpecifyResource | undefined; readonly id: string | undefined; readonly name: string | undefined; readonly field: LiteralField | undefined; readonly parser?: Parser; + readonly seriesInput?: FieldSeriesInput; }): JSX.Element { const { value, updateValue, validationRef, parser } = useResourceValue( resource, @@ -217,36 +229,116 @@ function Field({ validationAttributes; return ( - updateValue(target.value) - } - onValueChange={(value): void => updateValue(value, false)} - /* - * Update data model value before onBlur, as onBlur fires after onSubmit - * if form is submitted using the ENTER key - */ - onChange={(event): void => { - const input = event.target as HTMLInputElement; + <> + updateValue(target.value) + } + onValueChange={(value): void => updateValue(value, false)} /* - * Don't show validation errors on value change for input fields until - * field is blurred, unless user tried to paste a date (see definition - * of Input.Generic) + * Update data model value before onBlur, as onBlur fires after onSubmit + * if form is submitted using the ENTER key */ - updateValue(input.value, event.type === 'paste'); - }} + onChange={(event): void => { + const input = event.target as HTMLInputElement; + /* + * Don't show validation errors on value change for input fields until + * field is blurred, unless user tried to paste a date (see definition + * of Input.Generic) + */ + updateValue(input.value, event.type === 'paste'); + }} + /> + {seriesInput?.({ + name, + placeholder: customPlaceholder, + validationAttributes, + })} + + ); +} + +function SeriesField({ + resource, + field, + id, + name, + isSeries, + parser, +}: { + readonly id: string | undefined; + readonly name: string | undefined; + readonly resource: SpecifyResource | undefined; + readonly field: LiteralField | undefined; + readonly isSeries: boolean | undefined; + readonly parser?: Parser; +}): JSX.Element { + const { + seriesEnd: seriesRangeEnd, + setSeriesEnd: setSeriesRangeEnd, + setUsingSeries, + } = React.useContext(SeriesFormContext); + const tableName = resource?.specifyTable.name; + const [enableCarryForward] = userPreferences.use( + 'form', + 'preferences', + 'enableCarryForward' + ); + const [enableBulkCarryForwardRange] = userPreferences.use( + 'form', + 'preferences', + 'enableBulkCarryForwardRange' + ); + const showSeriesInput = + isSeries === true && + tableName !== undefined && + enableCarryForward.includes(tableName) && + enableBulkCarryForwardRange.includes(tableName) && + resource?.isNew() === true; + const handleSeriesRangeEndChange = ( + event: React.ChangeEvent + ): void => { + const input = event.target as HTMLInputElement; + setSeriesRangeEnd(input.value); + setUsingSeries(true); + }; + + return ( + + showSeriesInput ? ( + + ) : null + } /> ); } diff --git a/specifyweb/frontend/js_src/lib/components/FormFields/index.tsx b/specifyweb/frontend/js_src/lib/components/FormFields/index.tsx index 59a527a3e9a..6e9f8821d0b 100644 --- a/specifyweb/frontend/js_src/lib/components/FormFields/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormFields/index.tsx @@ -187,6 +187,7 @@ const fieldRenderers: { maxLength, minLength, whiteSpaceSensitive, + isSeries, }, }) { const parser = React.useMemo( @@ -218,6 +219,7 @@ const fieldRenderers: { name={name} parser={parser} resource={resource} + isSeries={isSeries} /> ); }, diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/cells.test.ts b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/cells.test.ts index a32c5297f2a..8d7f2d29b4d 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/cells.test.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/cells.test.ts @@ -117,6 +117,7 @@ describe('parseFormCell', () => { fieldDefinition: { defaultValue: undefined, isReadOnly: false, + isSeries: false, max: undefined, min: undefined, step: undefined, @@ -145,6 +146,7 @@ describe('parseFormCell', () => { fieldDefinition: { defaultValue: undefined, isReadOnly: false, + isSeries: false, max: undefined, min: undefined, step: undefined, @@ -170,6 +172,7 @@ describe('parseFormCell', () => { fieldDefinition: { defaultValue: undefined, isReadOnly: false, + isSeries: false, max: undefined, maxLength: undefined, min: undefined, @@ -203,6 +206,7 @@ describe('parseFormCell', () => { fieldDefinition: { defaultValue: undefined, isReadOnly: false, + isSeries: false, max: undefined, maxLength: undefined, min: undefined, @@ -253,6 +257,7 @@ describe('parseFormCell', () => { fieldDefinition: { defaultValue: 'A', isReadOnly: false, + isSeries: false, max: undefined, min: undefined, step: undefined, @@ -281,6 +286,7 @@ describe('parseFormCell', () => { fieldDefinition: { defaultValue: undefined, isReadOnly: false, + isSeries: false, max: undefined, min: undefined, step: undefined, diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/fields.test.ts b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/fields.test.ts index ebfaaf420a4..47d12d08827 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/fields.test.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/fields.test.ts @@ -31,6 +31,7 @@ describe('parseFormField', () => { expect(parse('', {})).toEqual({ defaultValue: undefined, isReadOnly: false, + isSeries: false, max: undefined, min: undefined, step: undefined, @@ -46,6 +47,7 @@ describe('parseFormField', () => { ).toEqual({ defaultValue: 'a', isReadOnly: true, + isSeries: false, max: -10, min: 4, step: 3.2, @@ -61,6 +63,7 @@ describe('parseFormField', () => { ).toEqual({ defaultValue: 'abc', isReadOnly: true, + isSeries: false, max: -10, min: 4, step: 3.2, @@ -72,6 +75,7 @@ describe('parseFormField', () => { expect(parse('', {})).toEqual({ defaultValue: 'abc', isReadOnly: false, + isSeries: false, max: undefined, min: undefined, step: undefined, @@ -83,6 +87,7 @@ describe('parseFormField', () => { expect(parse('', {})).toEqual({ defaultValue: 'abc', isReadOnly: false, + isSeries: false, max: undefined, min: undefined, step: undefined, @@ -197,6 +202,7 @@ describe('parseFormField', () => { expect(parse('', {})).toEqual({ defaultValue: undefined, isReadOnly: false, + isSeries: false, type: 'Text', max: undefined, min: undefined, @@ -299,6 +305,7 @@ describe('parseFormField', () => { expect(parse('', {})).toEqual({ defaultValue: undefined, isReadOnly: false, + isSeries: false, type: 'Text', max: undefined, maxLength: undefined, diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/index.test.ts b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/index.test.ts index 7e40f34979f..18ecc1059bc 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/index.test.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/index.test.ts @@ -104,6 +104,7 @@ const parsedFormView = { minLength: undefined, step: undefined, isReadOnly: false, + isSeries: false, defaultValue: undefined, whiteSpaceSensitive: false, }, @@ -561,6 +562,7 @@ test('parseRows', async () => { fieldDefinition: { defaultValue: undefined, isReadOnly: false, + isSeries: false, max: undefined, maxLength: undefined, min: undefined, diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/postProcessFormDef.test.ts b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/postProcessFormDef.test.ts index edb9259e971..835548295d8 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/postProcessFormDef.test.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/postProcessFormDef.test.ts @@ -94,6 +94,7 @@ const missingLabelTextField = ensure()({ fieldDefinition: { defaultValue: undefined, isReadOnly: false, + isSeries: false, min: undefined, max: undefined, step: undefined, diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts b/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts index ebe8fa93f99..f2d7ee34a93 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts @@ -82,6 +82,7 @@ export type FieldTypes = { readonly minLength: number | undefined; readonly maxLength: number | undefined; readonly whiteSpaceSensitive: boolean | undefined; + readonly isSeries: boolean | undefined; } >; readonly Plugin: State< @@ -218,6 +219,7 @@ const processFieldType: { minLength: f.parseInt(getProperty('minLength')), maxLength: f.parseInt(getProperty('maxLength')), whiteSpaceSensitive, + isSeries: getProperty('series')?.toLowerCase() === 'true', }; }, QueryComboBox({ getProperty, fields }) { diff --git a/specifyweb/frontend/js_src/lib/components/Forms/BulkCarryForward.tsx b/specifyweb/frontend/js_src/lib/components/Forms/BulkCarryForward.tsx index c9eb46590bb..8edabb18777 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/BulkCarryForward.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/BulkCarryForward.tsx @@ -42,7 +42,7 @@ export function useBulkCarryForward({ }): { readonly BulkCarryForward: JSX.Element | null; readonly handleBulkCarryForward: - | (() => Promise> | undefined>) + | ((saved?: boolean) => Promise> | undefined>) | undefined; readonly dialogs: JSX.Element | null; } { @@ -85,6 +85,10 @@ function useBulkCarryForwardRange( const [carryForwardRangeEnd, setCarryForwardRangeEnd] = React.useState(''); + const { seriesEnd: seriesRangeEndValue } = React.useContext(SeriesFormContext); + React.useEffect(() => { + setCarryForwardRangeEnd(seriesRangeEndValue); + }, [seriesRangeEndValue]); const [bulkCarryRangeBlocked, setBulkCarryRangeBlocked] = React.useState(false); @@ -93,7 +97,7 @@ function useBulkCarryForwardRange( const handleBulkCarryForward = typeof formatter === 'object' - ? async (): Promise> | undefined> => { + ? async (saved=false): Promise> | undefined> => { const carryForwardRangeStart = resource.get(field.name); if ( carryForwardRangeStart === null || @@ -142,6 +146,7 @@ function useBulkCarryForwardRange( return undefined; } + let recordsToBeSaved: RA>; const clones = await Promise.all( response.map(async (value) => { const clonedResource = await resource.clone(false, true); @@ -150,12 +155,18 @@ function useBulkCarryForwardRange( }) ); + if (saved === false) { + recordsToBeSaved = [resource, ...clones]; + } else { + recordsToBeSaved = clones; + } + const backendClones = await ajax>>( `/bulk_copy/bulk/${resource.specifyTable.name.toLowerCase()}/`, { method: 'POST', headers: { Accept: 'application/json' }, - body: clones, + body: recordsToBeSaved, } ).then(({ data }) => data.map((resource) => @@ -314,3 +325,15 @@ export function BulkCarryRangeBlockedDialog({ ); } + +export const SeriesFormContext = React.createContext<{ + seriesEnd: string; + setSeriesEnd: (v: string) => void; + usingSeries: boolean; + setUsingSeries: (v: boolean) => void; +}>({ + seriesEnd: '', + setSeriesEnd: () => {}, + usingSeries: false, + setUsingSeries: () => {}, +}); diff --git a/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx b/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx index d6e482ef971..b4533e3a581 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx @@ -31,6 +31,7 @@ import { useResourceView } from './BaseResourceView'; import { DeleteButton } from './DeleteButton'; import { SaveButton } from './Save'; import { propsToFormMode } from './useViewDefinition'; +import { SeriesFormContext } from './BulkCarryForward'; /** * There is special behavior required when creating one of these resources, @@ -307,20 +308,31 @@ export function ResourceView({ ); + const [seriesRangeEnd, setSeriesRangeEnd] = React.useState(''); + const [usingSeries, setUsingSeries] = React.useState(false); + if (dialog === false) { const formattedChildren = ( <> - {formComponent} - {typeof deleteButton === 'object' || - typeof saveButtonElement === 'object' || - typeof extraButtons === 'object' ? ( - - {deleteButton} - {referencingRecordsButton} - {extraButtons ?? } - {saveButtonElement} - - ) : undefined} + + {formComponent} + {typeof deleteButton === 'object' || + typeof saveButtonElement === 'object' || + typeof extraButtons === 'object' ? ( + + {deleteButton} + {referencingRecordsButton} + {extraButtons ?? } + {saveButtonElement} + + ) : undefined} + + ); diff --git a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx index 8e704ae5c70..1cb6c06fa35 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx @@ -34,7 +34,7 @@ import { hasTablePermission } from '../Permissions/helpers'; import { userPreferences } from '../Preferences/userPreferences'; import { generateMappingPathPreview } from '../WbPlanView/mappingPreview'; import { FormContext } from './BaseResourceView'; -import { useBulkCarryForward } from './BulkCarryForward'; +import { useBulkCarryForward, SeriesFormContext } from './BulkCarryForward'; import { FORBID_ADDING, NO_CLONE } from './ResourceView'; export const saveFormUnloadProtect = formsText.unsavedFormUnloadProtect(); @@ -179,6 +179,23 @@ export function SaveButton({ ) .then(handleSaved) .finally(() => { + // TODO: Update this code and uncomment + if (usingSeriesForm && seriesRangeEndValue !== '' && handleBulkCarryForward !== undefined) { + /* + * If using the in-form series input the record should not + * be saved before bulk carry forward. + */ + handleBulkCarryForward(true).then((resources) => { + if (handleAdd && resources !== undefined) { + handleAdd(resources); + } + }).then(() => { + unsetUnloadProtect(); + handleSaved?.(); + setIsSaving(false); + }) + return; + } unsetUnloadProtect(); setIsSaving(false); }); @@ -232,6 +249,7 @@ export function SaveButton({ ); + const { seriesEnd: seriesRangeEndValue, usingSeries: usingSeriesForm } = React.useContext(SeriesFormContext); const { BulkCarryForward, dialogs: BulkCarryForwardDialogs, diff --git a/specifyweb/frontend/js_src/lib/components/Forms/generateFormDefinition.ts b/specifyweb/frontend/js_src/lib/components/Forms/generateFormDefinition.ts index 3967409274a..b6b38669d5c 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/generateFormDefinition.ts +++ b/specifyweb/frontend/js_src/lib/components/Forms/generateFormDefinition.ts @@ -274,6 +274,7 @@ function getFieldDefinition( minLength: parser.minLength, maxLength: parser.maxLength, whiteSpaceSensitive: parser.whiteSpaceSensitive, + isSeries: false, }), }, }; diff --git a/specifyweb/frontend/js_src/lib/components/Merging/CompareField.tsx b/specifyweb/frontend/js_src/lib/components/Merging/CompareField.tsx index 5d09ed540bf..4b9e2f9812c 100644 --- a/specifyweb/frontend/js_src/lib/components/Merging/CompareField.tsx +++ b/specifyweb/frontend/js_src/lib/components/Merging/CompareField.tsx @@ -169,6 +169,7 @@ function fieldToDefinition( return { type: 'Text', defaultValue: undefined, + isSeries: false, min: undefined, max: undefined, minLength: undefined, diff --git a/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx b/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx index 89ecaaa0237..3ceb24c4f70 100644 --- a/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx @@ -122,7 +122,7 @@ export function WebLinkField({ } > {formType === 'form' && typeof field === 'object' ? ( - + ) : undefined} {typeof definition === 'object' ? ( <> diff --git a/specifyweb/specify/tests/test_series_autonumber.py b/specifyweb/specify/tests/test_series_autonumber.py index 2967469a7e7..ac41fff0065 100644 --- a/specifyweb/specify/tests/test_series_autonumber.py +++ b/specifyweb/specify/tests/test_series_autonumber.py @@ -151,4 +151,4 @@ def test_series_autonumber_existing(self): content_type="application/json", ) content = json.loads(response.content.decode()) - self.assertNotIn('existing', content) \ No newline at end of file + self.assertNotIn('existing', content)