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 => (
-
- ))
- )
+ 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"