* 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>
255 lines
7 KiB
JavaScript
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,
|
|
};
|