docbrown/app/javascript/readingList/readingList.jsx
rhymes 83852038e4
[Search 2.0] Reading list (#13052)
* Step one in populating the reading list with PG

This first attempt tries to recycle the `Search::ArticleSerializer` which is only
used in input in ES, but we're using it in output in PG.
For this reason it's currently 15.55x times slower

* Serialize only what is requested by the frontend

`Search::ArticleSerializer` which is only used in ES in the indexing step aims
to add as much info as possible for broader purposes, in this case
(with serialization in output) we should aim to save only what's requested from
the frontend.

* Optimize selection of articles columns

* Select only needed columns for users

* Compute total of reading list items

* Attach the basic filtering based on PG on the search controller

* Restructure in methods

* Add tags support

* Use LIKE on articles.cached_tag_list

* Fix tags as nil

* Fix default pagination

* Add optional FTS for reading list

* Reworded the tags comment explaining why

* Add index to reactions.status

* Fix total counter in Preact readingList component

* Fix total count in reading list backend search

* Add GIN index to articles.cached_tag_list

* Add service tests

* Add search request specs

* Added missing early return

* Update spec/requests/search_spec.rb

Co-authored-by: Julianna Tetreault <32834804+juliannatetreault@users.noreply.github.com>

* Extract MAX_PER_PAGE constant and add comments

Co-authored-by: Julianna Tetreault <32834804+juliannatetreault@users.noreply.github.com>
2021-03-24 15:40:00 +01:00

255 lines
7 KiB
JavaScript

import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import {
defaultState,
loadNextPage,
onSearchBoxType,
performInitialSearch,
search,
toggleTag,
clearSelectedTags,
} from '../searchableItemList/searchableItemList';
import { ItemListItem } from './components/ItemListItem';
import { ItemListItemArchiveButton } from './components/ItemListItemArchiveButton';
import { ItemListLoadMoreButton } from './components/ItemListLoadMoreButton';
import { ItemListTags } from './components/ItemListTags';
import { debounceAction } from '@utilities/debounceAction';
import { Button } from '@crayons';
import { request } from '@utilities/http';
const STATUS_VIEW_VALID = 'valid,confirmed';
const STATUS_VIEW_ARCHIVED = 'archived';
const READING_LIST_ARCHIVE_PATH = '/readinglist/archive';
const READING_LIST_PATH = '/readinglist';
const FilterText = ({ selectedTags, query, value }) => {
return (
<h2 className="fw-bold fs-l">
{selectedTags.length === 0 && query.length === 0
? value
: 'Nothing with this filter 🤔'}
</h2>
);
};
export class ReadingList extends Component {
constructor(props) {
super(props);
const { statusView } = this.props;
this.state = defaultState({ archiving: false, statusView });
// bind and initialize all shared functions
this.onSearchBoxType = debounceAction(onSearchBoxType.bind(this), {
leading: true,
});
this.loadNextPage = loadNextPage.bind(this);
this.performInitialSearch = performInitialSearch.bind(this);
this.search = search.bind(this);
this.toggleTag = toggleTag.bind(this);
this.clearSelectedTags = clearSelectedTags.bind(this);
}
componentDidMount() {
const { statusView } = this.state;
this.performInitialSearch({
searchOptions: { status: `${statusView}` },
});
}
toggleStatusView = (event) => {
event.preventDefault();
const { query, selectedTags } = this.state;
const isStatusViewValid = this.statusViewValid();
const newStatusView = isStatusViewValid
? STATUS_VIEW_ARCHIVED
: STATUS_VIEW_VALID;
const newPath = isStatusViewValid
? READING_LIST_ARCHIVE_PATH
: READING_LIST_PATH;
// empty items so that changing the view will start from scratch
this.setState({ statusView: newStatusView, items: [] });
this.search(query, {
page: 0,
tags: selectedTags,
statusView: newStatusView,
});
// change path in the address bar
window.history.replaceState(null, null, newPath);
};
toggleArchiveStatus = (event, item) => {
event.preventDefault();
const { statusView, items } = this.state;
request(`/reading_list_items/${item.id}`, {
method: 'PUT',
body: { current_status: statusView },
});
const newItems = items;
newItems.splice(newItems.indexOf(item), 1);
this.setState({
archiving: true,
items: newItems,
});
// hide the snackbar in a few moments
setTimeout(() => {
this.setState({ archiving: false });
}, 1000);
};
statusViewValid() {
const { statusView } = this.state;
return statusView === STATUS_VIEW_VALID;
}
renderEmptyItems() {
const { itemsLoaded, selectedTags, query } = this.state;
if (itemsLoaded && this.statusViewValid()) {
return (
<div className="align-center p-9 py-10 color-base-80">
<FilterText
selectedTags={selectedTags}
query={query}
value="Your reading list is empty"
/>
<p class="color-base-60 pt-2">
Click the{' '}
<span class="fw-bold">
bookmark reaction
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon mx-1"
xmlns="http://www.w3.org/2000/svg"
role="img"
>
<path d="M5 2h14a1 1 0 011 1v19.143a.5.5 0 01-.766.424L12 18.03l-7.234 4.536A.5.5 0 014 22.143V3a1 1 0 011-1zm13 2H6v15.432l6-3.761 6 3.761V4z" />
</svg>
</span>
when viewing a post to add it to your reading list.
</p>
</div>
);
}
return (
<div className="align-center p-9 py-10 color-base-80">
<FilterText
selectedTags={selectedTags}
query={query}
value="Your Archive is empty..."
/>
</div>
);
}
render() {
const {
items = [],
itemsTotal,
availableTags,
selectedTags,
showLoadMoreButton,
archiving,
} = this.state;
const isStatusViewValid = this.statusViewValid();
const archiveButtonLabel = isStatusViewValid ? 'Archive' : 'Unarchive';
const itemsToRender = items.map((item) => {
return (
<ItemListItem item={item}>
<ItemListItemArchiveButton
text={archiveButtonLabel}
onClick={(e) => this.toggleArchiveStatus(e, item)}
/>
</ItemListItem>
);
});
const snackBar = archiving ? (
<div className="snackbar">
{isStatusViewValid ? 'Archiving...' : 'Unarchiving...'}
</div>
) : (
''
);
return (
<section>
<header className="crayons-layout flex justify-between items-center pb-0">
<h1 class="crayons-title">
{isStatusViewValid ? 'Reading list' : 'Archive'}
{` (${itemsTotal})`}
</h1>
<div class="flex items-center">
<Button
onClick={(e) => this.toggleStatusView(e)}
className="mr-2 whitespace-nowrap"
variant="outlined"
url={READING_LIST_ARCHIVE_PATH}
tagName="a"
data-no-instant
>
{isStatusViewValid ? 'View archive' : 'View reading list'}
</Button>
<input
aria-label="Search..."
onKeyUp={this.onSearchBoxType}
placeholder="Search..."
className="crayons-textfield"
/>
</div>
</header>
<div className="crayons-layout crayons-layout--2-cols">
<ItemListTags
availableTags={availableTags}
selectedTags={selectedTags}
onClick={this.toggleTag}
/>
<main className="crayons-layout__content" id="main-content">
<div className="crayons-card mb-4">
{items.length > 0 ? itemsToRender : this.renderEmptyItems()}
</div>
<ItemListLoadMoreButton
show={showLoadMoreButton}
onClick={this.loadNextPage}
/>
</main>
{snackBar}
</div>
</section>
);
}
}
ReadingList.defaultProps = {
statusView: STATUS_VIEW_VALID,
};
ReadingList.propTypes = {
availableTags: PropTypes.arrayOf(PropTypes.string).isRequired,
statusView: PropTypes.oneOf([STATUS_VIEW_VALID, STATUS_VIEW_ARCHIVED]),
};
FilterText.propTypes = {
selectedTags: PropTypes.arrayOf(PropTypes.string).isRequired,
value: PropTypes.string.isRequired,
query: PropTypes.arrayOf(PropTypes.string).isRequired,
};