cspace-ui
Version:
CollectionSpace user interface for browsers
447 lines (389 loc) • 13.5 kB
JSX
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { batch, useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import Immutable from 'immutable';
import qs from 'qs';
import get from 'lodash/get';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import classNames from 'classnames';
import {
SEARCH_RESULT_GRID_VIEW,
SEARCH_RESULT_LIST_VIEW,
SEARCH_RESULT_PAGE_SEARCH_NAME,
SEARCH_RESULT_TABLE_VIEW,
} from '../../../constants/searchNames';
import SearchResultTitleBar from '../../search/SearchResultTitleBar';
import SearchResultFooter from '../../search/SearchResultFooter';
import SearchResultTable from '../../search/table/SearchTable';
import SearchResultGrid from '../../search/grid/SearchResultGrid';
import SearchDetailList from '../../search/list/SearchList';
import SearchResultSidebar from '../../search/SearchResultSidebar';
import SearchResultSummary from '../../search/SearchResultSummary';
import SortBy from '../../search/SortBy';
import { ToggleButton, ToggleButtonContainer } from '../../search/header/ToggleButtons';
import { useConfig } from '../../config/ConfigProvider';
import styles from '../../../../styles/cspace-ui/SearchResults.css';
import buttonBarStyles from '../../../../styles/cspace-ui/ButtonBar.css';
import {
setSearchPageRecordType,
setSearchPageVocabulary,
setSearchResultPagePageSize,
setSearchResultPageView,
} from '../../../actions/prefs';
import {
search, setAllResultItemsSelected,
} from '../../../actions/search';
import {
getSearchResult, isSearchResultSidebarOpen, getSearchSelectedItems, getUserPerms,
getSearchResultPageView,
} from '../../../reducers';
import SelectBar from '../../search/SelectBar';
import RelateResults from '../../search/RelateResults';
import ExportResults from '../../search/ExportResults';
import {
getListTypeFromResult,
createPageSizeChangeHandler,
extractAdvancedSearchGroupedTerms,
createSortByHandler,
createSortDirHandler,
normalizeSearchQueryParams,
} from '../../../helpers/searchHelpers';
import {
setSearchPageAdvanced,
setSearchPageAdvancedLimitBy,
setSearchPageAdvancedSearchTerms,
setSearchPageKeyword,
} from '../../../actions/searchPage';
const selectBarPropTypes = {
toggleBar: PropTypes.object,
searchResult: PropTypes.instanceOf(Immutable.Map),
config: PropTypes.object,
searchDescriptor: PropTypes.instanceOf(Immutable.Map),
};
export function SelectExportRelateToggleBar({
toggleBar, searchResult, config, searchDescriptor,
}) {
if (!searchResult) {
return null;
}
const selectedItems = useSelector((state) => getSearchSelectedItems(state,
SEARCH_RESULT_PAGE_SEARCH_NAME));
const perms = useSelector((state) => getUserPerms(state));
const dispatch = useDispatch();
// button bar (relate/export)
const exportButton = (
<ExportResults
config={config}
selectedItems={selectedItems}
searchDescriptor={searchDescriptor}
/>
);
const relateButton = (
<RelateResults
config={config}
selectedItems={selectedItems}
searchDescriptor={searchDescriptor}
perms={perms}
disabled={false}
key="relate"
/>
);
const buttonBar = (
<div className={buttonBarStyles.common}>
{relateButton}
{exportButton}
</div>
);
const listType = getListTypeFromResult(config, searchResult);
return (
<SelectBar
config={config}
listType={listType}
searchDescriptor={searchDescriptor}
searchName={SEARCH_RESULT_PAGE_SEARCH_NAME}
searchResult={searchResult}
selectedItems={selectedItems}
setAllItemsSelected={
(...args) => dispatch(setAllResultItemsSelected(...args))
}
>
{buttonBar}
{toggleBar}
</SelectBar>
);
}
// memoize?
const getSearchDescriptor = (query, props) => {
const {
match,
} = props;
const {
params,
} = match;
const { view, ...queryWithoutView } = query;
const searchQuery = {
...queryWithoutView,
p: parseInt(query.p, 10) - 1,
size: parseInt(query.size, 10),
};
const advancedSearchCondition = query.as;
if (advancedSearchCondition) {
searchQuery.as = JSON.parse(advancedSearchCondition);
}
const searchDescriptor = {
searchQuery,
};
['recordType', 'vocabulary', 'csid', 'subresource'].forEach((param) => {
const value = params[param];
if (typeof value !== 'undefined') {
searchDescriptor[param] = value;
}
});
return Immutable.fromJS(searchDescriptor);
};
function setPreferredPageSize(props, dispatch) {
const {
location,
} = props;
const {
search: searchFromLoc,
} = location;
const query = qs.parse(searchFromLoc.substring(1));
dispatch(setSearchResultPagePageSize(parseInt(query.size, 10)));
}
function normalizeQuery(props, config) {
const {
location,
preferredPageSize,
} = props;
const {
search: searchFromLoc,
} = location;
const query = qs.parse(searchFromLoc.substring(1));
const { normalizedQuery } = normalizeSearchQueryParams(
query,
preferredPageSize,
config.defaultSearchPageSize,
);
return normalizedQuery;
}
/**
* Gets the current view which we want to display. We prioritize
* query parameters first, then the last view a user was on (in redux),
* and finally fall back to the table view.
*/
const currentView = (props) => {
const {
history,
location,
} = props;
const {
search: searchFromLoc,
} = location;
const preferred = useSelector((state) => getSearchResultPageView(state));
let current;
const query = qs.parse(searchFromLoc.substring(1));
if (query) {
current = query.view;
const hasCurrent = current && (
current === SEARCH_RESULT_GRID_VIEW
|| current === SEARCH_RESULT_LIST_VIEW
|| current === SEARCH_RESULT_TABLE_VIEW
);
// update the query parameter to include our view
if (preferred && !hasCurrent) {
query.view = preferred;
history.push({
pathname: location.pathname,
search: `?${qs.stringify(query)}`,
state: location.state,
});
}
}
return current || preferred || SEARCH_RESULT_TABLE_VIEW;
};
const messages = defineMessages({
table: {
id: 'search.result.view.table',
description: 'The table button label',
defaultMessage: 'Switch to table view',
},
detailList: {
id: 'search.result.view.detailList',
description: 'The detailList button label',
defaultMessage: 'Switch to detail list view',
},
grid: {
id: 'search.result.view.grid',
description: 'The grid button label',
defaultMessage: 'Switch to grid view',
},
});
/**
* The page for displaying Search Results. Before rendering it first executes the search based on
* the query parameters provided by encapsulating them in a search descriptor and calling the search
* action in redux.
*
* Currently this is more for the new search views on CollectionObjects which includes a grid and
* detail based view compared to the older table based view. Ideally this be the only component for
* displaying search results but we first would need to make sure we only display the views which
* are supported for a given procedure or authority.
*
* @param {*} props
* @returns the SearchResults page component
*/
function SearchResults(props) {
const config = useConfig();
const dispatch = useDispatch();
const history = useHistory();
const { intl, location } = props;
const normalizedQuery = normalizeQuery(props, config);
const searchDescriptor = getSearchDescriptor(normalizedQuery, props);
const searchResults = useSelector((state) => getSearchResult(state,
SEARCH_RESULT_PAGE_SEARCH_NAME,
searchDescriptor));
const isSidebarOpen = useSelector((state) => isSearchResultSidebarOpen(state));
const display = currentView(props);
useEffect(() => {
setPreferredPageSize(props, dispatch);
dispatch(search(config, SEARCH_RESULT_PAGE_SEARCH_NAME, searchDescriptor));
}, [searchDescriptor.toString()]);
const handlePageSizeChange = createPageSizeChangeHandler({
history,
location,
dispatch,
setPreferredPageSize: setSearchResultPagePageSize,
});
const handleEditSearchLinkClick = () => {
// Transfer the search descriptor from this search to the search page. If this search
// originated from the search page, the original descriptor will be in the location state.
// Otherwise, build it from the URL params. If present, the search descriptor from the
// originating search page will be more complete than one constructed from the URL; for
// example, it will contain fields that are blank, which will have been removed from the
// URL, to reduce the size.
const origin = get(location.state, 'originSearchPage');
const conditionalSearchDescriptor = origin
? Immutable.fromJS(origin.searchDescriptor)
: getSearchDescriptor(normalizedQuery, props);
const searchQuery = conditionalSearchDescriptor.get('searchQuery');
batch(() => {
dispatch(setSearchPageRecordType(conditionalSearchDescriptor.get('recordType')));
dispatch(setSearchPageVocabulary(conditionalSearchDescriptor.get('vocabulary')));
dispatch(setSearchPageKeyword(searchQuery.get('kw')));
dispatch(setSearchPageAdvanced(searchQuery.get('as')));
dispatch(setSearchPageAdvancedLimitBy(extractAdvancedSearchGroupedTerms(searchQuery.get('as')).limitBy));
dispatch(setSearchPageAdvancedSearchTerms(extractAdvancedSearchGroupedTerms(searchQuery.get('as')).searchTerms));
});
};
const handleSortChange = createSortByHandler({ history, location });
const handleSortDirChange = createSortDirHandler({ history, location });
const renderSortBy = () => (
<SortBy
onSortChange={handleSortChange}
onSortDirChange={handleSortDirChange}
sort={normalizedQuery?.sort}
recordType={searchDescriptor.get('recordType')}
/>
);
const gridLabel = intl.formatMessage(messages.grid);
const tableLabel = intl.formatMessage(messages.table);
const detailListLabel = intl.formatMessage(messages.detailList);
const toggles = [
{ key: SEARCH_RESULT_TABLE_VIEW, label: tableLabel, icon: 'format_list_bulleted' },
{ key: SEARCH_RESULT_GRID_VIEW, label: gridLabel, icon: 'grid_view' },
{
key: SEARCH_RESULT_LIST_VIEW, label: detailListLabel, icon: 'vertical_split', class: styles.detailList,
},
];
const updatePageView = (key) => {
if (history && location) {
const { search: searchFromLoc } = location;
const query = qs.parse(searchFromLoc.substring(1));
query.view = key;
const queryString = qs.stringify(query);
history.push({
pathname: location.pathname,
search: `?${queryString}`,
state: location.state,
});
}
dispatch(setSearchResultPageView(key));
};
const displayToggles = (
<ToggleButtonContainer
items={toggles}
renderButton={(item) => (
<ToggleButton
icon={item.icon}
key={item.key}
name={item.key}
title={item.label}
className={classNames(styles.toggle, item.class)}
onClick={() => updatePageView(item.key)}
/>
)}
/>
);
let searchDisplay;
if (display === SEARCH_RESULT_GRID_VIEW) {
searchDisplay = <SearchResultGrid searchDescriptor={searchDescriptor} />;
} else if (display === SEARCH_RESULT_LIST_VIEW) {
searchDisplay = <SearchDetailList searchDescriptor={searchDescriptor} />;
} else {
searchDisplay = <SearchResultTable searchDescriptor={searchDescriptor} />;
}
const handleBatchInvokeComplete = () => {
dispatch(search(config, SEARCH_RESULT_PAGE_SEARCH_NAME, searchDescriptor));
};
const sidebar = (
<SearchResultSidebar
config={config}
history={history}
isOpen={isSidebarOpen}
onInvokeComplete={handleBatchInvokeComplete}
recordType={searchDescriptor.get('recordType')}
/>
);
return (
<main className={styles.common}>
<SearchResultTitleBar
config={config}
searchDescriptor={searchDescriptor}
searchName={SEARCH_RESULT_PAGE_SEARCH_NAME}
updateDocumentTitle
/>
<div className={isSidebarOpen ? styles.body : styles.full}>
{/* SearchResultHeader? */}
<div className={styles.results}>
<header>
<SearchResultSummary
config={config}
searchName={SEARCH_RESULT_PAGE_SEARCH_NAME}
searchDescriptor={searchDescriptor}
onPageSizeChange={handlePageSizeChange}
onEditSearchLinkClick={handleEditSearchLinkClick}
renderSortBy={() => renderSortBy()}
/>
<SelectExportRelateToggleBar
toggleBar={displayToggles}
searchResult={searchResults}
config={config}
searchDescriptor={searchDescriptor}
/>
</header>
{searchDisplay}
<SearchResultFooter searchDescriptor={searchDescriptor} />
</div>
{sidebar}
</div>
</main>
);
}
const searchResultsPropTypes = {
intl: intlShape,
location: PropTypes.object.isRequired,
};
SearchResults.propTypes = searchResultsPropTypes;
SelectExportRelateToggleBar.propTypes = selectBarPropTypes;
export default injectIntl(SearchResults);