From 2f41bb9283fed1ca35d4be9c019073a68b0f6145 Mon Sep 17 00:00:00 2001
From: Jure Triglav <juretriglav@gmail.com>
Date: Mon, 22 Jun 2020 01:19:38 +0200
Subject: [PATCH] feat: pagination in users manager/overview

---
 app/components/NextPageButton/index.js        |   2 +-
 .../src/SuperChatInput/SuperChatInput.jsx     |   2 +-
 .../component-profile/src/Profile.jsx         |   2 +-
 .../src/Pagination.jsx                        | 140 ++++++++++++++++++
 .../component-users-manager/src/User.jsx      |  15 +-
 .../src/UsersManager.jsx                      |  37 +++--
 .../component-users-manager/src/style.js      |  34 ++++-
 .../src/Messages => shared}/Icon.jsx          |   0
 app/components/{ => shared}/Spinner.jsx       |   0
 app/theme/elements/Button.js                  |   5 +-
 app/theme/elements/GlobalStyle.js             |   5 +-
 app/theme/index.js                            |   2 +-
 profiles/default_avatar.svg                   |  13 ++
 .../src/{graphql/index.js => graphql.js}      |   3 +-
 server/model-user/src/graphql.js              |  16 +-
 15 files changed, 240 insertions(+), 36 deletions(-)
 create mode 100644 app/components/component-users-manager/src/Pagination.jsx
 rename app/components/{component-chat/src/Messages => shared}/Icon.jsx (100%)
 rename app/components/{ => shared}/Spinner.jsx (100%)
 create mode 100644 profiles/default_avatar.svg
 rename server/model-message/src/{graphql/index.js => graphql.js} (97%)

diff --git a/app/components/NextPageButton/index.js b/app/components/NextPageButton/index.js
index ed990882a8..8877faea06 100644
--- a/app/components/NextPageButton/index.js
+++ b/app/components/NextPageButton/index.js
@@ -2,7 +2,7 @@ import VisibilitySensor from 'react-visibility-sensor'
 import { Link } from 'react-router-dom'
 import React from 'react'
 import PropTypes from 'prop-types'
-import Spinner from '../Spinner'
+import Spinner from '../shared/Spinner'
 import { HasNextPage, NextPageButton } from './style'
 
 const NextPageButtonWrapper = props => {
diff --git a/app/components/component-chat/src/SuperChatInput/SuperChatInput.jsx b/app/components/component-chat/src/SuperChatInput/SuperChatInput.jsx
index a6b54538df..6b3f494e42 100644
--- a/app/components/component-chat/src/SuperChatInput/SuperChatInput.jsx
+++ b/app/components/component-chat/src/SuperChatInput/SuperChatInput.jsx
@@ -7,7 +7,7 @@ import { useMutation } from '@apollo/react-hooks'
 
 // import compose from 'recompose/compose';
 // import { connect } from 'react-redux';
-import Icon from '../Messages/Icon'
+import Icon from '../../../shared/Icon'
 // import { addToastWithTimeout } from 'src/actions/toasts';
 // import { openModal } from 'src/actions/modals';
 // import { replyToMessage } from 'src/actions/message';
diff --git a/app/components/component-profile/src/Profile.jsx b/app/components/component-profile/src/Profile.jsx
index 7078609b74..23a6e81fdd 100644
--- a/app/components/component-profile/src/Profile.jsx
+++ b/app/components/component-profile/src/Profile.jsx
@@ -6,7 +6,7 @@ import gql from 'graphql-tag'
 import { useQuery } from '@apollo/react-hooks'
 import { useDropzone } from 'react-dropzone'
 
-import Spinner from '../../Spinner'
+import Spinner from '../../shared/Spinner'
 import ChangeUsername from './ChangeUsername'
 import { BigProfileImage } from './ProfileImage'
 import PageWithHeader from './PageWithHeader'
diff --git a/app/components/component-users-manager/src/Pagination.jsx b/app/components/component-users-manager/src/Pagination.jsx
new file mode 100644
index 0000000000..3c13cd0ae5
--- /dev/null
+++ b/app/components/component-users-manager/src/Pagination.jsx
@@ -0,0 +1,140 @@
+import React from 'react'
+import { Action } from '@pubsweet/ui'
+import styled, { css } from 'styled-components'
+import { th, grid } from '@pubsweet/ui-toolkit'
+// TODO: Standardize shared components
+import Icon from '../../shared/Icon'
+
+const Page = styled.div`
+  // height: ${grid(3)};
+  // padding: ${grid(1)};
+  line-height: ${grid(5)};
+
+  ${props =>
+    props.active &&
+    css`
+      color: ${th('colorPrimary')};
+    `}
+`
+
+const Pagination = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: ${grid(2)} ${grid(3)};
+`
+
+const PaginationInfo = styled.div``
+
+const Paginators = styled.div`
+  display: inline-flex;
+`
+
+const PreviousButton = styled.div`
+  border: 1px solid ${th('colorFurniture')};
+
+  svg {
+    stroke: ${th('colorFurniture')};
+  }
+
+  button {
+    padding: ${grid(1)};
+    line-height: ${grid(5)};
+    width: ${grid(5)};
+    cursor: pointer;
+    svg {
+      stroke: ${th('colorText')};
+    }
+  }
+`
+
+const NextButton = styled(PreviousButton)`
+  margin-left: -1px;
+
+  button {
+    &:before {
+      margin-left: -1px;
+    }
+  }
+`
+
+const Paginator = styled.div`
+  margin-left: -1px;
+  border: 1px solid ${th('colorFurniture')};
+  color: ${th('colorFurniture')};
+  text-align: center;
+  width: ${grid(5)};
+  svg {
+    stroke: ${th('colorFurniture')};
+  }
+
+  button {
+    width: ${grid(5)};
+    line-height: ${grid(5)};
+    cursor: pointer;
+    color: ${th('colorText')};
+    &:before {
+      margin-left: -1px;
+    }
+  }
+`
+
+const PaginationContainer = ({ setPage, limit, page, totalCount }) => {
+  const Previous = () => (
+    <PreviousButton>
+      <Action disabled={page <= 1} onClick={() => setPage(page - 1)}>
+        <Icon noPadding>chevron_left</Icon>
+      </Action>
+    </PreviousButton>
+  )
+
+  const Next = () => {
+    const lastPage = page >= pages.length
+    return (
+      <NextButton>
+        <Action disabled={lastPage} onClick={() => setPage(page + 1)}>
+          <Icon noPadding>chevron_right</Icon>
+        </Action>
+      </NextButton>
+    )
+  }
+
+  const PageNumber = ({ pageNumber }) => {
+    const active = page === pageNumber
+    return (
+      <Paginator>
+        <Page active={active}>
+          {active && <>{pageNumber}</>}
+          {!active && (
+            <Action onClick={() => setPage(pageNumber)}>{pageNumber}</Action>
+          )}
+        </Page>
+      </Paginator>
+    )
+  }
+
+  // e.g. Get [1,2,3] from totalCount 9, limit 3
+  const pages = [...new Array(Math.ceil(totalCount / limit)).keys()].map(
+    p => p + 1,
+  )
+
+  const firstResult = (page - 1) * limit + 1
+  const lastResult = Math.min((page - 1) * limit + limit, totalCount)
+
+  return (
+    <Pagination>
+      <PaginationInfo>
+        Showing {firstResult} to {lastResult} of {totalCount} results
+      </PaginationInfo>
+      <Paginators>
+        <Previous />
+        {pages.map(pageNumber => (
+          <PageNumber pageNumber={pageNumber} />
+        ))}
+        <Next />
+      </Paginators>
+    </Pagination>
+  )
+}
+
+export default PaginationContainer
diff --git a/app/components/component-users-manager/src/User.jsx b/app/components/component-users-manager/src/User.jsx
index 15fd9cc566..e97c282edf 100644
--- a/app/components/component-users-manager/src/User.jsx
+++ b/app/components/component-users-manager/src/User.jsx
@@ -3,7 +3,8 @@ import gql from 'graphql-tag'
 import { useMutation } from '@apollo/react-hooks'
 import { Action } from '@pubsweet/ui'
 import { UserAvatar } from '../../component-avatar/src'
-import { Row, Cell, LastCell, UserCombo } from './style'
+import { Row, Cell, LastCell, UserCombo, Primary, Secondary, UserInfo } from './style'
+import { convertTimestampToDate } from '../../../shared/time-formatting'
 
 const DELETE_USER = gql`
   mutation($id: ID) {
@@ -18,8 +19,16 @@ const User = ({ user }) => {
 
   return (
     <Row>
-      <Cell><UserCombo><UserAvatar user={user}/>{user.username} {user.email}</UserCombo></Cell>
-      <Cell>{user.created}</Cell>
+      <Cell>
+        <UserCombo>
+          <UserAvatar user={user} />
+          <UserInfo>
+            <Primary>{user.username}</Primary>
+            <Secondary>{user.email || '(via ORCID)'}</Secondary>
+          </UserInfo>
+        </UserCombo>
+      </Cell>
+      <Cell>{convertTimestampToDate(user.created)}</Cell>
       <Cell>{user.admin ? 'yes' : ''}</Cell>
       <LastCell>
         <Action onClick={() => deleteUser({ variables: { id: user.id } })}>
diff --git a/app/components/component-users-manager/src/UsersManager.jsx b/app/components/component-users-manager/src/UsersManager.jsx
index 7381f4f5dd..84e7adc8df 100644
--- a/app/components/component-users-manager/src/UsersManager.jsx
+++ b/app/components/component-users-manager/src/UsersManager.jsx
@@ -1,11 +1,12 @@
 import React, { useState } from 'react'
 import gql from 'graphql-tag'
 import { useQuery } from '@apollo/react-hooks'
-import { Heading, Action } from '@pubsweet/ui'
+import { Heading } from '@pubsweet/ui'
 
 import User from './User'
 import { Container, Table, Header } from './style'
-import Spinner from '../../Spinner'
+import Spinner from '../../shared/Spinner'
+import Pagination from './Pagination'
 
 const GET_USERS = gql`
   query Users(
@@ -15,13 +16,16 @@ const GET_USERS = gql`
     $limit: Int
   ) {
     users(sort: $sort, filter: $filter, offset: $offset, limit: $limit) {
-      id
-      username
-      admin
-      email
-      profilePicture
-      online
-      created
+      totalCount
+      users {
+        id
+        username
+        admin
+        email
+        profilePicture
+        online
+        created
+      }
     }
   }
 `
@@ -56,23 +60,25 @@ const UsersManager = () => {
   const [sortName, setSortName] = useState('created')
   const [sortDirection, setSortDirection] = useState('DESC')
   const [page, setPage] = useState(1)
-  const limit = 15
+  const limit = 10
   const sort = sortName && sortDirection && `${sortName}_${sortDirection}`
 
   const { loading, error, data } = useQuery(GET_USERS, {
     variables: {
       sort,
       offset: (page - 1) * limit,
-      limit
+      limit,
     },
   })
 
-  if (loading) return <Spinner/>
+  if (loading) return <Spinner />
   if (error) return `Error! ${error.message}`
 
+  const { users, totalCount } = data.users
+
   return (
     <Container>
-      <Heading level={1}>List of users</Heading>
+      <Heading level={1}>Users</Heading>
       <Table>
         <Header>
           <tr>
@@ -83,13 +89,12 @@ const UsersManager = () => {
           </tr>
         </Header>
         <tbody>
-          {data.users.map((user, key) => (
+          {users.map((user, key) => (
             <User key={user.id} number={key + 1} user={user} />
           ))}
         </tbody>
       </Table>
-      { page > 1 && <><Action onClick={() => setPage(page - 1)}>Previous</Action>&nbsp;</> }
-      { data.users.length === limit && <Action onClick={() => setPage(page + 1)}>Next</Action> }
+      <Pagination setPage={setPage} limit={limit} page={page} totalCount={totalCount} />
     </Container>
   )
 }
diff --git a/app/components/component-users-manager/src/style.js b/app/components/component-users-manager/src/style.js
index e9bc4b826b..cf45ed6ce3 100644
--- a/app/components/component-users-manager/src/style.js
+++ b/app/components/component-users-manager/src/style.js
@@ -1,4 +1,4 @@
-import styled from 'styled-components'
+import styled, { css } from 'styled-components'
 import { Action } from '@pubsweet/ui'
 import { th, grid } from '@pubsweet/ui-toolkit'
 
@@ -6,7 +6,6 @@ export const Table = styled.table`
   width: 100%;
   border-radius: ${th('borderRadius')};
   border-collapse: collapse;
-  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
   font-size: ${th('fontSizeBaseSmall')};
 
   td {
@@ -18,10 +17,10 @@ export const Header = styled.thead`
   font-variant: all-small-caps;
   border-bottom: 1px solid ${th('colorFurniture')};
 
-  background: ${th('colorBackgroundHue')};
+  background-color: ${th('colorBackgroundHue')};
 
   th {
-    padding: ${grid(2)} ${grid(3)};
+    padding: ${grid(1)} ${grid(3)};
   }
 `
 
@@ -30,13 +29,19 @@ export const Container = styled.div`
 `
 
 export const Row = styled.tr`
-  height: ${grid(6)};
+  max-height: ${grid(8)};
   border-bottom: 1px solid ${th('colorFurniture')};
+
+  &:hover {
+    background-color: ${th('colorBackgroundHue')};
+  }
 `
 
 export const Cell = styled.td`
-  padding: ${grid(2)} ${grid(3)};
-
+  padding-bottom: ${grid(2)};
+  padding-top: calc(${grid(2)} - 1px);
+  padding-left: ${grid(3)};
+  padding-right: ${grid(3)};
   button {
     font-size: ${th('fontSizeBaseSmall')};
   }
@@ -44,9 +49,22 @@ export const Cell = styled.td`
 
 export const UserCombo = styled.div`
   display: flex;
-  line-height: ${grid(5)};
+  line-height: ${grid(2.5)};
+  align-items: center;
 `
 
 export const LastCell = styled(Cell)`
   text-align: right;
 `
+
+export const Primary = styled.div`
+  font-weight: 500;
+`
+
+export const Secondary = styled.div`
+  color: ${th('colorTextPlaceholder')};
+`
+
+export const UserInfo = styled.div`
+  margin-left: ${grid(1)};
+`
diff --git a/app/components/component-chat/src/Messages/Icon.jsx b/app/components/shared/Icon.jsx
similarity index 100%
rename from app/components/component-chat/src/Messages/Icon.jsx
rename to app/components/shared/Icon.jsx
diff --git a/app/components/Spinner.jsx b/app/components/shared/Spinner.jsx
similarity index 100%
rename from app/components/Spinner.jsx
rename to app/components/shared/Spinner.jsx
diff --git a/app/theme/elements/Button.js b/app/theme/elements/Button.js
index 8d16db15fe..8e0021d717 100644
--- a/app/theme/elements/Button.js
+++ b/app/theme/elements/Button.js
@@ -19,10 +19,13 @@ const secondary = css`
 
   &[disabled] {
     color: ${th('colorTextPlaceholder')};
-
+    cursor: default;
     &:hover {
       background: none;
     }
+    &:hover:before {
+      visibility: hidden;
+    }
   }
 `
 
diff --git a/app/theme/elements/GlobalStyle.js b/app/theme/elements/GlobalStyle.js
index 291d6d6a6e..470093e9d9 100644
--- a/app/theme/elements/GlobalStyle.js
+++ b/app/theme/elements/GlobalStyle.js
@@ -28,9 +28,12 @@ height: 100%
 width: 100%
 }
 
+*, *:before, *:after {
+  box-sizing: inherit;
+}
+
 * {
 border: 0;
-box-sizing: inherit;
 -webkit-font-smoothing: auto;
 font-weight: inherit;
 margin: 0;
diff --git a/app/theme/index.js b/app/theme/index.js
index 2a882235b1..5ffdb00593 100644
--- a/app/theme/index.js
+++ b/app/theme/index.js
@@ -18,7 +18,7 @@ const cokoTheme = {
   colorBackground: 'white',
   colorPrimary: '#0B65CB',
   colorSecondary: '#E7E7E7',
-  colorFurniture: '#CCC',
+  colorFurniture: '#E8E8E8',
   colorBorder: '#AAA',
   colorBackgroundHue: '#f9fafb',
   colorSuccess: '#008800',
diff --git a/profiles/default_avatar.svg b/profiles/default_avatar.svg
new file mode 100644
index 0000000000..bc59fe59c3
--- /dev/null
+++ b/profiles/default_avatar.svg
@@ -0,0 +1,13 @@
+<svg width="340" height="340" viewBox="0 0 340 340" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0)">
+<rect width="340" height="340" fill="white"/>
+<rect width="340" height="340" fill="#E8E8E8"/>
+<path d="M259 254.091C259 303.294 281 410 170 410C59 410 81 303.294 81 254.091C81 204.887 120.847 165 170 165C219.153 165 259 204.887 259 254.091Z" fill="white"/>
+<ellipse cx="170" cy="96" rx="56" ry="55" fill="white"/>
+</g>
+<defs>
+<clipPath id="clip0">
+<rect width="340" height="340" fill="white"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/server/model-message/src/graphql/index.js b/server/model-message/src/graphql.js
similarity index 97%
rename from server/model-message/src/graphql/index.js
rename to server/model-message/src/graphql.js
index 1d20d61ddf..c5cb8146aa 100644
--- a/server/model-message/src/graphql/index.js
+++ b/server/model-message/src/graphql.js
@@ -5,7 +5,7 @@ const { getPubsub } = pubsubManager
 // Fires immediately when the message is created
 const MESSAGE_CREATED = 'MESSAGE_CREATED'
 
-const Message = require('../message')
+const Message = require('./message')
 
 const resolvers = {
   Query: {
@@ -85,6 +85,7 @@ const typeDefs = `
   type PageInfo {
     startCursor: String
     hasPreviousPage: Boolean
+    hasNextPage: Boolean
   }
 
   type MessagesRelay {
diff --git a/server/model-user/src/graphql.js b/server/model-user/src/graphql.js
index b5706df58e..d9d312acb8 100644
--- a/server/model-user/src/graphql.js
+++ b/server/model-user/src/graphql.js
@@ -15,6 +15,8 @@ const resolvers = {
         query.where({ admin: true })
       }
 
+      const totalCount = await query.resultSize()
+
       if (sort) {
         // e.g. 'created_DESC' into 'created' and 'DESC' arguments
         query.orderBy(...sort.split('_'))
@@ -28,7 +30,12 @@ const resolvers = {
         query.offset(offset)
       }
 
-      return query
+      const users = await query
+      return {
+        totalCount,
+        users,
+      }
+
       // return ctx.connectors.User.fetchAll(where, ctx, { eager })
     },
     // Authentication
@@ -152,10 +159,15 @@ const resolvers = {
 const typeDefs = `
   extend type Query {
     user(id: ID, username: String): User
-    users(sort: UsersSort, offset: Int, limit: Int, filter: UsersFilter): [User]
+    users(sort: UsersSort, offset: Int, limit: Int, filter: UsersFilter): PaginatedUsers
     searchUsers(teamId: ID, query: String): [User]
   }
 
+  type PaginatedUsers {
+    totalCount: Int
+    users: [User]
+  }
+
   extend type Mutation {
     createUser(input: UserInput): User
     deleteUser(id: ID): User
-- 
GitLab