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> </> } - { 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