Skip to content

Commit 082e72c

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

File tree

6 files changed

+205
-27
lines changed

6 files changed

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

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { ProtectedTable } from '../Permissions/PermissionDenied';
2626
import { OrderPicker } from '../Preferences/Renderers';
2727
import { attachmentSettingsPromise } from './attachments';
2828
import { AttachmentGallery } from './Gallery';
29+
import { replaceItem } from '../../utils/utils';
2930

3031
export const attachmentRelatedTables = f.store(() =>
3132
Object.keys(schema.models).filter((tableName) =>
@@ -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
@@ -20,6 +20,8 @@ import { hasTablePermission } from '../Permissions/helpers';
2020
import { SetUnloadProtectsContext } from '../Router/Router';
2121
import type { RecordSelectorProps } from './RecordSelector';
2222
import { useRecordSelector } from './RecordSelector';
23+
import { RecordSetAttachments } from '../Attachments/RecordSetAttachment';
24+
import { tablesWithAttachments } from '../Attachments';
2325

2426
/**
2527
* A Wrapper for RecordSelector that allows to specify list of records by their
@@ -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<undefined | RA<number | 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)