diff --git a/odl_video/settings.py b/odl_video/settings.py
index 045186d1..fca2a019 100644
--- a/odl_video/settings.py
+++ b/odl_video/settings.py
@@ -537,4 +537,4 @@ def get_all_config_keys():
# Paging parameters
PAGE_SIZE_QUERY_PARAM = get_string('PAGE_SIZE_QUERY_PARAM', 'page_size')
PAGE_SIZE_MAXIMUM = get_int('PAGE_SIZE_MAXIMUM', 1000)
-PAGE_SIZE_COLLECTIONS = get_int('PAGE_SIZE_COLLECTIONS', 1000)
+PAGE_SIZE_COLLECTIONS = get_int('PAGE_SIZE_COLLECTIONS', 50)
diff --git a/static/js/actions/collectionsPagination.js b/static/js/actions/collectionsPagination.js
new file mode 100644
index 00000000..c75eb570
--- /dev/null
+++ b/static/js/actions/collectionsPagination.js
@@ -0,0 +1,56 @@
+// @flow
+import { createAction } from "redux-actions"
+import type { Dispatch } from "redux"
+import * as api from "../lib/api"
+
+
+const qualifiedName = (name: string) => `COLLECTIONS_PAGINATION_${name}`
+const constants = {}
+const actionCreators = {}
+
+constants.REQUEST_GET_PAGE = qualifiedName("REQUEST_GET_PAGE")
+actionCreators.requestGetPage = createAction(
+ constants.REQUEST_GET_PAGE)
+
+constants.RECEIVE_GET_PAGE_SUCCESS = qualifiedName("RECEIVE_GET_PAGE_SUCCESS")
+actionCreators.receiveGetPageSuccess = createAction(
+ constants.RECEIVE_GET_PAGE_SUCCESS)
+
+constants.RECEIVE_GET_PAGE_FAILURE = qualifiedName("RECEIVE_GET_PAGE_FAILURE")
+actionCreators.receiveGetPageFailure = createAction(
+ constants.RECEIVE_GET_PAGE_FAILURE)
+
+actionCreators.getPage = (opts: {page: number}) => {
+ const { page } = opts
+ const thunk = async (dispatch: Dispatch) => {
+ dispatch(actionCreators.requestGetPage({page}))
+ try {
+ const response = await api.getCollections({pagination: {page}})
+ // @TODO: ideally we would dispatch an action here to save collections to
+ // a single place in state (e.g. state.collections).
+ // However, it take a non-trivial refactor to implement this schema
+ // change. So in the interest of scope, we store collections here.
+ // This will likely be confusing for future developers, and I recommend
+ // refactoring.
+ dispatch(actionCreators.receiveGetPageSuccess({
+ page,
+ count: response.count,
+ collections: response.results,
+ numPages: response.num_pages,
+ startIndex: response.start_index,
+ endIndex: response.end_index,
+ }))
+ } catch (error) {
+ dispatch(actionCreators.receiveGetPageFailure({
+ page,
+ error
+ }))
+ }
+ }
+ return thunk
+}
+
+constants.SET_CURRENT_PAGE = qualifiedName("SET_CURRENT_PAGE")
+actionCreators.setCurrentPage = createAction(constants.SET_CURRENT_PAGE)
+
+export { actionCreators, constants }
diff --git a/static/js/actions/collectionsPagination_test.js b/static/js/actions/collectionsPagination_test.js
new file mode 100644
index 00000000..a23d5955
--- /dev/null
+++ b/static/js/actions/collectionsPagination_test.js
@@ -0,0 +1,135 @@
+// @flow
+import { assert } from "chai"
+import sinon from "sinon"
+
+import * as api from "../lib/api"
+import { constants, actionCreators } from "./collectionsPagination"
+
+
+describe("collectionsPagination actions", () => {
+ let sandbox, dispatch
+
+ beforeEach(() => {
+ sandbox = sinon.sandbox.create()
+ dispatch = sandbox.spy()
+ })
+
+ afterEach(() => {
+ sandbox.restore()
+ })
+
+ describe("basic action creators", () => {
+ [
+ {
+ actionCreatorName: "requestGetPage",
+ constantName: "REQUEST_GET_PAGE",
+ },
+ {
+ actionCreatorName: "receiveGetPageSuccess",
+ constantName: "RECEIVE_GET_PAGE_SUCCESS",
+ },
+ {
+ actionCreatorName: "receiveGetPageFailure",
+ constantName: "RECEIVE_GET_PAGE_FAILURE",
+ },
+ ].forEach((actionSpec) => {
+ it(`has ${actionSpec.actionCreatorName} actionCreator`, () => {
+ const payload = 'some payload'
+ const action = actionCreators[actionSpec.actionCreatorName](payload)
+ const expectedAction = {
+ type: constants[actionSpec.constantName],
+ payload,
+ }
+ assert.deepEqual(action, expectedAction)
+ })
+ })
+ })
+
+ describe("getPage", () => {
+ const page = 42
+ let stubs = {}
+
+ beforeEach(() => {
+ stubs = {
+ getCollections: sandbox.stub(
+ api, 'getCollections'),
+ requestGetPage: sandbox.stub(
+ actionCreators, 'requestGetPage'),
+ receiveGetPageSuccess: sandbox.stub(
+ actionCreators, 'receiveGetPageSuccess'),
+ receiveGetPageFailure: sandbox.stub(
+ actionCreators, 'receiveGetPageFailure'),
+ }
+ })
+
+ const _getPage = async () => {
+ return actionCreators.getPage({page})(dispatch)
+ }
+
+ it("dispatches REQUEST_GET_PAGE", async () => {
+ await _getPage()
+ sinon.assert.calledWith(
+ dispatch,
+ stubs.requestGetPage.returnValues[0]
+ )
+ })
+
+ it("makes api call", async () => {
+ await _getPage()
+ sinon.assert.calledWith(stubs.getCollections, {pagination: {page} })
+ })
+
+ describe("when api call succeeds", () => {
+ const response = {
+ count: 4242,
+ num_pages: 424242,
+ results: [...Array(3).keys()].map((i) => ({key: i})),
+ start_index: 99,
+ end_index: 999,
+ }
+
+ beforeEach(() => {
+ stubs.getCollections.returns(Promise.resolve(response))
+ })
+
+ it("dispatches RECEIVE_GET_PAGE_SUCCESS", async () => {
+ await _getPage()
+ sinon.assert.calledWith(
+ stubs.receiveGetPageSuccess,
+ {
+ page,
+ count: response.count,
+ collections: response.results,
+ numPages: response.num_pages,
+ startIndex: response.start_index,
+ endIndex: response.end_index,
+ }
+ )
+ sinon.assert.calledWith(
+ dispatch,
+ stubs.receiveGetPageSuccess.returnValues[0]
+ )
+ })
+ })
+
+ describe("when api call fails", async () => {
+ const error = 'some error'
+
+ beforeEach(() => {
+ stubs.getCollections.returns(Promise.reject(error))
+ })
+
+ it("dispatches RECEIVE_GET_PAGE_FAILURE", async () => {
+ await _getPage()
+ sinon.assert.calledWith(
+ stubs.receiveGetPageFailure,
+ { error, page }
+ )
+ sinon.assert.calledWith(
+ dispatch,
+ stubs.receiveGetPageFailure.returnValues[0]
+ )
+ })
+ })
+ })
+})
diff --git a/static/js/actions/index.js b/static/js/actions/index.js
index 129174b4..51289bce 100644
--- a/static/js/actions/index.js
+++ b/static/js/actions/index.js
@@ -3,7 +3,11 @@ import { deriveActions } from "redux-hammock"
import { endpoints } from "../lib/redux_rest"
-const actions: Object = {}
+import * as collectionsPagination from './collectionsPagination'
+
+const actions: Object = {
+ collectionsPagination: collectionsPagination.actionCreators,
+}
endpoints.forEach(endpoint => {
actions[endpoint.name] = deriveActions(endpoint)
})
diff --git a/static/js/components/Paginator.js b/static/js/components/Paginator.js
new file mode 100644
index 00000000..4e7e40f9
--- /dev/null
+++ b/static/js/components/Paginator.js
@@ -0,0 +1,67 @@
+// @flow
+/* global SETTINGS: false */
+import React from "react"
+
+
+class Paginator extends React.Component<*, void> {
+ render () {
+ const { currentPage, totalPages } = this.props
+ return (
+
+
+ Page {currentPage}
+ of
+ {totalPages}
+
+
+
+ { this.renderPrevNextButton("prev") }
+ { this.renderPrevNextButton("next") }
+
+
+ )
+ }
+
+ renderPrevNextButton (nextPrevType: string) {
+ const { currentPage, totalPages, onClickPrev, onClickNext } = this.props
+ let iconKey
+ let disabled = true
+ let clickHandler = this.noop
+ if (nextPrevType === 'next') {
+ iconKey = 'chevron_right'
+ if (currentPage < totalPages) {
+ clickHandler = onClickNext
+ disabled = false
+ }
+ } else if (nextPrevType === 'prev') {
+ iconKey = 'chevron_left'
+ if (currentPage > 1) {
+ clickHandler = onClickPrev
+ disabled = false
+ }
+ }
+ let className = `paginator-button paginator-${nextPrevType}-button`
+ if (disabled) {
+ className += ' disabled'
+ }
+ return (
+
+ {iconKey}
+
+ )
+ }
+
+ noop () {}
+}
+
+export default Paginator
diff --git a/static/js/components/Paginator_test.js b/static/js/components/Paginator_test.js
new file mode 100644
index 00000000..aaf1f423
--- /dev/null
+++ b/static/js/components/Paginator_test.js
@@ -0,0 +1,112 @@
+// @flow
+import React from "react"
+import sinon from "sinon"
+import { shallow } from "enzyme"
+import { assert } from "chai"
+
+import Paginator from "./Paginator"
+
+describe("Paginator", () => {
+ let sandbox, stubs, props
+
+ beforeEach(() => {
+ sandbox = sinon.sandbox.create()
+ stubs = {
+ onClickNext: sandbox.spy(),
+ onClickPrev: sandbox.spy(),
+ }
+ props = {
+ currentPage: 42,
+ totalPages: 4242,
+ onClickNext: stubs.onClickNext,
+ onClickPrev: stubs.onClickPrev,
+ }
+ })
+
+ afterEach(() => {
+ sandbox.restore()
+ })
+
+ const renderComponent = (overrides = {}) => {
+ return shallow()
+ }
+
+ it("shows current page", () => {
+ const wrapper = renderComponent()
+ assert.equal(
+ wrapper.find('.paginator-current-page').text(),
+ props.currentPage,
+ )
+ })
+
+ it("shows total pages", () => {
+ const wrapper = renderComponent()
+ assert.equal(
+ wrapper.find('.paginator-total-pages').text(),
+ props.totalPages,
+ )
+ })
+
+ describe("when in the middle of the full range", () => {
+
+ beforeEach(() => {
+ props = {
+ ...props,
+ currentPage: 2,
+ totalPages: 3
+ }
+ })
+
+ it("triggers onClickNext when next button clicked", () => {
+ const wrapper = renderComponent()
+ sinon.assert.notCalled(stubs.onClickNext)
+ wrapper.find('.paginator-next-button').simulate('click')
+ sinon.assert.called(stubs.onClickNext)
+ })
+
+ it("triggers onClickPrev when prev button clicked", () => {
+ const wrapper = renderComponent()
+ sinon.assert.notCalled(stubs.onClickPrev)
+ wrapper.find('.paginator-prev-button').simulate('click')
+ sinon.assert.called(stubs.onClickPrev)
+ })
+ })
+
+ describe("when at the end of the full range", () => {
+ beforeEach(() => {
+ props = {
+ ...props,
+ currentPage: 3,
+ totalPages: 3
+ }
+ })
+
+ it("disables the next button", () => {
+ const wrapper = renderComponent()
+ sinon.assert.notCalled(stubs.onClickNext)
+ const nextButton = wrapper.find('.paginator-next-button')
+ nextButton.simulate('click')
+ sinon.assert.notCalled(stubs.onClickNext)
+ assert.isTrue(nextButton.hasClass('disabled'))
+ })
+ })
+
+ describe("when at the beginning of the full range", () => {
+ beforeEach(() => {
+ props = {
+ ...props,
+ currentPage: 1,
+ totalPages: 3
+ }
+ })
+
+ it("disables the prev button", () => {
+ const wrapper = renderComponent()
+ sinon.assert.notCalled(stubs.onClickPrev)
+ const prevButton = wrapper.find('.paginator-prev-button')
+ prevButton.simulate('click')
+ sinon.assert.notCalled(stubs.onClickPrev)
+ assert.isTrue(prevButton.hasClass('disabled'))
+ })
+ })
+})
diff --git a/static/js/containers/CollectionListPage.js b/static/js/containers/CollectionListPage.js
index f9c101aa..17711100 100644
--- a/static/js/containers/CollectionListPage.js
+++ b/static/js/containers/CollectionListPage.js
@@ -14,91 +14,135 @@ import CollectionFormDialog from "../components/dialogs/CollectionFormDialog"
import { withDialogs } from "../components/dialogs/hoc"
import { makeCollectionUrl } from "../lib/urls"
import type { CommonUiState } from "../reducers/commonUi"
-import type { Collection } from "../flow/collectionTypes"
+import type { Collection, CollectionsPagination } from "../flow/collectionTypes"
+import withPagedCollections from './withPagedCollections'
+import LoadingIndicator from "../components/material/LoadingIndicator"
+import Paginator from "../components/Paginator"
-class CollectionListPage extends React.Component<*, void> {
+export class CollectionListPage extends React.Component<*, void> {
props: {
+ collectionsPagination: CollectionsPagination,
dispatch: Dispatch,
collections: Array,
editable: boolean,
- needsUpdate: boolean,
commonUi: CommonUiState
}
- renderCollectionLinks() {
- const { collections } = this.props
+ render() {
+ return (
+
+
+
My Collections
+ {this.renderPaginator()}
+ {this.renderCollectionLinks()}
+ {this.renderFormLink()}
+
+
+ )
+ }
- if (collections.length === 0) return null
+ renderPaginator () {
+ const { collectionsPagination } = this.props
+ const { currentPage, numPages, currentPageData } = collectionsPagination
+ if (currentPageData) {
+ currentPageData
+ }
+ return (
+ this.incrementCurrentPage(1)}
+ onClickPrev={() => this.incrementCurrentPage(-1)}
+ />
+ )
+ }
+ incrementCurrentPage (amount:number) {
+ const { currentPage, setCurrentPage } = this.props.collectionsPagination
+ if (setCurrentPage) {
+ setCurrentPage(currentPage + amount)
+ }
+ }
+
+ renderFormLink() {
return (
-
- {collections.map(collection => (
- -
-
-
- folder
-
-
-
-
- {collection.title}
-
-
- {collection.video_count} Videos
-
-
-
- ))}
-
+ SETTINGS.editable ? (
+
+ add
+ Create New Collection
+
+ ) : null
)
}
- openNewCollectionDialog = () => {
+ openNewCollectionDialog () {
const { dispatch } = this.props
-
dispatch(collectionUiActions.showNewCollectionDialog())
}
- render() {
- const formLink = SETTINGS.editable ? (
-
- add
- Create New Collection
-
- ) : null
+ renderCollectionLinks() {
+ const { currentPageData } = this.props.collectionsPagination
+ if (! currentPageData) {
+ return null
+ }
+ if (currentPageData.status === 'ERROR') {
+ return (Error!
)
+ } else if (currentPageData.status === 'LOADING') {
+ return ()
+ }
+ else if (currentPageData.status === 'LOADED') {
+ const { collections } = currentPageData
+ return (
+
+ {collections.map(collection => (
+ -
+
+
+ folder
+
+
+
+
+ {collection.title}
+
+
+ {collection.video_count} Videos
+
+
+
+ ))}
+
+ )
+ }
+ }
+}
+
+export class CollectionListPageWithDrawer extends React.Component<*, void> {
+ render () {
return (
-
-
-
My Collections
- {this.renderCollectionLinks()}
- {formLink}
-
-
+
)
}
}
const mapStateToProps = state => {
- const { collectionsList, commonUi } = state
- const collections = collectionsList.loaded ? (collectionsList.data.results ? collectionsList.data.results : collectionsList.data) : []
- const needsUpdate = !collectionsList.processing && !collectionsList.loaded
-
return {
- collections,
- commonUi,
- needsUpdate
+ commonUi: state.commonUi
}
}
-export default R.compose(
+export const ConnectedCollectionListPage = R.compose(
connect(mapStateToProps),
withDialogs([
{ name: DIALOGS.COLLECTION_FORM, component: CollectionFormDialog }
- ])
-)(CollectionListPage)
+ ]),
+ withPagedCollections
+)(CollectionListPageWithDrawer)
+
+export default ConnectedCollectionListPage
diff --git a/static/js/containers/CollectionListPage_test.js b/static/js/containers/CollectionListPage_test.js
index 1567c68b..5ba63523 100644
--- a/static/js/containers/CollectionListPage_test.js
+++ b/static/js/containers/CollectionListPage_test.js
@@ -9,9 +9,11 @@ import configureTestStore from "redux-asserts"
import { MemoryRouter, Route } from "react-router"
import CollectionListPage from "./CollectionListPage"
+import { CollectionListPage as UnconnectedCollectionListPage } from "./CollectionListPage"
import * as api from "../lib/api"
import { actions } from "../actions"
+import * as collectionsPaginationActions from "../actions/collectionsPagination"
import { SET_IS_NEW } from "../actions/collectionUi"
import { SHOW_DIALOG } from "../actions/commonUi"
import rootReducer from "../reducers"
@@ -19,14 +21,20 @@ import { makeCollection } from "../factories/collection"
import { DIALOGS } from "../constants"
describe("CollectionListPage", () => {
- let sandbox, store, collections, listenForActions
+ let sandbox, store, collections, listenForActions, collectionsPagination
beforeEach(() => {
sandbox = sinon.sandbox.create()
store = configureTestStore(rootReducer)
listenForActions = store.createListenForActions()
collections = [makeCollection(), makeCollection(), makeCollection()]
-
+ collectionsPagination = {
+ currentPage: 1,
+ currentPageData: {
+ status: 'LOADED',
+ collections
+ }
+ }
sandbox.stub(api, "getCollections").returns(Promise.resolve({results: collections}))
})
@@ -40,7 +48,8 @@ describe("CollectionListPage", () => {
await listenForActions(
[
actions.collectionsList.get.requestType,
- actions.collectionsList.get.successType
+ actions.collectionsList.get.successType,
+ collectionsPaginationActions.constants.REQUEST_GET_PAGE,
],
() => {
wrapper = mount(
@@ -59,10 +68,15 @@ describe("CollectionListPage", () => {
return wrapper
}
- it("loads the collections on load", async () => {
- await renderPage()
- assert.deepEqual(store.getState().collectionsList.data.results, collections)
- })
+ const renderUnconnectedPage = (props = {}) => {
+ props = {collectionsPagination, ...props}
+ const wrapper = mount(
+
+ )
+ if (!wrapper) throw new Error("Never will happen, make flow happy")
+ wrapper.update()
+ return wrapper
+ }
it("doesn't show the create collection button if SETTINGS.editable is false", async () => {
SETTINGS.editable = false
@@ -88,9 +102,34 @@ describe("CollectionListPage", () => {
assert.isTrue(store.getState().commonUi.drawerOpen)
})
- it("has video counts per collection", async () => {
+ describe("when page has loaded", () => {
+ it("has video counts per collection", async () => {
+ const wrapper = await renderPage()
+ const counts = wrapper.find(".mdc-list-item__secondary-text")
+ assert.equal(counts.at(0).text(), `${collections[2].video_count} Videos`)
+ })
+ })
+
+ it("has paginator", async () => {
const wrapper = await renderPage()
- const counts = wrapper.find(".mdc-list-item__secondary-text")
- assert.equal(counts.at(0).text(), `${collections[2].video_count} Videos`)
+ const paginator = wrapper.find("Paginator")
+ assert.equal(paginator.length, 1)
+ })
+
+ describe("when page.status is loading", () => {
+ it("renders loading indicator", () => {
+ collectionsPagination.currentPageData.status = 'LOADING'
+ const wrapper = renderUnconnectedPage()
+ assert.exists(wrapper.find("LoadingIndicator"))
+ })
})
+
+ describe("when page.status is error", () => {
+ it("renders error indicator", () => {
+ collectionsPagination.currentPageData.status = 'ERROR'
+ const wrapper = renderUnconnectedPage()
+ assert.exists(wrapper.find(".collection-list-page-error"))
+ })
+ })
+
})
diff --git a/static/js/containers/withPagedCollections.js b/static/js/containers/withPagedCollections.js
new file mode 100644
index 00000000..522de3ce
--- /dev/null
+++ b/static/js/containers/withPagedCollections.js
@@ -0,0 +1,90 @@
+import React from "react"
+import _ from "lodash"
+import R from "ramda"
+import { connect } from "react-redux"
+import type { Dispatch } from "redux"
+
+import { actions } from "../actions"
+
+
+export const withPagedCollections = (WrappedComponent) => {
+ return class WithPagedCollections extends React.Component<*, void> {
+ props: {
+ dispatch: Dispatch,
+ }
+ constructor (props) {
+ super(props)
+ this.setCurrentPage = this.setCurrentPage.bind(this)
+ }
+
+ render() {
+ return (
+
+ )
+ }
+
+ generatePropsForWrappedComponent () {
+ return {
+ ...(_.omit(this.props, ["needsUpdate"])),
+ collectionsPagination: {
+ ...this.props.collectionsPagination,
+ setCurrentPage: this.setCurrentPage,
+ currentPageData: this.getCurrentPageData(),
+ }
+ }
+ }
+
+ setCurrentPage (nextCurrentPage: number) {
+ this.props.dispatch(actions.collectionsPagination.setCurrentPage({
+ currentPage: nextCurrentPage
+ }))
+ }
+
+ getCurrentPageData () {
+ const { collectionsPagination } = this.props
+ if (collectionsPagination && collectionsPagination.pages
+ && collectionsPagination.currentPage
+ ) {
+ return collectionsPagination.pages[collectionsPagination.currentPage]
+ }
+ return undefined
+ }
+
+ componentDidMount () {
+ this.updateCurrentPageIfNeedsUpdate()
+ }
+
+ componentDidUpdate () {
+ this.updateCurrentPageIfNeedsUpdate()
+ }
+
+ updateCurrentPageIfNeedsUpdate () {
+ if (this.props.needsUpdate) {
+ this.updateCurrentPage()
+ }
+ }
+
+ updateCurrentPage () {
+ this.props.dispatch(
+ actions.collectionsPagination.getPage({
+ page: this.props.collectionsPagination.currentPage,
+ })
+ )
+ }
+ }
+}
+
+export const mapStateToProps = (state) => {
+ const { collectionsPagination } = state
+ const { currentPage, pages } = collectionsPagination
+ const needsUpdate = (pages && (pages[currentPage] === undefined))
+ return {
+ collectionsPagination,
+ needsUpdate
+ }
+}
+
+export default R.compose(
+ connect(mapStateToProps),
+ withPagedCollections
+)
diff --git a/static/js/containers/withPagedCollections_test.js b/static/js/containers/withPagedCollections_test.js
new file mode 100644
index 00000000..290b0116
--- /dev/null
+++ b/static/js/containers/withPagedCollections_test.js
@@ -0,0 +1,161 @@
+// @flow
+/* global SETTINGS: false */
+import React from "react"
+import { assert } from "chai"
+import sinon from "sinon"
+import { mount } from "enzyme"
+
+import { actions } from "../actions"
+
+import { mapStateToProps, withPagedCollections } from './withPagedCollections'
+
+
+describe("withPagedCollections", () => {
+ let sandbox
+
+ beforeEach(() => {
+ sandbox = sinon.sandbox.create()
+ })
+
+ afterEach(() => {
+ sandbox.restore()
+ })
+
+ describe("mapStateToProps", () => {
+ describe("needsUpdate", () => {
+ it("is true when current page state is undefined", () => {
+ const state = {
+ collectionsPagination: {
+ count: 0,
+ currentPage: 42,
+ pages: {},
+ }
+ }
+ const props = mapStateToProps(state)
+ assert.equal(props.needsUpdate, true)
+ })
+
+ it("is false when current page status is defined", () => {
+ const state = {
+ collectionsPagination: {
+ count: 0,
+ currentPage: 42,
+ pages: {
+ "42": {}
+ },
+ }
+ }
+ const props = mapStateToProps(state)
+ assert.equal(props.needsUpdate, false)
+ })
+ })
+
+ it("passes collectionsPagination state", () => {
+ const state = {
+ collectionsPagination: {
+ someKey: 'someValue',
+ someOtherKey: 'someOtherValue',
+ }
+ }
+ const props = mapStateToProps(state)
+ assert.equal(props.collectionsPagination, state.collectionsPagination)
+ })
+ })
+
+ describe("WrappedComponent", () => {
+ class DummyComponent extends React.Component<*, void> {
+ render () {
+ return (DummyComponent
)
+ }
+ }
+
+ const WrappedComponent = withPagedCollections(DummyComponent)
+
+ describe("page updates", () => {
+ let stubs
+
+ beforeEach(() => {
+ stubs = {
+ updateCurrentPage: sandbox.stub(
+ WrappedComponent.prototype,
+ "updateCurrentPage"
+ ),
+ }
+ })
+
+ describe("when needsUpdate is true", () => {
+ it("calls updateCurrentPage", () => {
+ mount()
+ sinon.assert.called(stubs.updateCurrentPage)
+ })
+ })
+
+ describe("when pageNeedsUpdate is false", () => {
+ it("does not dispatch getPage action", () => {
+ mount()
+ sinon.assert.notCalled(stubs.updateCurrentPage)
+ })
+ })
+ })
+
+ describe("updateCurrentPage", () => {
+ let stubs
+
+ beforeEach(() => {
+ stubs = {
+ dispatch: sandbox.spy(),
+ getPage: sandbox.stub(actions.collectionsPagination, 'getPage'),
+ }
+ })
+
+ it("dispatches getPage action with currentPage", () => {
+ const currentPage = 42
+ mount(
+
+ )
+ sinon.assert.calledWith(
+ stubs.getPage,
+ {page: currentPage}
+ )
+ sinon.assert.calledWith(stubs.dispatch, stubs.getPage.returnValues[0])
+ })
+ })
+
+ describe("generatePropsForWrappedComponent", () => {
+ it("passes on expected props", () => {
+ const extraProps = {someKey: 'someVal', someOtherKey: 'someOtherVal'}
+ const currentPage = 42
+ const collectionsPagination = {
+ currentPage,
+ pages: {
+ [currentPage]: {
+ somePageDataKey: 'somePageDataValue'
+ }
+ }
+ }
+ const wrapper = mount(
+
+ )
+ const wrapped = wrapper.find('DummyComponent')
+ assert.deepEqual(
+ wrapped.props(),
+ {
+ ...extraProps,
+ collectionsPagination: {
+ ...collectionsPagination,
+ setCurrentPage: wrapper.instance().setCurrentPage,
+ currentPageData: collectionsPagination.pages[currentPage],
+ }
+ }
+ )
+ })
+ })
+ })
+})
diff --git a/static/js/flow/collectionTypes.js b/static/js/flow/collectionTypes.js
index 587103c7..8b4bc4af 100644
--- a/static/js/flow/collectionTypes.js
+++ b/static/js/flow/collectionTypes.js
@@ -41,3 +41,19 @@ export type CollectionUiState = {
selectedVideoKey: ?string,
errors?: CollectionValidation
};
+
+export type CollectionsPage = {
+ collections: Array,
+ status: string,
+}
+
+export type CollectionsPagination = {
+ count: number,
+ currentPage: number,
+ currentPageData?: CollectionsPage,
+ numPages?: number,
+ pages: {
+ [string|number]: CollectionsPage,
+ },
+ setCurrentPage?: (nextCurrentPage: number) => void,
+}
diff --git a/static/js/lib/api.js b/static/js/lib/api.js
index 76d2dd3e..d53ca456 100644
--- a/static/js/lib/api.js
+++ b/static/js/lib/api.js
@@ -1,4 +1,5 @@
// @flow
+import _ from 'lodash'
import {
fetchJSONWithCSRF,
fetchWithCSRF
@@ -9,8 +10,23 @@ export type VideoUpdatePayload = {
description?: string
}
-export function getCollections() {
- return fetchJSONWithCSRF(`/api/v0/collections/`)
+export type PaginationParams = {
+ page: (string|number),
+}
+
+export function getCollections(opts:{pagination?:PaginationParams} = {}) {
+ const { pagination } = opts
+ let url = "/api/v0/collections/"
+ if (pagination) {
+ url += `?${makeQueryParamsStr(pagination)}`
+ }
+ return fetchJSONWithCSRF(url)
+}
+
+export function makeQueryParamsStr (queryParams: {[string]:(string|number)}): string {
+ return _.map(queryParams, (v, k) => {
+ return [k, v].map(encodeURIComponent).join('=')
+ }).join('&')
}
export function getCollection(collectionKey: string) {
diff --git a/static/js/lib/api_test.js b/static/js/lib/api_test.js
index b502f20d..2fffb809 100644
--- a/static/js/lib/api_test.js
+++ b/static/js/lib/api_test.js
@@ -32,13 +32,21 @@ describe("api", () => {
sandbox.restore()
})
- it("gets collection list", async () => {
- const collections = _.times(2, () => makeCollection())
- fetchStub.returns(Promise.resolve({results: collections}))
+ describe("getCollections", () => {
+ it("gets collection list", async () => {
+ const collections = _.times(2, () => makeCollection())
+ fetchStub.returns(Promise.resolve({results: collections}))
+
+ const result = await getCollections()
+ sinon.assert.calledWith(fetchStub, `/api/v0/collections/`)
+ assert.deepEqual(result.results, collections)
+ })
- const result = await getCollections()
- sinon.assert.calledWith(fetchStub, `/api/v0/collections/`)
- assert.deepEqual(result.results, collections)
+ it("sends pagination parameters", async () => {
+ const paginationParams = {page: 2}
+ await getCollections({pagination: paginationParams})
+ sinon.assert.calledWith(fetchStub, `/api/v0/collections/?page=2`)
+ })
})
it("creates a new collection", async () => {
@@ -145,4 +153,5 @@ describe("api", () => {
}
assert.deepEqual(result, expectedResult)
})
+
})
diff --git a/static/js/reducers/collectionsPagination.js b/static/js/reducers/collectionsPagination.js
new file mode 100644
index 00000000..3559c96a
--- /dev/null
+++ b/static/js/reducers/collectionsPagination.js
@@ -0,0 +1,72 @@
+// @flow
+import type { Action } from "../flow/reduxTypes"
+import type { CollectionsPagination } from "../flow/collectionTypes"
+
+import { constants } from "../actions/collectionsPagination"
+
+export const INITIAL_COLLECTIONS_PAGINATION_STATE:CollectionsPagination = {
+ count: 0,
+ currentPage: 1,
+ pages: {},
+}
+
+const generateInitialPageState = () => ({
+ status: null,
+ collections: [],
+})
+
+const reducer = (
+ state:CollectionsPagination = INITIAL_COLLECTIONS_PAGINATION_STATE,
+ action: Action
+) => {
+ switch (action.type) {
+ case constants.REQUEST_GET_PAGE:
+ return {
+ ...state,
+ pages: {
+ ...state.pages,
+ [action.payload.page]: {
+ ...generateInitialPageState(),
+ status: 'LOADING',
+ }
+ }
+ }
+ case constants.RECEIVE_GET_PAGE_SUCCESS:
+ return {
+ ...state,
+ count: action.payload.count,
+ numPages: action.payload.numPages,
+ pages: {
+ ...state.pages,
+ [action.payload.page]: {
+ ...state.pages[action.payload.page],
+ collections: action.payload.collections,
+ startIndex: action.payload.startIndex,
+ endIndex: action.payload.endIndex,
+ status: 'LOADED',
+ }
+ }
+ }
+ case constants.RECEIVE_GET_PAGE_FAILURE:
+ return {
+ ...state,
+ pages: {
+ ...state.pages,
+ [action.payload.page]: {
+ ...state.pages[action.payload.page],
+ status: 'ERROR',
+ error: action.payload.error,
+ }
+ }
+ }
+ case constants.SET_CURRENT_PAGE:
+ return {
+ ...state,
+ currentPage: action.payload.currentPage,
+ }
+ default:
+ return state
+ }
+}
+
+export default reducer
diff --git a/static/js/reducers/collectionsPagination_test.js b/static/js/reducers/collectionsPagination_test.js
new file mode 100644
index 00000000..9ef80d8c
--- /dev/null
+++ b/static/js/reducers/collectionsPagination_test.js
@@ -0,0 +1,138 @@
+// @flow
+import { assert } from "chai"
+import sinon from "sinon"
+import configureTestStore from "redux-asserts"
+
+import rootReducer from "../reducers"
+import { actions } from "../actions"
+
+
+describe("collectionsPagination reducer", () => {
+ let store, sandbox
+
+ beforeEach(() => {
+ store = configureTestStore(rootReducer)
+ sandbox = sinon.sandbox.create()
+ })
+
+ afterEach(() => {
+ sandbox.restore()
+ })
+
+ const getPaginationState = () => {
+ return store.getState().collectionsPagination
+ }
+
+ const getPageState = (page) => {
+ return getPaginationState().pages[page]
+ }
+
+ const dispatchRequestGetPage = (page) => {
+ store.dispatch(actions.collectionsPagination.requestGetPage({page}))
+ }
+
+ it("has initial state", () => {
+ const expectedInitialState = {
+ currentPage: 1,
+ count: 0,
+ pages: {},
+ }
+ assert.deepEqual(getPaginationState(), expectedInitialState)
+ })
+
+ describe("REQUEST_GET_PAGE", () => {
+
+ it("sets page status to loading", async () => {
+ const page = 42
+ assert.notExists(getPageState(page))
+ dispatchRequestGetPage(page)
+ assert.deepEqual(getPageState(page).status, 'LOADING')
+ })
+ })
+
+ describe("RECEIVE_GET_PAGE_SUCCESS", () => {
+ const page = 42
+ const count = 4242
+ const numPages = 37
+ const collections = [...Array(3).keys()].map((i) => {
+ return {"title": `collection${i}`}
+ })
+ const startIndex = 1
+ const endIndex = 4
+
+ const dispatchReceiveGetPageSuccess = () => {
+ store.dispatch(actions.collectionsPagination.receiveGetPageSuccess({
+ page,
+ numPages,
+ count,
+ startIndex,
+ endIndex,
+ collections,
+ }))
+ }
+
+ beforeEach(() => {
+ dispatchRequestGetPage(page)
+ })
+
+ it("updates count", async () => {
+ assert.equal(getPaginationState().count, 0)
+ dispatchReceiveGetPageSuccess()
+ assert.equal(getPaginationState().count, count)
+ })
+
+ it("updates numPages", async () => {
+ assert.notEqual(getPaginationState().numPages, numPages)
+ dispatchReceiveGetPageSuccess()
+ assert.equal(getPaginationState().numPages, numPages)
+ })
+
+ it("updates page data", async () => {
+ const expectedPageData = {
+ status: 'LOADED',
+ collections,
+ startIndex,
+ endIndex,
+ }
+ assert.notDeepEqual(getPageState(page), expectedPageData)
+ dispatchReceiveGetPageSuccess()
+ assert.deepEqual(getPageState(page), expectedPageData)
+ })
+ })
+
+ describe("RECEIVE_GET_PAGE_FAILURE", () => {
+
+ const page = 42
+ const error = 'some_error'
+
+ const dispatchReceiveGetPageFailure = () => {
+ store.dispatch(actions.collectionsPagination.receiveGetPageFailure({page, error}))
+ }
+
+ beforeEach(() => {
+ dispatchRequestGetPage(page)
+ })
+
+ it("updates page status", async () => {
+ assert.notEqual(getPageState(page).status, 'ERROR')
+ dispatchReceiveGetPageFailure()
+ assert.equal(getPageState(page).status, 'ERROR')
+ })
+
+ it("updates page error", async () => {
+ assert.notExists(getPageState(page).error)
+ dispatchReceiveGetPageFailure()
+ assert.equal(getPageState(page).error, error)
+ })
+ })
+
+ describe("SET_CURRENT_PAGE", () => {
+ it("sets currentPage", () => {
+ const currentPage = 42
+ assert.notEqual(getPaginationState().currentPage, currentPage)
+ store.dispatch(actions.collectionsPagination.setCurrentPage({currentPage}))
+ assert.equal(getPaginationState().currentPage, currentPage)
+ })
+ })
+
+})
diff --git a/static/js/reducers/index.js b/static/js/reducers/index.js
index 7442a533..fdbf1ce5 100644
--- a/static/js/reducers/index.js
+++ b/static/js/reducers/index.js
@@ -5,10 +5,12 @@ import { deriveReducers } from "redux-hammock"
import { actions } from "../actions"
import { endpoints } from "../lib/redux_rest"
import commonUi from "./commonUi"
+import collectionsPagination from "./collectionsPagination"
import collectionUi from "./collectionUi"
import videoUi from "./videoUi"
const reducers: Object = {
+ collectionsPagination,
commonUi,
collectionUi,
videoUi
diff --git a/static/scss/layout.scss b/static/scss/layout.scss
index 725a0741..b371c20c 100644
--- a/static/scss/layout.scss
+++ b/static/scss/layout.scss
@@ -31,3 +31,4 @@
@import 'help-page';
@import 'terms-page';
@import 'mdlDataTable';
+@import 'paginator';
diff --git a/static/scss/paginator.scss b/static/scss/paginator.scss
new file mode 100644
index 00000000..c4ef43e3
--- /dev/null
+++ b/static/scss/paginator.scss
@@ -0,0 +1,19 @@
+.paginator {
+
+ .paginator-button {
+ font-weight: bold;
+
+ &.disabled {
+ opacity: .4;
+
+ .material-icons {
+ cursor: inherit;
+
+ &:hover {
+ color: inherit;
+ opacity: inherit;
+ }
+ }
+ }
+ }
+}