Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add series data entry feature #4804

Draft
wants to merge 19 commits into
base: production
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import React from 'react';
import { useOutletContext } from 'react-router';
import { useNavigate } from 'react-router-dom';
import type { LocalizedString } from 'typesafe-i18n';
import { useAsyncState } from '../../hooks/useAsyncState';

import { useAsyncState } from '../../hooks/useAsyncState';
import { useBooleanState } from '../../hooks/useBooleanState';
import { useId } from '../../hooks/useId';
import { commonText } from '../../localization/common';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
onClone: handleClone,
onDelete: handleDelete,
onFetch: handleFetch,
onCarryBulk: handleCarryBulk,
...rest
}: Omit<RecordSelectorProps<SCHEMA>, 'index' | 'records'> & {
/*
Expand All @@ -74,6 +75,7 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
readonly onFetch?: (
index: number
) => Promise<RA<number | undefined> | undefined>;
readonly onCarryBulk?: (ids: RA<number>) => void;
}): JSX.Element | null {
const [records, setRecords] = React.useState<
RA<SpecifyResource<SCHEMA> | undefined>
Expand Down Expand Up @@ -233,12 +235,14 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
</div>
)}
isDependent={isDependent}
isInRecordSet={!isNewRecordSet}
isLoading={isLoading || isExternalLoading}
isSubForm={false}
resource={resource}
title={title}
viewName={viewName}
onAdd={handleClone}
onCarryBulk={handleCarryBulk}
onClose={handleClose}
onDeleted={
resource?.isNew() === true || hasTablePermission(table.name, 'delete')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { SpecifyResource } from '../DataModel/legacyTypes';
import {
createResource,
deleteResource,
fetchResource,
getResourceViewUrl,
} from '../DataModel/resource';
import { serializeResource } from '../DataModel/serializers';
Expand Down Expand Up @@ -306,8 +307,23 @@ function RecordSet<SCHEMA extends AnySchema>({
}

async function createNewRecordSet(
ids: RA<number | undefined>
ids: RA<number | undefined>,
fromBulkCarry: boolean = false
): Promise<void> {
if (
fromBulkCarry &&
typeof ids[0] === 'number' &&
typeof ids.at(-1) === 'number'
) {
const startingResource = fetchResource('CollectionObject', ids[0]);
const endingResource = fetchResource('CollectionObject', ids.at(-1)!);
const startingResourceCatNumber = (await startingResource).catalogNumber;
const endingResourceCatNumber = (await endingResource).catalogNumber;
Comment on lines +318 to +321
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

once you change the code to remove handleCarryBulk and make handleAdd receive an array of resources, you won't need to do re-fetching of resources manually here as handleAdd is already being called with fetched resource

recordSet.set(
'name',
`Batch #${startingResourceCatNumber} - #${endingResourceCatNumber}`
);
}
await recordSet.save();
await addIdsToRecordSet(ids);
navigate(`/specify/record-set/${recordSet.id}/`);
Expand Down Expand Up @@ -397,6 +413,9 @@ function RecordSet<SCHEMA extends AnySchema>({
})
: undefined
}
onCarryBulk={(ids) => {
loading(createNewRecordSet(ids, true));
}}
onClone={(newResource): void => go(totalCount, 'new', newResource)}
onClose={handleClose}
onDelete={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -112,6 +113,8 @@ export function ResourceView<SCHEMA extends AnySchema>({
isCollapsed,
preHeaderButtons,
containerRef,
onCarryBulk: handleCarryBulk,
isInRecordSet,
}: {
readonly isLoading?: boolean;
readonly resource: SpecifyResource<SCHEMA> | undefined;
Expand All @@ -136,6 +139,8 @@ export function ResourceView<SCHEMA extends AnySchema>({
readonly isCollapsed?: boolean;
readonly preHeaderButtons?: JSX.Element | undefined;
readonly containerRef?: React.RefObject<HTMLDivElement>;
readonly onCarryBulk?: (ids: RA<number>) => void;
readonly isInRecordSet?: boolean;
}): JSX.Element {
const [isDeleted, setDeleted, setNotDeleted] = useBooleanState();
// Remove isDeleted status when resource changes
Expand Down Expand Up @@ -225,8 +230,10 @@ export function ResourceView<SCHEMA extends AnySchema>({
) : (
<SaveButton
form={formElement}
isInRecordSet={isInRecordSet}
resource={resource}
onAdd={handleAdd}
onCarryBulk={handleCarryBulk}
onSaved={(): void => {
const printOnSave = userPreferences.get(
'form',
Expand Down
89 changes: 86 additions & 3 deletions specifyweb/frontend/js_src/lib/components/Forms/Save.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import { commonText } from '../../localization/common';
import { formsText } from '../../localization/forms';
import { smoothScroll } from '../../utils/dom';
import { listen } from '../../utils/events';
import type { RA, WritableArray } from '../../utils/types';
import { replaceKey } from '../../utils/utils';
import { Button } from '../Atoms/Button';
import { className } from '../Atoms/className';
import { Input, Label } from '../Atoms/Form';
import { Submit } from '../Atoms/Submit';
import { LoadingContext } from '../Core/Contexts';
import type { AnySchema } from '../DataModel/helperTypes';
Expand Down Expand Up @@ -55,6 +57,8 @@ export function SaveButton<SCHEMA extends AnySchema = AnySchema>({
onSaving: handleSaving,
onSaved: handleSaved,
onAdd: handleAdd,
onCarryBulk: handleCarryBulk,
isInRecordSet,
}: {
readonly resource: SpecifyResource<SCHEMA>;
readonly form: HTMLFormElement;
Expand All @@ -73,6 +77,8 @@ export function SaveButton<SCHEMA extends AnySchema = AnySchema>({
readonly onAdd?: (newResource: SpecifyResource<SCHEMA>) => void;
// Only display save blockers for a given field
readonly filterBlockers?: LiteralField | Relationship;
readonly onCarryBulk?: (ids: RA<number>) => void;
readonly isInRecordSet?: boolean;
}): JSX.Element {
const id = useId('save-button');
const saveRequired = useIsModified(resource);
Expand Down Expand Up @@ -195,7 +201,10 @@ export function SaveButton<SCHEMA extends AnySchema = AnySchema>({
const copyButton = (
label: LocalizedString,
description: LocalizedString,
handleClick: () => Promise<SpecifyResource<SCHEMA>>
handleClick: () =>
| Promise<RA<SpecifyResource<SCHEMA>>>
| Promise<SpecifyResource<SCHEMA>>,
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
originalResourceId?: number
): JSX.Element => (
<ButtonComponent
className={saveBlocked ? '!cursor-not-allowed' : undefined}
Expand All @@ -204,17 +213,69 @@ export function SaveButton<SCHEMA extends AnySchema = AnySchema>({
onClick={(): void => {
// Scroll to the top of the form on clone
smoothScroll(form, 0);
loading(handleClick().then(handleAdd));
loading(
handleClick()
.then((result) => {
const ids: WritableArray<number> = [];
if (originalResourceId !== undefined) {
ids.push(originalResourceId);
}
if (Array.isArray(result)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change the signature of handleAdd() to make it be callable with an array of resources rather than one resource
and use that to simplify the code here
that would also shift the responsibility to the consumer of Save for deciding how it wants to handle or not to handle the case of more than one resource being present (i.e imagine that some code right now does a redirect on handleAdd() - by calling handleAdd multiples times here, that code is now potentially broken; where as by changing handleAdd signature to include array, that place would now be forced to consider how the array case needs to be handled)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and once handleAdd() is being called with an array, there won't be need for handleCarryBulk() anymore as it can be combined

result.forEach((newResource) => {
handleAdd?.(newResource);
ids.push(newResource.id);
});
} else {
handleAdd?.(result);
}
return ids;
})
.then((ids) => {
if (handleCarryBulk && ids.length > 2) {
handleCarryBulk(ids);
}
})
);
}}
>
{label}
</ButtonComponent>
);

const [carryForwardAmount, setCarryForwardAmount] = React.useState<number>(2);
const [showBulkCarryInput, setBulkCarryInput] = React.useState(false);

return (
<>
{typeof handleAdd === 'function' && canCreate ? (
<>
{resource.specifyTable.name === 'CollectionObject' &&
!isInRecordSet &&
!showBulkCarryInput &&
!resource.needsSaved ? (
<Button.Save
title={commonText.bulkCarry()}
onClick={() => setBulkCarryInput(true)}
>
{commonText.bulkCarry()}
</Button.Save>
) : null}
{resource.specifyTable.name === 'CollectionObject' &&
!isInRecordSet &&
showBulkCarryInput ? (
<Label.Inline>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be in a dialog?
otherwise, you would need to add aria-controls and forward focus from the button to input on button click

{commonText.bulkCarry()}
<Input.Generic
type="number"
value={carryForwardAmount}
onValueChange={(value): void =>
carryForwardAmount === undefined
? setCarryForwardAmount(2)
: setCarryForwardAmount(Number.parseInt(value))
}
/>
</Label.Inline>
) : null}
{showClone &&
copyButton(
formsText.clone(),
Expand All @@ -225,7 +286,29 @@ export function SaveButton<SCHEMA extends AnySchema = AnySchema>({
copyButton(
formsText.carryForward(),
formsText.carryForwardDescription(),
async () => resource.clone(false)
resource.specifyTable.name === 'CollectionObject' &&
carryForwardAmount > 2
? async () => {
const clones = Array.from(
{ length: carryForwardAmount },
async () => {
const clonedResource = await resource.clone(false);
const formatter = clonedResource.specifyTable
.strictGetLiteralField('catalogNumber')
.getUiFormatter()!;
const wildCard = formatter.valueOrWild();
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
await clonedResource.set(
'catalogNumber',
wildCard as never
);
await clonedResource.save();
return clonedResource;
}
);
return Promise.all(clones);
}
: async () => resource.clone(false),
resource.id
)}
{showAdd &&
copyButton(
Expand Down
1 change: 1 addition & 0 deletions specifyweb/frontend/js_src/lib/localization/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,7 @@ export const commonText = createDictionary({
'ru-ru': 'Массовый выбор',
'uk-ua': 'Масовий вибір',
},
bulkCarry: { 'en-us': 'Bulk Carry' },
bulkReturn: {
'en-us': 'Bulk Return',
'de-ch': 'Massenrücksendung',
Expand Down