Skip to content

Commit

Permalink
Improve identifier picker. Enable import from a file on touch #450
Browse files Browse the repository at this point in the history
  • Loading branch information
tnajdek committed Dec 2, 2024
1 parent 5f52fc5 commit 4efe041
Show file tree
Hide file tree
Showing 8 changed files with 145 additions and 68 deletions.
13 changes: 8 additions & 5 deletions src/js/actions/identifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const searchIdentifier = (identifier, { shouldImport = false } = {}) => {
url = `${translateUrl}/import`;
}

dispatch({ type: REQUEST_ADD_BY_IDENTIFIER, identifier, identifierIsUrl });
dispatch({ type: REQUEST_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, import: shouldImport });

try {
const response = await fetch(url, {
Expand All @@ -83,20 +83,21 @@ const searchIdentifier = (identifier, { shouldImport = false } = {}) => {
identifierIsUrl,
identifier,
items,
import: shouldImport,
response
});
return items;
} else if(response.status !== 200) {
const message = 'Unexpected response from the server.';
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message });
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message, import: shouldImport });
} else if (!response.headers.get('content-type').startsWith('application/json')) {
const message = 'Unexpected response from the server.';
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message });
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message, import: shouldImport });
} else {
const json = await response.json();
if (!json.length) {
const message = 'Zotero could not find any identifiers in your input. Please verify your input and try again.';
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message });
dispatch({ type: RECEIVE_ADD_BY_IDENTIFIER, identifier, identifierIsUrl, result: EMPTY, message, import: shouldImport });
} else {
const rootItems = json.filter(item => !item.parentItem);

Expand All @@ -107,6 +108,7 @@ const searchIdentifier = (identifier, { shouldImport = false } = {}) => {
items: rootItems.map(ri => omit(ri, ['key', 'version'])),
identifierIsUrl,
identifier,
import: shouldImport,
response
});
return rootItems;
Expand All @@ -121,14 +123,15 @@ const searchIdentifier = (identifier, { shouldImport = false } = {}) => {
item,
identifier,
identifierIsUrl,
import: shouldImport,
response
});
return item;
}
}
}
} catch(error) {
dispatch({ type: ERROR_ADD_BY_IDENTIFIER, error, identifier });
dispatch({ type: ERROR_ADD_BY_IDENTIFIER, error, identifier, import: shouldImport });
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/js/component/item/actions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ const ItemActionsTouch = memo(() => {
<DropdownItem onClick={ handleAddByIdentifierModalOpen } >
Add By Identifier
</DropdownItem>
<ImportAction />
</Fragment>
)}
{ !isEmbedded && (
Expand Down
2 changes: 1 addition & 1 deletion src/js/component/item/actions/add-by-identifier.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ const AddByIdentifier = props => {
}, [addItem, isOpen, item, prevItem]);

useEffect(() => {
if(items && prevItems === null && [CHOICE, CHOICE_EXHAUSTED, MULTIPLE].includes(result)) {
if (isOpen && items && prevItems === null && [CHOICE, CHOICE_EXHAUSTED, MULTIPLE].includes(result)) {
setIsOpen(!isOpen);
dispatch(toggleModal(IDENTIFIER_PICKER, true));
}
Expand Down
43 changes: 36 additions & 7 deletions src/js/component/item/actions/import.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import PropTypes from 'prop-types';
import { memo, useCallback, useId, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { Button, Icon } from 'web-common/components';
import { useDispatch, useSelector } from 'react-redux';
import { Button, DropdownItem, Icon } from 'web-common/components';

import { importFromFile } from '../../../actions';
import { importFromFile, toggleModal } from '../../../actions';
import { getFileData } from '../../../common/event';
import { IDENTIFIER_PICKER } from '../../../constants/modals';


const ImportAction = ({ disabled, onFocusNext, onFocusPrev, tabIndex }) => {
const dispatch = useDispatch();
const isTouchOrSmall = useSelector(state => state.device.isTouchOrSmall);
const uploadFileId = useId();
const fileInputRef = useRef(null);

Expand All @@ -24,18 +26,45 @@ const ImportAction = ({ disabled, onFocusNext, onFocusPrev, tabIndex }) => {
}
}, [onFocusNext, onFocusPrev]);

const handleImportClick = useCallback(() => {
fileInputRef.current.click();
const handleImportClick = useCallback(ev => {
if (ev.currentTarget === ev.target) {
fileInputRef.current.click();
}
ev.stopPropagation();
}, []);

const handleFileInputChange = useCallback(async ev => {
const fileData = await getFileData(ev.currentTarget.files[0]);
const target = ev.currentTarget; // persist, or it will be nullified after await
const fileData = await getFileData(target.files[0]);
target.value = ''; // clear the invisible input so that onChange is triggered even if the same file is selected again
if (fileData) {
dispatch(importFromFile(fileData));
dispatch(toggleModal(IDENTIFIER_PICKER, true));
}
}, [dispatch]);

return (
return isTouchOrSmall ? (
<DropdownItem
onClick={handleImportClick}
className="btn-file"
aria-labelledby={uploadFileId}
>
<span
id={uploadFileId}
className="flex-row align-items-center"
>
Import From a File
</span>
<input
aria-labelledby={uploadFileId}
multiple={false}
onChange={handleFileInputChange}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
</DropdownItem>
) : (
<div className="btn-file">
<input
aria-labelledby={uploadFileId}
Expand Down
102 changes: 66 additions & 36 deletions src/js/component/modal/identifier-picker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback, useEffect, memo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { Button, Spinner } from 'web-common/components';
import { Button, Icon, Spinner } from 'web-common/components';
import { usePrevious } from 'web-common/hooks';

import Modal from '../ui/modal';
Expand All @@ -12,6 +12,7 @@ import { useBufferGate } from '../../hooks';
import { getUniqueId, processIdentifierMultipleItems } from '../../utils';
import { getBaseMappedValue } from '../../common/item';
import { CHOICE } from '../../constants/identifier-result-types';
import { pluralize } from '../../common/format';

const Item = memo(({ onChange, identifierIsUrl, isPicked, item, mappings }) => {
const { description } = item;
Expand Down Expand Up @@ -92,6 +93,7 @@ const IdentifierPicker = () => {
const isOpen = useSelector(state => state.modal.id === IDENTIFIER_PICKER);
const itemTypes = useSelector(state => state.meta.itemTypes);
const items = useSelector(state => state.identifier.items);
const isImport = useSelector(state => state.identifier.import);
const isSearchingMultiple = useSelector(state => state.identifier.isSearchingMultiple);
const identifierIsUrl = useSelector(state => state.identifier.identifierIsUrl);
const identifierResult = useSelector(state => state.identifier.result);
Expand All @@ -101,7 +103,9 @@ const IdentifierPicker = () => {
const wasSearchingMultiple = usePrevious(isSearchingMultiple);
const processedItems = items && processIdentifierMultipleItems(items, itemTypes, false); //@TODO: isUrl source should be stored in redux
const [selectedKeys, setSelectedKeys] = useState([]);
const isBusy = useBufferGate(!wasSearchingMultiple && isSearchingMultiple, 200);
const isBusy = useBufferGate((isImport && isSearching) || (!wasSearchingMultiple && isSearchingMultiple), 200);
const isReady = isOpen && !isBusy;
const wasReady = usePrevious(isReady);

const handleCancel = useCallback(() => {
dispatch(toggleModal(IDENTIFIER_PICKER, false));
Expand All @@ -122,12 +126,26 @@ const IdentifierPicker = () => {
dispatch(searchIdentifierMore());
}, [dispatch]);

const handleSelectAll = useCallback(() => {
setSelectedKeys(processedItems.map(i => i.key));
}, [processedItems]);

const handleClearSelection = useCallback(() => {
setSelectedKeys([]);
}, []);

useEffect(() => {
if(wasSearchingMultiple && !isSearchingMultiple) {
dispatch(toggleModal(IDENTIFIER_PICKER, false));
}
}, [dispatch, isSearchingMultiple, wasSearchingMultiple]);

useEffect(() => {
if(!wasReady && isReady) {
handleSelectAll();
}
}, [handleSelectAll, isReady, wasReady]);

const className = cx({
'identifier-picker-modal modal-scrollable modal-lg': true,
'modal-touch': isTouchOrSmall
Expand All @@ -142,30 +160,20 @@ const IdentifierPicker = () => {
onRequestClose={ handleCancel }
>
<div className="modal-header">
<div className="modal-header-left">
<Button
className="btn-link"
onClick={ handleCancel }
>
Cancel
</Button>
</div>
<div className="modal-header-center">
<h4 className="modal-title truncate">
Select Items
</h4>
</div>
<div className="modal-header-right">
<Button
onClick={ handleAddSelected }
className="btn-link"
>
Add
</Button>
</div>
<h4 className="modal-title truncate">
Select Items
</h4>
<Button
icon
className="close"
onClick={handleCancel}
title="Close Dialog"
>
<Icon type={'16/close'} width="16" height="16" />
</Button>
</div>
<div className="modal-body">
<div className="results">
<ul className="results">
{ Array.isArray(processedItems) && processedItems
.map(item => <Item
identifierIsUrl={ identifierIsUrl }
Expand All @@ -176,19 +184,41 @@ const IdentifierPicker = () => {
onChange={ handleItemChange }
/>)
}
</ul>
</div>
<div className="modal-footer">
<div className="modal-footer-left">
<Button className="btn btn-link" onClick={handleSelectAll} tabIndex={-2}>
Select All
</Button>
<Button className="btn btn-link" onClick={handleClearSelection} tabIndex={-2}>
Clear Selection
</Button>
</div>
{ identifierResult === CHOICE && (
<div className="modal-footer-center">
{ isSearching ? <Spinner /> : (
<Button
className="btn btn btn-lg btn-secondary more-button"
onClick={ handleSearchMore }
>
More
</Button>
) }
</div>
) }
<div className="modal-footer-right">
<Button
disabled={selectedKeys.length === 0}
className="btn-outline-secondary btn-min-width"
onClick={ handleAddSelected }
tabIndex={-2}
>
{
selectedKeys.length > 0 ? `Add ${selectedKeys.length} ${pluralize('Item', selectedKeys.length)}` : 'Add Selected'
}
</Button>
</div>
{ identifierResult === CHOICE && (
<div className="more-button-wrap">
{ isSearching ? <Spinner /> : (
<Button
className="btn btn btn-lg btn-secondary more-button"
onClick={ handleSearchMore }
>
More
</Button>
) }
</div>
)}
</div>
</Modal>
);
Expand Down
4 changes: 4 additions & 0 deletions src/js/reducers/identifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const defaultState = {
items: null,
identifier: null,
identifierIsUrl: null,
import: false,
next: null,
}

Expand All @@ -32,6 +33,7 @@ const identifier = (state = defaultState, action) => {
result: null,
item: null,
items: null,
import: action.import,
next: null,
};
case RECEIVE_ADD_BY_IDENTIFIER:
Expand All @@ -44,6 +46,7 @@ const identifier = (state = defaultState, action) => {
result: action.result,
item: action.item || null,
items: action.items || null,
import: action.import,
next: action.next,
};
case ERROR_ADD_BY_IDENTIFIER:
Expand All @@ -56,6 +59,7 @@ const identifier = (state = defaultState, action) => {
item: null,
items: null,
identifierIsUrl: null,
import: action.import,
next: null,
};
case REQUEST_IDENTIFIER_MORE:
Expand Down
18 changes: 14 additions & 4 deletions src/scss/components/modal/_identifier-picker.scss
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
//
// Multiple choice dialog
//

.identifier-picker-modal {
.modal-body {
overflow: auto;
max-height: calc(100vh - 96px);

@include mouse-and-bp-up(md) {
max-height: calc(90vh - 96px - 32px); // 90% of the screen, accounting for the modal header and modal margin
}
}

.modal-footer-left {
.btn-link + .btn-link {
margin-left: $space-sm;
}
}

.results {
margin: 0 - $modal-inner-padding;
Expand Down
Loading

0 comments on commit 4efe041

Please sign in to comment.