From 30bae222f6eb9d4c024595e434e22798c3f879ef Mon Sep 17 00:00:00 2001 From: Simone Busoli Date: Sat, 12 Dec 2020 20:33:42 +0100 Subject: [PATCH] feat: pagination --- .../lib/plugins/auth/auth0/provider.js | 15 ++- .../lib/plugins/auth/azure/operations.js | 52 ++++++++ .../lib/plugins/auth/azure/parameters.js | 83 ++++++++++++ .../lib/plugins/auth/azure/provider.js | 16 ++- .../lib/plugins/auth/cognito/provider.js | 8 +- .../lib/plugins/graphql/resolvers.js | 4 +- .../lib/plugins/graphql/typeDefs.js | 7 +- packages/brokeneck-react/package.json | 3 + .../src/components/FormField.js | 12 +- .../brokeneck-react/src/components/Users.js | 125 +++++++++++------- packages/brokeneck-react/src/graphql.js | 10 +- .../src/hooks/useAddUsersToGroupDialog.js | 65 +++++++-- .../brokeneck-react/src/hooks/useDialog.js | 9 +- .../src/hooks/useSurrogatePagination.js | 89 +++++++++++++ yarn.lock | 16 +++ 15 files changed, 431 insertions(+), 83 deletions(-) create mode 100644 packages/brokeneck-fastify/lib/plugins/auth/azure/operations.js create mode 100644 packages/brokeneck-fastify/lib/plugins/auth/azure/parameters.js create mode 100644 packages/brokeneck-react/src/hooks/useSurrogatePagination.js diff --git a/packages/brokeneck-fastify/lib/plugins/auth/auth0/provider.js b/packages/brokeneck-fastify/lib/plugins/auth/auth0/provider.js index e1b5cf4e..1ca91737 100644 --- a/packages/brokeneck-fastify/lib/plugins/auth/auth0/provider.js +++ b/packages/brokeneck-fastify/lib/plugins/auth/auth0/provider.js @@ -11,8 +11,19 @@ function Auth0Provider(options, logger) { return { name: 'auth0', - async listUsers() { - const users = await auth0.getUsers({}) + async listUsers({ pageNumber, pageSize }) { + const page = pageNumber ? Number(pageNumber) - 1 : 0 + + const data = await auth0.getUsers({ + page: page, + per_page: pageSize, + include_totals: true + }) + + const users = { + data: data.users, + nextPage: data.length === data.limit ? (page + 2).toString() : '' + } logger.debug({ users }, 'loaded users') diff --git a/packages/brokeneck-fastify/lib/plugins/auth/azure/operations.js b/packages/brokeneck-fastify/lib/plugins/auth/azure/operations.js new file mode 100644 index 00000000..e16e12fb --- /dev/null +++ b/packages/brokeneck-fastify/lib/plugins/auth/azure/operations.js @@ -0,0 +1,52 @@ +'use strict' + +const { Serializer } = require('@azure/ms-rest-js') +const { GraphRbacManagementMappers: Mappers } = require('@azure/graph') + +const Parameters = require('./parameters') + +const serializer = new Serializer(Mappers) + +const listOperationSpec = { + httpMethod: 'GET', + path: '{tenantID}/users', + urlParameters: [Parameters.tenantID], + queryParameters: [ + Parameters.filter, + Parameters.top, + Parameters.apiVersion, + Parameters.search + ], + headerParameters: [Parameters.acceptLanguage], + responses: { + 200: { + bodyMapper: Mappers.UserListResult + }, + default: { + bodyMapper: Mappers.GraphError + } + }, + serializer +} + +const listNextOperationSpec = { + httpMethod: 'GET', + path: '{tenantID}/{nextLink}', + urlParameters: [Parameters.nextLink, Parameters.tenantID], + queryParameters: [Parameters.apiVersion, Parameters.top, Parameters.search], + headerParameters: [Parameters.acceptLanguage], + responses: { + 200: { + bodyMapper: Mappers.UserListResult + }, + default: { + bodyMapper: Mappers.GraphError + } + }, + serializer +} + +module.exports = { + listOperationSpec, + listNextOperationSpec +} diff --git a/packages/brokeneck-fastify/lib/plugins/auth/azure/parameters.js b/packages/brokeneck-fastify/lib/plugins/auth/azure/parameters.js new file mode 100644 index 00000000..fcee4aba --- /dev/null +++ b/packages/brokeneck-fastify/lib/plugins/auth/azure/parameters.js @@ -0,0 +1,83 @@ +'use strict' + +const acceptLanguage = { + parameterPath: 'acceptLanguage', + mapper: { + serializedName: 'accept-language', + defaultValue: 'en-US', + type: { + name: 'String' + } + } +} +const apiVersion = { + parameterPath: 'apiVersion', + mapper: { + required: true, + serializedName: 'api-version', + type: { + name: 'String' + } + } +} +const filter = { + parameterPath: ['options', 'filter'], + mapper: { + serializedName: '$filter', + type: { + name: 'String' + } + } +} + +const top = { + parameterPath: ['options', 'top'], + mapper: { + serializedName: '$top', + type: { + name: 'Number' + } + } +} + +const search = { + parameterPath: ['options', 'search'], + mapper: { + serializedName: '$search', + type: { + name: 'String' + } + } +} + +const nextLink = { + parameterPath: 'nextLink', + mapper: { + required: true, + serializedName: 'nextLink', + type: { + name: 'String' + } + }, + skipEncoding: true +} +const tenantID = { + parameterPath: 'tenantID', + mapper: { + required: true, + serializedName: 'tenantID', + type: { + name: 'String' + } + } +} + +module.exports = { + tenantID, + acceptLanguage, + filter, + apiVersion, + top, + search, + nextLink +} diff --git a/packages/brokeneck-fastify/lib/plugins/auth/azure/provider.js b/packages/brokeneck-fastify/lib/plugins/auth/azure/provider.js index 60aedba2..7845de55 100644 --- a/packages/brokeneck-fastify/lib/plugins/auth/azure/provider.js +++ b/packages/brokeneck-fastify/lib/plugins/auth/azure/provider.js @@ -2,13 +2,25 @@ const { GraphRbacManagementClient } = require('@azure/graph') +const { listNextOperationSpec, listOperationSpec } = require('./operations') + function AzureProvider(options, credentials, logger) { const azure = new GraphRbacManagementClient(credentials, options.tenantId) return { name: 'azure', - async listUsers() { - const users = await azure.users.list() + async listUsers({ pageNumber, pageSize, search }) { + const result = await (pageNumber + ? azure.sendOperationRequest( + { nextLink: pageNumber, options: { top: pageSize, search } }, + listNextOperationSpec + ) + : azure.sendOperationRequest( + { options: { top: pageSize, search } }, + listOperationSpec + )) + + const users = { data: result, nextPage: result.odatanextLink } logger.debug({ users }, 'loaded users') diff --git a/packages/brokeneck-fastify/lib/plugins/auth/cognito/provider.js b/packages/brokeneck-fastify/lib/plugins/auth/cognito/provider.js index 0546fdfb..3da8b3f8 100644 --- a/packages/brokeneck-fastify/lib/plugins/auth/cognito/provider.js +++ b/packages/brokeneck-fastify/lib/plugins/auth/cognito/provider.js @@ -11,14 +11,16 @@ function CognitoProvider(options, logger) { return { name: 'cognito', - async listUsers() { + async listUsers({ pageNumber, pageSize }) { const result = await cognito .listUsers({ - UserPoolId + UserPoolId, + Limit: pageSize, + PaginationToken: pageNumber || undefined }) .promise() - const users = result.Users + const users = { data: result.Users, nextPage: result.PaginationToken } logger.debug({ users }) diff --git a/packages/brokeneck-fastify/lib/plugins/graphql/resolvers.js b/packages/brokeneck-fastify/lib/plugins/graphql/resolvers.js index 130668e1..ab114284 100644 --- a/packages/brokeneck-fastify/lib/plugins/graphql/resolvers.js +++ b/packages/brokeneck-fastify/lib/plugins/graphql/resolvers.js @@ -3,8 +3,8 @@ module.exports = function makeResolvers(fastify) { return { Query: { - users() { - return fastify.provider.listUsers() + users(_, { pageNumber, pageSize, search }) { + return fastify.provider.listUsers({ pageNumber, pageSize, search }) }, user(_, { id }) { return fastify.provider.getUser(id) diff --git a/packages/brokeneck-fastify/lib/plugins/graphql/typeDefs.js b/packages/brokeneck-fastify/lib/plugins/graphql/typeDefs.js index c2ae2a61..8b9d607c 100644 --- a/packages/brokeneck-fastify/lib/plugins/graphql/typeDefs.js +++ b/packages/brokeneck-fastify/lib/plugins/graphql/typeDefs.js @@ -39,8 +39,13 @@ const typeDefs = gql` id: String! } + type PaginatedUsers { + data: [User]! + nextPage: String + } + type Query { - users: [User] + users(pageNumber: String, pageSize: Int, search: String): PaginatedUsers user(id: String!): User groups: [Group] group(id: String!): Group diff --git a/packages/brokeneck-react/package.json b/packages/brokeneck-react/package.json index 92039bb9..8c1a06b7 100644 --- a/packages/brokeneck-react/package.json +++ b/packages/brokeneck-react/package.json @@ -24,8 +24,11 @@ "precommit": "lint-staged" }, "dependencies": { + "@material-ui/lab": "^4.0.0-alpha.57", "graphql-hooks": "^5.0.0", + "lodash.debounce": "^4.0.8", "lodash.startcase": "^4.4.0", + "lodash.throttle": "^4.1.1", "prop-types": "^15.7.2" }, "peerDependencies": { diff --git a/packages/brokeneck-react/src/components/FormField.js b/packages/brokeneck-react/src/components/FormField.js index 390565f7..a2ada632 100644 --- a/packages/brokeneck-react/src/components/FormField.js +++ b/packages/brokeneck-react/src/components/FormField.js @@ -1,12 +1,18 @@ import React from 'react' import T from 'prop-types' import { Box, Checkbox, FormControlLabel, TextField } from '@material-ui/core' +import Autocomplete from '@material-ui/lab/Autocomplete' export default function FormField({ - field: { initialValue, ...field }, + // eslint-disable-next-line no-unused-vars + field: { initialValue, autocomplete, getValueFromObject, ...field }, handleChange, formValues }) { + if (autocomplete) { + return + } + switch (field.type) { case 'checkbox': return ( @@ -52,7 +58,9 @@ FormField.propTypes = { name: T.string.isRequired, label: T.string.isRequired, type: T.string.isRequired, - initialValue: T.any.isRequired + initialValue: T.any.isRequired, + autocomplete: T.bool, + getValueFromObject: T.func }).isRequired, handleChange: T.func.isRequired, formValues: T.object.isRequired diff --git a/packages/brokeneck-react/src/components/Users.js b/packages/brokeneck-react/src/components/Users.js index 46f94785..4ab8db8b 100644 --- a/packages/brokeneck-react/src/components/Users.js +++ b/packages/brokeneck-react/src/components/Users.js @@ -8,6 +8,7 @@ import { Table, TableBody, TableCell, + TableContainer, TableHead, TableRow, Typography @@ -17,19 +18,38 @@ import { useQuery } from 'graphql-hooks' import startCase from 'lodash.startcase' import useCreateUserDialog from '../hooks/useCreateUserDialog' -import { LOAD_USERS } from '../graphql' import useFields from '../hooks/useFields' +import useSurrogatePagination from '../hooks/useSurrogatePagination' +import { LOAD_USERS } from '../graphql' import Square from './Square' export default function Users() { const match = useRouteMatch() const userFields = useFields('User') + + const { + pageSize, + surrogatePageNumber, + useUpdateSurrogatePageNumber, + useTablePagination + } = useSurrogatePagination({ pageSizeOptions: [2, 3, 4] }) + const { data, loading, refetch: loadUsers } = useQuery( - LOAD_USERS(userFields.all) + LOAD_USERS(userFields.all), + { + variables: { + pageNumber: surrogatePageNumber, + pageSize + } + } ) const [dialog, openDialog] = useCreateUserDialog(loadUsers) + useUpdateSurrogatePageNumber(data?.users.nextPage) + + const tablePagination = useTablePagination(data?.users) + return ( <> {dialog} @@ -51,58 +71,61 @@ export default function Users() { - - - - {userFields.all.map(field => ( - - {startCase(field)} - - ))} - - - - {data?.users.map(user => ( - - {userFields.all.map((field, index) => ( - - {!index ? ( - - {user[field]} - - ) : userFields.metadata[field].type === 'checkbox' ? ( - - - {user[field] ? '✅' : '❌'} - - - ) : ( - {user[field]} - )} + +
+ + + {userFields.all.map(field => ( + + {startCase(field)} ))} - ))} - {loading && ( - - - - - - )} - -
+ + + {data?.users.data.map(user => ( + + {userFields.all.map((field, index) => ( + + {!index ? ( + + {user[field]} + + ) : userFields.metadata[field].type === 'checkbox' ? ( + + + {user[field] ? '✅' : '❌'} + + + ) : ( + {user[field]} + )} + + ))} + + ))} + {loading && ( + + + + + + )} + + + + {tablePagination} ) diff --git a/packages/brokeneck-react/src/graphql.js b/packages/brokeneck-react/src/graphql.js index d8066d43..f8062e1c 100644 --- a/packages/brokeneck-react/src/graphql.js +++ b/packages/brokeneck-react/src/graphql.js @@ -26,9 +26,13 @@ query LoadUser($id: String!) { } ` -export const LOAD_USERS = fields => `{ - users { - ${fields.join('\n')} +export const LOAD_USERS = fields => ` +query LoadUsers($pageNumber: String, $pageSize: Int, $search: String) { + users(pageNumber: $pageNumber, pageSize: $pageSize, search: $search) { + data { + ${fields.join('\n')} + } + nextPage } }` diff --git a/packages/brokeneck-react/src/hooks/useAddUsersToGroupDialog.js b/packages/brokeneck-react/src/hooks/useAddUsersToGroupDialog.js index 0c5ac0da..1a72cce9 100644 --- a/packages/brokeneck-react/src/hooks/useAddUsersToGroupDialog.js +++ b/packages/brokeneck-react/src/hooks/useAddUsersToGroupDialog.js @@ -1,7 +1,8 @@ -import React from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { useQuery, useMutation } from 'graphql-hooks' -import { CircularProgress, MenuItem } from '@material-ui/core' +import { CircularProgress, TextField } from '@material-ui/core' import startCase from 'lodash.startcase' +import debounce from 'lodash.debounce' import { ADD_USER_TO_GROUP, LOAD_USERS } from '../graphql' import GraphQLError from '../GraphQLError' @@ -11,7 +12,19 @@ import useFields from './useFields' export default function useAddUsersToGroupDialog(groupId, onConfirm) { const userFields = useFields('User') - const { data, loading } = useQuery(LOAD_USERS(userFields.all)) + const [search, setSearch] = useState() + + const debouncedSetSearch = useMemo(() => debounce(setSearch, 500), [ + setSearch + ]) + + useEffect(() => () => debouncedSetSearch.cancel(), [debouncedSetSearch]) + + const { data, loading } = useQuery(LOAD_USERS(userFields.all), { + variables: { + search + } + }) const [addUserToGroup] = useMutation(ADD_USER_TO_GROUP) const handleConfirm = async input => { @@ -29,6 +42,10 @@ export default function useAddUsersToGroupDialog(groupId, onConfirm) { onConfirm() } + function handleAutocompleteChange(e, search) { + debouncedSetSearch(search) + } + return useDialog({ onConfirm: handleConfirm, title: `Add users to group ${groupId}`, @@ -38,17 +55,39 @@ export default function useAddUsersToGroupDialog(groupId, onConfirm) { { name: userFields.id, label: startCase(userFields.description), - select: true, + autocomplete: true, + options: data?.users.data || [], + getOptionLabel: option => option[userFields.description], + loading, + renderInput: function Input(params) { + // eslint-disable-next-line no-unused-vars + const { initialValue, ...metadata } = userFields.metadata[ + userFields.id + ] + + return ( + + {loading ? ( + + ) : null} + {params.InputProps.endAdornment} + + ) + }} + /> + ) + }, ...userFields.metadata[userFields.id], - children: loading ? ( - - ) : ( - data.users.map(user => ( - - {user[userFields.description]} - - )) - ) + getValueFromObject: o => o[userFields.id], + onInputChange: handleAutocompleteChange } ] }) diff --git a/packages/brokeneck-react/src/hooks/useDialog.js b/packages/brokeneck-react/src/hooks/useDialog.js index 08d173f3..ed9459bf 100644 --- a/packages/brokeneck-react/src/hooks/useDialog.js +++ b/packages/brokeneck-react/src/hooks/useDialog.js @@ -28,11 +28,12 @@ export default function useDialog({ onClose() } - const handleChange = e => { + const handleChange = field => (e, value) => { return setFormValues(s => ({ ...s, - [e.target.name]: - e.target[e.target.type === 'checkbox' ? 'checked' : 'value'] + [field.name]: field.getValueFromObject + ? field.getValueFromObject(value) + : e.target[e.target.type === 'checkbox' ? 'checked' : 'value'] })) } @@ -64,7 +65,7 @@ export default function useDialog({ ))} diff --git a/packages/brokeneck-react/src/hooks/useSurrogatePagination.js b/packages/brokeneck-react/src/hooks/useSurrogatePagination.js new file mode 100644 index 00000000..86280da0 --- /dev/null +++ b/packages/brokeneck-react/src/hooks/useSurrogatePagination.js @@ -0,0 +1,89 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { TablePagination } from '@material-ui/core' + +const INITIAL_PAGE_NUMBER = 1 + +export default function useSurrogatePagination({ + pageSizeOptions = [5, 10, 20] +}) { + const [defaultPageSize] = pageSizeOptions + + const [{ pageNumber, pageSize, ...mapping }, setPageMapping] = useState({ + pageNumber: INITIAL_PAGE_NUMBER, + pageSize: defaultPageSize, + [defaultPageSize]: { + [INITIAL_PAGE_NUMBER]: '' + } + }) + + const changePageNumber = useCallback((e, zeroBasedPageNumber) => { + setPageMapping(m => ({ + ...m, + pageNumber: zeroBasedPageNumber + 1 + })) + }, []) + + const changePageSize = useCallback(({ target: { value: pageSize } }) => { + setPageMapping(m => ({ + ...m, + pageNumber: INITIAL_PAGE_NUMBER, + pageSize, + [pageSize]: { + [INITIAL_PAGE_NUMBER]: '' + } + })) + }, []) + + const useUpdateSurrogatePageNumber = useMemo( + () => + function useUpdateSurrogatePageNumber(nextPage = '') { + useEffect(() => { + setPageMapping(m => ({ + ...m, + [pageSize]: { + ...m[pageSize], + [pageNumber + 1]: nextPage + } + })) + }, [nextPage]) + }, + [pageNumber, pageSize] + ) + + const useTablePagination = useMemo( + () => + function useTablePagination(data) { + return ( + { + const total = + data?.data.length + pageSize * (pageNumber - 1) || '-' + return `${from}-${total} of ${ + data?.nextPage ? `more than ${to}` : total + }` + }} + /> + ) + }, + [changePageNumber, changePageSize, pageNumber, pageSize, pageSizeOptions] + ) + + return { + pageSize, + surrogatePageNumber: mapping[pageSize][pageNumber], + useUpdateSurrogatePageNumber, + useTablePagination + } +} diff --git a/yarn.lock b/yarn.lock index 198d487d..ddf3e1f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2690,6 +2690,17 @@ react-is "^16.8.0 || ^17.0.0" react-transition-group "^4.4.0" +"@material-ui/lab@^4.0.0-alpha.57": + version "4.0.0-alpha.57" + resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-4.0.0-alpha.57.tgz#e8961bcf6449e8a8dabe84f2700daacfcafbf83a" + integrity sha512-qo/IuIQOmEKtzmRD2E4Aa6DB4A87kmY6h0uYhjUmrrgmEAgbbw9etXpWPVXuRK6AGIQCjFzV6WO2i21m1R4FCw== + dependencies: + "@babel/runtime" "^7.4.4" + "@material-ui/utils" "^4.11.2" + clsx "^1.0.4" + prop-types "^15.7.2" + react-is "^16.8.0 || ^17.0.0" + "@material-ui/styles@^4.11.2": version "4.11.2" resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.11.2.tgz#e70558be3f41719e8c0d63c7a3c9ae163fdc84cb" @@ -11390,6 +11401,11 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= + lodash.flattendeep@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2"