diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx index 6412d85f29d..33012c04304 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx @@ -233,7 +233,7 @@ export function AppResourceEditor({ onAdd={ hasToolPermission('resources', 'create') && typeof handleClone === 'function' - ? (newResource): void => { + ? ([newResource]): void => { const resource = serializeResource(newResource); const isClone = typeof resource.spAppResourceDir === 'string'; handleClone( diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap index 515ef8cadf3..feccec42001 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap @@ -9,6 +9,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": true, "pattern": undefined, "size": 4, + "type": "year", "value": "YEAR", }, SeparatorField { @@ -16,6 +17,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 1, + "type": "separator", "value": "-", }, AlphaNumberField { @@ -23,6 +25,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 2, + "type": "alphanumeric", "value": "AA", }, SeparatorField { @@ -30,6 +33,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 1, + "type": "separator", "value": "-", }, NumericField { @@ -37,6 +41,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 3, + "type": "numeric", "value": "###", }, ], @@ -51,6 +56,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": true, "pattern": undefined, "size": 4, + "type": "year", "value": "YEAR", }, SeparatorField { @@ -58,6 +64,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 1, + "type": "separator", "value": "-", }, AlphaNumberField { @@ -65,6 +72,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 2, + "type": "alphanumeric", "value": "AA", }, SeparatorField { @@ -72,6 +80,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 1, + "type": "separator", "value": "-", }, AlphaNumberField { @@ -79,6 +88,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 3, + "type": "alphanumeric", "value": "AAA", }, ], @@ -93,6 +103,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 10, + "type": "alphanumeric", "value": "AAAAAAAAAA", }, ], @@ -107,6 +118,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 4, + "type": "year", "value": "YEAR", }, SeparatorField { @@ -114,6 +126,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 1, + "type": "separator", "value": "-", }, NumericField { @@ -121,6 +134,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 6, + "type": "numeric", "value": "######", }, ], @@ -135,6 +149,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": true, "pattern": undefined, "size": 4, + "type": "year", "value": "YEAR", }, SeparatorField { @@ -142,6 +157,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 1, + "type": "separator", "value": "-", }, NumericField { @@ -149,6 +165,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 6, + "type": "numeric", "value": "######", }, ], @@ -163,6 +180,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 9, + "type": "numeric", "value": "#########", }, ], @@ -177,6 +195,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": "####[-A]", "size": 1, + "type": "regex", "value": "[0-9]{4}(-[A-Z])?", }, ], @@ -197,6 +216,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": true, "pattern": undefined, "size": 4, + "type": "year", "value": "YEAR", }, SeparatorField { @@ -204,6 +224,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 1, + "type": "separator", "value": "-", }, AlphaNumberField { @@ -211,6 +232,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 2, + "type": "alphanumeric", "value": "AA", }, SeparatorField { @@ -218,6 +240,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 1, + "type": "separator", "value": "-", }, NumericField { @@ -225,6 +248,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 3, + "type": "numeric", "value": "###", }, ], @@ -239,6 +263,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": true, "pattern": undefined, "size": 4, + "type": "year", "value": "YEAR", }, SeparatorField { @@ -246,6 +271,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 1, + "type": "separator", "value": "-", }, NumericField { @@ -253,6 +279,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 3, + "type": "numeric", "value": "###", }, ], @@ -267,6 +294,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": true, "pattern": undefined, "size": 4, + "type": "year", "value": "YEAR", }, SeparatorField { @@ -274,6 +302,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 1, + "type": "separator", "value": "-", }, NumericField { @@ -281,6 +310,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 3, + "type": "numeric", "value": "###", }, ], @@ -295,6 +325,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 9, + "type": "numeric", "value": "#########", }, ], @@ -309,6 +340,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": true, "pattern": undefined, "size": 4, + "type": "year", "value": "YEAR", }, SeparatorField { @@ -316,6 +348,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 1, + "type": "separator", "value": "-", }, NumericField { @@ -323,6 +356,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 3, + "type": "numeric", "value": "###", }, ], @@ -337,6 +371,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 15, + "type": "numeric", "value": "###############", }, ], @@ -351,6 +386,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 3, + "type": "numeric", "value": "###", }, ], @@ -365,6 +401,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 10, + "type": "numeric", "value": "##########", }, ], @@ -379,6 +416,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 10, + "type": "numeric", "value": "##########", }, ], @@ -393,6 +431,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 10, + "type": "numeric", "value": "##########", }, ], @@ -407,6 +446,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 10, + "type": "numeric", "value": "##########", }, ], @@ -421,6 +461,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "byYear": false, "pattern": undefined, "size": 5, + "type": "numeric", "value": "#####", }, ], diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index 37b9c8f9c61..f13185f3aca 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -124,6 +124,9 @@ abstract class Field { public readonly pattern: LocalizedString | undefined; + // eslint-disable-next-line functional/prefer-readonly-type + public type: keyof typeof formatterTypeMapper = undefined!; + public constructor({ size, value, @@ -174,12 +177,22 @@ abstract class Field { } class ConstantField extends Field { + public constructor(options: ConstructorParameters[0]) { + super(options); + this.type = 'constant'; + } + public valueRegexp(): LocalizedString { return this.wildRegexp(); } } class AlphaField extends Field { + public constructor(options: ConstructorParameters[0]) { + super(options); + this.type = 'alpha'; + } + public valueRegexp(): LocalizedString { return localized(`[a-zA-Z]{${this.size}}`); } @@ -193,6 +206,7 @@ class NumericField extends Field { ...options, value: localized(''.padStart(options.size, '#')), }); + this.type = 'numeric'; } public valueRegexp(): LocalizedString { @@ -201,30 +215,55 @@ class NumericField extends Field { } class YearField extends Field { + public constructor(options: ConstructorParameters[0]) { + super(options); + this.type = 'year'; + } + public valueRegexp(): LocalizedString { return localized(`\\d{${this.size}}`); } } class AlphaNumberField extends Field { + public constructor(options: ConstructorParameters[0]) { + super(options); + this.type = 'alphanumeric'; + } + public valueRegexp(): LocalizedString { return localized(`[a-zA-Z0-9]{${this.size}}`); } } class AnyCharField extends Field { + public constructor(options: ConstructorParameters[0]) { + super(options); + this.type = 'anychar'; + } + public valueRegexp(): LocalizedString { return localized(`.{${this.size}}`); } } class RegexField extends Field { + public constructor(options: ConstructorParameters[0]) { + super(options); + this.type = 'regex'; + } + public valueRegexp(): LocalizedString { return this.value; } } -class SeparatorField extends ConstantField {} +class SeparatorField extends ConstantField { + public constructor(options: ConstructorParameters[0]) { + super(options); + this.type = 'separator'; + } +} class CatalogNumberNumericField extends NumericField { public valueRegexp(): LocalizedString { diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx b/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx index c3e8fe8f1cf..d9578a9f250 100644 --- a/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx @@ -19,7 +19,7 @@ import { Submit } from '../Atoms/Submit'; import { getFieldsToClone, getUniqueFields } from '../DataModel/resource'; import type { LiteralField, Relationship } from '../DataModel/specifyField'; import type { SpecifyTable } from '../DataModel/specifyTable'; -import { genericTables } from '../DataModel/tables'; +import { genericTables, tables } from '../DataModel/tables'; import { NO_CLONE } from '../Forms/ResourceView'; import { Dialog } from '../Molecules/Dialog'; import { userPreferences } from '../Preferences/userPreferences'; @@ -99,7 +99,7 @@ export function CarryForwardConfig({ 'preferences', 'enableCarryForward' ); - const isEnabled = globalEnabled.includes(table.name); + const isCarryForwardEnabled = globalEnabled.includes(table.name); const canChange = !NO_CLONE.has(table.name); return canChange ? ( @@ -107,7 +107,7 @@ export function CarryForwardConfig({ {type === 'button' ? ( setGlobalEnabled(toggleItem(globalEnabled, table.name)) } @@ -128,6 +128,7 @@ export function CarryForwardConfig({ onClick={handleOpen} /> )} + {isCarryForwardEnabled ? : null} {isOpen && ( ): RA => Array.from(fields).sort(sortFunction(f.id)); +/** + * FEATURE: Extend this to all tables + * See https://github.com/specify/specify7/pull/4804 + */ +function BulkCloneConfig({ + table, +}: { + readonly table: SpecifyTable; +}): JSX.Element | null { + const [globalBulkEnabled, setGlobalBulkEnabled] = userPreferences.use( + 'form', + 'preferences', + 'enableBukCarryForward' + ); + + const isBulkCarryEnabled = globalBulkEnabled.includes(table.name); + + return tableValidForBulkClone(table) ? ( + + + setGlobalBulkEnabled(toggleItem(globalBulkEnabled, table.name)) + } + /> + {formsText.bulkCarryForwardEnabled()} + + ) : null; +} + +export const tableValidForBulkClone = (table: SpecifyTable): boolean => + table === tables.CollectionObject && + !( + tables.CollectionObject.strictGetLiteralField('catalogNumber') + .getUiFormatter() + ?.fields.some( + (field) => + field.type === 'regex' || + (field.type === 'numeric' && !field.canAutonumber()) + ) ?? false + ); + function CarryForwardConfigDialog({ table, parentTable, diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index 297fba22962..5572e425d32 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx @@ -70,7 +70,7 @@ export function RecordSelectorFromIds({ readonly onClose: () => void; readonly onSaved: (resource: SpecifyResource) => void; readonly onClone: - | ((newResource: SpecifyResource) => void) + | ((resources: RA>) => void) | undefined; readonly onFetch?: ( index: number @@ -185,6 +185,8 @@ export function RecordSelectorFromIds({ const hasAttachments = tablesWithAttachments().includes(table); + const isNewRecordSet = isInRecordSet && title === undefined; + return ( <> ({ )} isDependent={isDependent} + isInRecordSet={!isNewRecordSet} isLoading={isLoading || isExternalLoading} isSubForm={false} resource={resource} diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx index 31a9120031d..86ba9f041f8 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -8,7 +8,7 @@ import { formsText } from '../../localization/forms'; import { f } from '../../utils/functools'; import type { RA } from '../../utils/types'; import { defined } from '../../utils/types'; -import { clamp, split } from '../../utils/utils'; +import { clamp, sortFunction, split } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; import { LoadingContext } from '../Core/Contexts'; @@ -27,8 +27,13 @@ import { } from '../DataModel/resource'; import { serializeResource } from '../DataModel/serializers'; import { tables } from '../DataModel/tables'; -import type { RecordSet as RecordSetSchema, Tables } from '../DataModel/types'; +import type { + CollectionObject, + RecordSet as RecordSetSchema, + Tables, +} from '../DataModel/types'; import { softFail } from '../Errors/Crash'; +import { format } from '../Formatters/formatters'; import { recordSetView } from '../FormParse/webOnlyViews'; import { ResourceView } from '../Forms/ResourceView'; import { Dialog } from '../Molecules/Dialog'; @@ -412,7 +417,31 @@ function RecordSet({ }) : undefined } - onClone={(newResource): void => go(totalCount, 'new', newResource)} + onClone={(resources: RA>): void => { + go(totalCount, 'new', resources[0]); + if (resources.length > 1) { + const sortedResources = Array.from(resources).sort( + sortFunction((r) => r.id) + ) as RA>; + loading( + createNewRecordSet( + sortedResources.map((resource) => resource.id) + ).then(async () => { + const firstCollectionObject = await format(sortedResources[0]); + const lastCollectionObject = await format( + sortedResources.at(-1) + ); + recordSet.set( + 'name', + `${ + tables.CollectionObject.label + } Batch ${firstCollectionObject!} - ${lastCollectionObject!}` + ); + await recordSet.save(); + }) + ); + } + }} onClose={handleClose} onDelete={ (recordSet.isNew() || hasToolPermission('recordSets', 'delete')) && diff --git a/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx b/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx index 002f09e62a3..0dfe0eae213 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx @@ -7,6 +7,7 @@ import { useIsModified } from '../../hooks/useIsModified'; import { useTriggerState } from '../../hooks/useTriggerState'; import { commonText } from '../../localization/common'; import { formsText } from '../../localization/forms'; +import type { RA } from '../../utils/types'; import { Container } from '../Atoms'; import { Button } from '../Atoms/Button'; import { className } from '../Atoms/className'; @@ -109,6 +110,7 @@ export function ResourceView({ isCollapsed, preHeaderButtons, containerRef, + isInRecordSet, }: { readonly isLoading?: boolean; readonly resource: SpecifyResource | undefined; @@ -121,7 +123,9 @@ export function ResourceView({ readonly dialog: 'modal' | 'nonModal' | false; readonly onSaving?: (unsetUnloadProtect: () => void) => false | void; readonly onSaved: (() => void) | undefined; - readonly onAdd: ((newResource: SpecifyResource) => void) | undefined; + readonly onAdd: + | ((resources: RA>) => void) + | undefined; readonly onDeleted: (() => void) | undefined; readonly onClose: () => void; readonly children?: JSX.Element; @@ -133,6 +137,7 @@ export function ResourceView({ readonly isCollapsed?: boolean; readonly preHeaderButtons?: JSX.Element | undefined; readonly containerRef?: React.RefObject; + readonly isInRecordSet?: boolean; }): JSX.Element { const [isDeleted, setDeleted, setNotDeleted] = useBooleanState(); // Remove isDeleted status when resource changes @@ -224,6 +229,7 @@ export function ResourceView({ ) : ( { diff --git a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx index e332afeaaa2..01a707328dd 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx @@ -7,26 +7,35 @@ import { useId } from '../../hooks/useId'; import { useIsModified } from '../../hooks/useIsModified'; import { commonText } from '../../localization/common'; import { formsText } from '../../localization/forms'; +import { ajax } from '../../utils/ajax'; import { smoothScroll } from '../../utils/dom'; import { listen } from '../../utils/events'; +import type { RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; import { replaceKey } from '../../utils/utils'; import { appResourceSubTypes } from '../AppResources/types'; import { Button } from '../Atoms/Button'; import { className } from '../Atoms/className'; +import { Input } from '../Atoms/Form'; import { Submit } from '../Atoms/Submit'; import { LoadingContext } from '../Core/Contexts'; -import type { AnySchema } from '../DataModel/helperTypes'; +import type { AnySchema, SerializedRecord } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import type { BlockerWithResource } from '../DataModel/saveBlockers'; import { findUnclaimedBlocker, useAllSaveBlockers, } from '../DataModel/saveBlockers'; +import { + deserializeResource, + serializeResource, +} from '../DataModel/serializers'; import type { LiteralField, Relationship } from '../DataModel/specifyField'; +import { tables } from '../DataModel/tables'; import { error } from '../Errors/assert'; import { errorHandledBy } from '../Errors/FormatError'; import { InFormEditorContext } from '../FormEditor/Context'; +import { tableValidForBulkClone } from '../FormMeta/CarryForward'; import { Dialog } from '../Molecules/Dialog'; import { hasTablePermission } from '../Permissions/helpers'; import { userPreferences } from '../Preferences/userPreferences'; @@ -57,6 +66,7 @@ export function SaveButton({ onSaving: handleSaving, onSaved: handleSaved, onAdd: handleAdd, + isInRecordSet, }: { readonly resource: SpecifyResource; readonly form: HTMLFormElement; @@ -72,9 +82,10 @@ export function SaveButton({ unsetUnloadProtect: () => void ) => false | undefined | void; readonly onSaved?: () => void; - readonly onAdd?: (newResource: SpecifyResource) => void; + readonly onAdd?: (resources: RA>) => void; // Only display save blockers for a given field readonly filterBlockers?: LiteralField | Relationship; + readonly isInRecordSet?: boolean; }): JSX.Element { const id = useId('save-button'); const saveRequired = useIsModified(resource); @@ -101,7 +112,8 @@ export function SaveButton({ const loading = React.useContext(LoadingContext); const [_, setFormContext] = React.useContext(FormContext); - const { showClone, showCarry, showAdd } = useEnabledButtons(resource); + const { showClone, showCarry, showBulkCarry, showAdd } = + useEnabledButtons(resource); const canCreate = hasTablePermission(resource.specifyTable.name, 'create'); const canUpdate = hasTablePermission(resource.specifyTable.name, 'update'); @@ -198,7 +210,7 @@ export function SaveButton({ const copyButton = ( label: LocalizedString, description: LocalizedString, - handleClick: () => Promise> + handleClick: () => Promise>> ): JSX.Element => ( ({ ); + const [carryForwardAmount, setCarryForwardAmount] = React.useState(1); + return ( <> {typeof handleAdd === 'function' && canCreate ? ( <> - {showClone && - copyButton( - formsText.clone(), - formsText.cloneDescription(), - async () => resource.clone(true) - )} + {resource.specifyTable.name === 'CollectionObject' && + (isInRecordSet === false || isInRecordSet === undefined) && + isSaveDisabled && + showCarry && + showBulkCarry ? ( + + Number.isNaN(value) + ? setCarryForwardAmount(1) + : setCarryForwardAmount(Number(value)) + } + /> + ) : null} {showCarry && copyButton( formsText.carryForward(), formsText.carryForwardDescription(), - async () => resource.clone(false) + /** + * FEATURE: Extend this functionality to all tables + * See https://github.com/specify/specify7/pull/4804 + * + */ + resource.specifyTable.name === 'CollectionObject' && + carryForwardAmount > 2 + ? async (): Promise>> => { + const formatter = + tables.CollectionObject.strictGetLiteralField( + 'catalogNumber' + ).getUiFormatter()!; + const wildCard = formatter.valueOrWild(); + + const clonePromises = Array.from( + { length: carryForwardAmount }, + async () => { + const clonedResource = await resource.clone(false); + clonedResource.set('catalogNumber', wildCard as never); + return clonedResource; + } + ); + + const clones = await Promise.all(clonePromises); + + const backendClones = await ajax< + RA> + >( + `/api/specify/bulk/${resource.specifyTable.name.toLowerCase()}/`, + { + method: 'POST', + headers: { Accept: 'application/json' }, + body: clones, + } + ).then(({ data }) => + data.map((resource) => + deserializeResource(serializeResource(resource)) + ) + ); + + return Promise.all([resource, ...backendClones]); + } + : async (): Promise>> => [ + await resource.clone(false), + ] + )} + {showClone && + copyButton( + formsText.clone(), + formsText.cloneDescription(), + async () => [await resource.clone(true)] )} {showAdd && copyButton( commonText.add(), formsText.addButtonDescription(), - async () => new resource.specifyTable.Resource() + async () => [new resource.specifyTable.Resource()] )} ) : undefined} @@ -313,12 +389,18 @@ function useEnabledButtons( readonly showClone: boolean; readonly showCarry: boolean; readonly showAdd: boolean; + readonly showBulkCarry: boolean; } { const [enableCarryForward] = userPreferences.use( 'form', 'preferences', 'enableCarryForward' ); + const [enableBulkCarryForward] = userPreferences.use( + 'form', + 'preferences', + 'enableBukCarryForward' + ); const [disableClone] = userPreferences.use( 'form', 'preferences', @@ -338,6 +420,10 @@ function useEnabledButtons( const showCarry = enableCarryForward.includes(tableName) && !NO_CLONE.has(tableName); + const showBulkCarry = + enableBulkCarryForward.includes(tableName) && + !NO_CLONE.has(tableName) && + tableValidForBulkClone(resource.specifyTable); const showClone = !disableClone.includes(tableName) && !NO_CLONE.has(tableName) && @@ -345,7 +431,7 @@ function useEnabledButtons( const showAdd = !disableAdd.includes(tableName) && !FORBID_ADDING.has(tableName); - return { showClone, showCarry, showAdd }; + return { showClone, showCarry, showBulkCarry, showAdd }; } const appResourcesToNotClone = filterArray( diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index 53b31b4bebc..307861ab5c7 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -1178,6 +1178,14 @@ export const userPreferenceDefinitions = { renderer: f.never, container: 'div', }), + enableBukCarryForward: definePref>({ + title: localized('_enableBulkCarryForward'), + requiresReload: false, + visible: false, + defaultValue: [], + renderer: f.never, + container: 'div', + }), /* * Can temporary disable clone for a given table * Since most tables are likely to have carry enabled, this pref is diff --git a/specifyweb/frontend/js_src/lib/components/Security/User.tsx b/specifyweb/frontend/js_src/lib/components/Security/User.tsx index 7cbd2f012bb..1d11ed7e22a 100644 --- a/specifyweb/frontend/js_src/lib/components/Security/User.tsx +++ b/specifyweb/frontend/js_src/lib/components/Security/User.tsx @@ -15,7 +15,7 @@ import { Http } from '../../utils/ajax/definitions'; import { formData } from '../../utils/ajax/helpers'; import { ping } from '../../utils/ajax/ping'; import { f } from '../../utils/functools'; -import type { IR } from '../../utils/types'; +import type { IR, RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; import { removeKey, replaceKey } from '../../utils/utils'; import { Container } from '../Atoms'; @@ -106,7 +106,7 @@ export function SecurityUser(): JSX.Element { { + onAdd={([newUser]): void => { navigate( formatUrl(`/specify/security/user/new/`, { collection: initialCollection, @@ -158,7 +158,7 @@ function UserView({ readonly user: SerializedResource; readonly initialCollectionId: number | undefined; readonly onSave: (changedUser: SerializedResource) => void; - readonly onAdd: (resource: SpecifyResource) => void; + readonly onAdd: (resources: RA>) => void; readonly onDeleted: () => void; }): JSX.Element { const collections = useAvailableCollections(); @@ -433,8 +433,8 @@ function UserView({ , diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx index 6dcf4571673..acadbcb5e86 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx @@ -280,7 +280,7 @@ function EditRecordDialog({ resource={resource} resourceView={{ dialog: 'nonModal', - onAdd: isRoot ? undefined : setResource, + onAdd: isRoot ? undefined : ([resource]) => setResource(resource), onDeleted: handleRefresh, onSaved: handleRefresh, }} diff --git a/specifyweb/frontend/js_src/lib/localization/forms.ts b/specifyweb/frontend/js_src/lib/localization/forms.ts index e0f95960133..c036fd0747d 100644 --- a/specifyweb/frontend/js_src/lib/localization/forms.ts +++ b/specifyweb/frontend/js_src/lib/localization/forms.ts @@ -835,6 +835,12 @@ export const formsText = createDictionary({ 'uk-ua': 'Показати кнопку «Перенести вперед».', 'de-ch': 'Schaltfläche „Übertrag anzeigen“', }, + bulkCarryForwardEnabled: { + 'en-us': 'Show Bulk Carry Forward count', + }, + bulkCarryForwardCount: { + 'en-us': 'Bulk Carry Forward count', + }, carryForwardDescription: { 'en-us': 'Create a new record with certain fields carried over', 'ru-ru': 'Создайте новую запись с переносом определенных полей.', diff --git a/specifyweb/specify/api.py b/specifyweb/specify/api.py index 4dfd4d4b88a..288a3c072d7 100644 --- a/specifyweb/specify/api.py +++ b/specifyweb/specify/api.py @@ -17,7 +17,7 @@ from django.db import transaction from django.apps import apps from django.http import (HttpResponse, HttpResponseBadRequest, - Http404, HttpResponseNotAllowed, QueryDict) + Http404, HttpResponseNotAllowed, JsonResponse, QueryDict) from django.core.exceptions import ObjectDoesNotExist, FieldError, FieldDoesNotExist from django.db.models.fields import DateTimeField, FloatField, DecimalField @@ -241,7 +241,7 @@ def collection_dispatch(request, model) -> HttpResponse: elif request.method == 'POST': obj = post_resource(request.specify_collection, request.specify_user_agent, - model, json.load(request), + model, json.loads(request.body), request.GET.get('recordsetid', None)) resp = HttpResponseCreated(toJson(_obj_to_data(obj, checker)), @@ -252,6 +252,52 @@ def collection_dispatch(request, model) -> HttpResponse: return resp +def collection_dispatch_bulk(request, model) -> HttpResponse: + """ + Do the same as collection_dispatch, but for bulk POST operations. + Call this endpoint with a list of objects of the same type to create. + This reduces the amount of API calls needed to create multiple objects, like when creating multiple carry forwards. + """ + checker = table_permissions_checker(request.specify_collection, request.specify_user_agent, "read") + + if request.method != 'POST': + return HttpResponseNotAllowed(['POST']) + + data = json.loads(request.body) + resp_objs = [] + for obj_data in data: + obj = post_resource( + request.specify_collection, + request.specify_user_agent, + model, + obj_data, + request.GET.get("recordsetid", None), + ) + resp_objs.append(_obj_to_data(obj, checker)) + + return HttpResponseCreated(toJson(resp_objs), content_type='application/json') + +def collection_dispatch_bulk_copy(request, model, copies) -> HttpResponse: + checker = table_permissions_checker(request.specify_collection, request.specify_user_agent, "read") + + if request.method != 'POST': + return HttpResponseNotAllowed(['POST']) + + data = json.loads(request.body) + data = dict(filter(lambda item: item[0] != 'id', data.items())) # Remove ID field before making copies + resp_objs = [] + for _ in range(int(copies)): + obj = post_resource( + request.specify_collection, + request.specify_user_agent, + model, + data, + request.GET.get("recordsetid", None), + ) + resp_objs.append(_obj_to_data(obj, checker)) + + return HttpResponseCreated(toJson(resp_objs), content_type='application/json') + def get_model_or_404(name: str): """Lookup a specify model by name. Raise Http404 if not found.""" try: diff --git a/specifyweb/specify/schema.py b/specifyweb/specify/schema.py index 4d55f9f41f5..9718d59ed45 100644 --- a/specifyweb/specify/schema.py +++ b/specifyweb/specify/schema.py @@ -3,7 +3,7 @@ from django import http from django.conf import settings from django.utils.translation import gettext as _ -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, cast from specifyweb.middleware.general import require_GET from .datamodel import ( @@ -375,11 +375,11 @@ def table_to_endpoint(table: Table) -> List[Tuple[str, Dict]]: "summary": f"Query multiple records from the {table.django_name} table", "description": ( f"Query multiple records from the {table.django_name} table
" - f"Filterring is supported by providing field values as GET parameters
" + f"Filtering is supported by providing field values as GET parameters
" f"Example: /api/specify/sometable/?field=value. Advanced filtering " f"options are also supported (e.g. ?numericfield__gte=4). More filters " f"are documented here: " - f"https://docs.djangoproject.com/en/4.0/ref/models/querysets/#field-lookups-1" + f"https://docs.djangoproject.com/en/4.0/ref/models/querysets/#field-lookups-1" ), "parameters": [ {"$ref": "#/components/parameters/limit"}, @@ -419,7 +419,7 @@ def table_to_endpoint(table: Table) -> List[Tuple[str, Dict]]: "description": "Permission denied", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/_permission_denied_error" } + "schema": {"$ref": "#/components/schemas/_permission_denied_error"} } } }, @@ -446,7 +446,7 @@ def table_to_endpoint(table: Table) -> List[Tuple[str, Dict]]: }, }, "responses": { - "200": { + "201": { "description": "A newly created object", "content": { "application/json": { @@ -461,7 +461,102 @@ def table_to_endpoint(table: Table) -> List[Tuple[str, Dict]]: }, ), ( - f"/api/specify/{table.django_name}/{{id}}", + f"/api/specify/bulk/{table.django_name}/", + { + "post": { + "tags": [table.django_name], + "summary": f"Upload multiple records to the {table.django_name} table", + "description": f"Upload multiple records to the {table.django_name} table", + "parameters": [ + { + "$ref": "#/components/parameters/collection_recordsetid" + } + ], + "requestBody": { + "required": True, + "description": "An array containing a JSON representation of the objects to create", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": f"#/components/schemas/{table.django_name}", + } + } + } + }, + }, + "responses": { + "201": { + "description": "An array containing the newly created objects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": f"#/components/schemas/{table.django_name}", + } + } + } + }, + } + }, + }, + } + ), + ( + f"/api/specify/bulk/{table.django_name}/{{copies}}/", + { + "post": { + "tags": [table.django_name], + "summary": f"Upload multiple records to the {table.django_name} table", + "description": f"Upload multiple records to the {table.django_name} table", + "parameters": [ + { + "$ref": "#/components/parameters/collection_recordsetid" + }, + { + "name": "copies", + "in": "path", + "required": True, + "schema": { + "type": "number", + "minimum": 1, + }, + "description": "Number of copies to create", + }, + ], + "requestBody": { + "required": True, + "description": "A serialized JSON representation of the object to clone", + "content": { + "application/json": { + "schema": { + "$ref": f"#/components/schemas/{table.django_name}" + } + } + }, + }, + "responses": { + "201": { + "description": "An array containing the newly created objects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": f"#/components/schemas/{table.django_name}", + } + } + } + }, + } + }, + }, + } + ), + ( + f"/api/specify/{table.django_name}/{{id}}/", { "parameters": [ { @@ -476,8 +571,8 @@ def table_to_endpoint(table: Table) -> List[Tuple[str, Dict]]: ], "get": { "tags": [table.django_name], - "summary": f"Query and manipulate records from the {table.django_name} table", - "description": "TODO: description", + "summary": f"Fetch a single record from the {table.django_name} table", + "description": f"Fetch a single record from the {table.django_name} table", "parameters": [ { "$ref": "#/components/parameters/record_recordsetid" @@ -582,8 +677,10 @@ def table_to_endpoint(table: Table) -> List[Tuple[str, Dict]]: "schema": { "type": "string" }, - "example": "localityname,latitude1,longitude1", - "description": "Comma separated list of fileds to fetch", + "example": "localityname,latitude1,localitycitations__referencework__title", + "description": ("Comma separated list of fileds to fetch. " + "Can include relationships and their fields separated by __ " + "to include data from other tables") }, {"$ref": "#/components/parameters/limit"}, {"$ref": "#/components/parameters/offset"}, @@ -605,24 +702,40 @@ def table_to_endpoint(table: Table) -> List[Tuple[str, Dict]]: "summary": f"Get rows from the {table.django_name} table", "description": ( f"Query multiple records from the {table.django_name} table
" - f"Filterring is supported by providing field values as GET parameters
" - f"Example: /api/specify_rows/sometable/?field=value. Advanced filtering " + f"Filtering is supported by providing field values as GET parameters
" + f"Example: /api/specify_rows/sometable/fields=someFields&field=value. Advanced filtering " f"options are also supported (e.g. ?numericfield__gte=4). More filters " f"are documented here: " - f"https://docs.djangoproject.com/en/4.0/ref/models/querysets/#field-lookups-1" + f"https://docs.djangoproject.com/en/4.0/ref/models/querysets/#field-lookups-1" ), "responses": { "200": { - "description": "Empty response", + "description": "2D array of results", "content": { "application/json": { "schema": { "type": "array", "items": { + "description": f"A single record from the {table.django_name} table", "type": "array", - "items": {}, + "items": { + "description": "The values of the fields for the record passed into the 'fields' parameter", + "oneOf": [ + { + "type": "string", + }, + { + "type": "null" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, }, - "description": "2D array of results", } } } @@ -632,15 +745,15 @@ def table_to_endpoint(table: Table) -> List[Tuple[str, Dict]]: } ), ( - f"/api/delete_blockers/{table.django_name}/{{id}}", + f"/api/delete_blockers/{table.django_name}/{{id}}/", { "get": { "tags": [table.django_name], "summary": "Returns a JSON list of fields that prevent " + - "the record from getting deleted", + "the record from getting deleted", "description": "Returns a JSON list of fields that " + - "point to related resources which prevent the resource " + - "of that model from being deleted.", + "point to related resources which prevent the resource " + + "of that model from being deleted.", "parameters": [ { "$ref": "#/components/parameters/id" @@ -652,14 +765,27 @@ def table_to_endpoint(table: Table) -> List[Tuple[str, Dict]]: "content": { "application/json": { "schema": { - "type": "string", + "type": "array", "items": { - "type": "string", - }, - "example": [ - "Collectingevent.locality" - ], - "description": "List of fields" + "type": "object", + "properties": { + "table": { + "type": "string", + "example": get_first_valid_relationship_attr(cast(List[Relationship], table.relationships), "relatedModelName").lower().capitalize() + }, + "field": { + "type": "string", + "example": get_first_valid_relationship_attr(cast(List[Relationship], table.relationships), "otherSideName"), + }, + "ids": { + "type": "array", + "description": "An array of blocking resources from ", + "items": { + "type": "number" + } + } + } + } } } } @@ -725,10 +851,11 @@ def field_to_schema(field: Field) -> Dict: OpenAPI schema's schema object for a field of a table """ if field.is_relationship: - if not isinstance(field, Relationship): raise AssertionError( - f"Field '{field.name}' is not a Relationship", - {"field" : field.name, - "localizaitonKey" : "fieldNotRelationship"}) + if not isinstance(field, Relationship): + raise AssertionError( + f"Field '{field.name}' is not a Relationship", + {"field": field.name, + "localizaitonKey": "fieldNotRelationship"}) if field.dependent: if ( field.type == "one-to-one" @@ -800,3 +927,9 @@ def field_to_schema(field: Field) -> Dict: def required_to_schema(field: Field, ftype: str) -> Dict: return {"type": ftype} if field.required else {"type": ftype, "nullable": True} + +def get_first_valid_relationship_attr(relationships: List[Relationship], attr_name: str) -> str: + for rel in relationships: + if hasattr(rel, 'otherSideName') and rel.type.endswith('to-many') and not rel.dependent: + return getattr(rel, attr_name, 'Default') + return 'Default' diff --git a/specifyweb/specify/urls.py b/specifyweb/specify/urls.py index 1a8a6a47301..4dfcb049bdc 100644 --- a/specifyweb/specify/urls.py +++ b/specifyweb/specify/urls.py @@ -17,6 +17,8 @@ url(r'^specify_schema/openapi.json$', schema.openapi), url(r'^specify_schema/(?P\w+)/$', schema.view), url(r'^specify/(?P\w+)/(?P\d+)/$', views.resource), # permissions added + url(r'^specify/bulk/(?P\w+)/(?P\d+)/$', views.collection_bulk_copy), # permissions added + url(r'^specify/bulk/(?P\w+)/$', views.collection_bulk), # permissions added url(r'^specify/(?P\w+)/$', views.collection), # permissions added url(r'^specify_rows/(?P\w+)/$', views.rows), # permissions added diff --git a/specifyweb/specify/views.py b/specifyweb/specify/views.py index 0abd0c45fc3..c497183e644 100644 --- a/specifyweb/specify/views.py +++ b/specifyweb/specify/views.py @@ -79,6 +79,8 @@ def view(request, *args, **kwargs): resource = api_view(api.resource_dispatch) collection = api_view(api.collection_dispatch) +collection_bulk_copy = api_view(api.collection_dispatch_bulk_copy) +collection_bulk = api_view(api.collection_dispatch_bulk) def raise_error(request):