This commit is contained in:
k88hudson 2016-05-25 12:30:13 -04:00
Родитель 6f8b72ce3f
Коммит 620d1097f0
14 изменённых файлов: 283 добавлений и 183 удалений

Просмотреть файл

@ -9,5 +9,13 @@ module.exports = {
HIGHLIGHTS_THRESHOLDS: {
created: "-3 day",
visited: "-30 minutes",
}
},
// This is how many pixels before the bottom that
// infinite scroll is triggered
INFINITE_SCROLL_THRESHOLD: 20,
// How many pixels offset for the infinite scroll top
// due to the header?
SCROLL_TOP_OFFSET: 50
};

Просмотреть файл

@ -17,6 +17,7 @@ const constants = {
"UNBLOCK_ALL",
"SHARE",
"LOAD_MORE",
"LOAD_MORE_SCROLL",
"SEARCH"
]),
sources: new Set([

Просмотреть файл

@ -11,19 +11,10 @@
.spinner {
flex-shrink: 0;
margin-right: 10px;
width: $loader-height;
height: $loader-height;
width: $loader-size;
height: $loader-size;
display: inline-block;
border-radius: 50%;
background: transparent;
border-top: $loader-border-width solid $loader-color-light;
border-right: $loader-border-width solid $loader-color-light;
border-bottom: $loader-border-width solid $loader-color;
border-left: $loader-border-width solid $loader-color;
animation: spin $loader-duration linear infinite;
background-image: url('img/loading@2x.png');
background-size: $loader-size $loader-size;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}

Просмотреть файл

@ -1,43 +1,18 @@
const React = require("react");
const {connect} = require("react-redux");
const {selectBookmarks} = require("selectors/selectors");
const GroupedActivityFeed = require("components/ActivityFeed/ActivityFeed");
const {RequestMoreBookmarks, NotifyEvent} = require("common/action-manager").actions;
const LoadMore = require("components/LoadMore/LoadMore");
const classNames = require("classnames");
const PAGE_NAME = "TIMELINE_BOOKMARKS";
const {RequestMoreBookmarks} = require("common/action-manager").actions;
const TimelineFeed = require("./TimelineFeed");
const TimelineBookmarks = React.createClass({
getMore() {
const bookmarks = this.props.Bookmarks.rows;
if (!bookmarks.length) {
return;
}
const beforeDate = bookmarks[bookmarks.length - 1].lastModified;
this.props.dispatch(RequestMoreBookmarks(beforeDate));
this.props.dispatch(NotifyEvent({
event: "LOAD_MORE",
page: "TIMELINE_BOOKMARKS",
source: "ACTIVITY_FEED"
}));
},
render() {
const props = this.props;
return (<div className={classNames("wrapper", "show-on-init", {on: props.Bookmarks.init})}>
<GroupedActivityFeed
sites={props.Bookmarks.rows}
length={20}
dateKey="bookmarkDateCreated"
page={PAGE_NAME}
showDateHeadings={true}
/>
<LoadMore
loading={props.Bookmarks.isLoading}
hidden={!props.Bookmarks.canLoadMore || !props.Bookmarks.rows.length}
onClick={this.getMore}
label="See more activity"/>
</div>);
return (<TimelineFeed
loadMoreAction={RequestMoreBookmarks}
dateKey={"bookmarkDateCreated"}
pageName={"TIMELINE_BOOKMARKS"}
Feed={props.Bookmarks}
/>);
}
});

Просмотреть файл

@ -0,0 +1,91 @@
const React = require("react");
const {connect} = require("react-redux");
const {justDispatch} = require("selectors/selectors");
const {NotifyEvent} = require("common/action-manager").actions;
const GroupedActivityFeed = require("components/ActivityFeed/ActivityFeed");
const Spotlight = require("components/Spotlight/Spotlight");
const Loader = require("components/Loader/Loader");
const classNames = require("classnames");
const {INFINITE_SCROLL_THRESHOLD, SCROLL_TOP_OFFSET} = require("common/constants");
const debounce = require("lodash.debounce");
const TimelineFeed = React.createClass({
loadMore() {
const items = this.props.Feed.rows;
if (!items.length) {
return;
}
const beforeDate = items[items.length - 1][this.props.dateKey];
this.props.dispatch(this.props.loadMoreAction(beforeDate));
this.props.dispatch(NotifyEvent({
event: "LOAD_MORE_SCROLL",
page: this.props.pageName,
source: "ACTIVITY_FEED"
}));
},
windowHeight: null,
handleScroll(values) {
const {Feed} = this.props;
const {scrollTop, scrollHeight} = values;
if (!Feed.canLoadMore || Feed.isLoading) {
return;
}
if (!this.windowHeight) {
this.windowHeight = window.innerHeight;
}
if (scrollHeight - (scrollTop + this.windowHeight - SCROLL_TOP_OFFSET) < INFINITE_SCROLL_THRESHOLD) {
this.loadMore();
}
},
onResize: debounce(function() {
this.windowHeight = window.innerHeight;
}, 100),
onScroll: debounce(function() {
this.handleScroll({
scrollHeight: this.refs.scrollElement.scrollHeight,
scrollTop: this.refs.scrollElement.scrollTop
});
}, 100),
componentDidUpdate(prevProps) {
// Firefox will emit a scroll event if we don't do this
// There is a weird behaviour that makes the scroll bar stick in a lower
// position sometimes if we set scrollTop to 0 instead of 1
if (!prevProps.Feed.init && this.props.Feed.init) {
this.refs.scrollElement.scrollTop = 1;
}
},
componentDidMount() {
window.addEventListener("resize", this.onResize);
},
componentWillUnmount() {
window.removeEventListener("resize", this.onResize);
},
render() {
const props = this.props;
return (<section className="content" ref="scrollElement" onScroll={!props.Feed.isLoading && props.Feed.canLoadMore && this.onScroll}>
<div ref="wrapper" className={classNames("wrapper", "show-on-init", {on: props.Feed.init})}>
{props.Spotlight ? <Spotlight page={this.props.pageName} sites={props.Spotlight.rows} /> : null }
<GroupedActivityFeed
sites={props.Feed.rows}
page={props.pageName}
dateKey={props.dateKey}
showDateHeadings={true} />
<Loader className="infinite-scroll" ref="loader" show={props.Feed.isLoading} />
</div>
</section>);
}
});
TimelineFeed.propTypes = {
Spotlight: React.PropTypes.object,
Feed: React.PropTypes.object.isRequired,
pageName: React.PropTypes.string.isRequired,
loadMoreAction: React.PropTypes.func.isRequired,
dateKey: React.PropTypes.string.isRequired
};
module.exports = connect(justDispatch)(TimelineFeed);
module.exports.TimelineFeed = TimelineFeed;

Просмотреть файл

@ -1,39 +1,19 @@
const React = require("react");
const {connect} = require("react-redux");
const {selectHistory} = require("selectors/selectors");
const {RequestMoreRecentLinks, NotifyEvent} = require("common/action-manager").actions;
const GroupedActivityFeed = require("components/ActivityFeed/ActivityFeed");
const Spotlight = require("components/Spotlight/Spotlight");
const LoadMore = require("components/LoadMore/LoadMore");
const classNames = require("classnames");
const PAGE_NAME = "TIMELINE_ALL";
const {RequestMoreRecentLinks} = require("common/action-manager").actions;
const TimelineFeed = require("./TimelineFeed");
const TimelineHistory = React.createClass({
getMore() {
const history = this.props.History.rows;
if (!history.length) {
return;
}
const beforeDate = history[history.length - 1].lastVisitDate;
this.props.dispatch(RequestMoreRecentLinks(beforeDate));
this.props.dispatch(NotifyEvent({
event: "LOAD_MORE",
page: "TIMELINE_ALL",
source: "ACTIVITY_FEED"
}));
},
render() {
const props = this.props;
return (<div className={classNames("wrapper", "show-on-init", {on: props.History.init})}>
<Spotlight page={PAGE_NAME} sites={props.Spotlight.rows} />
<GroupedActivityFeed
sites={props.History.rows}
page={PAGE_NAME}
showDateHeadings={true} />
<LoadMore loading={props.History.isLoading} hidden={!props.History.canLoadMore || !props.History.rows.length} onClick={this.getMore}
label="See more activity"/>
</div>);
return (<TimelineFeed
loadMoreAction={RequestMoreRecentLinks}
dateKey={"lastVisitDate"}
pageName={"TIMELINE_ALL"}
Feed={props.History}
Spotlight={props.Spotlight}
/>);
}
});
@ -41,7 +21,5 @@ TimelineHistory.propTypes = {
Spotlight: React.PropTypes.object.isRequired,
History: React.PropTypes.object.isRequired
};
module.exports = connect(selectHistory)(TimelineHistory);
module.exports.TimelineHistory = TimelineHistory;

Просмотреть файл

@ -36,9 +36,7 @@ const TimelinePage = React.createClass({
})}
</ul>
</nav>
<section className="content">
{this.props.children}
</section>
{this.props.children}
</main>
</div>);
}

Просмотреть файл

@ -1,6 +1,7 @@
main.timeline {
margin-top: 50px;
padding: 0;
overflow: hidden;
.wrapper {
margin-left: 20px;
@ -66,5 +67,10 @@ main.timeline {
background: $bg-grey;
margin-bottom: 0;
margin-left: $header-nav-width;
.infinite-scroll {
margin-top: $base-gutter / 2;
margin-left: $feed-gutter-h;
}
}
}

Просмотреть файл

@ -1,6 +1,5 @@
const am = require("common/action-manager");
const setRowsOrError = require("reducers/SetRowsOrError");
const {LINKS_QUERY_LIMIT} = require("common/constants");
function setSearchState(type) {
return (prevState = {currentEngine: {}, error: false, init: false}, action) => {
@ -31,8 +30,8 @@ function setSearchState(type) {
module.exports = {
TopSites: setRowsOrError("TOP_FRECENT_SITES_REQUEST", "TOP_FRECENT_SITES_RESPONSE"),
History: setRowsOrError("RECENT_LINKS_REQUEST", "RECENT_LINKS_RESPONSE", LINKS_QUERY_LIMIT),
Bookmarks: setRowsOrError("RECENT_BOOKMARKS_REQUEST", "RECENT_BOOKMARKS_RESPONSE", LINKS_QUERY_LIMIT),
History: setRowsOrError("RECENT_LINKS_REQUEST", "RECENT_LINKS_RESPONSE"),
Bookmarks: setRowsOrError("RECENT_BOOKMARKS_REQUEST", "RECENT_BOOKMARKS_RESPONSE"),
Highlights: setRowsOrError("HIGHLIGHTS_LINKS_REQUEST", "HIGHLIGHTS_LINKS_RESPONSE"),
Search: setSearchState("SEARCH_STATE_RESPONSE")
};

Двоичные данные
content-src/static/img/loading@2x.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 22 KiB

Просмотреть файл

@ -91,12 +91,7 @@ $placeholder-border: 1px solid $faintest-black;
$item-shadow: 0 1px 0 0 $faintest-black;
$item-shadow-hover: 0 1px 0 0 $faintest-black, 0 0 0 5px $faintest-black;
$loader-height: 25px;
$loader-width: 25px;
$loader-border-width: 5px;
$loader-color: $search-blue;
$loader-color-light: rgba($loader-color, 0.1);
$loader-duration: 1.3s;
$loader-size: 16px;
$context-menu-shadow: 0 5px 10px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(0, 0, 0, 0.2);
$context-menu-font-size: 14px;

Просмотреть файл

@ -4,13 +4,17 @@ const ReactDOM = require("react-dom");
const TestUtils = require("react-addons-test-utils");
const TimelinePage = require("components/TimelinePage/TimelinePage");
const ConnectedTimelineFeed = require("components/TimelinePage/TimelineFeed");
const {TimelineFeed} = ConnectedTimelineFeed;
const ConnectedTimelineHistory = require("components/TimelinePage/TimelineHistory");
const {TimelineHistory} = ConnectedTimelineHistory;
const ConnectedTimelineBookmarks = require("components/TimelinePage/TimelineBookmarks");
const {TimelineBookmarks} = ConnectedTimelineBookmarks;
const {GroupedActivityFeed} = require("components/ActivityFeed/ActivityFeed");
const LoadMore = require("components/LoadMore/LoadMore");
const Loader = require("components/Loader/Loader");
const Spotlight = require("components/Spotlight/Spotlight");
const {mockData, renderWithProvider} = require("test/test-utils");
const {INFINITE_SCROLL_THRESHOLD, SCROLL_TOP_OFFSET} = require("common/constants");
describe("Timeline", () => {
@ -45,15 +49,152 @@ describe("Timeline", () => {
});
});
describe("TimelineFeed", () => {
let instance;
let loader;
let loaderEl;
const fakeProps = {
Feed: {
init: true,
isLoading: false,
canLoadMore: true,
rows: mockData.History.rows,
},
dateKey: "lastVisitDate",
pageName: "TIMELINE_ALL",
loadMoreAction: () => {}
};
function setup(customProps = {}, dispatch) {
const props = Object.assign({}, fakeProps, customProps);
const connected = renderWithProvider(<ConnectedTimelineFeed {...props} />, dispatch && {dispatch});
instance = TestUtils.findRenderedComponentWithType(connected, TimelineFeed);
loader = TestUtils.findRenderedComponentWithType(instance, Loader);
loaderEl = ReactDOM.findDOMNode(loader);
}
beforeEach(setup);
it("should create a TimelineFeed", () => {
assert.ok(TestUtils.isCompositeComponentWithType(instance, TimelineFeed));
});
describe("Elements", () => {
it("should render GroupedActivityFeed with correct data", () => {
const activityFeed = TestUtils.findRenderedComponentWithType(instance, GroupedActivityFeed);
assert.equal(activityFeed.props.sites, fakeProps.Feed.rows);
assert.equal(activityFeed.props.dateKey, fakeProps.dateKey);
});
it("should not render a Spotlight if Spotlight data is missing", () => {
assert.lengthOf(TestUtils.scryRenderedComponentsWithType(instance, Spotlight), 0);
});
it("should render a Spotlight if Spotlight data is provided", () => {
setup({Spotlight: mockData.Highlights});
assert.ok(TestUtils.findRenderedComponentWithType(instance, Spotlight));
});
});
describe("Loader", () => {
it("should have a Loader element", () => {
assert.ok(loader);
});
it("should show Loader if History.isLoading is true", () => {
setup({
Feed: Object.assign({}, fakeProps.Feed, {isLoading: true}),
});
assert.equal(loaderEl.hidden, false);
});
});
describe("#loadMore", () => {
it("should dispatch loadMoreAction", done => {
setup({loadMoreAction: () => "foo"}, action => {
assert.equal(action, "foo");
done();
});
instance.loadMore();
});
it("should select the {dateKey} of the last item", done => {
setup({
dateKey: "foo",
Feed: Object.assign({}, fakeProps.Feed, {
rows: [
{url: "asd.com", foo: 3},
{url: "blah.com", foo: 2},
{url: "324ads.com", foo: 1}
]
}),
loadMoreAction: date => {
assert.equal(date, 1);
done();
}
});
instance.loadMore();
});
it("should dispatch a NotifyEvent", done => {
setup({}, action => {
if (action && action.type === "NOTIFY_USER_EVENT") {
assert.equal(action.type, "NOTIFY_USER_EVENT");
assert.equal(action.data.event, "LOAD_MORE_SCROLL");
assert.equal(action.data.page, fakeProps.pageName);
done();
}
});
instance.loadMore();
});
});
describe("#handleScroll", function() {
it("should set this.windowHeight if it is falsey", () => {
instance.windowHeight = null;
instance.handleScroll({scrollTop: 0, scrollHeight: 5000});
assert.equal(instance.windowHeight, window.innerHeight);
});
it("should not call loadMore if the scrollTop is before the threshold", () => {
setup({loadMoreAction: () => {
throw new Error("Should not call loadMore");
}});
instance.windowHeight = 200;
instance.handleScroll({scrollTop: 0, scrollHeight: 400});
});
it("should loadMore if the scrollTop is past the threshold", done => {
setup({loadMoreAction: () => done()});
instance.windowHeight = 200;
const scrollTop = 200 + SCROLL_TOP_OFFSET - INFINITE_SCROLL_THRESHOLD + 1;
instance.handleScroll({scrollTop, scrollHeight: 200});
});
it("should not call loadMore if canLoadMore is false", () => {
setup({
Feed: Object.assign({}, fakeProps.Feed, {canLoadMore: false}),
loadMoreAction: () => {
throw new Error("Should not call loadMore");
}
});
instance.windowHeight = 200;
instance.handleScroll({scrollTop: 1000, scrollHeight: 200});
});
it("should not call loadMore if isLoading is true", () => {
setup({
Feed: Object.assign({}, fakeProps.Feed, {isLoading: true}),
loadMoreAction: () => {
throw new Error("Should not call loadMore");
}
});
instance.windowHeight = 200;
instance.handleScroll({scrollTop: 1000, scrollHeight: 200});
});
});
});
describe("TimelineHistory", () => {
let instance;
let loadMore;
const fakeProps = mockData;
function setup(customProps = {}, dispatch) {
const props = Object.assign({}, fakeProps, customProps);
instance = renderWithProvider(<TimelineHistory {...props} />, dispatch && {dispatch});
loadMore = TestUtils.findRenderedComponentWithType(instance, LoadMore);
}
beforeEach(setup);
@ -62,64 +203,20 @@ describe("Timeline", () => {
assert.ok(TestUtils.isCompositeComponentWithType(instance, TimelineHistory));
});
it("should render GroupedActivityFeed with correct data", () => {
const activityFeed = TestUtils.findRenderedComponentWithType(instance, GroupedActivityFeed);
assert.equal(activityFeed.props.sites, fakeProps.History.rows);
});
it("should render the connected container with the correct props", () => {
const container = renderWithProvider(<ConnectedTimelineHistory />);
const inner = TestUtils.findRenderedComponentWithType(container, TimelineHistory);
Object.keys(TimelineHistory.propTypes).forEach(key => assert.property(inner.props, key));
});
it("should have a LoadMore element", () => {
assert.ok(loadMore);
});
it("should show a loader if History.isLoading is true", () => {
setup({
History: {
isLoading: true,
canLoadMore: true,
rows: [{url: "https://foo.com"}]
}
});
assert.equal(ReactDOM.findDOMNode(loadMore.refs.loader).hidden, false);
});
it("should hide LoadMore if canLoadMore is false", () => {
setup({
History: {
isLoading: false,
canLoadMore: false,
rows: [{url: "https://foo.com"}]
}
});
assert.equal(ReactDOM.findDOMNode(loadMore).hidden, true);
});
it("should hide LoadMore if rows are empty", () => {
setup({
History: {
isLoading: false,
canLoadMore: true,
rows: []
}
});
assert.equal(ReactDOM.findDOMNode(loadMore).hidden, true);
});
});
describe("TimelineBookmarks", () => {
let instance;
let loadMore;
const fakeProps = mockData;
function setup(customProps = {}, dispatch) {
const props = Object.assign({}, fakeProps, customProps);
instance = renderWithProvider(<TimelineBookmarks {...props} />, dispatch && {dispatch});
loadMore = TestUtils.findRenderedComponentWithType(instance, LoadMore);
}
beforeEach(setup);
@ -128,53 +225,11 @@ describe("Timeline", () => {
assert.ok(TestUtils.isCompositeComponentWithType(instance, TimelineBookmarks));
});
it("should render GroupedActivityFeed with correct data", () => {
const activityFeed = TestUtils.findRenderedComponentWithType(instance, GroupedActivityFeed);
assert.equal(activityFeed.props.sites, fakeProps.Bookmarks.rows);
});
it("should render the connected container with the correct props", () => {
const container = renderWithProvider(<ConnectedTimelineBookmarks />);
const inner = TestUtils.findRenderedComponentWithType(container, TimelineBookmarks);
Object.keys(TimelineBookmarks.propTypes).forEach(key => assert.property(inner.props, key));
});
it("should have a LoadMore element", () => {
assert.ok(loadMore);
});
it("should show a loader if Bookmarks.isLoading is true", () => {
setup({
Bookmarks: {
isLoading: true,
canLoadMore: true,
rows: [{url: "https://foo.com"}]
}
});
assert.equal(ReactDOM.findDOMNode(loadMore.refs.loader).hidden, false);
});
it("should hide LoadMore if canLoadMore is false", () => {
setup({
Bookmarks: {
isLoading: false,
canLoadMore: false,
rows: [{url: "https://foo.com"}]
}
});
assert.equal(ReactDOM.findDOMNode(loadMore).hidden, true);
});
it("should hide LoadMore if rows are empty", () => {
setup({
Bookmarks: {
isLoading: false,
canLoadMore: true,
rows: []
}
});
assert.equal(ReactDOM.findDOMNode(loadMore).hidden, true);
});
});
});

Просмотреть файл

@ -1,4 +1,5 @@
const React = require("react");
const ReactDOM = require("react-dom");
const {Provider} = require("react-redux");
const mockData = require("lib/fake-data");
const {selectNewTabSites} = require("selectors/selectors");
@ -10,7 +11,7 @@ const DEFAULT_STORE = {
subscribe: () => {}
};
function createMockProvider(custom = {}) {
function createMockProvider(custom) {
const store = Object.assign({}, DEFAULT_STORE, custom);
store.subscribe = () => {};
return React.createClass({
@ -20,9 +21,10 @@ function createMockProvider(custom = {}) {
});
}
function renderWithProvider(component, store) {
const ProviderWrapper = createMockProvider(store);
const container = TestUtils.renderIntoDocument(<ProviderWrapper>{component}</ProviderWrapper>);
function renderWithProvider(component, store, node) {
const ProviderWrapper = createMockProvider(store && store);
const render = node ? instance => ReactDOM.render(instance, node) : TestUtils.renderIntoDocument;
const container = render(<ProviderWrapper>{component}</ProviderWrapper>);
return TestUtils.findRenderedComponentWithType(container, component.type);
}

Просмотреть файл

@ -10,6 +10,7 @@
"classnames": "2.2.3",
"fancy-dedupe": "0.1.0",
"history": "1.17.0",
"lodash.debounce": "4.0.6",
"moment": "2.11.2",
"react": "0.14.8",
"react-dom": "0.14.8",