diff --git a/scripts/build-client-js.sh b/scripts/build-client-js.sh index afcbf26811..ea9eaa2241 100755 --- a/scripts/build-client-js.sh +++ b/scripts/build-client-js.sh @@ -13,6 +13,7 @@ cross-env BABEL_ENV="browser" browserify -t [babelify] \ revision.js \ search.js \ statistics.js \ + recent-imports.js \ -p [ factor-bundle \ -o ../../../static/js/entity-editor.js \ -o ../../../static/js/editor/edit.js \ @@ -25,5 +26,6 @@ cross-env BABEL_ENV="browser" browserify -t [babelify] \ -o ../../../static/js/revision.js \ -o ../../../static/js/search.js \ -o ../../../static/js/statistics.js \ + -o ../../../static/js/recent-imports.js \ ] > ../../../static/js/bundle.js popd diff --git a/scripts/watch-client-js.sh b/scripts/watch-client-js.sh index e985025705..8075e3b8d3 100755 --- a/scripts/watch-client-js.sh +++ b/scripts/watch-client-js.sh @@ -13,6 +13,7 @@ cross-env BABEL_ENV="browser" watchify -t [babelify] \ revision.js \ search.js \ statistics.js \ + recent-imports.js \ -p [ factor-bundle \ -o ../../../static/js/entity-editor.js \ -o ../../../static/js/editor/edit.js \ @@ -25,5 +26,6 @@ cross-env BABEL_ENV="browser" watchify -t [babelify] \ -o ../../../static/js/revision.js \ -o ../../../static/js/search.js \ -o ../../../static/js/statistics.js \ + -o ../../../static/js/recent-imports.js \ ] -o ../../../static/js/bundle.js -dv popd diff --git a/src/client/components/pages/parts/recent-import-results.js b/src/client/components/pages/parts/recent-import-results.js new file mode 100644 index 0000000000..63abefe159 --- /dev/null +++ b/src/client/components/pages/parts/recent-import-results.js @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2018 Shivam Tripathi + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import * as bootstrap from 'react-bootstrap'; +import * as utilsHelper from '../../../helpers/utils'; +import PropTypes from 'prop-types'; +import React from 'react'; + + +const {formatDate, getImportUrl} = utilsHelper; +const {Table} = bootstrap; + +/** + * Renders the document and displays the recentImports table. + * @returns {ReactElement} a HTML document which displays the recentImports + */ + +function RecentImportsTable(props) { + const {offset, recentImports} = props; + return ( +
+

Click to review them!

+ + + + + + + + + + + + { + recentImports.map((imports, i) => { + const type = imports.type.toLowerCase(); + const {import_id: id, importedAt} = imports; + return ( + + + + + + + + ); + }) + } + +
#NameTypeDate AddedSource
{i + 1 + offset} + + {imports.defaultAlias.name} + + {imports.type} + {formatDate(new Date(importedAt))} + + {imports.source} +
+
+ ); +} + +RecentImportsTable.propTypes = { + offset: PropTypes.number.isRequired, + recentImports: PropTypes.array.isRequired +}; + +export default RecentImportsTable; diff --git a/src/client/components/pages/recent-imports.js b/src/client/components/pages/recent-imports.js new file mode 100644 index 0000000000..7468219405 --- /dev/null +++ b/src/client/components/pages/recent-imports.js @@ -0,0 +1,106 @@ +import * as bootstrap from 'react-bootstrap'; +import PaginationProps from '../../helpers/pagination-props'; +import PropTypes from 'prop-types'; +import React from 'react'; +import RecentImportsTable from './parts/recent-import-results'; +import request from 'superagent-bluebird-promise'; + + +const {PageHeader, Pagination} = bootstrap; + +class RecentImports extends React.Component { + constructor(props) { + super(props); + + this.paginationPropsGenerator = PaginationProps({ + displayedPagesRange: 10, + itemsPerPage: props.limit + }); + + this.state = { + currentPage: props.currentPage, + offset: 0, + paginationProps: { + hasBeginningPage: false, + hasEndPage: false, + hasNextPage: false, + hasPreviousPage: false, + totalPages: 0 + }, + recentImports: [] + }; + + this.handleClick = this.handleClick.bind(this); + this.handleCb = this.handleCb.bind(this); + } + + componentDidMount() { + this.handleClick(this.state.currentPage); + } + + componentDidUpdate() { + window.history.replaceState( + null, null, `?page=${this.state.currentPage}` + ); + } + + async handleClick(pageNumber) { + const {currentPage, limit, offset, totalResults, recentImports} = + await request.get(`/imports/recent/raw?page=${pageNumber}`) + .then((res) => JSON.parse(res.text)); + + const paginationProps = this.paginationPropsGenerator( + totalResults, currentPage + ); + + this.setState({ + currentPage, limit, offset, paginationProps, recentImports, + totalResults + }); + } + + handleCb() { + this.handleClick(this.state.currentPage + 1); + } + + render() { + const {currentPage, limit, totalResults, paginationProps} = this.state; + return ( +
+ Recent Imports +

The following data has been imported recently.

+ +

{`Displaying ${limit} of ${totalResults} results`}

+ +
+ ); + } +} + +RecentImports.displayName = 'RecentImports'; +RecentImports.propTypes = { + currentPage: PropTypes.number, + limit: PropTypes.number +}; +RecentImports.defaultProps = { + currentPage: 1, + limit: 10 +}; + +export default RecentImports; + diff --git a/src/client/containers/layout.js b/src/client/containers/layout.js index 23e77823cd..d0c82b0938 100644 --- a/src/client/containers/layout.js +++ b/src/client/containers/layout.js @@ -140,6 +140,12 @@ class Layout extends React.Component { {' Statistics '} + {!(homepage || hideSearch) &&
+ + +); + +ReactDOM.hydrate(markup, document.getElementById('target')); diff --git a/src/client/helpers/pagination-props.js b/src/client/helpers/pagination-props.js new file mode 100644 index 0000000000..be5252a88e --- /dev/null +++ b/src/client/helpers/pagination-props.js @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2018 Shivam Tripathi + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +// @flow + +/* + ______ _ ______ _ +| ___ \ | | | ___ \ (_) +| |_/ / ___ ___ | | __| |_/ / _ __ __ _ _ _ __ ____ +| ___ \ / _ \ / _ \ | |/ /| ___ \| '__|/ _` || || '_ \ |_ / +| |_/ /| (_) || (_) || < | |_/ /| | | (_| || || | | | / / +\____/ \___/ \___/ |_|\_\\____/ |_| \__,_||_||_| |_|/___| + +*/ + +import _ from 'lodash'; + + +type getPaginationPropsGeneratorType = { + displayedPagesRange: number, + itemsPerPage: number +}; + +/** Pagination::validateDefault - Validates, if invalid passed default + * @param {number} currentPage - The current active page + * @param {number} totalResults - Total number of results + * @param {number} itemsPerPage - number of items per page + * @returns {Object} - Returns an object encapsulating validated + * currentPage, totalResults, totalPages + */ +function validatePaginationArgs( + currentPage: number, + totalResults: number, + itemsPerPage: number +) { + const totalPages: number = Math.ceil(totalResults / itemsPerPage); + const args = {currentPage, totalPages, totalResults}; + if (!args.currentPage || + !_.isNumber(args.currentPage) || + args.currentPage < 1 + ) { + args.currentPage = 1; + } + + if (!args.totalPages || + !_.isNumber(args.totalPages) || + args.totalPages < 1 + ) { + args.totalPages = 1; + args.totalResults = itemsPerPage; + } + + if (args.currentPage > args.totalPages) { + args.currentPage = args.totalPages; + } + return args; +} + +/** getPaginationPropsGenerator - Takes in number of pages and items per page, + * defaults to 10, 25 and returns propsGenerator function + * @param {object} args - encapsulates args + * @param {number} args.displayedPagesRange - Range of pages surrounding the + * current page to be displayed + * @param {number} args.itemsPerPage - Number of items per page + * @returns {function} - Returns a function which give pagination props given + * currentPage and totalResults + */ +export default function getPaginationPropsGenerator( + args: getPaginationPropsGeneratorType +) { + const {displayedPagesRange = 10, itemsPerPage = 25} = args; + + /** + * @param {number} totalRes - Total number of results + * @param {number} curPage - Current page + * @returns {object} Returns result encapsulating pagination details + */ + return function getDetails(totalRes: number, curPage: number) { + // Extract results, clean them up + const {currentPage, totalPages, totalResults} = + validatePaginationArgs(curPage, totalRes, itemsPerPage); + + // The first and last page links to be displayed, exactly halfway left + // and right from the currentpage + const halfwayDistance: number = + Math.floor(displayedPagesRange / 2); + let firstPage: number = Math.max(1, currentPage - halfwayDistance); + let lastPage: number = + Math.min(totalPages, currentPage + halfwayDistance); + + // Incase number of pages lying in [firstPage, lastPage] are not + // covering the range due to being at extremes, we adjust the range + const defaultRange: number = lastPage - firstPage + 1; + const defaultDiffInRange = displayedPagesRange - defaultRange; + if (defaultDiffInRange) { + if (lastPage < totalPages) { + lastPage = Math.min(lastPage + defaultDiffInRange, totalPages); + } + if (firstPage > 1) { + firstPage = Math.max(firstPage - defaultDiffInRange, 1); + } + } + + // First result on current page + const firstResultOnCurrentPage: number = _.clamp( + displayedPagesRange * (currentPage - 1), 1, totalResults + ); + const lastResultOnCurrentPage: number = _.clamp( + displayedPagesRange * currentPage, 1, totalResults + ); + + return { + beginningPage: 1, + currentPage, + endPage: totalPages, + firstPage, + firstResultOnCurrentPage, + hasBeginningPage: totalPages > 1, + hasEndPage: totalPages > 1, + hasNextPage: currentPage < totalPages, + hasPreviousPage: (currentPage - 1) > 0, + itemsPerPage, + lastPage, + lastResultOnCurrentPage, + nextPage: currentPage + 1, + pagesRange: _.clamp( + lastPage - firstPage + 1, + 1, totalPages + ), + previousPage: currentPage - 1, + resultsRange: _.clamp( + lastResultOnCurrentPage - firstResultOnCurrentPage + 1, + 1, totalResults + ), + totalPages, + totalResults + }; + }; +} diff --git a/src/client/helpers/utils.js b/src/client/helpers/utils.js index 26cd784796..7c5c8e61c5 100644 --- a/src/client/helpers/utils.js +++ b/src/client/helpers/utils.js @@ -53,3 +53,8 @@ const MILLISECONDS_PER_DAY = 86400000; export function isWithinDayFromNow(date) { return Boolean(Date.now() - date.getTime() < MILLISECONDS_PER_DAY); } + +export function getImportUrl(importType, importId) { + return `/imports/${importType.toLowerCase()}/${importId}`; +} + diff --git a/src/server/routes.js b/src/server/routes.js index 20e4fec9b3..58ad28a689 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -21,6 +21,7 @@ import authRouter from './routes/auth'; import creatorRouter from './routes/entity/creator'; import editionRouter from './routes/entity/edition'; import editorRouter from './routes/editor'; +import importRouter from './routes/imports'; import indexRouter from './routes/index'; import publicationRouter from './routes/entity/publication'; import publisherRouter from './routes/entity/publisher'; @@ -37,6 +38,7 @@ function initRootRoutes(app) { app.use('/search', searchRouter); app.use('/register', registerRouter); app.use('/statistics', statisticsRouter); + app.use('/imports', importRouter); } function initPublicationRoutes(app) { diff --git a/src/server/routes/imports/index.js b/src/server/routes/imports/index.js new file mode 100644 index 0000000000..59ac944eb8 --- /dev/null +++ b/src/server/routes/imports/index.js @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2018 Shivam Tripathi + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import ImportRecentRouter from './recent'; +import express from 'express'; + + +const importRouter = express.Router(); + +importRouter.use('/recent', ImportRecentRouter); + +export default importRouter; diff --git a/src/server/routes/imports/recent.js b/src/server/routes/imports/recent.js new file mode 100644 index 0000000000..bf7976cba5 --- /dev/null +++ b/src/server/routes/imports/recent.js @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2018 Shivam Tripathi + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import * as propHelpers from '../../../client/helpers/props'; +import {escapeProps, generateProps} from '../../helpers/props'; +import Layout from '../../../client/containers/layout'; +import React from 'react'; +import ReactDOMServer from 'react-dom/server'; +import RecentImports from '../../../client/components/pages/recent-imports'; +import express from 'express'; + + +// The limit for number of results fetched from database +const LIMIT = 10; + +const router = express.Router(); + +/* Function to fetch data from the database and create the object to be sent as + response */ +function fetchRecentImportsData(orm, page) { + let pageNumber = page; + + return orm.bookshelf.transaction(async (transacting) => { + // First fetch total imports + const totalResults = + await orm.func.imports.getTotalImports(transacting); + + if (totalResults < ((pageNumber - 1) * LIMIT)) { + pageNumber = Math.ceil(totalResults / LIMIT); + } + const offset = (pageNumber - 1) * LIMIT; + + // Now fetch recent imports according to the generated offset + const recentImports = await orm.func.imports.getRecentImports( + orm, transacting, LIMIT, offset + ); + + return { + currentPage: pageNumber, + limit: LIMIT, + offset, + recentImports, + totalResults + }; + }); +} + +// This handles the router to send initial container to hold recentImports data +function recentImportsRoute(req, res) { + const queryPage = parseInt(req.query.page, 10) || 1; + const props = generateProps(req, res, { + currentPage: queryPage, limit: LIMIT + }); + + const markup = ReactDOMServer.renderToString( + + + + ); + + res.render('target', { + markup, + props: escapeProps(props), + script: '/js/recent-imports.js', + title: 'Recent Imports' + }); +} + +// This handles the data fetching route for recent imports, sends JSON as res +async function rawRecentImportsRoute(req, res) { + const {orm} = req.app.locals; + const queryPage = parseInt(req.query.page, 10) || 1; + const recentImportsData = await fetchRecentImportsData(orm, queryPage); + + res.send(recentImportsData); +} + +router.get('/', recentImportsRoute); +router.get('/raw', rawRecentImportsRoute); + +export default router;