Skip to content

Commit

Permalink
Add option to replace the attachment file. Close #469
Browse files Browse the repository at this point in the history
To avoid cramped UI, the "Open" and "Download" buttons are now combined
into a single dropdown. Adjusted existing tests and introduced a new one
to cover the extended functionality.
  • Loading branch information
tnajdek committed Feb 13, 2025
1 parent a497e55 commit 141d4bb
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 65 deletions.
36 changes: 35 additions & 1 deletion src/js/actions/items-write.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { READER_CONTENT_TYPES } from '../constants/reader';
import {
BEGIN_ONGOING,
COMPLETE_ONGOING,
CLEAR_ONGOING,
ERROR_ADD_ITEMS_TO_COLLECTION,
ERROR_ADD_TAGS_TO_ITEMS,
ERROR_CREATE_ITEM,
Expand Down Expand Up @@ -1636,6 +1637,38 @@ const createLinkedUrlAttachments = (linkedUrlItems, { collection = null, parentI
}
}

// uploads new file and updates the attachment item with the new file's metadata
const updateAttachment = (attachmentKey, fd, libraryKey) => {
return async (dispatch) => {
const patch = {
contentType: sniffForMIMEType(fd.file) || fd.contentType,
filename: fd.fileName,
title: fd.fileName,
};

const id = getUniqueId();
dispatch({
id,
data: { count: 1 },
kind: 'upload-new',
libraryKey,
type: BEGIN_ONGOING,
});

const updatePromise = dispatch(updateItem(attachmentKey, patch, libraryKey));
const uploadPromise = dispatch(uploadAttachment(attachmentKey, fd, libraryKey));
await Promise.allSettled([updatePromise, uploadPromise]);

dispatch({
id,
data: { count: 1 },
kind: 'upload-new',
libraryKey,
type: CLEAR_ONGOING, // auto-clear ongoing
});
}
}

const createItemOfType = (itemType, { collection = null } = {}) => {
return async (dispatch, getState) => {
const state = getState();
Expand Down Expand Up @@ -1677,5 +1710,6 @@ export {
updateItem,
updateItemWithMapping,
updateMultipleItems,
uploadAttachment
uploadAttachment,
updateAttachment,
};
135 changes: 89 additions & 46 deletions src/js/component/item-details/attachment-details.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { memo, Fragment, useCallback, useEffect, useRef, useId, useState } from 'react';
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
import PropTypes from 'prop-types';
import { Button, Dropdown, DropdownToggle, DropdownMenu, DropdownItem, Icon, Spinner } from 'web-common/components';
Expand All @@ -7,11 +7,12 @@ import { useFocusManager, useForceUpdate } from 'web-common/hooks';

import RichEditor from 'component/rich-editor';
import { isReaderCompatibleBrowser, get } from 'utils';
import { getAttachmentUrl, updateItem, exportAttachmentWithAnnotations } from 'actions';
import { getAttachmentUrl, updateItem, updateAttachment, exportAttachmentWithAnnotations } from 'actions';
import { makePath }from '../../common/navigation';
import { READER_CONTENT_TYPES } from '../../constants/reader';
import { extraFields } from '../../constants/item';
import Boxfields from './boxfields';
import { getFileData } from '../../common/event';

const AttachmentDetails = ({ attachmentKey, isReadOnly }) => {
const dispatch = useDispatch();
Expand All @@ -23,6 +24,7 @@ const AttachmentDetails = ({ attachmentKey, isReadOnly }) => {
);
const libraryKey = useSelector(state => state.current.libraryKey);
const isFetchingUrl = useSelector(state => get(state, ['libraries', libraryKey, 'attachmentsUrl', attachmentKey, 'isFetching'], false));
const isUploading = useSelector(state => get(state, ['libraries', libraryKey, 'attachmentsUrl', attachmentKey, 'isUploading'], false));
const url = useSelector(state => get(state, ['libraries', libraryKey, 'attachmentsUrl', attachmentKey, 'url']));
const timestamp = useSelector(state => get(state, ['libraries', libraryKey, 'attachmentsUrl', attachmentKey, 'timestamp'], 0));
const isPreppingPDF = useSelector(state => state.libraries[libraryKey]?.attachmentsExportPDF[attachmentKey]?.isFetching);
Expand Down Expand Up @@ -55,6 +57,8 @@ const AttachmentDetails = ({ attachmentKey, isReadOnly }) => {
const forceRerender = useForceUpdate();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { focusNext, focusPrev, receiveFocus, receiveBlur } = useFocusManager(downloadOptionsRef);
const uploadFileId = useId();
const fileInputRef = useRef(null);

const itemTypeFields = useSelector(state => state.meta.itemTypeFields);
const fields = [
Expand All @@ -69,9 +73,12 @@ const AttachmentDetails = ({ attachmentKey, isReadOnly }) => {
dispatch(updateItem(key, { note: newContent }));
}, [dispatch]);

const handleExport = useCallback((ev) => {
const handleExport = useCallback(async (ev) => {
ev.preventDefault();
ev.stopPropagation();
ev.currentTarget.blur();
dispatch(exportAttachmentWithAnnotations(attachmentKey));
await dispatch(exportAttachmentWithAnnotations(attachmentKey));
setIsDropdownOpen(false);
}, [attachmentKey, dispatch]);

const handleKeyDown = useCallback(ev => {
Expand All @@ -88,6 +95,14 @@ const AttachmentDetails = ({ attachmentKey, isReadOnly }) => {
setIsDropdownOpen(state => !state);
}, []);

const handleUploadNew = useCallback(async ev => {
const target = ev.currentTarget; // persist, or it will be nullified after await
const fileData = await getFileData(ev.currentTarget.files[0]);
target.value = ''; // clear the invisible input so that onChange is triggered even if the same file is selected again
clearTimeout(timeoutRef.current); // prevent any URL refreshes during the upload. Once it completes, another effect dispatches `getAttachmentUrl`
await dispatch(updateAttachment(attachmentKey, fileData, libraryKey));
}, [attachmentKey, dispatch, libraryKey]);

useEffect(() => {
if(urlIsFresh) {
const urlExpiresTimestamp = timestamp + 60000;
Expand All @@ -99,10 +114,10 @@ const AttachmentDetails = ({ attachmentKey, isReadOnly }) => {
}, [forceRerender, urlIsFresh, timestamp, attachmentKey, dispatch, isFetchingUrl]);

useEffect(() => {
if (!urlIsFresh && !isFetchingUrl && hasURL) {
if (!urlIsFresh && !isFetchingUrl && !isUploading && hasURL) {
dispatch(getAttachmentUrl(attachmentKey));
}
}, [attachmentKey, isFetchingUrl, urlIsFresh, dispatch, hasURL]);
}, [attachmentKey, isFetchingUrl, urlIsFresh, dispatch, hasURL, isUploading]);

return (
<Fragment>
Expand All @@ -119,60 +134,65 @@ const AttachmentDetails = ({ attachmentKey, isReadOnly }) => {
className="download-options"
ref={ downloadOptionsRef }
>
{(isReaderCompatibleBrowser() && isReaderCompatible) && (
<a
className="btn btn-default"
href={ openInReaderPath }
rel="noreferrer"
role="button"
target="_blank"
title="Open in Reader"
tabIndex={ isTouchOrSmall ? null : -2 }
onKeyDown={handleKeyDown }
>
Open
</a>
) }
{(isReaderCompatibleBrowser() && isPDF) ? (
{(isReaderCompatibleBrowser() && isReaderCompatible) ? (
<Dropdown
isOpen={ isDropdownOpen }
onToggle={ handleToggleDropdown }
className="btn-group"
>
{ preppedPDFURL ? (
<a
className="btn btn-default export-pdf"
download={ preppedPDFFileName }
href={ preppedPDFURL }
rel="noreferrer"
role="button"
tabIndex={ isTouchOrSmall ? null : -2 }
title="Export attachment with annotations"
onKeyDown={handleKeyDown}
>
Download
</a>
) : (
<Button
className='btn-default export-pdf'
disabled={ isPreppingPDF }
onClick={ handleExport }
tabIndex={ isTouchOrSmall ? null : -2 }
onKeyDown={handleKeyDown}
>
{isPreppingPDF ? <Fragment>&nbsp;<Spinner className="small" /></Fragment> : "Download" }
</Button>
) }
<a
className="btn btn-default"
href={openInReaderPath}
rel="noreferrer"
role="button"
target="_blank"
title="Open in Reader"
tabIndex={isTouchOrSmall ? null : -2}
onKeyDown={handleKeyDown}
>
Open
</a>
<DropdownToggle
className="btn-default btn-icon dropdown-toggle"
tabIndex={ isTouchOrSmall ? null : -2 }
onKeyDown={handleKeyDown}
title="More Download Options"
title="Download Options"
>
<Icon type="16/chevron-9" className="touch" width="16" height="16" />
<Icon type="16/chevron-7" className="mouse" width="16" height="16" />
</DropdownToggle>
<DropdownMenu>
{ isPDF && (
<>
{preppedPDFURL ? (
<DropdownItem
tag="a"
className="btn export-pdf"
download={preppedPDFFileName}
href={preppedPDFURL}
rel="noreferrer"
tabIndex={isTouchOrSmall ? null : -2}
title="Download"
onKeyDown={handleKeyDown}
role="listitem"
>
Download
</DropdownItem>
) : (
<DropdownItem
className="btn export-pdf"
disabled={isPreppingPDF}
onClick={handleExport}
tabIndex={isTouchOrSmall ? null : -2}
onKeyDown={handleKeyDown}
title="Download"
role="listitem"
>
{isPreppingPDF ? <Fragment><span>Preparing</span><Spinner className="small" /></Fragment> : "Download"}
</DropdownItem>
)}
</>
) }
<DropdownItem
className="btn"
href={ url }
Expand Down Expand Up @@ -201,6 +221,29 @@ const AttachmentDetails = ({ attachmentKey, isReadOnly }) => {
{ isReaderCompatible ? "Download (no annotations)" : "Download Attachment" }
</a>
) }
{ !isReadOnly && (
<Button
className="btn-file btn-default upload-new"
tabIndex={isTouchOrSmall ? null : -2}
onKeyDown={handleKeyDown}
>
<span
id={uploadFileId}
className="flex-row align-items-center"
>
Upload New
</span>
<input
aria-labelledby={uploadFileId}
data-no-toggle
multiple={false}
onChange={handleUploadNew}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
</Button>
) }
</div>
) }
<RichEditor
Expand Down
4 changes: 4 additions & 0 deletions src/js/component/ongoing.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ const PROCESSES = {
'undo-metadata-retrieval': {
title: 'Undoing',
getMessage: process => `${process.completed ? 'Undone' : 'Undoing'} for ${process.data.count} ${pluralize('item', process.data.count)}`,
},
'upload-new': { // when uploading a new file for an existing attachment
title: 'Uploading',
getMessage: process => `${process.completed ? 'Uploaded' : 'Uploading'}`,
}
};

Expand Down
16 changes: 14 additions & 2 deletions src/js/reducers/libraries/attachments-url.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
REQUEST_ATTACHMENT_URL, RECEIVE_ATTACHMENT_URL, ERROR_ATTACHMENT_URL
import { ERROR_ATTACHMENT_URL, ERROR_UPLOAD_ATTACHMENT, RECEIVE_ATTACHMENT_URL,
RECEIVE_UPLOAD_ATTACHMENT, REQUEST_ATTACHMENT_URL, REQUEST_UPLOAD_ATTACHMENT
} from '../../constants/actions';
import { omit } from 'web-common/utils';

const settings = (state = {}, action) => {
switch(action.type) {
Expand Down Expand Up @@ -30,6 +31,17 @@ const settings = (state = {}, action) => {
isFetching: false,
}
}
case REQUEST_UPLOAD_ATTACHMENT:
return {
...state,
[action.itemKey]: {
...(state[action.itemKey] || {}),
isUploading: true,
}
}
case RECEIVE_UPLOAD_ATTACHMENT:
case ERROR_UPLOAD_ATTACHMENT:
return omit(state, action.itemKey);
default:
return state;
}
Expand Down
5 changes: 3 additions & 2 deletions src/scss/components/_button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -284,12 +284,12 @@
z-index: 1;
}

&:not(:last-of-type) {
&:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}

&:not(:first-of-type) {
&:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
margin-left: -$input-border-width;
Expand All @@ -303,6 +303,7 @@
}

> .dropdown-toggle {

&.btn-lg {
padding-left: $input-btn-padding-x-lg - 3px; // Square button
padding-right: $input-btn-padding-x-lg - 3px;
Expand Down
16 changes: 6 additions & 10 deletions src/scss/components/attachment/_pane.scss
Original file line number Diff line number Diff line change
Expand Up @@ -131,29 +131,25 @@
.export-pdf {
min-width: 80px; // prevent button shrinking when showing spinner
display: flex;
justify-content: center;
align-items: center;

// ensure no extra border when using <a> in .btn-group
border-top-right-radius: 0;
border-bottom-right-radius: 0;
text-decoration: none;

@include touch-or-bp-down(sm) {
min-width: 106px; // prevent button shrinking when showing spinner
}
}

// ensure no extra border when using <a> in .btn-group
.export-pdf + .btn {
margin-left: -1px;
> .icon {
margin-left: $space-xs;
}
}

.btn-icon .icon {
color: $icon-color;
}

> .btn + .btn-group,
> .btn + .btn {
> .btn + .btn,
> .btn-group + .btn {
margin-left: $space-xs;
}
}
Expand Down
Loading

0 comments on commit 141d4bb

Please sign in to comment.