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 => ( -
  • - - - - - - {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 => ( +
  • + + + + + + {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; + } + } + } + } +}