diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Dialog.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Dialog.tsx index ceb3c5e7b62..890c0896fd8 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Dialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Dialog.tsx @@ -62,7 +62,7 @@ export function AttachmentDialog({ {form !== null && ( { handleChange(serializeResource(resource)); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx index 3a94718b21b..6e645186fd2 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx @@ -29,7 +29,10 @@ export function AttachmentGallery({ readonly onFetchMore: (() => Promise) | undefined; readonly scale: number; readonly isComplete: boolean; - readonly onChange: (attachments: RA>) => void; + readonly onChange: ( + attachment: SerializedResource, + index: number + ) => void; readonly onClick?: (attachment: SerializedResource) => void; }): JSX.Element { const containerRef = React.useRef(null); @@ -63,6 +66,7 @@ export function AttachmentGallery({ const [openIndex, setOpenIndex] = React.useState( undefined ); + const [related, setRelated] = React.useState< RA | undefined> >([]); @@ -129,7 +133,7 @@ export function AttachmentGallery({ (item): void => setRelated(replaceItem(related, openIndex, item)), ]} onChange={(newAttachment): void => - handleChange(replaceItem(attachments, openIndex, newAttachment)) + handleChange(newAttachment, openIndex) } onClose={(): void => setOpenIndex(undefined)} onNext={ diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx index 369ccb6b4fa..908bcc0354c 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { useAsyncState } from '../../hooks/useAsyncState'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { Attachment } from '../DataModel/types'; -import { loadingGif } from '../Molecules'; import type { AttachmentThumbnail } from './attachments'; import { fetchThumbnail } from './attachments'; @@ -37,13 +36,13 @@ export function AttachmentPreview({ export function Thumbnail({ attachment, thumbnail, + className, }: { readonly attachment: SerializedResource; readonly thumbnail: AttachmentThumbnail | undefined; -}): JSX.Element { - return thumbnail === undefined ? ( - loadingGif - ) : ( + readonly className?: string; +}): JSX.Element | null { + return thumbnail === undefined ? null : ( { 0 @@ -51,9 +50,9 @@ export function Thumbnail({ : thumbnail.alt } className={` - max-h-full max-w-full border-2 border-white object-contain - dark:border-black - `} + ${className} + max-h-full max-w-full border-2 border-white object-contain + dark:border-black`} src={thumbnail.src} style={{ width: `${thumbnail.width}px`, diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx new file mode 100644 index 00000000000..15c45c3427f --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -0,0 +1,154 @@ +import React from 'react'; + +import { useAsyncState } from '../../hooks/useAsyncState'; +import { useBooleanState } from '../../hooks/useBooleanState'; +import { useCachedState } from '../../hooks/useCachedState'; +import { attachmentsText } from '../../localization/attachments'; +import { commonText } from '../../localization/common'; +import { f } from '../../utils/functools'; +import type { RA } from '../../utils/types'; +import { filterArray } from '../../utils/types'; +import { Button } from '../Atoms/Button'; +import { serializeResource } from '../DataModel/helpers'; +import type { AnySchema } from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import type { CollectionObjectAttachment } from '../DataModel/types'; +import { Dialog, dialogClassNames } from '../Molecules/Dialog'; +import { defaultAttachmentScale } from '.'; +import { AttachmentGallery } from './Gallery'; + +const haltIncrementSize = 300; + +export function RecordSetAttachments({ + records, + onFetch: handleFetch, +}: { + readonly records: RA | undefined>; + readonly onFetch: + | ((index: number) => Promise | void>) + | undefined; +}): JSX.Element { + const fetchedCount = React.useRef(0); + + const [showAttachments, handleShowAttachments, handleHideAttachments] = + useBooleanState(); + + const [attachments] = useAsyncState( + React.useCallback(async () => { + const relatedAttachmentRecords = await Promise.all( + records.map(async (record) => + record + ?.rgetCollection(`${record.specifyModel.name}Attachments`) + .then( + ({ models }) => + models as RA> + ) + ) + ); + + const fetchCount = records.findIndex( + (record) => record?.populated !== true + ); + + fetchedCount.current = fetchCount === -1 ? records.length : fetchCount; + + const attachments = await Promise.all( + filterArray(relatedAttachmentRecords.flat()).map( + async (collectionObjectAttachment) => ({ + attachment: await collectionObjectAttachment + .rgetPromise('attachment') + .then((resource) => serializeResource(resource)), + related: collectionObjectAttachment, + }) + ) + ); + + return { + attachments: attachments.map(({ attachment }) => attachment), + related: attachments.map(({ related }) => related), + }; + }, [records]), + false + ); + const attachmentsRef = React.useRef(attachments); + + if (typeof attachments === 'object') attachmentsRef.current = attachments; + + /* + * Stop fetching records if the first 300 don't have attachments + * to save computing resources. Ask the user to continue and fetch + * the next haltIncrementSize (300) if desired. + */ + const [haltValue, setHaltValue] = React.useState(39); + const halt = + attachments?.attachments.length === 0 && records.length >= haltValue; + + const [scale = defaultAttachmentScale] = useCachedState( + 'attachments', + 'scale' + ); + + return ( + <> + + {showAttachments && ( + {commonText.close()} + } + className={{ + container: dialogClassNames.wideContainer, + }} + header={ + attachmentsRef.current?.attachments === undefined + ? attachmentsText.attachments() + : commonText.countLine({ + resource: attachmentsText.attachments(), + count: attachmentsRef.current.attachments.length, + }) + } + onClose={handleHideAttachments} + > + {halt ? ( + haltValue === records.length ? ( + <>{attachmentsText.noAttachments()} + ) : ( +
+ {attachmentsText.attachmentHaltLimit({ halt: haltValue })} + + setHaltValue( + Math.min(haltValue + haltIncrementSize, records.length) + ) + } + > + {attachmentsText.fetchNextAttachments()} + +
+ ) + ) : ( + + void attachments?.related[index].set(`attachment`, attachment) + } + onFetchMore={ + attachments === undefined || handleFetch === undefined || halt + ? undefined + : async (): Promise => + handleFetch?.(fetchedCount.current).then(f.void) + } + /> + )} +
+ )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx index d17477313af..50914589c71 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx @@ -13,6 +13,7 @@ import { commonText } from '../../localization/common'; import { schemaText } from '../../localization/schema'; import { f } from '../../utils/functools'; import { filterArray } from '../../utils/types'; +import { replaceItem } from '../../utils/utils'; import { Container, H2 } from '../Atoms'; import { DialogContext } from '../Atoms/Button'; import { className } from '../Atoms/className'; @@ -49,7 +50,7 @@ export const tablesWithAttachments = f.store(() => ) ); -export const defaultScale = 10; +export const defaultAttachmentScale = 10; const minScale = 4; const maxScale = 50; const defaultSortOrder = '-timestampCreated'; @@ -136,7 +137,7 @@ function Attachments({ false ); - const [scale = defaultScale, setScale] = useCachedState( + const [scale = defaultAttachmentScale, setScale] = useCachedState( 'attachments', 'scale' ); @@ -257,10 +258,13 @@ function Attachments({ } key={`${order}_${JSON.stringify(filter)}`} scale={scale} - onChange={(records): void => + onChange={(attachment, index): void => collection === undefined ? undefined - : setCollection({ records, totalCount: collection.totalCount }) + : setCollection({ + records: replaceItem(collection.records, index, attachment), + totalCount: collection.totalCount, + }) } onClick={onClick} onFetchMore={collection === undefined ? undefined : fetchMore} diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx index 80ff59dc8a3..f85b18c415a 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx @@ -8,7 +8,7 @@ import type { RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { icons } from '../Atoms/Icons'; -import { defaultScale } from '../Attachments'; +import { defaultAttachmentScale } from '../Attachments'; import { AttachmentGallery } from '../Attachments/Gallery'; import { serializeResource } from '../DataModel/helpers'; import type { AnySchema, SerializedResource } from '../DataModel/helperTypes'; @@ -27,7 +27,10 @@ export function AttachmentsCollection({ const [showAllAttachments, handleOpenAttachments, handleCloseAttachments] = useBooleanState(); - const [scale = defaultScale] = useCachedState('attachments', 'scale'); + const [scale = defaultAttachmentScale] = useCachedState( + 'attachments', + 'scale' + ); const attachments: RA> = filterArray( Array.from(collection.models, (model) => { diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index 95752271e17..7b7a97219a0 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx @@ -9,6 +9,8 @@ import type { RA } from '../../utils/types'; import { removeItem } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; +import { tablesWithAttachments } from '../Attachments'; +import { RecordSetAttachments } from '../Attachments/RecordSetAttachment'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { schema } from '../DataModel/schema'; @@ -46,6 +48,7 @@ export function RecordSelectorFromIds({ onAdd: handleAdd, onClone: handleClone, onDelete: handleDelete, + onFetch: handleFetch, ...rest }: Omit, 'index' | 'records'> & { /* @@ -70,6 +73,9 @@ export function RecordSelectorFromIds({ readonly onClone: | ((newResource: SpecifyResource) => void) | undefined; + readonly onFetch?: ( + index: number + ) => Promise | undefined>; }): JSX.Element | null { const [records, setRecords] = React.useState< RA | undefined> @@ -176,6 +182,8 @@ export function RecordSelectorFromIds({ }) : commonText.delete(); + const hasAttachments = tablesWithAttachments().includes(model); + return ( <> ({ !isDependent && dialog !== false ? resource : undefined } /> - {hasTablePermission( model.name, isDependent ? 'create' : 'read' @@ -201,7 +208,6 @@ export function RecordSelectorFromIds({ onClick={handleAdding} /> ) : undefined} - {typeof handleRemove === 'function' && canRemove ? ( ({ onClick={(): void => handleRemove('minusButton')} /> ) : undefined} - {typeof newResource === 'object' && handleAdd !== undefined ? (

{formsText.creatingNewRecord()}

) : ( @@ -218,7 +223,9 @@ export function RecordSelectorFromIds({ className={`flex-1 ${dialog === false ? '-ml-2' : '-ml-4'}`} /> )} - + {hasAttachments && ( + + )} {specifyNetworkBadge} {totalCount > 1 &&
{slider}
} diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx index 6686a083c8d..3debaecac85 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -209,7 +209,7 @@ function RecordSet({ replace: boolean = false ): void => recordId === undefined - ? handleFetch(index) + ? handleFetchMore(index) : navigate( getResourceViewUrl( currentRecord.specifyModel.name, @@ -233,10 +233,10 @@ function RecordSet({ const [isLoading, handleLoading, handleLoaded] = useBooleanState(); const totalCount = ids.length; const handleFetch = React.useCallback( - (index: number): void => { - if (index >= totalCount || recordSet.isNew()) return; + async (index: number): Promise | undefined> => { + if (index >= totalCount) return undefined; handleLoading(); - fetchItems( + return fetchItems( recordSet.id, // If new index is smaller (i.e, going back), fetch previous 40 IDs clamp( @@ -244,29 +244,42 @@ function RecordSet({ previousIndex.current > index ? index - fetchSize + 1 : index, totalCount ) - ) - .then((updates) => - setIds((oldIds = []) => { - handleLoaded(); - const newIds = updateIds(oldIds, updates); - go(index, newIds[index]); - return newIds; - }) - ) - .catch(softFail); + ).then( + async (updates) => + new Promise((resolve) => + setIds((oldIds = []) => { + handleLoaded(); + const newIds = updateIds(oldIds, updates); + resolve(newIds); + return newIds; + }) + ) + ); }, [totalCount, recordSet.id, loading, handleLoading, handleLoaded] ); + const handleFetchMore = React.useCallback( + (index: number): void => { + handleFetch(index) + .then((newIds) => { + if (newIds === undefined) return; + go(index, newIds[index]); + }) + .catch(softFail); + }, + [handleFetch] + ); + // Fetch ID of record at current index const currentRecordId = ids[currentIndex]; React.useEffect(() => { - if (currentRecordId === undefined) handleFetch(currentIndex); + if (currentRecordId === undefined) handleFetchMore(currentIndex); return (): void => { previousIndex.current = currentIndex; }; - }, [totalCount, currentRecordId, handleFetch, currentIndex]); + }, [totalCount, currentRecordId, handleFetchMore, currentIndex]); const [hasDuplicate, handleHasDuplicate, handleDismissDuplicate] = useBooleanState(); @@ -415,6 +428,7 @@ function RecordSet({ } : undefined } + onFetch={handleFetch} onSaved={(resource): void => // Don't do anything if saving existing resource ids[currentIndex] === resource.id diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index 3ab6fa2c882..d16c76f1a45 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -98,6 +98,13 @@ export const attachmentsText = createDictionary({ 'uk-ua': 'Показати форму', 'de-ch': 'Formular anzeigen', }, + attachmentHaltLimit: { + 'en-us': + 'No attachments have been found in the first {halt:number} records.', + }, + fetchNextAttachments: { + 'en-us': 'Look for more attachments', + }, hideForm: { 'en-us': 'Hide Form', 'de-ch': 'Formular ausblenden',