diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx index 7b0b118ef2f..d8631e81510 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx @@ -9,20 +9,20 @@ import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; import { ReadOnlyContext } from '../Core/Contexts'; import { DependentCollection } from '../DataModel/collectionApi'; -import type { AnySchema } from '../DataModel/helperTypes'; +import type { + AnyInteractionPreparation, + AnySchema, +} from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { useAllSaveBlockers } from '../DataModel/saveBlockers'; -import type { Collection } from '../DataModel/specifyTable'; -import type { - DisposalPreparation, - GiftPreparation, - LoanPreparation, -} from '../DataModel/types'; +import type { Collection, SpecifyTable } from '../DataModel/specifyTable'; import { FormTableCollection } from '../FormCells/FormTableCollection'; import type { FormType } from '../FormParse'; import type { SubViewSortField } from '../FormParse/cells'; import { augmentMode, ResourceView } from '../Forms/ResourceView'; import { useFirstFocus } from '../Forms/SpecifyForm'; +import type { InteractionWithPreps } from '../Interactions/helpers'; +import { interactionPrepTables } from '../Interactions/helpers'; import { InteractionDialog } from '../Interactions/InteractionDialog'; import { hasTablePermission } from '../Permissions/helpers'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; @@ -104,8 +104,9 @@ export function IntegratedRecordSelector({ typeof relationship === 'object' && relationshipIsToMany(relationship) && typeof collection.related === 'object' && - ['LoanPreparation', 'GiftPreparation', 'DisposalPreparation'].includes( - relationship.relatedTable.name + interactionPrepTables.includes( + (relationship.relatedTable as SpecifyTable) + .name ); const [isDialogOpen, handleOpenDialog, handleCloseDialog] = useBooleanState(); @@ -154,9 +155,7 @@ export function IntegratedRecordSelector({ actionTable={collection.related.specifyTable} interactionResource={interactionResource} itemCollection={ - collection as Collection< - DisposalPreparation | GiftPreparation | LoanPreparation - > + collection as Collection } onClose={handleCloseDialog} /> diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx index f41d6924685..b977b1bed9d 100644 --- a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx @@ -25,24 +25,24 @@ import { H3 } from '../Atoms'; import { Button } from '../Atoms/Button'; import { Link } from '../Atoms/Link'; import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; -import type { AnySchema, SerializedResource } from '../DataModel/helperTypes'; +import type { AnySchema, SerializedResource, AnyInteractionPreparation, + SerializedResource, } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { getResourceViewUrl } from '../DataModel/resource'; import type { LiteralField } from '../DataModel/specifyField'; import type { Collection, SpecifyTable } from '../DataModel/specifyTable'; import { tables } from '../DataModel/tables'; -import type { - DisposalPreparation, - Gift, - GiftPreparation, - LoanPreparation, - RecordSet, -} from '../DataModel/types'; +import type { RecordSet } from '../DataModel/types'; import { AutoGrowTextArea } from '../Molecules/AutoGrowTextArea'; import { Dialog } from '../Molecules/Dialog'; import { userPreferences } from '../Preferences/userPreferences'; import { RecordSetsDialog } from '../Toolbar/RecordSets'; -import type { PreparationData, PreparationRow } from './helpers'; +import type { + InteractionWithPreps, + PreparationData, + PreparationRow, +} from './helpers'; +import { interactionsWithPrepTables } from './helpers'; import { getPrepsAvailableForLoanCoIds, getPrepsAvailableForLoanRs, @@ -57,11 +57,9 @@ export function InteractionDialog({ interactionResource, }: { readonly onClose: () => void; - readonly actionTable: SpecifyTable; + readonly actionTable: SpecifyTable; readonly isLoanReturn?: boolean; - readonly itemCollection?: Collection< - DisposalPreparation | GiftPreparation | LoanPreparation - >; + readonly itemCollection?: Collection; readonly interactionResource?: SpecifyResource; }): JSX.Element { const itemTable = isLoanReturn ? tables.Loan : tables.CollectionObject; @@ -245,7 +243,7 @@ export function InteractionDialog({ // BUG: make this readOnly if don't have necessary permissions itemCollection={itemCollection} preparations={state.entries} - table={actionTable as SpecifyTable} + table={actionTable} onClose={handleClose} /> ) : ( @@ -299,19 +297,11 @@ export function InteractionDialog({ > {interactionsText.addUnassociated()} - ) : actionTable.name === 'Loan' && - state.type === 'MissingState' && - prepsData?.length === 0 ? ( - + ) : interactionsWithPrepTables.includes(actionTable.name) ? ( + {interactionsText.withoutPreparations()} ) : undefined} - {actionTable.name === 'Gift' && - itemCollection === undefined && ( - - {interactionsText.withoutPreparations()} - - )} {state.type === 'MissingState' && prepsData?.length !== 0 && prepsData ? ( diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionsDialog.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionsDialog.tsx index 627a94558ba..025ecdc6f24 100644 --- a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionsDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionsDialog.tsx @@ -4,7 +4,7 @@ import { useParams } from 'react-router-dom'; import { useBooleanState } from '../../hooks/useBooleanState'; import { commonText } from '../../localization/common'; import { interactionsText } from '../../localization/interactions'; -import type { RA, RR } from '../../utils/types'; +import type { RA } from '../../utils/types'; import { Ul } from '../Atoms'; import { DataEntry } from '../Atoms/DataEntry'; import { icons } from '../Atoms/Icons'; @@ -14,7 +14,6 @@ import { useDataEntryTables } from '../DataEntryTables/fetchTables'; import { getResourceViewUrl } from '../DataModel/resource'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { getTable, tables } from '../DataModel/tables'; -import type { Tables } from '../DataModel/types'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { TableIcon } from '../Molecules/TableIcon'; import { hasTablePermission } from '../Permissions/helpers'; @@ -22,23 +21,16 @@ import { ProtectedTable } from '../Permissions/PermissionDenied'; import { Redirect } from '../Router/Redirect'; import { OverlayContext } from '../Router/Router'; import { fetchLegacyInteractions } from './fetch'; +import type { InteractionWithPreps } from './helpers'; +import { interactionsWithPrepTables } from './helpers'; import { InteractionDialog } from './InteractionDialog'; -export const interactionWorkflowTables: Partial< - RR -> = { - /* - * Accession: 'CollectionObject', - * Appraisal: 'CollectionObject', - */ - Loan: 'CollectionObject', - Gift: 'CollectionObject', - Disposal: 'CollectionObject', - /* - * ExchangeOut: 'Preparation', - * InfoRequest: 'CollectionObject', - */ -}; +/** + * FEATURE: If needed, implement a dialog for: + * Accession -> CollectionObjects + * Appraisal -> CollectionObjects + * InfoRequest -> CollectionObjects + */ export function InteractionsOverlay(): JSX.Element | null { const tables = useDataEntryTables('interactions'); @@ -79,7 +71,9 @@ function Interactions({ href={ table.name === 'LoanReturnPreparation' ? `/specify/overlay/interactions/return-loan/` - : table.name in interactionWorkflowTables + : interactionsWithPrepTables.includes( + (table as SpecifyTable).name + ) ? `/specify/overlay/interactions/create/${table.name}/` : getResourceViewUrl(table.name) } @@ -103,8 +97,11 @@ export function InteractionAction(): JSX.Element | null { const { tableName = '' } = useParams(); const rawTable = React.useMemo(() => getTable(tableName), [tableName]); const table = - typeof rawTable === 'object' && rawTable.name in interactionWorkflowTables - ? rawTable + typeof rawTable === 'object' && + interactionsWithPrepTables.includes( + (rawTable as SpecifyTable).name + ) + ? (rawTable as SpecifyTable) : undefined; return table === undefined ? ( diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/PrepDialog.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/PrepDialog.tsx index c1a46009f22..4ea6b51f039 100644 --- a/specifyweb/frontend/js_src/lib/components/Interactions/PrepDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Interactions/PrepDialog.tsx @@ -6,28 +6,23 @@ import { useLiveState } from '../../hooks/useLiveState'; import { commonText } from '../../localization/common'; import { interactionsText } from '../../localization/interactions'; import type { RA } from '../../utils/types'; -import { filterArray } from '../../utils/types'; +import { defined, filterArray } from '../../utils/types'; import { group, replaceItem } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { Form, Input, Label } from '../Atoms/Form'; import { Submit } from '../Atoms/Submit'; import { ReadOnlyContext } from '../Core/Contexts'; import { getField, toTable } from '../DataModel/helpers'; +import type { AnyInteractionPreparation } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { getResourceApiUrl, getResourceViewUrl } from '../DataModel/resource'; import { serializeResource } from '../DataModel/serializers'; import type { Collection, SpecifyTable } from '../DataModel/specifyTable'; -import { strictGetTable, tables } from '../DataModel/tables'; -import type { - Disposal, - DisposalPreparation, - Gift, - GiftPreparation, - Loan, - LoanPreparation, -} from '../DataModel/types'; +import { tables } from '../DataModel/tables'; +import type { ExchangeOut, ExchangeOutPrep } from '../DataModel/types'; import { Dialog } from '../Molecules/Dialog'; -import type { PreparationData } from './helpers'; +import type { InteractionWithPreps, PreparationData } from './helpers'; +import { interactionPrepTables } from './helpers'; import { PrepDialogRow } from './PrepDialogRow'; export function PrepDialog({ @@ -38,10 +33,8 @@ export function PrepDialog({ }: { readonly onClose: () => void; readonly preparations: RA; - readonly table: SpecifyTable; - readonly itemCollection?: Collection< - DisposalPreparation | GiftPreparation | LoanPreparation - >; + readonly table: SpecifyTable; + readonly itemCollection?: Collection; }): JSX.Element { const preparations = React.useMemo(() => { if (itemCollection === undefined) return rawPreparations; @@ -152,11 +145,16 @@ export function PrepDialog({
{ - const itemTable = strictGetTable( - `${table.name}Preparation` - ) as SpecifyTable< - DisposalPreparation | GiftPreparation | LoanPreparation - >; + const itemTable = defined( + table.relationships.find((relationship) => + interactionPrepTables.includes( + ( + relationship.relatedTable as SpecifyTable + ).name + ) + )?.relatedTable + ) as SpecifyTable; + const items = filterArray( preparations.map((preparation, index) => { if (selected[index] === 0) return undefined; @@ -178,20 +176,10 @@ export function PrepDialog({ handleClose(); } else { const interaction = new table.Resource(); + setPreparationItems(interaction, items); + const loan = toTable(interaction, 'Loan'); - loan?.set( - 'loanPreparations', - items as RA> - ); loan?.set('isClosed', false); - toTable(interaction, 'Gift')?.set( - 'giftPreparations', - items as RA> - ); - toTable(interaction, 'Disposal')?.set( - 'disposalPreparations', - items as RA> - ); navigate(getResourceViewUrl(table.name, undefined), { state: { type: 'RecordSet', @@ -238,3 +226,23 @@ export function PrepDialog({ ); } + +function setPreparationItems( + interaction: SpecifyResource, + items: RA> +): void { + const preparationRelationship = defined( + interaction.specifyTable.relationships.find((relationship) => + interactionPrepTables.includes( + (relationship.relatedTable as SpecifyTable) + .name + ) + ) + ); + + // Typecast as a single case because the relatiships do not exist in the union type. + (interaction as SpecifyResource).set( + preparationRelationship.name as 'exchangeOutPreps', + items as RA> + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/helpers.ts b/specifyweb/frontend/js_src/lib/components/Interactions/helpers.ts index 6f29bc5c002..4ddd41d0c35 100644 --- a/specifyweb/frontend/js_src/lib/components/Interactions/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/Interactions/helpers.ts @@ -1,6 +1,29 @@ import { ajax } from '../../utils/ajax'; import { formData } from '../../utils/ajax/helpers'; -import type { RA } from '../../utils/types'; +import type { RA, RestrictedTuple } from '../../utils/types'; +import type { AnyInteractionPreparation } from '../DataModel/helperTypes'; +import type { Tables } from '../DataModel/types'; + +export const interactionPrepTables: RestrictedTuple< + AnyInteractionPreparation['tableName'] +> = [ + 'DisposalPreparation', + 'ExchangeInPrep', + 'ExchangeOutPrep', + 'GiftPreparation', + 'LoanPreparation', +]; + +type ExtractInteraction = + T extends `${infer Prefix}Prep${string}` ? Prefix : never; + +export type InteractionWithPreps = Tables[ExtractInteraction< + AnyInteractionPreparation['tableName'] +>]; + +export const interactionsWithPrepTables: RestrictedTuple< + InteractionWithPreps['tableName'] +> = ['Disposal', 'ExchangeIn', 'ExchangeOut', 'Gift', 'Loan']; export type PreparationData = { readonly catalogNumber: string; @@ -17,18 +40,19 @@ export type PreparationData = { }; export type PreparationRow = readonly [ - string, - number, - string, - number, - number, - string, - number, - string | null, - string | null, - string | null, - string + catalogNumber: string, + collectionObjectId: number, + taxonFullName: string, + taxonId: number, + preparationId: number, + prepType: string, + preparationCountAmt: number, + amountLoaned: string | null, + amountedGifted: string | null, + amountExchanged: string | null, + amountAvailable: string ]; + export const getPrepsAvailableForLoanRs = async (recordSetId: number) => ajax>( `/interactions/preparations_available_rs/${recordSetId}/`, diff --git a/specifyweb/frontend/js_src/lib/utils/types.ts b/specifyweb/frontend/js_src/lib/utils/types.ts index 524160c7315..90cef29f30b 100644 --- a/specifyweb/frontend/js_src/lib/utils/types.ts +++ b/specifyweb/frontend/js_src/lib/utils/types.ts @@ -67,6 +67,34 @@ export type Writable = { -readonly [K in keyof T]: T[K]; }; +/** + * Inspired by https://stackoverflow.com/a/69676731 + * Constructs a tuple type which must contain exactly one of every possible + * type of VALUES along with the values in RESULT + * + * @remarks + * While the RESULT parameter can be explicitly provided, it is primarily used + * to recursively generate the tuple + * + * @example + * ```ts + * type Colors = RestrictedTuple<'blue' | 'green' | 'red'>; + * const missing: Colors = ['blue']; // Invalid + * const duplicates: Colors = ['blue', 'blue', 'red']; // Invalid + * const wrongColor: Colors = ['green', 'blue', 'yellow']; // Invalid + * const wrongLength: Colors = ['blue', 'red', 'green', 'green']; // Invalid + * const rightColors: Colors = ['blue', 'red', 'green']; // Valid + * ``` + */ +export type RestrictedTuple< + VALUES extends string, + RESULT extends RA = readonly [] +> = ValueOf<{ + readonly [KEY in VALUES]: Exclude extends never + ? readonly [KEY, ...RESULT] + : RestrictedTuple, readonly [KEY, ...RESULT]>; +}>; + /** * Cast type to writable. Equivalent to doing "as Writable", except this * way, don't have to manually specify the generic type