Fixed Reading List on Mobile + Small Improvements (#12755)

* Some div soup to semantic markup.

* Small refactor to inline mapping of available tags.

* Renamed <ItemListTags /> component to <TagList />.

* Now a select is used for picking a tag to filter on.

* Added custom Cypress command to create an article.

* Added documentation for the create article custom command.

* Removed unnecesary properties from payload to create an article.

* reading list mobile view wip.

* Reworked styles in <TagList />.

* Reworked reading list to use <MediaQuery /> component.

* Removed bottom padding from reading list header.

* styling tweaks if there are no available tags.

* Added some E2E tests.

* Removed reading list component test in favour of e2e test.

* Made breakpoint values numbers.

* Added some padding and more grid gap to filter on small screens.

* Adjusted jest coverage thresholds as we're moving some tests to e2e tests.

* Reverting a VS Code setting change caused by one of my extensions.

* First pass for E2E tests for the reading list.

* Added some more grid gap.

* Fixed load next page to send tags properly.

* Added some more tests.

* Improved label and placeholder for text filter in reading list.

* Added more tests

* Fixed media queries so it works in Chrome as well.

* Removed aside as tag filters are not complimentary information.

* Update app/javascript/readingList/components/TagList.jsx

Co-authored-by: Suzanne Aitchison <suzanne@forem.com>

* Update docs/tests/e2e-tests.md

Co-authored-by: Suzanne Aitchison <suzanne@forem.com>

* Turned off deprecated rule in jsx-a11y eslint plugin.

* Reverted to links instead of radio buttons.

* Added an all tags link and select option.

* Fixed relayout issue.

* Fixed View Archive button size.

* Fixed styling of the load more button.

* Fixed empty list issue toggling between archive and reading list.

* Fixed request changes from PR review.

* Removed CSS change that is no longer required.

* Trigger Build

* Fixed centering of items in top fieldset.

* Fixed issue with search text field resetting reading list.

* Fixed component tests for the reading list.

* Fixed empty state popping up between search queries.

* Fixed casing of fixture filenames.

* Update app/javascript/readingList/readingList.jsx

Co-authored-by: Suzanne Aitchison <suzanne@forem.com>

* Reverted change in reading list component test.

* Added missing JSDoc comment.

* Now links are in an unordered list.

* Promoted some CSS classes from the <nav /> to the <ul /> for spacing.

Co-authored-by: Suzanne Aitchison <suzanne@forem.com>
This commit is contained in:
Nick Taylor 2021-04-08 07:53:43 -04:00 committed by GitHub
parent 1522b6d41e
commit 86deddbb75
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 890 additions and 290 deletions

View file

@ -5,6 +5,17 @@ import fetch from 'jest-fetch-mock';
import { ReadingList } from '../readingList';
describe('<ReadingList />', () => {
beforeAll(() => {
global.window.matchMedia = jest.fn((query) => {
return {
matches: false,
media: query,
addListener: jest.fn(),
removeListener: jest.fn(),
};
});
});
const getMockResponse = () =>
JSON.stringify({
result: [
@ -62,16 +73,4 @@ describe('<ReadingList />', () => {
expect(results).toHaveNoViolations();
});
it('renders all the elements', () => {
const { queryByPlaceholderText, queryByText } = render(
<ReadingList availableTags={['discuss']} />,
);
expect(queryByPlaceholderText('search your list')).toBeDefined();
expect(queryByText('#discuss')).toBeDefined();
expect(queryByText('View Archive')).toBeDefined();
expect(queryByText('Your Archive List is Lonely')).toBeDefined();
expect(queryByText('Reading List (empty)')).toBeDefined();
});
});

View file

@ -1,23 +0,0 @@
// Load more button for item list
import { h } from 'preact';
import PropTypes from 'prop-types';
import { Button } from '@crayons';
export const ItemListLoadMoreButton = ({ show, onClick }) => {
if (!show) {
return '';
}
return (
<div>
<Button onClick={onClick} className="w-100" variant="secondary">
Load more
</Button>
</div>
);
};
ItemListLoadMoreButton.propTypes = {
show: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
};

View file

@ -1,29 +0,0 @@
// Sidebar tags for item list page
import { h } from 'preact';
import PropTypes from 'prop-types';
export const ItemListTags = ({ availableTags, selectedTags, onClick }) => {
const tagsHTML = availableTags.map((tag) => (
<a
className={`crayons-link crayons-link--block ${
selectedTags.indexOf(tag) > -1 ? 'crayons-link--current' : ''
}`}
href={`/t/${tag}`}
data-no-instant
onClick={(e) => onClick(e, tag)}
>
{`#${tag}`}
</a>
));
return (
<nav className="crayons-layout__sidebar-left" data-testid="tags">
{tagsHTML}
</nav>
);
};
ItemListTags.propTypes = {
availableTags: PropTypes.arrayOf(PropTypes.string).isRequired,
selectedTags: PropTypes.arrayOf(PropTypes.string).isRequired,
onClick: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,88 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
function LargeScreenTagList({ availableTags, selectedTag, onSelectTag }) {
return (
<nav aria-label="Filter by tag">
<ul className="list-none grid grid-cols-1 gap-2">
<li>
<a
className={`crayons-link crayons-link--block${
!selectedTag ? ' crayons-link--current' : ''
}`}
data-no-instant
onClick={onSelectTag}
href="/t"
>
all tags
</a>
</li>
{availableTags.map((tag) => (
<li>
<a
className={`crayons-link crayons-link--block${
selectedTag === tag ? ' crayons-link--current' : ''
}`}
data-no-instant
data-tag={tag}
onClick={onSelectTag}
key={tag}
href={`t/${tag}`}
>
#{tag}
</a>
</li>
))}
</ul>
</nav>
);
}
/**
*
* @param {object} props
* @param {Array<string>} props.availableTags A list of available tags.
* @param {string} [props.selectedTag=''] The currently selected tag.
* @param {function} props.onSelectTag A handler for when the selected tag changes.
* @param {boolean} [props.isMobile=false] Whether or not we're displaying the mobile view.
*/
export function TagList({
availableTags,
selectedTag = '',
onSelectTag,
isMobile = false,
}) {
return isMobile ? (
<select
class="crayons-select"
aria-label="Filter by tag"
onChange={onSelectTag}
>
<option>all tags</option>
{availableTags.map((tag) => (
<option
selected={tag === selectedTag}
className={`crayons-link crayons-link--block ${
tag === selectedTag ? 'crayons-link--current' : ''
}`}
value={tag}
>
{tag}
</option>
))}
</select>
) : (
<LargeScreenTagList
availableTags={availableTags}
selectedTag={selectedTag}
onSelectTag={onSelectTag}
/>
);
}
TagList.propTypes = {
isMobile: PropTypes.boolean,
availableTags: PropTypes.arrayOf(PropTypes.string).isRequired,
selectedTag: PropTypes.string,
onSelectTag: PropTypes.func.isRequired,
};

View file

@ -1,31 +0,0 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { axe } from 'jest-axe';
import { ItemListLoadMoreButton } from '../ItemListLoadMoreButton';
describe('<ItemListLoadMoreButton />', () => {
it('should have no a11y violations when the load more button is not shown', async () => {
const { container } = render(<ItemListLoadMoreButton show={false} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have no a11y violations when the load more button is shown', async () => {
const { container } = render(<ItemListLoadMoreButton show={true} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('renders nothing when not required', () => {
const { queryByText } = render(<ItemListLoadMoreButton show={false} />);
expect(queryByText(/load more/i)).toBeNull();
});
it('renders a button when required', () => {
const { queryByText } = render(<ItemListLoadMoreButton show={true} />);
expect(queryByText(/load more/i)).toBeDefined();
});
});

View file

@ -1,70 +0,0 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { axe } from 'jest-axe';
import { ItemListTags } from '../ItemListTags';
describe('<ItemListTags />', () => {
it('should have no a11y violations with two different sets of tags', async () => {
const { container } = render(
<ItemListTags
availableTags={['discuss']}
selectedTags={['javascript']}
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have no a11y violations with two some shared tags', async () => {
const { container } = render(
<ItemListTags
availableTags={['discuss', 'javascript']}
selectedTags={['javascript']}
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('renders properly with two different sets of tags', () => {
const { getByText, queryByText } = render(
<ItemListTags
availableTags={['discuss']}
selectedTags={['javascript']}
/>,
);
getByText('#discuss');
expect(queryByText('#javascript')).toBeNull();
});
it('renders properly with some shared tags', () => {
const { queryByText } = render(
<ItemListTags
availableTags={['discuss', 'javascript']}
selectedTags={['javascript']}
/>,
);
expect(queryByText('#discuss')).toBeDefined();
expect(queryByText('#javascript')).toBeDefined();
});
it('triggers the onClick', () => {
const onClick = jest.fn();
const { getByText } = render(
<ItemListTags
availableTags={['discuss', 'javascript']}
selectedTags={['javascript']}
onClick={onClick}
/>,
);
let tag = getByText('#discuss', { selector: 'a' });
tag.click();
expect(onClick).toHaveBeenCalled();
});
});

View file

@ -2,43 +2,61 @@ import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import {
defaultState,
loadNextPage,
onSearchBoxType,
performInitialSearch,
search,
toggleTag,
selectTag,
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 { TagList } from './components/TagList';
import { MediaQuery } from '@components/MediaQuery';
import { BREAKPOINTS } from '@components/useMediaQuery';
import { debounceAction } from '@utilities/debounceAction';
import { Button } from '@crayons';
import { request } from '@utilities/http';
const NO_RESULTS_WITH_FILTER_MESSAGE = 'Nothing with this filter 🤔';
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>
);
};
function ItemList({ items, archiveButtonLabel, toggleArchiveStatus }) {
return items.map((item) => {
return (
<ItemListItem item={item}>
<ItemListItemArchiveButton
text={archiveButtonLabel}
onClick={(e) => toggleArchiveStatus(e, item)}
/>
</ItemListItem>
);
});
}
export class ReadingList extends Component {
constructor(props) {
super(props);
const { statusView } = this.props;
this.state = defaultState({ archiving: false, statusView });
this.state = {
archiving: false,
query: '',
index: null,
page: 0,
hitsPerPage: 80,
items: [],
itemsLoaded: false,
itemsTotal: 0,
availableTags: [],
selectedTag: '',
showLoadMoreButton: false,
statusView,
};
// bind and initialize all shared functions
this.onSearchBoxType = debounceAction(onSearchBoxType.bind(this), {
@ -47,7 +65,7 @@ export class ReadingList extends Component {
this.loadNextPage = loadNextPage.bind(this);
this.performInitialSearch = performInitialSearch.bind(this);
this.search = search.bind(this);
this.toggleTag = toggleTag.bind(this);
this.selectTag = selectTag.bind(this);
this.clearSelectedTags = clearSelectedTags.bind(this);
}
@ -62,7 +80,7 @@ export class ReadingList extends Component {
toggleStatusView = (event) => {
event.preventDefault();
const { query, selectedTags } = this.state;
const { query, selectedTag } = this.state;
const isStatusViewValid = this.statusViewValid();
const newStatusView = isStatusViewValid
@ -73,11 +91,11 @@ export class ReadingList extends Component {
: READING_LIST_PATH;
// empty items so that changing the view will start from scratch
this.setState({ statusView: newStatusView, items: [] });
this.setState({ statusView: newStatusView, items: [], selectedTag });
this.search(query, {
page: 0,
tags: selectedTags,
tags: selectedTag ? [selectedTag] : [],
statusView: newStatusView,
});
@ -113,16 +131,17 @@ export class ReadingList extends Component {
}
renderEmptyItems() {
const { itemsLoaded, selectedTags, query } = this.state;
const { itemsLoaded, selectedTag = '', query } = this.state;
const showMessage = selectedTag.length === 0 && query.length === 0;
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"
/>
<section className="align-center p-9 py-10 color-base-80">
<h2 className="fw-bold fs-l">
{showMessage
? 'Your reading list is empty'
: NO_RESULTS_WITH_FILTER_MESSAGE}
</h2>
<p class="color-base-60 pt-2">
Click the{' '}
<span class="fw-bold">
@ -140,18 +159,16 @@ export class ReadingList extends Component {
</span>
when viewing a post to add it to your reading list.
</p>
</div>
</section>
);
}
return (
<div className="align-center p-9 py-10 color-base-80">
<FilterText
selectedTags={selectedTags}
query={query}
value="Your Archive is empty..."
/>
</div>
<h2 className="align-center p-9 py-10 color-base-80 fw-bold fs-l">
{showMessage
? 'Your Archive is empty...'
: NO_RESULTS_WITH_FILTER_MESSAGE}
</h2>
);
}
@ -160,24 +177,14 @@ export class ReadingList extends Component {
items = [],
itemsTotal,
availableTags,
selectedTags,
selectedTag = '',
showLoadMoreButton,
archiving,
loading = false,
} = 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">
@ -187,17 +194,17 @@ export class ReadingList extends Component {
''
);
return (
<section>
<header className="crayons-layout flex justify-between items-center pb-0">
<main id="main-content">
<header className="crayons-layout l:grid-cols-2 pb-0">
<h1 class="crayons-title">
{isStatusViewValid ? 'Reading list' : 'Archive'}
{` (${itemsTotal})`}
</h1>
<div class="flex items-center">
<fieldset className="grid gap-2 m:flex m:justify-end m:items-center l:mb-0 mb-2 px-2 m:px-0">
<legend className="hidden">Filter</legend>
<Button
onClick={(e) => this.toggleStatusView(e)}
className="mr-2 whitespace-nowrap"
className="whitespace-nowrap l:mr-2"
variant="outlined"
url={READING_LIST_ARCHIVE_PATH}
tagName="a"
@ -206,35 +213,70 @@ export class ReadingList extends Component {
{isStatusViewValid ? 'View archive' : 'View reading list'}
</Button>
<input
aria-label="Search..."
aria-label="Filter reading list by text"
onKeyUp={this.onSearchBoxType}
placeholder="Search..."
placeholder="Enter some text to filter on..."
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}
<MediaQuery
query={`(max-width: ${BREAKPOINTS.Medium - 1}px)`}
render={(matches) => {
return (
matches && (
<TagList
availableTags={availableTags}
selectedTag={selectedTag}
onSelectTag={this.selectTag}
isMobile={true}
/>
)
);
}}
/>
</main>
{snackBar}
</div>
</section>
</fieldset>
</header>
<MediaQuery
query={`(min-width: ${BREAKPOINTS.Medium}px)`}
render={(matches) => {
return (
<div className="crayons-layout crayons-layout--2-cols">
{matches && (
<div className="crayons-layout__sidebar-left">
<TagList
availableTags={availableTags}
selectedTag={selectedTag}
onSelectTag={this.selectTag}
/>
</div>
)}
<section className="crayons-layout__content crayons-card mb-4">
{items.length > 0 ? (
<ItemList
items={items}
archiveButtonLabel={archiveButtonLabel}
toggleArchiveStatus={this.toggleArchiveStatus}
/>
) : loading ? null : (
this.renderEmptyItems()
)}
{showLoadMoreButton && (
<div className="flex justify-center my-2">
<Button
onClick={this.loadNextPage}
variant="secondary"
className="w-max"
>
Load more
</Button>
</div>
)}
</section>
</div>
);
}}
/>
{snackBar}
</main>
);
}
}
@ -247,9 +289,3 @@ 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,
};

View file

@ -1,55 +1,34 @@
// Shared behavior between the reading list and history pages
import { fetchSearch } from '../utilities/search';
// Provides the initial state for the component
export function defaultState(options) {
const state = {
query: '',
index: null,
page: 0,
hitsPerPage: 80,
items: [],
itemsLoaded: false,
itemsTotal: 0,
availableTags: [],
selectedTags: [],
showLoadMoreButton: false,
};
return Object.assign({}, state, options);
}
// Starts the search when the user types in the search box
export function onSearchBoxType(event) {
const component = this;
const query = event.target.value;
const { selectedTags, statusView } = component.state;
const { selectedTag, statusView } = component.state;
component.setState({ page: 0 });
component.search(query, {
tags: selectedTags,
tags: selectedTag ? [selectedTag] : [],
statusView,
appendItems: false,
});
}
export function toggleTag(event, tag) {
export function selectTag(event) {
event.preventDefault();
const { value, dataset } = event.target;
const selectedTag = value ?? dataset.tag;
const component = this;
const { query, selectedTags, statusView } = component.state;
const newTags = selectedTags;
if (newTags.indexOf(tag) === -1) {
newTags.push(tag);
} else {
newTags.splice(newTags.indexOf(tag), 1);
}
component.setState({ selectedTags: newTags, page: 0, items: [] });
component.search(query, { tags: newTags, statusView, appendItems: false });
const { query, statusView } = component.state;
component.setState({ selectedTag, page: 0, items: [] });
component.search(query, {
tags: selectedTag ? [selectedTag] : [],
statusView,
appendItems: false,
});
}
export function clearSelectedTags(event) {
@ -57,9 +36,8 @@ export function clearSelectedTags(event) {
const component = this;
const { query, statusView } = component.state;
const newTags = [];
component.setState({ selectedTags: newTags, page: 0, items: [] });
component.search(query, { tags: newTags, statusView, appendItems: false });
component.setState({ selectedTag: '', page: 0, items: [] });
component.search(query, { tags: [], statusView, appendItems: false });
}
// Perform the initial search
@ -68,6 +46,8 @@ export function performInitialSearch({ searchOptions = {} }) {
const { hitsPerPage } = component.state;
const dataHash = { page: 0, per_page: hitsPerPage };
component.setState({ loading: true });
if (searchOptions.status) {
dataHash.status = searchOptions.status.split(',');
}
@ -87,6 +67,7 @@ export function performInitialSearch({ searchOptions = {} }) {
itemsTotal: response.total,
showLoadMoreButton: hitsPerPage < response.total,
availableTags,
loading: false,
});
});
}
@ -95,6 +76,8 @@ export function performInitialSearch({ searchOptions = {} }) {
export function search(query, { page, tags, statusView, appendItems = false }) {
const component = this;
component.setState({ loading: true });
// allow the page number to come from the calling function
// we check `undefined` because page can be 0
const newPage = page === undefined ? component.state.page : page;
@ -134,6 +117,7 @@ export function search(query, { page, tags, statusView, appendItems = false }) {
items,
itemsTotal: response.total,
showLoadMoreButton: items.length < response.total,
loading: false,
});
});
}
@ -142,11 +126,11 @@ export function search(query, { page, tags, statusView, appendItems = false }) {
export function loadNextPage() {
const component = this;
const { query, selectedTags, page, statusView } = component.state;
const { query, selectedTag, page, statusView } = component.state;
component.setState({ page: page + 1 });
component.search(query, {
page: page + 1,
tags: selectedTags,
tags: [selectedTag],
statusView,
appendItems: true,
});

View file

@ -6,9 +6,9 @@ import { useState, useEffect } from 'preact/hooks';
* Note: These were copied from _import.scss.
*/
export const BREAKPOINTS = Object.freeze({
Small: '640',
Medium: '768',
Large: '1024',
Small: 640,
Medium: 768,
Large: 1024,
});
/**

View file

@ -0,0 +1 @@
{ "result": [], "total": 0 }

View file

@ -0,0 +1,152 @@
{
"result": [
{
"id": 7375469,
"user_id": 9597,
"reactable": {
"reading_time": 3,
"main_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--XE2mPGud--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/9597/68d6245f-3152-4ed2-a245-d015fca4160b.jpeg",
"readable_publish_date_string": "Feb 22",
"cloudinary_video_url": null,
"video_duration_in_minutes": 0,
"title": "Test Article 1",
"video_duration_string": "00:00",
"tags": [
{
"name": "productivity",
"keywords_for_search": ""
},
{
"name": "webdev",
"keywords_for_search": "web development"
}
],
"path": "/somember/web-vitals-explained-114j",
"comments_count": 4,
"public_reactions_count": 94,
"id": 614848,
"published_at": "2021-02-22T17:00:55.966Z",
"class_name": "Article",
"user": {
"name": "Some Member",
"id": 67245,
"pro": null,
"profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--XE2mPGud--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/9597/68d6245f-3152-4ed2-a245-d015fca4160b.jpeg",
"username": "somember"
},
"tag_list": ["productivity", "webdev"],
"flare_tag": null,
"user_id": 67245,
"highlight": null,
"readable_publish_date": "Feb 22",
"podcast": {
"slug": null,
"image_url": "https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9iebxyedkwozybopkn6e.jpeg",
"title": "Test Article 1"
},
"_score": 0,
"published_at_int": 1614013255,
"published_timestamp": "2021-02-22T17:00:55.966Z"
}
},
{
"id": 7375469,
"user_id": 9597,
"reactable": {
"reading_time": 3,
"main_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--XE2mPGud--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/9597/68d6245f-3152-4ed2-a245-d015fca4160b.jpeg",
"readable_publish_date_string": "Feb 22",
"cloudinary_video_url": null,
"video_duration_in_minutes": 0,
"title": "Test Article 2",
"video_duration_string": "00:00",
"tags": [
{
"name": "performance",
"keywords_for_search": null
},
{
"name": "webdev",
"keywords_for_search": "web development"
}
],
"path": "/somember/web-vitals-explained-114j",
"comments_count": 4,
"public_reactions_count": 94,
"id": 614848,
"published_at": "2021-02-22T17:00:55.966Z",
"class_name": "Article",
"user": {
"name": "Some Member",
"id": 67245,
"pro": null,
"profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--XE2mPGud--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/9597/68d6245f-3152-4ed2-a245-d015fca4160b.jpeg",
"username": "somember"
},
"tag_list": ["performance", "webdev"],
"flare_tag": null,
"user_id": 67245,
"highlight": null,
"readable_publish_date": "Feb 22",
"podcast": {
"slug": null,
"image_url": "https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9iebxyedkwozybopkn6e.jpeg",
"title": "Test Article 2"
},
"_score": 0,
"published_at_int": 1614013255,
"published_timestamp": "2021-02-22T17:00:55.966Z"
}
},
{
"id": 7375469,
"user_id": 9597,
"reactable": {
"reading_time": 3,
"main_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--XE2mPGud--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/9597/68d6245f-3152-4ed2-a245-d015fca4160b.jpeg",
"readable_publish_date_string": "Feb 22",
"cloudinary_video_url": null,
"video_duration_in_minutes": 0,
"title": "Test Article 3",
"video_duration_string": "00:00",
"tags": [
{
"name": "productivity",
"keywords_for_search": ""
},
{
"name": "javascript",
"keywords_for_search": "js"
}
],
"path": "/somember/web-vitals-explained-114j",
"comments_count": 4,
"public_reactions_count": 94,
"id": 614848,
"published_at": "2021-02-22T17:00:55.966Z",
"class_name": "Article",
"user": {
"name": "Some Member",
"id": 67245,
"pro": null,
"profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--XE2mPGud--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/9597/68d6245f-3152-4ed2-a245-d015fca4160b.jpeg",
"username": "somember"
},
"tag_list": ["productivity", "javascript"],
"flare_tag": null,
"user_id": 67245,
"highlight": null,
"readable_publish_date": "Feb 22",
"podcast": {
"slug": null,
"image_url": "https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9iebxyedkwozybopkn6e.jpeg",
"title": "Test Article 3"
},
"_score": 0,
"published_at_int": 1614013255,
"published_timestamp": "2021-02-22T17:00:55.966Z"
}
}
],
"total": 3
}

View file

@ -0,0 +1,103 @@
{
"result": [
{
"id": 7375469,
"user_id": 9597,
"reactable": {
"reading_time": 3,
"main_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--XE2mPGud--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/9597/68d6245f-3152-4ed2-a245-d015fca4160b.jpeg",
"readable_publish_date_string": "Feb 22",
"cloudinary_video_url": null,
"video_duration_in_minutes": 0,
"title": "Test Article 1",
"video_duration_string": "00:00",
"tags": [
{
"name": "productivity",
"keywords_for_search": ""
},
{
"name": "webdev",
"keywords_for_search": "web development"
}
],
"path": "/somember/web-vitals-explained-114j",
"comments_count": 4,
"public_reactions_count": 94,
"id": 614848,
"published_at": "2021-02-22T17:00:55.966Z",
"class_name": "Article",
"user": {
"name": "Some Member",
"id": 67245,
"pro": null,
"profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--XE2mPGud--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/9597/68d6245f-3152-4ed2-a245-d015fca4160b.jpeg",
"username": "somember"
},
"tag_list": ["productivity", "webdev"],
"flare_tag": null,
"user_id": 67245,
"highlight": null,
"readable_publish_date": "Feb 22",
"podcast": {
"slug": null,
"image_url": "https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9iebxyedkwozybopkn6e.jpeg",
"title": "Test Article 1"
},
"_score": 0,
"published_at_int": 1614013255,
"published_timestamp": "2021-02-22T17:00:55.966Z"
}
},
{
"id": 7375469,
"user_id": 9597,
"reactable": {
"reading_time": 3,
"main_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--XE2mPGud--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/9597/68d6245f-3152-4ed2-a245-d015fca4160b.jpeg",
"readable_publish_date_string": "Feb 22",
"cloudinary_video_url": null,
"video_duration_in_minutes": 0,
"title": "Test Article 3",
"video_duration_string": "00:00",
"tags": [
{
"name": "productivity",
"keywords_for_search": ""
},
{
"name": "javascript",
"keywords_for_search": "js"
}
],
"path": "/somember/web-vitals-explained-114j",
"comments_count": 4,
"public_reactions_count": 94,
"id": 614848,
"published_at": "2021-02-22T17:00:55.966Z",
"class_name": "Article",
"user": {
"name": "Some Member",
"id": 67245,
"pro": null,
"profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--XE2mPGud--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/9597/68d6245f-3152-4ed2-a245-d015fca4160b.jpeg",
"username": "somember"
},
"tag_list": ["productivity", "javascript"],
"flare_tag": null,
"user_id": 67245,
"highlight": null,
"readable_publish_date": "Feb 22",
"podcast": {
"slug": null,
"image_url": "https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9iebxyedkwozybopkn6e.jpeg",
"title": "Test Article 3"
},
"_score": 0,
"published_at_int": 1614013255,
"published_timestamp": "2021-02-22T17:00:55.966Z"
}
}
],
"total": 2
}

View file

@ -0,0 +1,54 @@
{
"result": [
{
"id": 7375469,
"user_id": 9597,
"reactable": {
"reading_time": 3,
"main_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--XE2mPGud--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/9597/68d6245f-3152-4ed2-a245-d015fca4160b.jpeg",
"readable_publish_date_string": "Feb 22",
"cloudinary_video_url": null,
"video_duration_in_minutes": 0,
"title": "Test Article 3",
"video_duration_string": "00:00",
"tags": [
{
"name": "productivity",
"keywords_for_search": ""
},
{
"name": "javascript",
"keywords_for_search": "js"
}
],
"path": "/somember/web-vitals-explained-114j",
"comments_count": 4,
"public_reactions_count": 94,
"id": 614848,
"published_at": "2021-02-22T17:00:55.966Z",
"class_name": "Article",
"user": {
"name": "Some Member",
"id": 67245,
"pro": null,
"profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--XE2mPGud--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/9597/68d6245f-3152-4ed2-a245-d015fca4160b.jpeg",
"username": "somember"
},
"tag_list": ["productivity", "javascript"],
"flare_tag": null,
"user_id": 67245,
"highlight": null,
"readable_publish_date": "Feb 22",
"podcast": {
"slug": null,
"image_url": "https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9iebxyedkwozybopkn6e.jpeg",
"title": "Test Article 3"
},
"_score": 0,
"published_at_int": 1614013255,
"published_timestamp": "2021-02-22T17:00:55.966Z"
}
}
],
"total": 1
}

View file

@ -0,0 +1,166 @@
import { BREAKPOINTS } from '../../../app/javascript/shared/components/useMediaQuery';
describe('Reading List Archive', () => {
beforeEach(() => {
cy.testSetup();
cy.fixture('users/articleEditorV1User.json').as('user');
cy.get('@user').then((user) => {
cy.loginUser(user);
});
});
it('should load an empty reading list', () => {
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?page=0&per_page=80&status%5B%5D=valid&status%5B%5D=confirmed',
{ fixture: 'search/emptyReadingList.json' },
).as('emptyReadingList');
cy.visit('/readinglist');
cy.wait('@emptyReadingList');
cy.findByRole('main')
.as('main')
.findByText(/^Your reading list is empty$/i);
cy.get('@main').findByText(/^View archive$/i);
cy.get('@main').findByLabelText(/^Filter reading list by text$/i);
cy.get('@main').findByText(/^Reading list \(0\)$/i);
cy.get('@main')
.findByRole('navigation', { name: /^Filter by tag$/i })
.findByText(/all tags/i);
});
it('should filter by text', () => {
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?page=0&per_page=80&status%5B%5D=valid&status%5B%5D=confirmed',
{ fixture: 'search/readingList.json' },
).as('readingList');
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?search_fields=article+3&page=0&per_page=80&status%5B%5D=valid&status%5B%5D=confirmed',
{ fixture: 'search/readingListFilterByText.json' },
).as('readingListFilteredByText');
cy.visit('/readinglist');
cy.wait('@readingList');
cy.findByRole('main').as('main');
cy.get('@main')
.findByLabelText(/^Filter reading list by text$/i)
.type('article 3');
cy.wait('@readingListFilteredByText');
cy.get('@main').findByText('Test Article 1').should('not.exist');
cy.get('@main').findByText('Test Article 2').should('not.exist');
cy.get('@main').findByText('Test Article 3');
});
describe('small screens', () => {
beforeEach(() => {
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?page=0&per_page=80&status%5B%5D=valid&status%5B%5D=confirmed',
{ fixture: 'search/readingList.json' },
).as('readingList');
cy.viewport(BREAKPOINTS.Medium - 1, BREAKPOINTS.Medium);
cy.visit('/readinglist');
cy.wait('@readingList');
});
it('should load the reading list with items', () => {
cy.findByRole('main')
.as('main')
.findByText(/^Your reading list is empty$/i)
.should('not.exist');
cy.get('@main').findByText(/^View archive$/i);
cy.get('@main').findByLabelText(/Filter reading list by text$/i);
cy.get('@main').findByText(/^Reading list \(3\)$/);
cy.get('@main').findByLabelText(/^Filter by tag$/i, {
selector: 'select',
});
cy.get('@main')
.findByText(/^Filter by tag$/i, { selector: 'legend' })
.should('not.exist');
cy.get('@main').findByText('Test Article 1');
cy.get('@main').findByText('Test Article 2');
cy.get('@main').findByText('Test Article 3');
});
it('should filter by tag', () => {
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?search_fields=&page=0&per_page=80&tag_names%5B%5D=productivity&tag_boolean_mode=all&status%5B%5D=valid&status%5B%5D=confirmed',
{ fixture: 'search/readingListFilterByTagProductivity.json' },
).as('filteredReadingList');
cy.findByRole('main')
.as('main')
.findByLabelText('Filter by tag')
.as('tagFilter')
.select('productivity');
cy.wait('@filteredReadingList');
cy.get('@main').findByText('Test Article 1');
cy.get('@main').findByText('Test Article 2').should('not.exist');
cy.get('@main').findByText('Test Article 3');
});
});
describe('large screens', () => {
beforeEach(() => {
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?page=0&per_page=80&status%5B%5D=valid&status%5B%5D=confirmed',
{ fixture: 'search/readingList.json' },
).as('readingList');
cy.viewport(BREAKPOINTS.Large, 600);
cy.visit('/readinglist');
cy.wait('@readingList');
});
it('should load the reading list with items', () => {
cy.findByRole('main')
.as('main')
.findByText(/^Your reading list is empty$/i)
.should('not.exist');
cy.get('@main').findByText(/^View archive$/i);
cy.get('@main').findByLabelText(/Filter reading list by text$/i);
cy.get('@main').findByText(/^Reading list \(3\)$/);
cy.get('@main').findByRole('navigation', { name: /^Filter by tag$/i });
cy.get('@main')
.findByRole('select', { name: /^Filter by tag$/i })
.should('not.exist');
cy.get('@main').findByText('Test Article 1');
cy.get('@main').findByText('Test Article 2');
cy.get('@main').findByText('Test Article 3');
});
it('should filter by tag', () => {
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?search_fields=&page=0&per_page=80&tag_names%5B%5D=productivity&tag_boolean_mode=all&status%5B%5D=valid&status%5B%5D=confirmed',
{ fixture: 'search/readingListFilterByTagProductivity.json' },
).as('filteredReadingList');
cy.findByRole('main')
.as('main')
.findByRole('navigation', { name: /^Filter by tag$/i })
.findByText('#productivity')
.click();
cy.wait('@filteredReadingList');
cy.get('@main').findByText('Test Article 1');
cy.get('@main').findByText('Test Article 2').should('not.exist');
cy.get('@main').findByText('Test Article 3');
});
});
});

View file

@ -0,0 +1,170 @@
import { BREAKPOINTS } from '../../../app/javascript/shared/components/useMediaQuery';
describe('Reading List Archive', () => {
beforeEach(() => {
cy.testSetup();
cy.fixture('users/articleEditorV1User.json').as('user');
cy.get('@user').then((user) => {
cy.loginUser(user);
});
});
it('should load an empty archive', () => {
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?page=0&per_page=80&status%5B%5D=archived',
{ fixture: 'search/emptyReadingList.json' },
).as('emptyReadingList');
cy.visit('/readinglist/archive');
cy.wait('@emptyReadingList');
cy.findByRole('main')
.as('main')
.findByText(/^Your Archive is empty...$/i);
cy.get('@main').findByLabelText(/^Filter reading list by text$/i);
cy.get('@main').findByText(/^Archive \(0\)$/i);
cy.get('@main')
.findByRole('navigation', { name: /^Filter by tag$/i })
.findByText(/all tags/i);
});
it('should filter by text', () => {
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?page=0&per_page=80&status%5B%5D=archived',
{ fixture: 'search/readingList.json' },
).as('readingList');
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?search_fields=article+3&page=0&per_page=80&status%5B%5D=archived',
{ fixture: 'search/readingListFilterByText.json' },
).as('readingListFilteredByText');
cy.visit('/readinglist/archive');
cy.wait('@readingList');
cy.findByRole('main').as('main');
cy.get('@main')
.findByLabelText(/^Filter reading list by text$/i)
.type('article 3');
cy.wait('@readingListFilteredByText');
cy.get('@main').findByText('Test Article 1').should('not.exist');
cy.get('@main').findByText('Test Article 2').should('not.exist');
cy.get('@main').findByText('Test Article 3');
});
describe('small screens', () => {
beforeEach(() => {
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?page=0&per_page=80&status%5B%5D=archived',
{ fixture: 'search/readingList.json' },
).as('archiveList');
cy.viewport(BREAKPOINTS.Medium - 1, BREAKPOINTS.Medium);
cy.visit('/readinglist/archive');
cy.wait('@archiveList');
});
it('should load the reading list archive with items', () => {
cy.findByRole('main')
.as('main')
.findByText(/^Your reading list is empty$/i)
.should('not.exist');
cy.get('@main').findByText(/^View reading list$/i);
cy.get('@main').findByLabelText(/Filter reading list by text$/i);
cy.get('@main').findByText(/^Archive \(3\)$/);
cy.get('@main').findByLabelText(/^Filter by tag$/i, {
selector: 'select',
});
cy.get('@main')
.findByText(/^Filter by tag$/i, { selector: 'legend' })
.should('not.exist');
cy.get('@main').findByText('Test Article 1');
cy.get('@main').findByText('Test Article 2');
cy.get('@main').findByText('Test Article 3');
});
it('should filter by tag', () => {
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?search_fields=&page=0&per_page=80&tag_names%5B%5D=productivity&tag_boolean_mode=all&status%5B%5D=archived',
{
fixture: 'search/readingListFilterByTagProductivity.json',
},
).as('filteredArchiveList');
cy.findByRole('main')
.as('main')
.findByLabelText('Filter by tag')
.as('tagFilter')
.select('productivity');
cy.wait('@filteredArchiveList');
cy.get('@main').findByText('Test Article 1');
cy.get('@main').findByText('Test Article 2').should('not.exist');
cy.get('@main').findByText('Test Article 3');
});
});
describe('large screens', () => {
beforeEach(() => {
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?page=0&per_page=80&status%5B%5D=archived',
{ fixture: 'search/readingList.json' },
).as('archiveList');
cy.viewport(BREAKPOINTS.Large, 600);
cy.visit('/readinglist/archive');
cy.wait('@archiveList');
});
it('should load the reading list archive items', () => {
cy.findByRole('main')
.as('main')
.findByText(/^Your Archive is empty$/i)
.should('not.exist');
cy.get('@main').findByText(/^View reading list$/i);
cy.get('@main').findByLabelText(/Filter reading list by text$/i);
cy.get('@main').findByText(/^Archive \(3\)$/);
cy.get('@main').findByRole('navigation', { name: /^Filter by tag$/i });
cy.get('@main')
.findByRole('select', { name: /^Filter by tag$/i })
.should('not.exist');
cy.get('@main').findByText('Test Article 1');
cy.get('@main').findByText('Test Article 2');
cy.get('@main').findByText('Test Article 3');
});
it('should filter by tag', () => {
cy.intercept(
Cypress.config().baseUrl +
'search/reactions?search_fields=&page=0&per_page=80&tag_names%5B%5D=productivity&tag_boolean_mode=all&status%5B%5D=archived',
{
fixture: 'search/readingListFilterByTagProductivity.json',
},
).as('filteredArchiveList');
cy.findByRole('main')
.as('main')
.findByRole('navigation', { name: /^Filter by tag$/i })
.findByText('#productivity')
.click();
cy.wait('@filteredArchiveList');
cy.get('@main').findByText('Test Article 1');
cy.get('@main').findByText('Test Article 2').should('not.exist');
cy.get('@main').findByText('Test Article 3');
});
});
});

View file

@ -25,8 +25,8 @@ module.exports = {
],
coverageThreshold: {
global: {
statements: 43,
branches: 39,
statements: 42,
branches: 38,
functions: 41,
lines: 43,
},