Skip to content

Commit 23e1586

Browse files
CarolineDenismaxpatiiuk
authored andcommitted
Implement attachment viewer gallery in record sets
Fixes #2132
1 parent 0dafeaa commit 23e1586

File tree

6 files changed

+191
-27
lines changed

6 files changed

+191
-27
lines changed

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,15 @@ export function AttachmentGallery({
2525
onChange: handleChange,
2626
}: {
2727
readonly attachments: RA<SerializedResource<Attachment>>;
28-
readonly onFetchMore: (() => Promise<void>) | undefined;
28+
readonly onFetchMore:
29+
| (() => Promise<RA<number | undefined> | void>)
30+
| undefined;
2931
readonly scale: number;
3032
readonly isComplete: boolean;
31-
readonly onChange: (attachments: RA<SerializedResource<Attachment>>) => void;
33+
readonly onChange: (
34+
attachment: SerializedResource<Attachment>,
35+
index: number
36+
) => void;
3237
}): JSX.Element {
3338
const containerRef = React.useRef<HTMLElement | null>(null);
3439

@@ -61,6 +66,7 @@ export function AttachmentGallery({
6166
const [openIndex, setOpenIndex] = React.useState<number | undefined>(
6267
undefined
6368
);
69+
6470
const [related, setRelated] = React.useState<
6571
RA<SpecifyResource<AnySchema> | undefined>
6672
>([]);
@@ -121,7 +127,7 @@ export function AttachmentGallery({
121127
(item): void => setRelated(replaceItem(related, openIndex, item)),
122128
]}
123129
onChange={(newAttachment): void =>
124-
handleChange(replaceItem(attachments, openIndex, newAttachment))
130+
handleChange(newAttachment, openIndex)
125131
}
126132
onClose={(): void => setOpenIndex(undefined)}
127133
onNext={
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React from 'react';
2+
3+
import { useAsyncState } from '../../hooks/useAsyncState';
4+
import { useCachedState } from '../../hooks/useCachedState';
5+
import { attachmentsText } from '../../localization/attachments';
6+
import { commonText } from '../../localization/common';
7+
import type { RA } from '../../utils/types';
8+
import { filterArray } from '../../utils/types';
9+
import { Button } from '../Atoms/Button';
10+
import { serializeResource } from '../DataModel/helpers';
11+
import type { AnySchema } from '../DataModel/helperTypes';
12+
import type { SpecifyResource } from '../DataModel/legacyTypes';
13+
import type { CollectionObjectAttachment } from '../DataModel/types';
14+
import { Dialog } from '../Molecules/Dialog';
15+
import { defaultScale } from '.';
16+
import { AttachmentGallery } from './Gallery';
17+
18+
export function RecordSetAttachments<SCHEMA extends AnySchema>({
19+
records,
20+
onClose: handleClose,
21+
onFetch: handleFetch,
22+
}: {
23+
readonly records: RA<SpecifyResource<SCHEMA> | undefined>;
24+
readonly onClose: () => void;
25+
readonly onFetch?:
26+
| ((index: number) => Promise<RA<number | undefined> | void>)
27+
| undefined;
28+
}): JSX.Element {
29+
const recordFetched = React.useRef<number>(0);
30+
31+
const [attachments] = useAsyncState(
32+
React.useCallback(async () => {
33+
const relatedAttachementRecords = await Promise.all(
34+
records.map((record) =>
35+
record
36+
?.rgetCollection(`${record.specifyModel.name}Attachments`)
37+
.then(
38+
({ models }) =>
39+
models as RA<SpecifyResource<CollectionObjectAttachment>>
40+
)
41+
)
42+
);
43+
44+
const fetchCount = records.findIndex(
45+
(record) => record?.populated !== true
46+
);
47+
48+
recordFetched.current = fetchCount === -1 ? records.length : fetchCount;
49+
50+
return Promise.all(
51+
filterArray(relatedAttachementRecords.flat()).map(
52+
async (collectionObjectAttachment) => ({
53+
attachment: await collectionObjectAttachment
54+
.rgetPromise('attachment')
55+
.then((resource) => serializeResource(resource)),
56+
related: collectionObjectAttachment,
57+
})
58+
)
59+
);
60+
}, [records]),
61+
true
62+
);
63+
64+
const [haltValue, setHaltValue] = React.useState(300);
65+
const halt = attachments?.length === 0 && records.length >= haltValue;
66+
67+
const [scale = defaultScale] = useCachedState('attachments', 'scale');
68+
69+
const children = halt ? (
70+
haltValue === records.length ? (
71+
<>{attachmentsText.noAttachments()}</>
72+
) : (
73+
<div className="flex flex-col gap-4">
74+
{attachmentsText.attachmentHaltLimit({ halt: haltValue })}
75+
<Button.Orange
76+
onClick={() => {
77+
if (haltValue + 300 > records.length) {
78+
setHaltValue(records.length);
79+
} else {
80+
setHaltValue(haltValue + 300);
81+
}
82+
}}
83+
>
84+
{attachmentsText.fetchNextAttachments()}
85+
</Button.Orange>
86+
</div>
87+
)
88+
) : (
89+
<AttachmentGallery
90+
attachments={attachments?.map(({ attachment }) => attachment) ?? []}
91+
isComplete={recordFetched.current === records.length}
92+
scale={scale}
93+
onChange={(attachment, index): void =>
94+
void attachments?.[index].related.set(`attachment`, attachment)
95+
}
96+
onFetchMore={
97+
attachments === undefined || handleFetch === undefined || halt
98+
? undefined
99+
: async () => handleFetch?.(recordFetched.current)
100+
}
101+
/>
102+
);
103+
104+
return (
105+
<Dialog
106+
buttons={<Button.DialogClose>{commonText.close()}</Button.DialogClose>}
107+
header={attachmentsText.attachments()}
108+
onClose={handleClose}
109+
>
110+
{children}
111+
</Dialog>
112+
);
113+
}

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

Lines changed: 7 additions & 3 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 defaultScale = 10;
5152
const minScale = 4;
5253
const maxScale = 50;
5354
const defaultSortOrder = '-timestampCreated';
@@ -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: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ 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 { attachmentsText } from '../../localization/attachments';
25+
import { tablesWithAttachments } from '../Attachments';
2326

2427
/**
2528
* A Wrapper for RecordSelector that allows to specify list of records by their
@@ -45,6 +48,7 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
4548
onAdd: handleAdd,
4649
onClone: handleClone,
4750
onDelete: handleDelete,
51+
onFetch: handleFetch,
4852
...rest
4953
}: Omit<RecordSelectorProps<SCHEMA>, 'index' | 'records'> & {
5054
/*
@@ -69,13 +73,18 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
6973
readonly onClone:
7074
| ((newResource: SpecifyResource<SCHEMA>) => void)
7175
| undefined;
76+
readonly onFetch?: (
77+
index: number
78+
) => Promise<undefined | RA<number | undefined>>;
7279
}): JSX.Element | null {
7380
const [records, setRecords] = React.useState<
7481
RA<SpecifyResource<SCHEMA> | undefined>
7582
>(() =>
7683
ids.map((id) => (id === undefined ? undefined : new model.Resource({ id })))
7784
);
7885

86+
const [attachmentState, setAttachmentState] = React.useState(false);
87+
7988
const previousIds = React.useRef(ids);
8089
React.useEffect(() => {
8190
setRecords((records) =>
@@ -183,6 +192,9 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
183192
recordSetTable: schema.models.RecordSet.label,
184193
})
185194
: commonText.delete();
195+
196+
const hasAttachments = tablesWithAttachments().includes(model);
197+
186198
return (
187199
<>
188200
<ResourceView
@@ -191,13 +203,11 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
191203
<div className="flex flex-col items-center gap-2 md:contents md:flex-row md:gap-8">
192204
<div className="flex items-center gap-2 md:contents">
193205
{headerButtons}
194-
195206
<DataEntry.Visit
196207
resource={
197208
!isDependent && dialog !== false ? resource : undefined
198209
}
199210
/>
200-
201211
{hasTablePermission(
202212
model.name,
203213
isDependent ? 'create' : 'read'
@@ -209,7 +219,6 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
209219
onClick={handleAdding}
210220
/>
211221
) : undefined}
212-
213222
{typeof handleRemove === 'function' && canRemove ? (
214223
<DataEntry.Remove
215224
aria-label={removeLabel}
@@ -218,15 +227,25 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
218227
onClick={(): void => handleRemove('minusButton')}
219228
/>
220229
) : undefined}
221-
222230
{typeof newResource === 'object' ? (
223231
<p className="flex-1">{formsText.creatingNewRecord()}</p>
224232
) : (
225233
<span
226234
className={`flex-1 ${dialog === false ? '-ml-2' : '-ml-4'}`}
227235
/>
228236
)}
229-
237+
{hasAttachments && (
238+
<Button.Blue onClick={() => setAttachmentState(true)}>
239+
{attachmentsText.attachments()}
240+
</Button.Blue>
241+
)}
242+
{attachmentState === true ? (
243+
<RecordSetAttachments
244+
records={records}
245+
onClose={() => setAttachmentState(!attachmentState)}
246+
onFetch={handleFetch}
247+
/>
248+
) : null}
230249
{specifyNetworkBadge}
231250
</div>
232251
<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)