Skip to content

Commit e1bb380

Browse files
CarolineDenismaxpatiiuk
authored andcommitted
Implement attachment viewer gallery in record sets
1 parent 393538b commit e1bb380

File tree

6 files changed

+211
-27
lines changed

6 files changed

+211
-27
lines changed

specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ export function AttachmentGallery({
2828
readonly onFetchMore: (() => Promise<void>) | undefined;
2929
readonly scale: number;
3030
readonly isComplete: boolean;
31-
readonly onChange: (attachments: RA<SerializedResource<Attachment>>) => void;
31+
readonly onChange: (
32+
attachment: SerializedResource<Attachment>,
33+
index: number
34+
) => void;
3235
}): JSX.Element {
3336
const containerRef = React.useRef<HTMLElement | null>(null);
3437

@@ -61,6 +64,7 @@ export function AttachmentGallery({
6164
const [openIndex, setOpenIndex] = React.useState<number | undefined>(
6265
undefined
6366
);
67+
6468
const [related, setRelated] = React.useState<
6569
RA<SpecifyResource<AnySchema> | undefined>
6670
>([]);
@@ -121,7 +125,7 @@ export function AttachmentGallery({
121125
(item): void => setRelated(replaceItem(related, openIndex, item)),
122126
]}
123127
onChange={(newAttachment): void =>
124-
handleChange(replaceItem(attachments, openIndex, newAttachment))
128+
handleChange(newAttachment, openIndex)
125129
}
126130
onClose={(): void => setOpenIndex(undefined)}
127131
onNext={
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import React from 'react';
2+
3+
import { useAsyncState } from '../../hooks/useAsyncState';
4+
import { useBooleanState } from '../../hooks/useBooleanState';
5+
import { useCachedState } from '../../hooks/useCachedState';
6+
import { attachmentsText } from '../../localization/attachments';
7+
import { commonText } from '../../localization/common';
8+
import { f } from '../../utils/functools';
9+
import type { RA } from '../../utils/types';
10+
import { filterArray } from '../../utils/types';
11+
import { Button } from '../Atoms/Button';
12+
import { serializeResource } from '../DataModel/helpers';
13+
import type { AnySchema } from '../DataModel/helperTypes';
14+
import type { SpecifyResource } from '../DataModel/legacyTypes';
15+
import type { CollectionObjectAttachment } from '../DataModel/types';
16+
import { Dialog } from '../Molecules/Dialog';
17+
import { defaultAttachmentScale } from '.';
18+
import { AttachmentGallery } from './Gallery';
19+
20+
const haltIncrementSize = 300;
21+
22+
export function RecordSetAttachments<SCHEMA extends AnySchema>({
23+
records,
24+
onFetch: handleFetch,
25+
}: {
26+
readonly records: RA<SpecifyResource<SCHEMA> | undefined>;
27+
readonly onFetch:
28+
| ((index: number) => Promise<RA<number | undefined> | void>)
29+
| undefined;
30+
}): JSX.Element {
31+
const fetchedCount = React.useRef<number>(0);
32+
33+
const [showAttachments, handleShowAttachments, handleHideAttachments] =
34+
useBooleanState();
35+
36+
const [attachments] = useAsyncState(
37+
React.useCallback(async () => {
38+
const relatedAttachmentRecords = await Promise.all(
39+
records.map((record) =>
40+
record
41+
?.rgetCollection(`${record.specifyModel.name}Attachments`)
42+
.then(
43+
({ models }) =>
44+
models as RA<SpecifyResource<CollectionObjectAttachment>>
45+
)
46+
)
47+
);
48+
49+
const fetchCount = records.findIndex(
50+
(record) => record?.populated !== true
51+
);
52+
53+
fetchedCount.current = fetchCount === -1 ? records.length : fetchCount;
54+
55+
const attachements = await Promise.all(
56+
filterArray(relatedAttachmentRecords.flat()).map(
57+
async (collectionObjectAttachment) => ({
58+
attachment: await collectionObjectAttachment
59+
.rgetPromise('attachment')
60+
.then((resource) => serializeResource(resource)),
61+
related: collectionObjectAttachment,
62+
})
63+
)
64+
);
65+
66+
return {
67+
attachments: attachements.map(({ attachment }) => attachment),
68+
related: attachements.map(({ related }) => related),
69+
};
70+
}, [records]),
71+
true
72+
);
73+
74+
/*
75+
* Stop fetching records if the first 300 don't have attachments
76+
* to save computing resources. Ask the user to continue and fetch
77+
* the next haltIncrementSize (300) if desired.
78+
*/
79+
const [haltValue, setHaltValue] = React.useState(300);
80+
const halt =
81+
attachments?.attachments.length === 0 && records.length >= haltValue;
82+
83+
const [scale = defaultAttachmentScale] = useCachedState(
84+
'attachments',
85+
'scale'
86+
);
87+
88+
return (
89+
<>
90+
<Button.Icon
91+
icon="photos"
92+
title="attachments"
93+
onClick={handleShowAttachments}
94+
/>
95+
{showAttachments && (
96+
<Dialog
97+
buttons={
98+
<Button.DialogClose>{commonText.close()}</Button.DialogClose>
99+
}
100+
header={
101+
attachments?.attachments === undefined
102+
? attachmentsText.attachments()
103+
: commonText.countLine({
104+
resource: attachmentsText.attachments(),
105+
count: attachments.attachments.length,
106+
})
107+
}
108+
onClose={handleHideAttachments}
109+
>
110+
{halt ? (
111+
haltValue === records.length ? (
112+
<>{attachmentsText.noAttachments()}</>
113+
) : (
114+
<div className="flex flex-col gap-4">
115+
{attachmentsText.attachmentHaltLimit({ halt: haltValue })}
116+
<Button.Orange
117+
onClick={(): void =>
118+
setHaltValue(
119+
Math.min(haltValue + haltIncrementSize, records.length)
120+
)
121+
}
122+
>
123+
{attachmentsText.fetchNextAttachments()}
124+
</Button.Orange>
125+
</div>
126+
)
127+
) : (
128+
<AttachmentGallery
129+
attachments={attachments?.attachments ?? []}
130+
isComplete={fetchedCount.current === records.length}
131+
scale={scale}
132+
onChange={(attachment, index): void =>
133+
void attachments?.related[index].set(`attachment`, attachment)
134+
}
135+
onFetchMore={
136+
attachments === undefined || handleFetch === undefined || halt
137+
? undefined
138+
: async (): void =>
139+
handleFetch?.(fetchedCount.current).then(f.void)
140+
}
141+
/>
142+
)}
143+
</Dialog>
144+
)}
145+
</>
146+
);
147+
}

specifyweb/frontend/js_src/lib/components/Attachments/index.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { commonText } from '../../localization/common';
1313
import { schemaText } from '../../localization/schema';
1414
import { f } from '../../utils/functools';
1515
import { filterArray } from '../../utils/types';
16+
import { replaceItem } from '../../utils/utils';
1617
import { Container, H2 } from '../Atoms';
1718
import { className } from '../Atoms/className';
1819
import { Input, Label, Select } from '../Atoms/Form';
@@ -47,7 +48,7 @@ export const tablesWithAttachments = f.store(() =>
4748
)
4849
);
4950

50-
const defaultScale = 10;
51+
export const defaultAttachmentScale = 10;
5152
const minScale = 4;
5253
const maxScale = 50;
5354
const defaultSortOrder = '-timestampCreated';
@@ -124,7 +125,7 @@ function Attachments(): JSX.Element {
124125
false
125126
);
126127

127-
const [scale = defaultScale, setScale] = useCachedState(
128+
const [scale = defaultAttachmentScale, setScale] = useCachedState(
128129
'attachments',
129130
'scale'
130131
);
@@ -242,10 +243,13 @@ function Attachments(): JSX.Element {
242243
}
243244
key={`${order}_${JSON.stringify(filter)}`}
244245
scale={scale}
245-
onChange={(records): void =>
246+
onChange={(attachment, index): void =>
246247
collection === undefined
247248
? undefined
248-
: setCollection({ records, totalCount: collection.totalCount })
249+
: setCollection({
250+
records: replaceItem(collection.records, index, attachment),
251+
totalCount: collection.totalCount,
252+
})
249253
}
250254
onFetchMore={collection === undefined ? undefined : fetchMore}
251255
/>

specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type { RA } from '../../utils/types';
99
import { removeItem } from '../../utils/utils';
1010
import { Button } from '../Atoms/Button';
1111
import { DataEntry } from '../Atoms/DataEntry';
12+
import { tablesWithAttachments } from '../Attachments';
13+
import { RecordSetAttachments } from '../Attachments/RecordSetAttachment';
1214
import type { AnySchema } from '../DataModel/helperTypes';
1315
import type { SpecifyResource } from '../DataModel/legacyTypes';
1416
import { schema } from '../DataModel/schema';
@@ -45,6 +47,7 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
4547
onAdd: handleAdd,
4648
onClone: handleClone,
4749
onDelete: handleDelete,
50+
onFetch: handleFetch,
4851
...rest
4952
}: Omit<RecordSelectorProps<SCHEMA>, 'index' | 'records'> & {
5053
/*
@@ -69,6 +72,9 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
6972
readonly onClone:
7073
| ((newResource: SpecifyResource<SCHEMA>) => void)
7174
| undefined;
75+
readonly onFetch?: (
76+
index: number
77+
) => Promise<RA<number | undefined> | undefined>;
7278
}): JSX.Element | null {
7379
const [records, setRecords] = React.useState<
7480
RA<SpecifyResource<SCHEMA> | undefined>
@@ -183,6 +189,9 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
183189
recordSetTable: schema.models.RecordSet.label,
184190
})
185191
: commonText.delete();
192+
193+
const hasAttachments = tablesWithAttachments().includes(model);
194+
186195
return (
187196
<>
188197
<ResourceView
@@ -191,13 +200,11 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
191200
<div className="flex flex-col items-center gap-2 md:contents md:flex-row md:gap-8">
192201
<div className="flex items-center gap-2 md:contents">
193202
{headerButtons}
194-
195203
<DataEntry.Visit
196204
resource={
197205
!isDependent && dialog !== false ? resource : undefined
198206
}
199207
/>
200-
201208
{hasTablePermission(
202209
model.name,
203210
isDependent ? 'create' : 'read'
@@ -209,7 +216,6 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
209216
onClick={handleAdding}
210217
/>
211218
) : undefined}
212-
213219
{typeof handleRemove === 'function' && canRemove ? (
214220
<DataEntry.Remove
215221
aria-label={removeLabel}
@@ -218,15 +224,16 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
218224
onClick={(): void => handleRemove('minusButton')}
219225
/>
220226
) : undefined}
221-
222227
{typeof newResource === 'object' ? (
223228
<p className="flex-1">{formsText.creatingNewRecord()}</p>
224229
) : (
225230
<span
226231
className={`flex-1 ${dialog === false ? '-ml-2' : '-ml-4'}`}
227232
/>
228233
)}
229-
234+
{hasAttachments && (
235+
<RecordSetAttachments records={records} onFetch={handleFetch} />
236+
)}
230237
{specifyNetworkBadge}
231238
</div>
232239
<div>{slider}</div>

specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ function RecordSet<SCHEMA extends AnySchema>({
198198
replace: boolean = false
199199
): void =>
200200
recordId === undefined
201-
? handleFetch(index)
201+
? handleFetchMore(index)
202202
: navigate(
203203
getResourceViewUrl(
204204
currentRecord.specifyModel.name,
@@ -220,40 +220,54 @@ function RecordSet<SCHEMA extends AnySchema>({
220220

221221
const previousIndex = React.useRef<number>(currentIndex);
222222
const [isLoading, handleLoading, handleLoaded] = useBooleanState();
223+
223224
const handleFetch = React.useCallback(
224-
(index: number): void => {
225-
if (index >= totalCount) return;
225+
async (index: number): Promise<RA<number | undefined> | undefined> => {
226+
if (index >= totalCount) return undefined;
226227
handleLoading();
227-
fetchItems(
228+
return fetchItems(
228229
recordSet.id,
229230
// If new index is smaller (i.e, going back), fetch previous 40 IDs
230231
clamp(
231232
0,
232233
previousIndex.current > index ? index - fetchSize + 1 : index,
233234
totalCount
234235
)
235-
)
236-
.then((updates) =>
237-
setIds((oldIds = []) => {
238-
handleLoaded();
239-
const newIds = updateIds(oldIds, updates);
240-
go(index, newIds[index]);
241-
return newIds;
242-
})
243-
)
244-
.catch(softFail);
236+
).then(
237+
async (updates) =>
238+
new Promise((resolve) =>
239+
setIds((oldIds = []) => {
240+
handleLoaded();
241+
const newIds = updateIds(oldIds, updates);
242+
resolve(newIds);
243+
return newIds;
244+
})
245+
)
246+
);
245247
},
246248
[totalCount, recordSet.id, loading, handleLoading, handleLoaded]
247249
);
248250

251+
const handleFetchMore = React.useCallback(
252+
(index: number): void => {
253+
handleFetch(index)
254+
.then((newIds) => {
255+
if (newIds === undefined) return;
256+
go(index, newIds[index]);
257+
})
258+
.catch(softFail);
259+
},
260+
[handleFetch]
261+
);
262+
249263
// Fetch ID of record at current index
250264
const currentRecordId = ids[currentIndex];
251265
React.useEffect(() => {
252-
if (currentRecordId === undefined) handleFetch(currentIndex);
266+
if (currentRecordId === undefined) handleFetchMore(currentIndex);
253267
return (): void => {
254268
previousIndex.current = currentIndex;
255269
};
256-
}, [totalCount, currentRecordId, handleFetch, currentIndex]);
270+
}, [totalCount, currentRecordId, handleFetchMore, currentIndex]);
257271

258272
const [hasDuplicate, handleHasDuplicate, handleDismissDuplicate] =
259273
useBooleanState();
@@ -373,6 +387,7 @@ function RecordSet<SCHEMA extends AnySchema>({
373387
}
374388
: undefined
375389
}
390+
onFetch={handleFetch}
376391
onSaved={(resource): void =>
377392
ids[currentIndex] === resource.id
378393
? undefined

specifyweb/frontend/js_src/lib/localization/attachments.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,11 @@ export const attachmentsText = createDictionary({
8484
'ru-ru': 'Показать форму',
8585
'uk-ua': 'Показати форму',
8686
},
87+
attachmentHaltLimit: {
88+
'en-us':
89+
'No attachments have been found in the first {halt:number} records.',
90+
},
91+
fetchNextAttachments: {
92+
'en-us': 'Look for more attachments',
93+
},
8794
} as const);

0 commit comments

Comments
 (0)