Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement attachment viewer gallery in record sets #3363

Merged
merged 31 commits into from
Nov 1, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
786ae34
Implement attachment viewer gallery in record sets
CarolineDenis Apr 17, 2023
c4b965b
Fix failing tests, change localized strings
CarolineDenis Apr 18, 2023
319ec82
Allow to create record set from express search results
maxpatiiuk Mar 15, 2023
0dafeaa
Lint code with ESLint and Prettier
maxpatiiuk Apr 10, 2023
23e1586
Implement attachment viewer gallery in record sets
CarolineDenis Apr 17, 2023
66f5d4c
Merge branch 'issue-2132' of https://github.com/specify/specify7 into…
CarolineDenis Apr 25, 2023
69c67d0
Change variable name
CarolineDenis Apr 25, 2023
b596831
Fix failing tests
CarolineDenis Apr 25, 2023
082e72c
Implement attachment viewer gallery in record sets
CarolineDenis Apr 26, 2023
fbf0c2c
Lint code with ESLint and Prettier
CarolineDenis Apr 26, 2023
cef641f
Merge branch 'issue-2132' of https://github.com/specify/specify7 into…
CarolineDenis Apr 26, 2023
f79e691
Implement attachment viewer gallery in record sets
CarolineDenis Apr 26, 2023
0edc050
Merge branch 'issue-2132' of https://github.com/specify/specify7 into…
CarolineDenis Apr 28, 2023
5921aa6
Lint code with ESLint and Prettier
CarolineDenis Apr 28, 2023
9e7d597
Allow to make changes to atatchment
CarolineDenis Apr 28, 2023
a3fb90a
Remove obstrusive loading dialog
CarolineDenis Apr 28, 2023
8c43fb0
Display previous fetched attachments while new ones are being fetched
CarolineDenis May 1, 2023
0b58ab2
Lint code with ESLint and Prettier
CarolineDenis May 1, 2023
bdf456b
Simplify code
CarolineDenis May 1, 2023
a277044
Lint code with ESLint and Prettier
CarolineDenis May 1, 2023
d3f995f
Change shadows in attachment gallery
CarolineDenis May 4, 2023
7053ebd
Change the minimum width value for attachment dialog
CarolineDenis May 4, 2023
772178a
Lint code with ESLint and Prettier
CarolineDenis May 4, 2023
dab39e4
Merge remote-tracking branch 'origin/production' into issue-2132
CarolineDenis May 19, 2023
86203e1
Lint code with ESLint and Prettier
CarolineDenis May 19, 2023
86b120a
Delete loading gif for attachment thumbnail
CarolineDenis May 22, 2023
9d0ac45
Merge remote-tracking branch 'origin/production' into issue-2132
CarolineDenis Sep 25, 2023
d347557
Disable attachment set until attachments are defined
CarolineDenis Sep 25, 2023
cbf4129
Lint code with ESLint and Prettier
CarolineDenis Sep 25, 2023
983a29a
Merge remote-tracking branch 'origin/production' into issue-2132
CarolineDenis Oct 30, 2023
1753216
Merge branch 'production' into issue-2132
CarolineDenis Nov 1, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,15 @@ export function AttachmentGallery({
onChange: handleChange,
}: {
readonly attachments: RA<SerializedResource<Attachment>>;
readonly onFetchMore: (() => Promise<void>) | undefined;
readonly onFetchMore:
| (() => Promise<RA<number | undefined> | void>)
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
| undefined;
readonly scale: number;
readonly isComplete: boolean;
readonly onChange: (attachments: RA<SerializedResource<Attachment>>) => void;
readonly onChange: (
attachment: SerializedResource<Attachment>,
index: number
) => void;
}): JSX.Element {
const containerRef = React.useRef<HTMLElement | null>(null);

Expand Down Expand Up @@ -61,6 +66,7 @@ export function AttachmentGallery({
const [openIndex, setOpenIndex] = React.useState<number | undefined>(
undefined
);

const [related, setRelated] = React.useState<
RA<SpecifyResource<AnySchema> | undefined>
>([]);
Expand Down Expand Up @@ -121,7 +127,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={
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React from 'react';

import { useAsyncState } from '../../hooks/useAsyncState';
import { useCachedState } from '../../hooks/useCachedState';
import { attachmentsText } from '../../localization/attachments';
import { commonText } from '../../localization/common';
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 } from '../Molecules/Dialog';
import { defaultScale } from '.';
import { AttachmentGallery } from './Gallery';

export function RecordSetAttachments<SCHEMA extends AnySchema>({
records,
onClose: handleClose,
onFetch: handleFetch,
}: {
readonly records: RA<SpecifyResource<SCHEMA> | undefined>;
readonly onClose: () => void;
readonly onFetch?:
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
| ((index: number) => Promise<RA<number | undefined> | void>)
| undefined;
}): JSX.Element {
const recordFetched = React.useRef<number>(0);
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved

const [attachments] = useAsyncState(
React.useCallback(async () => {
const relatedAttachementRecords = await Promise.all(
records.map((record) =>
record
?.rgetCollection(`${record.specifyModel.name}Attachments`)
.then(
({ models }) =>
models as RA<SpecifyResource<CollectionObjectAttachment>>
)
)
);

const fetchCount = records.findIndex(
(record) => record?.populated !== true
);

recordFetched.current = fetchCount === -1 ? records.length : fetchCount;

return Promise.all(
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
filterArray(relatedAttachementRecords.flat()).map(
async (collectionObjectAttachment) => ({
attachment: await collectionObjectAttachment
.rgetPromise('attachment')
.then((resource) => serializeResource(resource)),
related: collectionObjectAttachment,
})
)
);
}, [records]),
true
);

const [haltValue, setHaltValue] = React.useState(300);
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
const halt = attachments?.length === 0 && records.length >= haltValue;

const [scale = defaultScale] = useCachedState('attachments', 'scale');

const children = halt ? (
haltValue === records.length ? (
<>{attachmentsText.noAttachments()}</>
) : (
<div className="flex flex-col gap-4">
{attachmentsText.attachmentHaltLimit({ halt: haltValue })}
<Button.Orange
onClick={() => {
if (haltValue + 300 > records.length) {
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
setHaltValue(records.length);
} else {
setHaltValue(haltValue + 300);
}
}}
>
{attachmentsText.fetchNextAttachments()}
</Button.Orange>
</div>
)
) : (
<AttachmentGallery
attachments={attachments?.map(({ attachment }) => attachment) ?? []}
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
isComplete={recordFetched.current === records.length}
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
scale={scale}
onChange={(attachment, index): void =>
void attachments?.[index].related.set(`attachment`, attachment)
}
onFetchMore={
attachments === undefined || handleFetch === undefined || halt
? undefined
: async () => handleFetch?.(recordFetched.current)
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
}
/>
);

return (
<Dialog
buttons={<Button.DialogClose>{commonText.close()}</Button.DialogClose>}
header={attachmentsText.attachments()}
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
onClose={handleClose}
>
{children}
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
</Dialog>
);
}
10 changes: 7 additions & 3 deletions specifyweb/frontend/js_src/lib/components/Attachments/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ProtectedTable } from '../Permissions/PermissionDenied';
import { OrderPicker } from '../Preferences/Renderers';
import { attachmentSettingsPromise } from './attachments';
import { AttachmentGallery } from './Gallery';
import { replaceItem } from '../../utils/utils';

export const attachmentRelatedTables = f.store(() =>
Object.keys(schema.models).filter((tableName) =>
Expand All @@ -47,7 +48,7 @@ export const tablesWithAttachments = f.store(() =>
)
);

const defaultScale = 10;
export const defaultScale = 10;
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
const minScale = 4;
const maxScale = 50;
const defaultSortOrder = '-timestampCreated';
Expand Down Expand Up @@ -242,10 +243,13 @@ function Attachments(): JSX.Element {
}
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,
})
}
onFetchMore={collection === undefined ? undefined : fetchMore}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import { hasTablePermission } from '../Permissions/helpers';
import { SetUnloadProtectsContext } from '../Router/Router';
import type { RecordSelectorProps } from './RecordSelector';
import { useRecordSelector } from './RecordSelector';
import { RecordSetAttachments } from '../Attachments/RecordSetAttachment';
import { attachmentsText } from '../../localization/attachments';
import { tablesWithAttachments } from '../Attachments';

/**
* A Wrapper for RecordSelector that allows to specify list of records by their
Expand All @@ -45,6 +48,7 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
onAdd: handleAdd,
onClone: handleClone,
onDelete: handleDelete,
onFetch: handleFetch,
...rest
}: Omit<RecordSelectorProps<SCHEMA>, 'index' | 'records'> & {
/*
Expand All @@ -69,13 +73,18 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
readonly onClone:
| ((newResource: SpecifyResource<SCHEMA>) => void)
| undefined;
readonly onFetch?: (
index: number
) => Promise<undefined | RA<number | undefined>>;
}): JSX.Element | null {
const [records, setRecords] = React.useState<
RA<SpecifyResource<SCHEMA> | undefined>
>(() =>
ids.map((id) => (id === undefined ? undefined : new model.Resource({ id })))
);

const [attachmentState, setAttachmentState] = React.useState(false);
maxpatiiuk marked this conversation as resolved.
Show resolved Hide resolved

const previousIds = React.useRef(ids);
React.useEffect(() => {
setRecords((records) =>
Expand Down Expand Up @@ -183,6 +192,9 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
recordSetTable: schema.models.RecordSet.label,
})
: commonText.delete();

const hasAttachments = tablesWithAttachments().includes(model);

return (
<>
<ResourceView
Expand All @@ -191,13 +203,11 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
<div className="flex flex-col items-center gap-2 md:contents md:flex-row md:gap-8">
<div className="flex items-center gap-2 md:contents">
{headerButtons}

<DataEntry.Visit
resource={
!isDependent && dialog !== false ? resource : undefined
}
/>

{hasTablePermission(
model.name,
isDependent ? 'create' : 'read'
Expand All @@ -209,7 +219,6 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
onClick={handleAdding}
/>
) : undefined}

{typeof handleRemove === 'function' && canRemove ? (
<DataEntry.Remove
aria-label={removeLabel}
Expand All @@ -218,15 +227,25 @@ export function RecordSelectorFromIds<SCHEMA extends AnySchema>({
onClick={(): void => handleRemove('minusButton')}
/>
) : undefined}

{typeof newResource === 'object' ? (
<p className="flex-1">{formsText.creatingNewRecord()}</p>
) : (
<span
className={`flex-1 ${dialog === false ? '-ml-2' : '-ml-4'}`}
/>
)}

{hasAttachments && (
maxpatiiuk marked this conversation as resolved.
Show resolved Hide resolved
<Button.Blue onClick={() => setAttachmentState(true)}>
{attachmentsText.attachments()}
CarolineDenis marked this conversation as resolved.
Show resolved Hide resolved
</Button.Blue>
)}
{attachmentState === true ? (
<RecordSetAttachments
records={records}
onClose={() => setAttachmentState(!attachmentState)}
onFetch={handleFetch}
/>
) : null}
{specifyNetworkBadge}
</div>
<div>{slider}</div>
Expand Down
47 changes: 31 additions & 16 deletions specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ function RecordSet<SCHEMA extends AnySchema>({
replace: boolean = false
): void =>
recordId === undefined
? handleFetch(index)
? handleFetchMore(index)
: navigate(
getResourceViewUrl(
currentRecord.specifyModel.name,
Expand All @@ -220,40 +220,54 @@ function RecordSet<SCHEMA extends AnySchema>({

const previousIndex = React.useRef<number>(currentIndex);
const [isLoading, handleLoading, handleLoaded] = useBooleanState();

const handleFetch = React.useCallback(
(index: number): void => {
if (index >= totalCount) return;
async (index: number): Promise<RA<number | undefined> | 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(
0,
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();
Expand Down Expand Up @@ -373,6 +387,7 @@ function RecordSet<SCHEMA extends AnySchema>({
}
: undefined
}
onFetch={handleFetch}
onSaved={(resource): void =>
ids[currentIndex] === resource.id
? undefined
Expand Down
10 changes: 8 additions & 2 deletions specifyweb/frontend/js_src/lib/components/Molecules/AppTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ import { mainText } from '../../localization/main';
import { userPreferences } from '../Preferences/userPreferences';
import { UnloadProtectsContext } from '../Router/Router';

export function AppTitle({ title }: { readonly title: LocalizedString }): null {
export function AppTitle({
title,
source = 'form',
}: {
readonly title: LocalizedString;
readonly source?: 'form' | undefined;
}): null {
const [updateTitle] = userPreferences.use(
'form',
'behavior',
'updatePageTitle'
);
useTitle(updateTitle ? title : undefined);
useTitle(source !== 'form' && updateTitle ? title : undefined);
return null;
}

Expand Down
Loading