From 76b7e33adcf0c95f451d456ecfbc9f9fd741be89 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 22 Jul 2019 15:49:31 -0700 Subject: [PATCH 01/19] Add lambda and script to dump v3 account data --- cloudformation/template.yaml | 55 +++++++ dev-portal/scripts/dump-v3-account-data.js | 63 ++++++++ .../node_modules/dev-portal-common/pager.js | 42 +++++ lambdas/dump-v3-account-data/index.js | 150 ++++++++++++++++++ lambdas/jsconfig.json | 11 ++ 5 files changed, 321 insertions(+) create mode 100644 dev-portal/scripts/dump-v3-account-data.js create mode 100644 lambdas/common-layer/nodejs/node_modules/dev-portal-common/pager.js create mode 100644 lambdas/dump-v3-account-data/index.js create mode 100644 lambdas/jsconfig.json diff --git a/cloudformation/template.yaml b/cloudformation/template.yaml index 7720f9d88..df0602474 100644 --- a/cloudformation/template.yaml +++ b/cloudformation/template.yaml @@ -1476,6 +1476,61 @@ Resources: # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-route53-aliastarget.html HostedZoneId: 'Z2FDTNDATAQYW2' + DumpV3AccountDataFn: + Type: AWS::Serverless::Function + Properties: + CodeUri: ../lambdas/dump-v3-account-data + Handler: index.handler + MemorySize: 512 + Role: !GetAtt DumpV3AccountDataExecutionRole.Arn + Runtime: nodejs8.10 + Timeout: 300 + Environment: + Variables: + CustomersTableName: !Ref DevPortalCustomersTableName + UserPoolId: !Ref CognitoUserPool + AdminsGroupName: !Ref CognitoAdminsGroup + Layers: + - !Ref LambdaCommonLayer + + DumpV3AccountDataExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: WriteCloudWatchLogs + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: arn:aws:logs:*:*:* + - PolicyName: ReadCustomersTable + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: dynamodb:Scan + Resource: !GetAtt CustomersTable.Arn + - PolicyName: ListUserPool + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - cognito-idp:ListUsers + - cognito-idp:ListUsersInGroup + Resource: !GetAtt CognitoUserPool.Arn + Outputs: WebsiteURL: Value: !If [ 'DevelopmentMode', diff --git a/dev-portal/scripts/dump-v3-account-data.js b/dev-portal/scripts/dump-v3-account-data.js new file mode 100644 index 000000000..56f587d88 --- /dev/null +++ b/dev-portal/scripts/dump-v3-account-data.js @@ -0,0 +1,63 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Calls the DumpV3AccountDataFn lambda, and writes its JSON string output into +// a file. + +const fs = require('fs') +const os = require('os') +const path = require('path') +const util = require('util') + +const { execute } = require('./utils.js') + +const fetchLambdaOutput = async ({ stackName, workDir }) => { + const resourceData = JSON.parse((await execute( + `aws cloudformation describe-stack-resource` + + ` --logical-resource-id DumpV3AccountDataFn` + + ` --stack-name ${stackName}`, true)).stdout) + const lambdaId = resourceData.StackResourceDetail.PhysicalResourceId + const outFile = `${workDir}${path.sep}lambdaOut` + await execute( + `aws lambda invoke --function-name ${lambdaId} ${outFile}`, true) + const output = JSON.parse(fs.readFileSync(outFile)) + fs.unlinkSync(outFile) + return output +} + +const main = async () => { + if (process.argv.length !== 4) { + const [ node, script ] = process.argv + console.error(`Usage: ${node} ${script} STACK_NAME OUTPUT_FILE`) + process.exitCode = 127 + return + } + const [,, stackName, outFile] = process.argv + + const workDir = await util.promisify(fs.mkdtemp)(os.tmpdir() + path.sep) + .catch(error => { + throw new Error(`Failed to create temp directory: ${error.message}`) + }) + + console.log(`Fetching account data from stack ${stackName}...`) + const lambdaOutput = await fetchLambdaOutput({ stackName, workDir }) + .catch(error => { + throw new Error(`Failed to fetch account data: ${error.message}`) + }).finally(() => fs.rmdirSync(workDir)) + + console.log(`Writing account data to ${outFile}...`) + try { + fs.writeFileSync(outFile, lambdaOutput) + } catch (error) { + throw new Error(`Failed to write to ${outFile}: ${error.message}`) + } + + console.log(`Done.`) +} + +if (!module.parent) { + main().catch(error => { + console.error(error.message) + process.exitCode = 1 + }) +} diff --git a/lambdas/common-layer/nodejs/node_modules/dev-portal-common/pager.js b/lambdas/common-layer/nodejs/node_modules/dev-portal-common/pager.js new file mode 100644 index 000000000..b43f2f26a --- /dev/null +++ b/lambdas/common-layer/nodejs/node_modules/dev-portal-common/pager.js @@ -0,0 +1,42 @@ +const fetchAllItems = async ({ fetchPage, commonParams, selectItems, getNextPageParams }) => { + let page = await fetchPage(commonParams).promise() + const items = selectItems(page) + let nextPageParams = getNextPageParams(page) + while (nextPageParams) { + page = await fetchPage({ ...commonParams, ...nextPageParams }).promise() + items.push(...selectItems(page)) + nextPageParams = getNextPageParams(page) + } + return items +} + +const fetchUsersInCognitoUserPoolGroup = ({ cognitoClient, userPoolId, groupName }) => fetchAllItems({ + fetchPage: params => cognitoClient.listUsersInGroup(params), + commonParams: { UserPoolId: userPoolId, GroupName: groupName }, + selectItems: page => page.Users, + getNextPageParams: page => page.NextToken && { NextToken: page.NextToken }, +}) + +const fetchUsersInCognitoUserPool = ({ cognitoClient, userPoolId }) => fetchAllItems({ + fetchPage: params => cognitoClient.listUsers(params), + commonParams: { UserPoolId: userPoolId }, + selectItems: page => page.Users, + getNextPageParams: page => page.NextToken && { NextToken: page.NextToken } +}) + +const fetchItemsInDynamoDbTable = ({ dynamoDbClient, tableName, extraParams = {} }) => fetchAllItems({ + fetchPage: params => dynamoDbClient.scan(params), + commonParams: { + ...extraParams, + TableName: tableName, + }, + selectItems: page => page.Items, + getNextPageParams: page => page.LastEvaluatedKey && { ExclusiveStartKey: page.LastEvaluatedKey }, +}) + +module.exports = { + fetchAllItems, + fetchUsersInCognitoUserPoolGroup, + fetchUsersInCognitoUserPool, + fetchItemsInDynamoDbTable, +} diff --git a/lambdas/dump-v3-account-data/index.js b/lambdas/dump-v3-account-data/index.js new file mode 100644 index 000000000..3e45040a2 --- /dev/null +++ b/lambdas/dump-v3-account-data/index.js @@ -0,0 +1,150 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Dumps account data (as defined in v3) from Cognito and DynamoDB, to be used +// for migration to v4. Outputs tsv as a JSON string. + +'use strict'; + +const AWS = require('aws-sdk') +const pager = require('dev-portal-common/pager') + +const handler = async (_event, _context) => { + const { + CustomersTableName: customersTableName, + UserPoolId: userPoolId, + AdminsGroupName: adminsGroupName, + } = process.env + + console.log('Got params:') + console.log(`customersTableName: ${customersTableName}`) + console.log(`userPoolId: ${userPoolId}`) + console.log(`adminsGroupName: ${adminsGroupName}`) + + return await + fetchAccountData({ customersTableName, userPoolId, adminsGroupName }) +} + +/** + * Account fields, as they should be written to the exported TSV. + */ +const ACCOUNT_DATA_FIELDS = [ + 'emailAddress', + 'username', + 'isAdmin', + 'identityPoolId', + 'userPoolId', + 'apiKeyId', +] + +/** + * TSV header for account account fields. + */ +const ACCOUNT_DATA_TSV_HEADER = ACCOUNT_DATA_FIELDS.join('\t') + +const fetchAccountData = async ({ customersTableName, userPoolId, adminsGroupName }) => { + const [adminUserIds, accountsFromTable, usernamesByUserId] = + await Promise.all([ + fetchAdminUserIds({ userPoolId, adminsGroupName }), + fetchCustomersTableItems({ tableName: customersTableName }), + fetchUsernamesByUserId({ userPoolId }), + ]) + + let accounts = accountsFromTable + accounts = insertIsAdmin({ accounts, adminUserIds }) + accounts = insertUsernames({ accounts, usernamesByUserId }) + + const accountsAsTsv = + accounts.map(account => accountDataAsTsv(account)).join('\n') + return `${ACCOUNT_DATA_TSV_HEADER}\n${accountsAsTsv}\n` +} + +/** + * Get the `sub` attribute of a UserType object. + * + * See https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_UserType.html. + */ +const getCognitoUserSub = + user => user.Attributes.find(attribute => attribute.Name === 'sub').Value + +/** + * Fetches the UserPoolIds of all users in the AdminsGroup. + */ +const fetchAdminUserIds = async ({ userPoolId, adminsGroupName }) => { + const admins = await pager.fetchUsersInCognitoUserPoolGroup({ + userPoolId, + groupName: adminsGroupName, + cognitoClient: exports.cognitoClient, + }) + return new Set(admins.map(admin => getCognitoUserSub(admin))) +} + +/** + * Fetches all users in the given user pool, and returns a map of UserPoolId + * (`cognito:sub`) to Username. + */ +const fetchUsernamesByUserId = async ({ userPoolId }) => { + const users = await pager.fetchUsersInCognitoUserPool({ + userPoolId, + cognitoClient: exports.cognitoClient, + }) + return new Map(users.map(user => [getCognitoUserSub(user), user.Username])) +} + +/** + * Fetches all items from the CustomersTable, unwrapping DDB's datatype marker. + */ +const fetchCustomersTableItems = async ({ tableName }) => { + const rawItems = await pager.fetchItemsInDynamoDbTable({ + dynamoDbClient: exports.dynamoDbClient, + tableName, + extraParams: { + Limit: 2, + ProjectionExpression: 'Id, UserPoolId, ApiKeyId', + }, + }) + return rawItems.map(item => ({ + identityPoolId: item.Id.S, + userPoolId: item.UserPoolId.S, + apiKeyId: item.ApiKeyId.S, + })) +} + +/** + * Returns a copy of the `accounts` Array, except each element has `isAdmin` + * set to `true` iff its UserPoolId is in the `adminUserIds` set. + */ +const insertIsAdmin = ({ adminUserIds, accounts }) => accounts + .map(account => ({ + ...account, + isAdmin: adminUserIds.has(account.userPoolId), + })) + +/** + * Returns a copy of the `accounts` array, except each element has `username` + * set to the username as specified in the `usernamesByUserId` Map. + */ +const insertUsernames = ({ accounts, usernamesByUserId }) => accounts + .map(account => ({ + ...account, + username: usernamesByUserId.get(account.userPoolId), + })) + +/** + * Formats an account account object as a string of tab-separated values. + * + * Note that no escaping is required, since all fields consist of + * non-whitespace characters in the printable subset of Unicode. See [1] for + * username constraints. + * + * [1]: https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_UserType.html + */ +const accountDataAsTsv = account => ACCOUNT_DATA_FIELDS + .map(key => key === 'emailAddress' ? '' : account[key].toString()) + .join('\t') + +exports = module.exports = { + cognitoClient: new AWS.CognitoIdentityServiceProvider(), + dynamoDbClient: new AWS.DynamoDB(), + handler +} diff --git a/lambdas/jsconfig.json b/lambdas/jsconfig.json new file mode 100644 index 000000000..15bd96fd9 --- /dev/null +++ b/lambdas/jsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "lib": ["es2017"], + "module": "commonjs", + "target": "es2017", + "paths": { + "dev-portal-common/*": ["./common-layer/nodejs/node_modules/dev-portal-common/*"] + } + } +} From 526a21e399c5a9babeb36fdaebec31c9d68bc599 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 23 Jul 2019 11:37:22 -0700 Subject: [PATCH 02/19] Move DumpV3AccountDataFn runtime to nodejs10.x --- cloudformation/template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudformation/template.yaml b/cloudformation/template.yaml index df0602474..11a197b75 100644 --- a/cloudformation/template.yaml +++ b/cloudformation/template.yaml @@ -1483,7 +1483,7 @@ Resources: Handler: index.handler MemorySize: 512 Role: !GetAtt DumpV3AccountDataExecutionRole.Arn - Runtime: nodejs8.10 + Runtime: nodejs10.x Timeout: 300 Environment: Variables: From a9edb3830983d396e10e96c463b697dbe7da174d Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 23 Jul 2019 11:37:45 -0700 Subject: [PATCH 03/19] Document dev-porta-common/pager/fetchAllItems --- .../node_modules/dev-portal-common/pager.js | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/lambdas/common-layer/nodejs/node_modules/dev-portal-common/pager.js b/lambdas/common-layer/nodejs/node_modules/dev-portal-common/pager.js index b43f2f26a..afc7e6c32 100644 --- a/lambdas/common-layer/nodejs/node_modules/dev-portal-common/pager.js +++ b/lambdas/common-layer/nodejs/node_modules/dev-portal-common/pager.js @@ -1,9 +1,25 @@ +/** + * Returns an array of all items across pages. + * + * - The `fetchPage` function may accept a "fetch parameters" object and must + * return a Promise resolving to "page data". + * - The `commonParams` object represents "fetch parameters" which are passed + * to every `fetchPage` call. + * - The `selectItems` function must accept "page data" and must return an + * iterable of the page's items. + * - The `getNextPageParams` function may accept "page data" and must return + * either: + * - a "fetch parameters" object which will be merged with `commonParams` on + * the next call to `fetchPage`, indicating that there is a next page to + * fetch; or + * - a falsy value, to indicate that no next page should be fetched. + */ const fetchAllItems = async ({ fetchPage, commonParams, selectItems, getNextPageParams }) => { - let page = await fetchPage(commonParams).promise() - const items = selectItems(page) - let nextPageParams = getNextPageParams(page) + const firstPage = await fetchPage(commonParams).promise() + const items = [...selectItems(firstPage)] + let nextPageParams = getNextPageParams(firstPage) while (nextPageParams) { - page = await fetchPage({ ...commonParams, ...nextPageParams }).promise() + const page = await fetchPage({ ...commonParams, ...nextPageParams }).promise() items.push(...selectItems(page)) nextPageParams = getNextPageParams(page) } From eaa602cfcea3e8606d3d8ea935804e7b55371572 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 23 Jul 2019 11:38:06 -0700 Subject: [PATCH 04/19] Fix doc typo in DumpV3AccountData lambda --- lambdas/dump-v3-account-data/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/dump-v3-account-data/index.js b/lambdas/dump-v3-account-data/index.js index 3e45040a2..8efc4b70b 100644 --- a/lambdas/dump-v3-account-data/index.js +++ b/lambdas/dump-v3-account-data/index.js @@ -131,7 +131,7 @@ const insertUsernames = ({ accounts, usernamesByUserId }) => accounts })) /** - * Formats an account account object as a string of tab-separated values. + * Formats an account object as a string of tab-separated values. * * Note that no escaping is required, since all fields consist of * non-whitespace characters in the printable subset of Unicode. See [1] for From 548dcc9c924a47d3c0bc7d2e7646888836b53d54 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Wed, 31 Jul 2019 14:51:19 -0700 Subject: [PATCH 05/19] Implement "Registered Accounts" admin page --- cloudformation/template.yaml | 19 +- dev-portal/package-lock.json | 46 ++- dev-portal/package.json | 2 +- dev-portal/src/__tests__/utils.jsx | 25 +- .../Admin/Accounts/AccountsTable.jsx | 327 +++++++++++++++ .../Admin/Accounts/AccountsTable.module.css | 3 + .../Admin/Accounts/AccountsTableColumns.jsx | 70 ++++ dev-portal/src/components/MessageList.jsx | 35 ++ .../pages/Admin/Accounts/AccountInvites.jsx | 7 + .../pages/Admin/Accounts/AccountRequests.jsx | 7 + .../pages/Admin/Accounts/AdminAccounts.jsx | 7 + .../Admin/Accounts/RegisteredAccounts.jsx | 245 +++++++++++ .../Accounts/__tests__/RegisteredAccounts.jsx | 381 ++++++++++++++++++ dev-portal/src/pages/Admin/Admin.jsx | 11 +- dev-portal/src/pages/Admin/SideNav.jsx | 15 +- dev-portal/src/services/accounts.js | 49 +++ 16 files changed, 1235 insertions(+), 14 deletions(-) create mode 100644 dev-portal/src/components/Admin/Accounts/AccountsTable.jsx create mode 100644 dev-portal/src/components/Admin/Accounts/AccountsTable.module.css create mode 100644 dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx create mode 100644 dev-portal/src/components/MessageList.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/AccountRequests.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/AdminAccounts.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx create mode 100644 dev-portal/src/services/accounts.js diff --git a/cloudformation/template.yaml b/cloudformation/template.yaml index 7720f9d88..474b83685 100644 --- a/cloudformation/template.yaml +++ b/cloudformation/template.yaml @@ -20,6 +20,7 @@ Metadata: Parameters: - CognitoIdentityPoolName - DevPortalCustomersTableName + - AccountRegistrationMode - Label: default: "Subscription Notification Configuration" @@ -126,6 +127,15 @@ Parameters: - 'true' ConstraintDescription: Malformed input - Parameter DevelopmentMode value must be either 'true' or 'false' + AccountRegistrationMode: + Type: String + Description: Methods allowed for account registration. In 'open' mode, any user may register for an account. In 'request' mode, any user may request an account, but an Admin must approve the request in order for the account to perform any privileged actions (like subscribing to an API). In 'invite' mode, users cannot register or request an account; instead, an Admin must send an invite for the user to accept. See the documentation for details. + Default: 'open' + AllowedValues: + - 'open' + - 'request' + - 'invite' + Conditions: UseCustomDomainName: !And [!And [!Not [!Equals [!Ref CustomDomainName, '']], !Not [!Equals [!Ref CustomDomainNameAcmCertArn, '']]], !Condition NotDevelopmentMode] NoCustomDomainName: !And [!Not [ !Condition UseCustomDomainName ], !Condition NotDevelopmentMode] @@ -134,6 +144,7 @@ Conditions: DevelopmentMode: !Equals [!Ref DevelopmentMode, 'true'] NotDevelopmentMode: !Not [!Condition DevelopmentMode] InUSEastOne: !Equals [!Ref 'AWS::Region', 'us-east-1'] + InviteAccountRegistrationMode: !Equals [!Ref AccountRegistrationMode, 'invite'] Resources: ApiGatewayApi: @@ -1068,7 +1079,13 @@ Resources: Schema: - AttributeDataType: String Name: email - Required: false + Required: true + AdminCreateUserConfig: + AllowAdminCreateUserOnly: !If [ + InviteAccountRegistrationMode, 'true', 'false', + ] + AutoVerifiedAttributes: ['email'] + UsernameAttributes: ['email'] CognitoUserPoolClient: Type: AWS::Cognito::UserPoolClient diff --git a/dev-portal/package-lock.json b/dev-portal/package-lock.json index f31007d7d..4d60f5404 100644 --- a/dev-portal/package-lock.json +++ b/dev-portal/package-lock.json @@ -4441,6 +4441,15 @@ "object-assign": "^4.1.1" } }, + "create-react-context": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.2.2.tgz", + "integrity": "sha512-KkpaLARMhsTsgp0d2NA/R94F/eDLbhXERdIq3LvX2biCAXcDvHYoOqHfWCHf1+OLj+HKBotLG3KqaOOf+C1C+A==", + "requires": { + "fbjs": "^0.8.0", + "gud": "^1.0.0" + } + }, "cross-fetch": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-0.0.8.tgz", @@ -6813,6 +6822,11 @@ "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "dev": true }, + "gud": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz", + "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==" + }, "gzip-size": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.0.0.tgz", @@ -10798,6 +10812,11 @@ "ts-pnp": "^1.0.0" } }, + "popper.js": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.15.0.tgz", + "integrity": "sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA==" + }, "portfinder": { "version": "1.0.20", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.20.tgz", @@ -12228,6 +12247,19 @@ } } }, + "react-popper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.3.tgz", + "integrity": "sha512-ynMZBPkXONPc5K4P5yFWgZx5JGAUIP3pGGLNs58cfAPgK67olx7fmLp+AdpZ0+GoQ+ieFDa/z4cdV6u7sioH6w==", + "requires": { + "@babel/runtime": "^7.1.2", + "create-react-context": "<=0.2.2", + "popper.js": "^1.14.4", + "prop-types": "^15.6.1", + "typed-styles": "^0.0.7", + "warning": "^4.0.2" + } + }, "react-redux": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-4.4.10.tgz", @@ -13057,17 +13089,18 @@ } }, "semantic-ui-react": { - "version": "0.85.0", - "resolved": "https://registry.npmjs.org/semantic-ui-react/-/semantic-ui-react-0.85.0.tgz", - "integrity": "sha512-rroRoux+MMmLaZCZfFX9xZfTo1cy+JiM55fVaaqbfPq0s0RupraBhamJhvTDz7idl3ionaXPW7knJyZAz4XMGg==", + "version": "0.87.3", + "resolved": "https://registry.npmjs.org/semantic-ui-react/-/semantic-ui-react-0.87.3.tgz", + "integrity": "sha512-YJgFYEheeFBMm/epZpIpWKF9glgSShdLPiY8zoUi+KJ0IKtLtbI8RbMD/ELbZkY+SO/IWbK/f/86pWt3PVvMVA==", "requires": { "@babel/runtime": "^7.1.2", - "@semantic-ui-react/event-stack": "^3.0.1", + "@semantic-ui-react/event-stack": "^3.1.0", "classnames": "^2.2.6", "keyboard-key": "^1.0.4", "lodash": "^4.17.11", "prop-types": "^15.6.2", "react-is": "^16.7.0", + "react-popper": "^1.3.3", "shallowequal": "^1.1.0" } }, @@ -14457,6 +14490,11 @@ } } }, + "typed-styles": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz", + "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q==" + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", diff --git a/dev-portal/package.json b/dev-portal/package.json index 04bede631..814d1d825 100644 --- a/dev-portal/package.json +++ b/dev-portal/package.json @@ -17,7 +17,7 @@ "react-markdown": "^4.0.3", "react-router-dom": "^4.3.1", "semantic-ui-css": "^2.4.1", - "semantic-ui-react": "0.85.0", + "semantic-ui-react": "^0.87.3", "swagger-ui": "git@github.com:Trial-In-Error/swagger-ui.git#a183e909ab467693cb1bbf87d5cc4d1e6b899579", "yamljs": "^0.3.0" }, diff --git a/dev-portal/src/__tests__/utils.jsx b/dev-portal/src/__tests__/utils.jsx index 42841f2a9..e5e7be117 100644 --- a/dev-portal/src/__tests__/utils.jsx +++ b/dev-portal/src/__tests__/utils.jsx @@ -6,7 +6,11 @@ import { render } from '@testing-library/react' /* * Jest requires at least one test per file in __tests__. */ -test('', () => {}) +if (typeof test === 'function') { + test('', () => {}) +} else { + console.warn('__tests__/utils used outside of tests!') +} /* * Wrapper around react-testing-library's `render` function, providing a dummy @@ -14,10 +18,19 @@ test('', () => {}) * * [1]: https://testing-library.com/docs/example-react-router */ -export const renderWithRouter = (ui, { - route = '/', - history = createMemoryHistory({ initialEntries: [route] }) -} = {}) => ({ +export const renderWithRouter = ( + ui, + { + route = '/', + history = createMemoryHistory({ initialEntries: [route] }), + } = {}, +) => ({ ...render({ui}), - history + history, }) + +/** + * Returns a Promise that resolves after `ms` milliseconds with the value `resolution`. + */ +export const resolveAfter = (ms, resolution) => + new Promise(resolve => setTimeout(() => resolve(resolution), ms)) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx new file mode 100644 index 000000000..68f411714 --- /dev/null +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -0,0 +1,327 @@ +import _ from 'lodash' +import React, { useCallback, useEffect, useState } from 'react' +import { + Container, + Dropdown, + Icon, + Input, + Pagination, + Placeholder, + Table, +} from 'semantic-ui-react' + +import styles from './AccountsTable.module.css' + +const FILLER_ACCOUNT = Symbol('FILLER_ACCOUNT') + +const NO_FILTER_COLUMN = Symbol('NO_FILTER_COLUMN') +const NO_FILTER_VALUE = '' +const NO_ORDER_COLUMN = Symbol('NO_ORDER_COLUMN') +const NO_ORDER_DIRECTION = Symbol('NO_ORDER_DIRECTION') + +const NEXT_DIRECTION = { + [NO_ORDER_DIRECTION]: 'asc', + asc: 'desc', + desc: NO_ORDER_DIRECTION, +} + +const DIRECTION_ICON = { + [NO_ORDER_DIRECTION]: 'sort', + asc: 'sort up', + desc: 'sort down', +} + +/** + * A paginated table whose rows represent accounts. + * + * @param {Object} props + * @param {Object[]} props.accounts + * all Account objects to display (before filtering) + * @param {AccountsTableColumns~Descriptor[]} props.columns + * column descriptors + * @param {boolean} props.loading + * if true, the table displays a loading state; if false, the table displays + * the given accounts + * @param {Object} props.selectedAccount + * an Account object to highlight + * @param onSelectAccount + * when the row corresponding to `account` is clicked, AccountsTable calls + * `onSelectAccount(account)` + * @param children + * components to be placed in the actions section above the table + */ +export const AccountsTable = ({ + accounts, + columns, + loading, + selectedAccount, + onSelectAccount, + children: toolbarActions, +}) => { + const pageSize = 10 + + const [accountsView, setAccountsView] = useState(accounts) + const [activePage, setActivePage] = useState(0) + const [activePageAccounts, setActivePageAccounts] = useState( + [...Array(pageSize)].fill(FILLER_ACCOUNT), + ) + + const [filterableColumns, setFilterableColumns] = useState([]) + const [filterColumn, setFilterColumn] = useState(NO_FILTER_COLUMN) + const [filterValue, setFilterValue] = useState(NO_FILTER_VALUE) + const [orderColumn, setOrderColumn] = useState(NO_ORDER_COLUMN) + const [orderDirection, setOrderDirection] = useState(NO_ORDER_DIRECTION) + + useEffect(() => { + const filterableColumns = columns.filter(column => column.filtering) + setFilterableColumns(filterableColumns) + + // Reset filtering state if no columns are filterable + if (filterableColumns.length === 0) { + setFilterColumn(NO_FILTER_COLUMN) + setFilterValue(NO_FILTER_VALUE) + } + + // Pick the first filterable column if one is available + else if (filterColumn === NO_FILTER_COLUMN) { + setFilterColumn(filterableColumns[0]) + } + + // Reset filterColumn if it's no longer among the available columns + else if (!filterableColumns.includes(filterColumn)) { + setFilterColumn(NO_FILTER_COLUMN) + } + }, [ + columns, + filterColumn, + setFilterColumn, + setFilterValue, + setFilterableColumns, + ]) + + /** + * Sets `accountsView` to the filtered and sorted subset of `props.accounts`. + */ + useEffect(() => { + let view = _(accounts) + if (filterColumn !== NO_FILTER_COLUMN) { + const filterKey = filterColumn.filtering.accessor + view = view.filter( + item => + !!item[filterKey] && item[filterKey].toString().includes(filterValue), + ) + } + if (orderColumn !== NO_ORDER_COLUMN) { + view = view.orderBy([orderColumn.ordering.iteratee], [orderDirection]) + } + setAccountsView(view.value()) + }, [accounts, filterColumn, filterValue, orderColumn, orderDirection]) + + /** + * Returns a page of accounts from `accountView` according to the given page + * number. + */ + const computeAccountsPage = useCallback( + activePage => { + const start = activePage * pageSize + const pageItems = accountsView.slice(start, start + pageSize) + const fillerCount = pageSize - pageItems.length + if (fillerCount) { + pageItems.push(...Array(fillerCount).fill(FILLER_ACCOUNT)) + } + return pageItems + }, + [accountsView], + ) + + const totalPages = Math.ceil(accountsView.length / pageSize) + + const onPageChange = useCallback( + (_event, { activePage: newActivePage }) => { + // SemanticUI uses 1-indexing in Pagination. We prefer sanity. + --newActivePage + setActivePage(newActivePage) + setActivePageAccounts(computeAccountsPage(newActivePage, accountsView)) + onSelectAccount(undefined) + }, + [accountsView, onSelectAccount, computeAccountsPage], + ) + + useEffect(() => { + loading || onPageChange(undefined, { activePage: 1 }) + }, [accounts, loading, onPageChange]) + + const tableRows = _.range(pageSize).map(index => { + if (loading) { + return + } + + const account = activePageAccounts[index] + return account === FILLER_ACCOUNT ? ( + + ) : ( + + ) + }) + + const filterColumnDropdownOptions = filterableColumns.map( + ({ title, id }, index) => ({ key: index, text: title, value: id }), + ) + + const onFilterColumnDropdownChange = (_event, { value }) => + setFilterColumn( + filterableColumns.find(column => column.id === value) || NO_FILTER_COLUMN, + ) + const onSearchInputChange = (_event, { value }) => setFilterValue(value) + + const toolbar = ( + <> +
+ {filterableColumns.length > 0 && ( + + )} +
+
+ +
+
+ {toolbarActions} +
+ + ) + + const table = ( + + + {tableRows} + + + + + + + + + +
+ ) + + return ( + + {toolbar} + {table} + + ) +} + +const TableHeader = React.memo( + ({ + columns, + orderColumn, + setOrderColumn, + orderDirection, + setOrderDirection, + }) => { + // Clicking on a column makes it the "orderColumn". If that column was + // already the "orderColumn", cycle between order directions (none, + // ascending, descending). Otherwise, start at the beginning of the cycle + // (ascending). + const onToggleOrder = column => () => { + if (column === orderColumn) { + const nextDirection = NEXT_DIRECTION[orderDirection] + if (nextDirection === NO_ORDER_DIRECTION) { + setOrderColumn(NO_ORDER_COLUMN) + } + setOrderDirection(nextDirection) + } else { + setOrderColumn(column) + setOrderDirection(NEXT_DIRECTION[NO_ORDER_DIRECTION]) + } + } + + return ( + + + {columns.map((column, index) => ( + + {column.title} + {column === orderColumn && ( + + )} + {column.ordering && column !== orderColumn && ( + + )} + + ))} + + + ) + }, +) + +const LoadingAccountRow = React.memo(({ columnCount }) => ( + + {Array.from({ length: columnCount }).map((_value, index) => ( + + +   + + + ))} + +)) + +const FillerAccountRow = React.memo(({ columnCount }) => ( + + {Array.from({ length: columnCount }).map((_value, index) => ( +   + ))} + +)) + +const AccountRow = React.memo(({ account, columns, isSelected, onSelect }) => { + return ( + onSelect(account)}> + {columns.map(({ render }, index) => ( + {render(account)} + ))} + + ) +}) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.module.css b/dev-portal/src/components/Admin/Accounts/AccountsTable.module.css new file mode 100644 index 000000000..41faba180 --- /dev/null +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.module.css @@ -0,0 +1,3 @@ +.headerRow { + user-select: none; +} diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx new file mode 100644 index 000000000..84cca5030 --- /dev/null +++ b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx @@ -0,0 +1,70 @@ +/** + * An AccountsTable column descriptor. + * + * @typedef {Object} AccountsTableColumns~Descriptor + * @property {string} id + * a unique ID to distinguish this column from others + * @property {string} title + * column title to show in the header row + * @property {Function} render + * accepts an Account object, and returns content to be placed in the table + * cell in this column + * @property {(Object|undefined)} ordering + * ordering descriptor for this column. If absent, the user cannot order on + * this column. + * @property ordering.iteratee + * a lodash iteratee, used with `lodash.orderBy` + * @property {(Object|undefined)} filtering + * filtering descriptor for this column. If absent, the user cannot filter + * on this column. + * @property {string} filtering.accessor + * an Account object property name on which to search + */ + +export const EmailAddress = { + id: 'emailAddress', + title: 'Email address', + render: account => account.emailAddress, + ordering: { + iteratee: 'emailAddress', + }, + filtering: { + accessor: 'emailAddress', + }, +} + +const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('default', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', +}) + +const formatDate = isoDateString => + DATE_TIME_FORMATTER.format(new Date(isoDateString)) + +export const DateRegistered = { + id: 'dateRegistered', + title: 'Date registered', + render: account => formatDate(account.dateRegistered), + ordering: { + iteratee: 'dateRegistered', + }, +} + +export const RegistrationMethod = { + id: 'registrationMethod', + title: 'Registration method', + render: account => account.registrationMethod, +} + +export const ApiKeyId = { + id: 'apiKeyId', + title: 'API key ID', + render: account => account.apiKeyId, + filtering: { + accessor: 'apiKeyId', + }, +} diff --git a/dev-portal/src/components/MessageList.jsx b/dev-portal/src/components/MessageList.jsx new file mode 100644 index 000000000..2500ca2ac --- /dev/null +++ b/dev-portal/src/components/MessageList.jsx @@ -0,0 +1,35 @@ +import React, { useState } from 'react' + +export const MessageList = ({ messages, dismissMessage, renderers }) => ( + <> + {messages.map((message, index) => { + const { type, ...payload } = message + if (renderers[type]) { + return ( + + {renderers[type](payload, () => dismissMessage(message))} + + ) + } + throw new Error(`Unknown message type: ${type.toString()}`) + })} + +) + +export const useMessageQueue = initialMessages => { + const [messages, setMessages] = useState(initialMessages || []) + + const sendMessage = target => setMessages([...messages, target]) + const dismissMessage = target => { + const deleteIndex = messages.findIndex(message => message === target) + if (deleteIndex === -1) { + throw new Error('Message not found') + } + setMessages([ + ...messages.slice(0, deleteIndex), + ...messages.slice(deleteIndex + 1), + ]) + } + + return [messages, sendMessage, dismissMessage] +} diff --git a/dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx b/dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx new file mode 100644 index 000000000..98209e757 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx @@ -0,0 +1,7 @@ +import React, { Component } from 'react' + +export default class AccountInvites extends Component { + render = () => { + return

TODO: Account invites

+ } +} diff --git a/dev-portal/src/pages/Admin/Accounts/AccountRequests.jsx b/dev-portal/src/pages/Admin/Accounts/AccountRequests.jsx new file mode 100644 index 000000000..85930af11 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/AccountRequests.jsx @@ -0,0 +1,7 @@ +import React, { Component } from 'react' + +export default class AccountRequests extends Component { + render = () => { + return

TODO: Account requests

+ } +} diff --git a/dev-portal/src/pages/Admin/Accounts/AdminAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/AdminAccounts.jsx new file mode 100644 index 000000000..bde963471 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/AdminAccounts.jsx @@ -0,0 +1,7 @@ +import React, { Component } from 'react' + +export default class AdminAccounts extends Component { + render = () => { + return

TODO: Admin accounts

+ } +} diff --git a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx new file mode 100644 index 000000000..964e69e36 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx @@ -0,0 +1,245 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { Button, Container, Header, Message, Modal } from 'semantic-ui-react' + +import * as MessageList from 'components/MessageList' +import * as AccountService from 'services/accounts' +import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' +import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' + +const RegisteredAccounts = () => { + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedAccount, setSelectedAccount] = useState(undefined) + const [deleteModalOpen, setDeleteModalOpen] = useState(false) + const [promoteModalOpen, setPromoteModalOpen] = useState(false) + const [messages, sendMessage, dismissMessage] = MessageList.useMessageQueue() + + const refreshAccounts = () => + AccountService.fetchRegisteredAccounts().then(accounts => + setAccounts(accounts), + ) + + // Initial load + useEffect(() => { + refreshAccounts().finally(() => setLoading(false)) + }, []) + + const onSelectAccount = useCallback(account => setSelectedAccount(account), [ + setSelectedAccount, + ]) + + const onConfirmDelete = useCallback(async () => { + setLoading(true) + setDeleteModalOpen(false) + try { + await AccountService.deleteAccountByIdentityPoolId( + selectedAccount.identityPoolId, + ) + sendMessage({ type: DELETE_SUCCESS, account: selectedAccount }) + await refreshAccounts() + } catch (error) { + sendMessage({ + type: DELETE_FAILURE, + account: selectedAccount, + errorMessage: error.message, + }) + } finally { + setLoading(false) + } + }, [sendMessage, selectedAccount]) + + const onConfirmPromote = useCallback(async () => { + setLoading(true) + setPromoteModalOpen(false) + try { + await AccountService.promoteAccountByIdentityPoolId( + selectedAccount.identityPoolId, + ) + sendMessage({ type: PROMOTE_SUCCESS, account: selectedAccount }) + } catch (error) { + sendMessage({ + type: PROMOTE_FAILURE, + account: selectedAccount, + errorMessage: error.message, + }) + } finally { + setLoading(false) + } + }, [sendMessage, selectedAccount]) + + + return ( + +
Registered accounts
+ + + setDeleteModalOpen(true)} + canPromote={!loading && selectedAccount} + onClickPromote={() => setPromoteModalOpen(true)} + /> + + setDeleteModalOpen(false)} + /> + setPromoteModalOpen(false)} + /> +
+ ) +} +export default RegisteredAccounts + +const TableActions = React.memo( + ({ canDelete, onClickDelete, canPromote, onClickPromote }) => ( + + + + + + ), +) + +const PromoteAccountModal = React.memo( + ({ account, onConfirm, open, onClose }) => + account && ( + + Confirm promotion + +

+ Are you sure you want to promote the account{' '} + {account.emailAddress} to Admin? This will allow + the account to perform any Admin actions, including deleting and + promoting other accounts. +

+

+ This action can only be undone by + contacting the owner of the Developer Portal. +

+
+ + + + +
+ ), +) + +const DELETE_SUCCESS = Symbol('DELETE_SUCCESS') +const DELETE_FAILURE = Symbol('DELETE_FAILURE') +const PROMOTE_SUCCESS = Symbol('PROMOTE_SUCCESS') +const PROMOTE_FAILURE = Symbol('PROMOTE_FAILURE') + +const MESSAGE_RENDERERS = { + [DELETE_SUCCESS]: ({ account }, onDismiss) => ( + + ), + [DELETE_FAILURE]: ({ account, errorMessage }, onDismiss) => ( + + ), + [PROMOTE_SUCCESS]: ({ account }, onDismiss) => ( + + ), + [PROMOTE_FAILURE]: ({ account, errorMessage }, onDismiss) => ( + + ), +} + +const DeleteSuccessMessage = React.memo(({ account, onDismiss }) => ( + + + Deleted account {account.emailAddress}. + + +)) + +const DeleteFailureMessage = React.memo( + ({ account, errorMessage, onDismiss }) => ( + + +

+ Failed to delete account {account.emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
+ ), +) + +const PromoteSuccessMessage = React.memo(({ account, onDismiss }) => ( + + + Promoted account {account.emailAddress}. + + +)) + +const PromoteFailureMessage = React.memo( + ({ account, errorMessage, onDismiss }) => ( + + +

+ Failed to promote account {account.emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
+ ), +) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx new file mode 100644 index 000000000..13c43c3cf --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -0,0 +1,381 @@ +import _ from 'lodash' +import React from 'react' +import * as rtl from '@testing-library/react' +import '@testing-library/jest-dom/extend-expect' + +import { renderWithRouter } from '__tests__/utils' + +import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' +import * as AccountService from 'services/accounts' + +jest.mock('services/accounts') + +/** + * Suppress React 16.8 act() warnings globally. + * The React team's fix won't be out of alpha until 16.9.0. + * + * See + */ +const consoleError = console.error +beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation((...args) => { + if ( + !args[0].includes( + 'Warning: An update to %s inside a test was not wrapped in act', + ) + ) { + consoleError(...args) + } + }) +}) + +afterEach(rtl.cleanup) + +const renderPage = () => renderWithRouter() + +const waitForAccountsToLoad = page => + rtl.waitForElementToBeRemoved(() => + page.queryAllByTestId('accountRowPlaceholder'), + ) + +describe('RegisteredAccounts page', () => { + it('renders', async () => { + AccountService.fetchRegisteredAccounts = jest.fn().mockResolvedValue([]) + const page = renderPage() + expect(page.baseElement).toBeTruthy() + }) + + it('initially shows the loading state', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockReturnValue(new Promise(() => {})) + + const page = renderPage() + expect(page.queryAllByTestId('accountRowPlaceholder')).not.toHaveLength(0) + }) + + it('shows the accounts after loading', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + const page = renderPage() + await waitForAccountsToLoad(page) + + _.range(10).forEach(index => + expect(page.queryByText(`${index}@example.com`)).not.toBeNull(), + ) + }) + + it('orders pages for all accounts', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await waitForAccountsToLoad(page) + const pagination = page.getByRole('navigation') + + const page1Button = rtl.queryByText(pagination, '1') + expect(page1Button).not.toBeNull() + + const page16Button = rtl.queryByText(pagination, '16') + expect(page16Button).not.toBeNull() + rtl.fireEvent.click(page16Button) + expect( + page.queryByText(`${NUM_MOCK_ACCOUNTS - 1}@example.com`), + ).not.toBeNull() + }) + + it('orders accounts by email address', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await waitForAccountsToLoad(page) + + // Order ascending + const table = page.getByTestId('accountsTable') + const emailAddressHeader = rtl.getByText(table, 'Email address') + rtl.fireEvent.click(emailAddressHeader) + + // Check that first page is correct + ;[0, ..._.range(100, 109)] + .map(index => `${index}@example.com`) + .forEach(emailAddress => rtl.getByText(table, emailAddress)) + + // Check that last page is correct + const pagination = page.getByRole('navigation') + const lastPageButton = rtl.getByLabelText(pagination, 'Last item') + rtl.fireEvent.click(lastPageButton) + ;[..._.range(94, 100), 9] + .map(index => `${index}@example.com`) + .forEach(emailAddress => rtl.getByText(table, emailAddress)) + + // Order descending, go back to first page + rtl.fireEvent.click(emailAddressHeader) + const firstPageButton = rtl.getByLabelText(pagination, 'First item') + rtl.fireEvent.click(firstPageButton) + + // Check that first page is correct + ;[9, ..._.range(99, 90)] + .map(index => `${index}@example.com`) + .forEach(emailAddress => rtl.getByText(table, emailAddress)) + }) + + it('orders accounts by date registered', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await waitForAccountsToLoad(page) + + // Order ascending + const table = page.getByTestId('accountsTable') + const dateRegisteredHeader = rtl.getByText(table, 'Date registered') + rtl.fireEvent.click(dateRegisteredHeader) + + // Check that first page is correct + ;[0, 105, 53, 1, 106, 54, 2, 107, 55, 3] + .map(index => `${index}@example.com`) + .forEach(emailAddress => rtl.getByText(table, emailAddress)) + }) + + it('filters accounts by email address', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const table = page.getByTestId('accountsTable') + + rtl.fireEvent.change(filterInput, { target: { value: '11' } }) + ;[11, ..._.range(110, 119)] + .map(index => `${index}@example.com`) + .forEach(emailAddress => rtl.getByText(table, emailAddress)) + + rtl.fireEvent.change(filterInput, { target: { value: '111' } }) + rtl.getByText(table, '111@example.com') + expect(rtl.getAllByText(table, /@example\.com/)).toHaveLength(1) + + rtl.fireEvent.change(filterInput, { target: { value: 'apiKeyId' } }) + expect(rtl.queryAllByText(table, /@example\.com/)).toHaveLength(0) + }) + + it('filters accounts by API key', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const filterDropdown = page.getByTestId('filterDropdown') + const table = page.getByTestId('accountsTable') + + rtl.fireEvent.click(filterDropdown) + const filterByApiKeyIdOption = rtl.getByText(filterDropdown, 'API key ID') + rtl.fireEvent.click(filterByApiKeyIdOption) + + rtl.fireEvent.change(filterInput, { target: { value: '15' } }) + ;[15, 115, ..._.range(150, 157)] + .map(index => `apiKeyId${index}`) + .forEach(apiKeyId => rtl.getByText(table, apiKeyId)) + + rtl.fireEvent.change(filterInput, { target: { value: '155' } }) + rtl.getByText(table, 'apiKeyId155') + expect(rtl.getAllByText(table, /apiKeyId/)).toHaveLength(1) + + rtl.fireEvent.change(filterInput, { target: { value: '@example.com' } }) + expect(rtl.queryAllByText(table, /apiKeyId/)).toHaveLength(0) + }) + + it('filters and orders at the same time', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const table = page.getByTestId('accountsTable') + const dateRegisteredHeader = rtl.getByText(table, 'Date registered') + + rtl.fireEvent.change(filterInput, { target: { value: '13' } }) + rtl.fireEvent.click(dateRegisteredHeader) + ;[113, 13, ..._.range(131, 138)] + .map(index => `apiKeyId${index}`) + .forEach(apiKeyId => rtl.getByText(table, apiKeyId)) + }) + + it('deletes an account', async () => { + const targetAccountEmail = '1@example.com' + const targetAccountIdentityPoolId = 'identityPoolId1' + + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + .mockResolvedValueOnce( + MOCK_ACCOUNTS.filter( + account => account.emailAddress !== targetAccountEmail, + ), + ) + AccountService.deleteAccountByIdentityPoolId = jest + .fn() + .mockResolvedValueOnce(undefined) + + const page = renderPage() + await waitForAccountsToLoad(page) + const table = page.getByTestId('accountsTable') + const targetAccountEmailCell = rtl.getByText(table, targetAccountEmail) + const deleteButton = page.getByText('Delete') + + expect(deleteButton.disabled === true) + rtl.fireEvent.click(targetAccountEmailCell) + expect(deleteButton.disabled === false) + rtl.fireEvent.click(deleteButton) + + const modal = rtl.getByText(document, 'Confirm deletion').closest('.modal') + const confirmDeleteButton = rtl.getByText(modal, 'Delete') + rtl.getByText(modal, targetAccountEmail) + rtl.fireEvent.click(confirmDeleteButton) + + await waitForAccountsToLoad(page) + expect(rtl.queryByText(document, 'Confirm deletion')).toBeNull() + expect( + AccountService.deleteAccountByIdentityPoolId.mock.calls, + ).toHaveLength(1) + expect( + AccountService.deleteAccountByIdentityPoolId.mock.calls[0][0], + ).toEqual(targetAccountIdentityPoolId) + + await rtl.wait(() => + expect(page.getByText(/Deleted account/)).toBeInTheDocument(), + ) + expect(rtl.queryByText(table, targetAccountEmail)).toBeNull() + }) + + it('shows a message when deletion fails', async () => { + const targetAccountEmail = '1@example.com' + const errorMessage = 'Something weird happened!' + + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + AccountService.deleteAccountByIdentityPoolId = jest + .fn() + .mockImplementation(() => Promise.reject(new Error(errorMessage))) + + const page = renderPage() + await waitForAccountsToLoad(page) + const table = page.getByTestId('accountsTable') + const targetAccountEmailCell = rtl.getByText(table, targetAccountEmail) + const deleteButton = page.getByText('Delete') + rtl.fireEvent.click(targetAccountEmailCell) + rtl.fireEvent.click(deleteButton) + + const modal = rtl.getByText(document, 'Confirm deletion').closest('.modal') + const confirmDeleteButton = rtl.getByText(modal, 'Delete') + rtl.getByText(modal, targetAccountEmail) + rtl.fireEvent.click(confirmDeleteButton) + + await waitForAccountsToLoad(page) + await rtl.wait(() => + expect(page.getByText(/Failed to delete account/)).toBeInTheDocument(), + ) + expect(rtl.getByText(table, targetAccountEmail)).toBeInTheDocument() + }) + + it('promotes an account', async () => { + const targetAccountEmail = '2@example.com' + const targetAccountIdentityPoolId = 'identityPoolId2' + + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + AccountService.promoteAccountByIdentityPoolId = jest + .fn() + .mockResolvedValueOnce(undefined) + + const page = renderPage() + await waitForAccountsToLoad(page) + const table = page.getByTestId('accountsTable') + const targetAccountEmailCell = rtl.getByText(table, targetAccountEmail) + const promoteButton = page.getByText('Promote to Admin') + + expect(promoteButton.disabled === true) + rtl.fireEvent.click(targetAccountEmailCell) + expect(promoteButton.disabled === false) + rtl.fireEvent.click(promoteButton) + + const modal = rtl.getByText(document, 'Confirm promotion').closest('.modal') + const confirmPromoteButton = rtl.getByText(modal, 'Promote') + rtl.getByText(modal, targetAccountEmail) + rtl.fireEvent.click(confirmPromoteButton) + + await waitForAccountsToLoad(page) + expect(rtl.queryByText(document, 'Confirm promotion')).toBeNull() + expect( + AccountService.promoteAccountByIdentityPoolId.mock.calls, + ).toHaveLength(1) + expect( + AccountService.promoteAccountByIdentityPoolId.mock.calls[0][0], + ).toEqual(targetAccountIdentityPoolId) + + await rtl.wait(() => + expect(page.getByText(/Promoted account/)).toBeInTheDocument(), + ) + expect(rtl.getByText(table, targetAccountEmail)).toBeInTheDocument() + }) + + it('shows a message when promotion fails', async () => { + const targetAccountEmail = '2@example.com' + const errorMessage = 'Something strange occurred.' + + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + AccountService.deleteAccountByIdentityPoolId = jest + .fn() + .mockImplementation(() => Promise.reject(new Error(errorMessage))) + + const page = renderPage() + await waitForAccountsToLoad(page) + const table = page.getByTestId('accountsTable') + const targetAccountEmailCell = rtl.getByText(table, targetAccountEmail) + const deleteButton = page.getByText('Delete') + rtl.fireEvent.click(targetAccountEmailCell) + rtl.fireEvent.click(deleteButton) + + const modal = rtl.getByText(document, 'Confirm deletion').closest('.modal') + const confirmDeleteButton = rtl.getByText(modal, 'Delete') + rtl.getByText(modal, targetAccountEmail) + rtl.fireEvent.click(confirmDeleteButton) + + await waitForAccountsToLoad(page) + await rtl.wait(() => + expect(page.getByText(/Failed to delete account/)).toBeInTheDocument(), + ) + expect(rtl.getByText(table, targetAccountEmail)).toBeInTheDocument() + }) +}) + +const NUM_MOCK_ACCOUNTS = 157 // should be prime + +const MOCK_ACCOUNTS = (() => { + const now = Date.now() + return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => ({ + identityPoolId: `identityPoolId${index}`, + userPoolId: `userPoolId${index}`, + emailAddress: `${index}@example.com`, + dateRegistered: new Date( + now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000, + ).toJSON(), + apiKeyId: `apiKeyId${index}`, + registrationMethod: _.sample(['open', 'invite', 'request']), + isAdmin: index % 20 === 0, + })) +})() diff --git a/dev-portal/src/pages/Admin/Admin.jsx b/dev-portal/src/pages/Admin/Admin.jsx index 61dee895b..87d80ba04 100644 --- a/dev-portal/src/pages/Admin/Admin.jsx +++ b/dev-portal/src/pages/Admin/Admin.jsx @@ -2,7 +2,12 @@ import React, { Component } from 'react' import { BrowserRouter as Router } from 'react-router-dom' import { ApiManagement, SideNav } from './' -import { AdminRoute } from './../../'; +import { AdminRoute } from './../../' + +import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' +import AdminAccounts from 'pages/Admin/Accounts/AdminAccounts' +import AccountInvites from 'pages/Admin/Accounts/AccountInvites' +import AccountRequests from 'pages/Admin/Accounts/AccountRequests' export class Admin extends Component { render() { @@ -13,6 +18,10 @@ export class Admin extends Component {
+ + + +
diff --git a/dev-portal/src/pages/Admin/SideNav.jsx b/dev-portal/src/pages/Admin/SideNav.jsx index a798ad8c3..7f3e984ce 100644 --- a/dev-portal/src/pages/Admin/SideNav.jsx +++ b/dev-portal/src/pages/Admin/SideNav.jsx @@ -7,10 +7,23 @@ import { observer } from 'mobx-react' import { Link } from 'react-router-dom' import { Menu } from 'semantic-ui-react' - export const SideNav = observer(() => ( isAdmin() && ( APIs + + Accounts + + + Admins + + + Invites + + + Requests + + + ) )) diff --git a/dev-portal/src/services/accounts.js b/dev-portal/src/services/accounts.js new file mode 100644 index 000000000..d7fe392ef --- /dev/null +++ b/dev-portal/src/services/accounts.js @@ -0,0 +1,49 @@ +import _ from 'lodash' + +import { resolveAfter } from '__tests__/utils' + +const now = Date.now() +const numMockAccounts = 157 // should be prime +const mockData = Array.from({ length: numMockAccounts }).map( + (_value, index) => ({ + identityPoolId: `identityPoolId${index}`, + userPoolId: `userPoolId${index}`, + emailAddress: `${index}@example.com`, + dateRegistered: new Date( + now + ((index * 3) % numMockAccounts) * 1000, + ).toJSON(), + apiKeyId: `apiKeyId${index}`, + registrationMethod: _.sample(['open', 'invite', 'request']), + isAdmin: index % 20 === 0, + }), +) + +export const fetchRegisteredAccounts = () => { + return resolveAfter(1500, mockData.slice()) +} + +export const deleteAccountByIdentityPoolId = async identityPoolId => { + await resolveAfter(1500) + + const accountIndex = mockData.findIndex(account => account.identityPoolId === identityPoolId) + if (accountIndex === -1) { + throw new Error('Account not found!') + } + if (identityPoolId.endsWith('10')) { + throw new Error('Something weird happened!') + } + mockData.splice(accountIndex, 1) +} + +export const promoteAccountByIdentityPoolId = async identityPoolId => { + await resolveAfter(1500) + + const account = mockData.find(account => account.identityPoolId === identityPoolId) + if (account === undefined) { + throw new Error('Account not found!') + } + if (account.isAdmin) { + throw new Error('Account is already an Admin!') + } + account.isAdmin = true +} From 40283cf67211a1e5a4d63c1eef7f4dd315f0a5e6 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Wed, 31 Jul 2019 15:58:37 -0700 Subject: [PATCH 06/19] Configure prettier --- CONTRIBUTING.md | 7 ++++--- package-lock.json | 6 ++++++ package.json | 8 ++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e86a8397..ea81d6d3b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,9 +32,10 @@ To send us a pull request, please: 1. Fork the repository. 2. Working off the latest version of the *staging* branch, modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 3. Ensure local tests pass. -4. Commit to your fork using clear commit messages. -5. Send us a pull request merging into the *staging* branch, answering any default questions in the pull request interface. -6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. +4. Run `prettier` on your new code to ensure style consistency. Remember to only reformat files relevant to your changes. +5. Commit to your fork using clear commit messages. +6. Send us a pull request merging into the *staging* branch, answering any default questions in the pull request interface. +7. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). diff --git a/package-lock.json b/package-lock.json index 455e96cd1..e8eb71213 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3946,6 +3946,12 @@ "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", "dev": true }, + "prettier": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.18.2.tgz", + "integrity": "sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==", + "dev": true + }, "pretty-format": { "version": "24.8.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-24.8.0.tgz", diff --git a/package.json b/package.json index 41c3314ab..16fc00237 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,17 @@ "fs-extra": "^7.0.1", "jest": "^24.8.0", "memorystream": "^0.3.1", + "prettier": "1.18.2", "xml-js": "^1.6.8" }, "dependencies": { "request-promise": "^4.2.4" + }, + "prettier": { + "trailingComma": "all", + "tabWidth": 2, + "semi": "false", + "singleQuote": "true", + "jsxSingleQuote": "true" } } From 6b89bf46970d3c3cb698e7af16f57de3fb00d939 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Thu, 1 Aug 2019 14:07:04 -0700 Subject: [PATCH 07/19] Move test utils out of real-test scope --- .../Admin/Accounts/__tests__/RegisteredAccounts.jsx | 2 +- dev-portal/src/pages/__tests__/Home.jsx | 8 ++++---- dev-portal/src/services/accounts.js | 2 +- .../src/{__tests__/utils.jsx => utils/test-utils.jsx} | 9 ++------- 4 files changed, 8 insertions(+), 13 deletions(-) rename dev-portal/src/{__tests__/utils.jsx => utils/test-utils.jsx} (81%) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx index 13c43c3cf..9bdbb27a5 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -3,7 +3,7 @@ import React from 'react' import * as rtl from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' -import { renderWithRouter } from '__tests__/utils' +import { renderWithRouter } from 'utils/test-utils' import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' import * as AccountService from 'services/accounts' diff --git a/dev-portal/src/pages/__tests__/Home.jsx b/dev-portal/src/pages/__tests__/Home.jsx index 719fc5768..bddb87567 100644 --- a/dev-portal/src/pages/__tests__/Home.jsx +++ b/dev-portal/src/pages/__tests__/Home.jsx @@ -1,12 +1,12 @@ import React from 'react' -import {cleanup} from '@testing-library/react' +import { cleanup } from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' -import {renderWithRouter} from '__tests__/utils' +import { renderWithRouter } from 'utils/test-utils' -import {fragments} from 'services/get-fragments' +import { fragments } from 'services/get-fragments' -import {HomePage} from 'pages/Home' +import { HomePage } from 'pages/Home' beforeEach(() => { // Mock fragment diff --git a/dev-portal/src/services/accounts.js b/dev-portal/src/services/accounts.js index d7fe392ef..bdccf8625 100644 --- a/dev-portal/src/services/accounts.js +++ b/dev-portal/src/services/accounts.js @@ -1,6 +1,6 @@ import _ from 'lodash' -import { resolveAfter } from '__tests__/utils' +import { resolveAfter } from 'utils/test-utils' const now = Date.now() const numMockAccounts = 157 // should be prime diff --git a/dev-portal/src/__tests__/utils.jsx b/dev-portal/src/utils/test-utils.jsx similarity index 81% rename from dev-portal/src/__tests__/utils.jsx rename to dev-portal/src/utils/test-utils.jsx index e5e7be117..b268aef89 100644 --- a/dev-portal/src/__tests__/utils.jsx +++ b/dev-portal/src/utils/test-utils.jsx @@ -3,13 +3,8 @@ import { Router } from 'react-router-dom' import { createMemoryHistory } from 'history' import { render } from '@testing-library/react' -/* - * Jest requires at least one test per file in __tests__. - */ -if (typeof test === 'function') { - test('', () => {}) -} else { - console.warn('__tests__/utils used outside of tests!') +if (typeof jest !== 'object') { + console.warn('test-utils used outside of tests!') } /* From 6db1511eff0d29ec1d1545d97e78602598a016c3 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 11:18:30 -0700 Subject: [PATCH 08/19] AccountsTable: refactor order directions to use circular array --- .../Admin/Accounts/AccountsTable.jsx | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx index 68f411714..08701e9ec 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -17,19 +17,23 @@ const FILLER_ACCOUNT = Symbol('FILLER_ACCOUNT') const NO_FILTER_COLUMN = Symbol('NO_FILTER_COLUMN') const NO_FILTER_VALUE = '' const NO_ORDER_COLUMN = Symbol('NO_ORDER_COLUMN') -const NO_ORDER_DIRECTION = Symbol('NO_ORDER_DIRECTION') -const NEXT_DIRECTION = { - [NO_ORDER_DIRECTION]: 'asc', - asc: 'desc', - desc: NO_ORDER_DIRECTION, -} +const ORDER_DIRECTIONS = [ + { + lodashDirection: undefined, + iconName: 'sort', + }, + { + lodashDirection: 'asc', + iconName: 'sort up', + }, + { + lodashDirection: 'desc', + iconName: 'sort down', + }, +] -const DIRECTION_ICON = { - [NO_ORDER_DIRECTION]: 'sort', - asc: 'sort up', - desc: 'sort down', -} +const nextDirectionIndex = index => (index + 1) % ORDER_DIRECTIONS.length /** * A paginated table whose rows represent accounts. @@ -70,7 +74,7 @@ export const AccountsTable = ({ const [filterColumn, setFilterColumn] = useState(NO_FILTER_COLUMN) const [filterValue, setFilterValue] = useState(NO_FILTER_VALUE) const [orderColumn, setOrderColumn] = useState(NO_ORDER_COLUMN) - const [orderDirection, setOrderDirection] = useState(NO_ORDER_DIRECTION) + const [orderDirectionIndex, setOrderDirectionIndex] = useState(0) useEffect(() => { const filterableColumns = columns.filter(column => column.filtering) @@ -112,10 +116,13 @@ export const AccountsTable = ({ ) } if (orderColumn !== NO_ORDER_COLUMN) { - view = view.orderBy([orderColumn.ordering.iteratee], [orderDirection]) + view = view.orderBy( + [orderColumn.ordering.iteratee], + [ORDER_DIRECTIONS[orderDirectionIndex].lodashDirection], + ) } setAccountsView(view.value()) - }, [accounts, filterColumn, filterValue, orderColumn, orderDirection]) + }, [accounts, filterColumn, filterValue, orderColumn, orderDirectionIndex]) /** * Returns a page of accounts from `accountView` according to the given page @@ -219,8 +226,8 @@ export const AccountsTable = ({ columns={columns} orderColumn={orderColumn} setOrderColumn={setOrderColumn} - orderDirection={orderDirection} - setOrderDirection={setOrderDirection} + orderDirectionIndex={orderDirectionIndex} + setOrderDirectionIndex={setOrderDirectionIndex} /> {tableRows} @@ -253,8 +260,8 @@ const TableHeader = React.memo( columns, orderColumn, setOrderColumn, - orderDirection, - setOrderDirection, + orderDirectionIndex, + setOrderDirectionIndex, }) => { // Clicking on a column makes it the "orderColumn". If that column was // already the "orderColumn", cycle between order directions (none, @@ -262,17 +269,18 @@ const TableHeader = React.memo( // (ascending). const onToggleOrder = column => () => { if (column === orderColumn) { - const nextDirection = NEXT_DIRECTION[orderDirection] - if (nextDirection === NO_ORDER_DIRECTION) { + const nextIndex = nextDirectionIndex(orderDirectionIndex) + if (nextIndex === 0) { setOrderColumn(NO_ORDER_COLUMN) } - setOrderDirection(nextDirection) + setOrderDirectionIndex(nextIndex) } else { setOrderColumn(column) - setOrderDirection(NEXT_DIRECTION[NO_ORDER_DIRECTION]) + setOrderDirectionIndex(nextDirectionIndex(0)) } } + const orderDirection = ORDER_DIRECTIONS[orderDirectionIndex] return ( @@ -283,10 +291,10 @@ const TableHeader = React.memo( > {column.title} {column === orderColumn && ( - + )} {column.ordering && column !== orderColumn && ( - + )} ))} From b2d94f54e653fb4ac8edad556cfd23743e7a4e7f Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 11:25:21 -0700 Subject: [PATCH 09/19] RegisteredAccounts: clarified wording in delete/promote modals --- dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx index 964e69e36..57c479838 100644 --- a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx @@ -134,7 +134,7 @@ const DeleteAccountModal = React.memo(

Are you sure you want to delete the account{' '} {account.emailAddress}, and de-activate the - associated API key? This action cannot be undone. + associated API key? This action is irreversible.

@@ -160,8 +160,8 @@ const PromoteAccountModal = React.memo( promoting other accounts.

- This action can only be undone by - contacting the owner of the Developer Portal. + Only the owner of the Developer Portal can demote the account, + through the Cognito console.

From 8c26a9103a27c0dbff08736eeef9957866df7c89 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 11:26:42 -0700 Subject: [PATCH 10/19] RegisteredAccounts: move message types to top of file --- .../src/pages/Admin/Accounts/RegisteredAccounts.jsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx index 57c479838..c2de56a99 100644 --- a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx @@ -6,6 +6,11 @@ import * as AccountService from 'services/accounts' import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' +const DELETE_SUCCESS = Symbol('DELETE_SUCCESS') +const DELETE_FAILURE = Symbol('DELETE_FAILURE') +const PROMOTE_SUCCESS = Symbol('PROMOTE_SUCCESS') +const PROMOTE_FAILURE = Symbol('PROMOTE_FAILURE') + const RegisteredAccounts = () => { const [accounts, setAccounts] = useState([]) const [loading, setLoading] = useState(true) @@ -174,11 +179,6 @@ const PromoteAccountModal = React.memo( ), ) -const DELETE_SUCCESS = Symbol('DELETE_SUCCESS') -const DELETE_FAILURE = Symbol('DELETE_FAILURE') -const PROMOTE_SUCCESS = Symbol('PROMOTE_SUCCESS') -const PROMOTE_FAILURE = Symbol('PROMOTE_FAILURE') - const MESSAGE_RENDERERS = { [DELETE_SUCCESS]: ({ account }, onDismiss) => ( From aaf3b9a3c111ee1602da8f7ebaa7f9f019552fd6 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 11:32:07 -0700 Subject: [PATCH 11/19] AccountsTable: export default page size --- dev-portal/src/components/Admin/Accounts/AccountsTable.jsx | 4 +++- .../src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx index 08701e9ec..dad722dec 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -12,6 +12,8 @@ import { import styles from './AccountsTable.module.css' +export const DEFAULT_PAGE_SIZE = 10 + const FILLER_ACCOUNT = Symbol('FILLER_ACCOUNT') const NO_FILTER_COLUMN = Symbol('NO_FILTER_COLUMN') @@ -62,7 +64,7 @@ export const AccountsTable = ({ onSelectAccount, children: toolbarActions, }) => { - const pageSize = 10 + const pageSize = DEFAULT_PAGE_SIZE const [accountsView, setAccountsView] = useState(accounts) const [activePage, setActivePage] = useState(0) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx index 9bdbb27a5..0056c586d 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -6,6 +6,7 @@ import '@testing-library/jest-dom/extend-expect' import { renderWithRouter } from 'utils/test-utils' import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' +import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' import * as AccountService from 'services/accounts' jest.mock('services/accounts') @@ -61,7 +62,7 @@ describe('RegisteredAccounts page', () => { const page = renderPage() await waitForAccountsToLoad(page) - _.range(10).forEach(index => + _.range(AccountsTable.DEFAULT_PAGE_SIZE).forEach(index => expect(page.queryByText(`${index}@example.com`)).not.toBeNull(), ) }) From 7a2172c6e683468cb9f426f010ee8af590c8dd23 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 11:52:00 -0700 Subject: [PATCH 12/19] RegisteredAccounts: clarify filter/order test expected values --- .../Accounts/__tests__/RegisteredAccounts.jsx | 79 +++++++++++-------- 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx index 0056c586d..4a3f913d1 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -101,17 +101,19 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.click(emailAddressHeader) // Check that first page is correct - ;[0, ..._.range(100, 109)] - .map(index => `${index}@example.com`) - .forEach(emailAddress => rtl.getByText(table, emailAddress)) + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress'], ['asc']) + .take(10) + .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) // Check that last page is correct const pagination = page.getByRole('navigation') const lastPageButton = rtl.getByLabelText(pagination, 'Last item') rtl.fireEvent.click(lastPageButton) - ;[..._.range(94, 100), 9] - .map(index => `${index}@example.com`) - .forEach(emailAddress => rtl.getByText(table, emailAddress)) + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress'], ['asc']) + .drop(150) + .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) // Order descending, go back to first page rtl.fireEvent.click(emailAddressHeader) @@ -119,9 +121,10 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.click(firstPageButton) // Check that first page is correct - ;[9, ..._.range(99, 90)] - .map(index => `${index}@example.com`) - .forEach(emailAddress => rtl.getByText(table, emailAddress)) + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress'], ['desc']) + .take(10) + .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) }) it('orders accounts by date registered', async () => { @@ -138,9 +141,10 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.click(dateRegisteredHeader) // Check that first page is correct - ;[0, 105, 53, 1, 106, 54, 2, 107, 55, 3] - .map(index => `${index}@example.com`) - .forEach(emailAddress => rtl.getByText(table, emailAddress)) + _(MOCK_ACCOUNTS) + .orderBy('dateRegistered') + .take(10) + .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) }) it('filters accounts by email address', async () => { @@ -154,9 +158,10 @@ describe('RegisteredAccounts page', () => { const table = page.getByTestId('accountsTable') rtl.fireEvent.change(filterInput, { target: { value: '11' } }) - ;[11, ..._.range(110, 119)] - .map(index => `${index}@example.com`) - .forEach(emailAddress => rtl.getByText(table, emailAddress)) + _(MOCK_ACCOUNTS) + .filter(({ emailAddress }) => emailAddress.includes('11')) + .take(10) + .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) rtl.fireEvent.change(filterInput, { target: { value: '111' } }) rtl.getByText(table, '111@example.com') @@ -182,9 +187,10 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.click(filterByApiKeyIdOption) rtl.fireEvent.change(filterInput, { target: { value: '15' } }) - ;[15, 115, ..._.range(150, 157)] - .map(index => `apiKeyId${index}`) - .forEach(apiKeyId => rtl.getByText(table, apiKeyId)) + _(MOCK_ACCOUNTS) + .filter(({ apiKeyId }) => apiKeyId.includes('15')) + .take(10) + .forEach(({ apiKeyId }) => rtl.getByText(table, apiKeyId)) rtl.fireEvent.change(filterInput, { target: { value: '155' } }) rtl.getByText(table, 'apiKeyId155') @@ -207,9 +213,11 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: '13' } }) rtl.fireEvent.click(dateRegisteredHeader) - ;[113, 13, ..._.range(131, 138)] - .map(index => `apiKeyId${index}`) - .forEach(apiKeyId => rtl.getByText(table, apiKeyId)) + _(MOCK_ACCOUNTS) + .filter(({ emailAddress }) => emailAddress.includes('13')) + .orderBy('dateRegistered') + .take(10) + .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) }) it('deletes an account', async () => { @@ -366,17 +374,18 @@ describe('RegisteredAccounts page', () => { const NUM_MOCK_ACCOUNTS = 157 // should be prime -const MOCK_ACCOUNTS = (() => { - const now = Date.now() - return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => ({ - identityPoolId: `identityPoolId${index}`, - userPoolId: `userPoolId${index}`, - emailAddress: `${index}@example.com`, - dateRegistered: new Date( - now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000, - ).toJSON(), - apiKeyId: `apiKeyId${index}`, - registrationMethod: _.sample(['open', 'invite', 'request']), - isAdmin: index % 20 === 0, - })) -})() +const MOCK_DATES_REGISTERED = (() => + _.range(NUM_MOCK_ACCOUNTS).map(index => { + const now = Date.now() + return new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000) + }))() + +const MOCK_ACCOUNTS = _.range(NUM_MOCK_ACCOUNTS).map(index => ({ + identityPoolId: `identityPoolId${index}`, + userPoolId: `userPoolId${index}`, + emailAddress: `${index}@example.com`, + dateRegistered: MOCK_DATES_REGISTERED[index].toJSON(), + apiKeyId: `apiKeyId${index}`, + registrationMethod: _.sample(['open', 'invite', 'request']), + isAdmin: index % 20 === 0, +})) From 959f21393781964b7dc271d883f612dacfa7f902 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 11:54:42 -0700 Subject: [PATCH 13/19] Admin: clean up an import statement --- dev-portal/src/pages/Admin/Admin.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-portal/src/pages/Admin/Admin.jsx b/dev-portal/src/pages/Admin/Admin.jsx index 87d80ba04..648fb81c3 100644 --- a/dev-portal/src/pages/Admin/Admin.jsx +++ b/dev-portal/src/pages/Admin/Admin.jsx @@ -2,7 +2,7 @@ import React, { Component } from 'react' import { BrowserRouter as Router } from 'react-router-dom' import { ApiManagement, SideNav } from './' -import { AdminRoute } from './../../' +import { AdminRoute } from 'index' import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' import AdminAccounts from 'pages/Admin/Accounts/AdminAccounts' From ab5a860293415f7a4fb88dc83535407fd4a81d12 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 13:49:35 -0700 Subject: [PATCH 14/19] RegisteredAccounts: magic numbers begone --- .../Accounts/__tests__/RegisteredAccounts.jsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx index 4a3f913d1..9902fd81d 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -103,7 +103,7 @@ describe('RegisteredAccounts page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) .orderBy(['emailAddress'], ['asc']) - .take(10) + .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) // Check that last page is correct @@ -112,7 +112,10 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.click(lastPageButton) _(MOCK_ACCOUNTS) .orderBy(['emailAddress'], ['asc']) - .drop(150) + .drop( + Math.floor(NUM_MOCK_ACCOUNTS / AccountsTable.DEFAULT_PAGE_SIZE) * + AccountsTable.DEFAULT_PAGE_SIZE, + ) .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) // Order descending, go back to first page @@ -123,7 +126,7 @@ describe('RegisteredAccounts page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) .orderBy(['emailAddress'], ['desc']) - .take(10) + .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) }) @@ -143,7 +146,7 @@ describe('RegisteredAccounts page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) .orderBy('dateRegistered') - .take(10) + .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) }) @@ -160,7 +163,7 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: '11' } }) _(MOCK_ACCOUNTS) .filter(({ emailAddress }) => emailAddress.includes('11')) - .take(10) + .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) rtl.fireEvent.change(filterInput, { target: { value: '111' } }) @@ -189,7 +192,7 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: '15' } }) _(MOCK_ACCOUNTS) .filter(({ apiKeyId }) => apiKeyId.includes('15')) - .take(10) + .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ apiKeyId }) => rtl.getByText(table, apiKeyId)) rtl.fireEvent.change(filterInput, { target: { value: '155' } }) @@ -216,7 +219,7 @@ describe('RegisteredAccounts page', () => { _(MOCK_ACCOUNTS) .filter(({ emailAddress }) => emailAddress.includes('13')) .orderBy('dateRegistered') - .take(10) + .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) }) From 594f375e59ebfe59060dbefc2dae358a82742247 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 14:46:56 -0700 Subject: [PATCH 15/19] RegisteredAccounts: typo --- .../Admin/Accounts/__tests__/RegisteredAccounts.jsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx index 9902fd81d..9941d038f 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -377,11 +377,12 @@ describe('RegisteredAccounts page', () => { const NUM_MOCK_ACCOUNTS = 157 // should be prime -const MOCK_DATES_REGISTERED = (() => - _.range(NUM_MOCK_ACCOUNTS).map(index => { - const now = Date.now() - return new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000) - }))() +const MOCK_DATES_REGISTERED = (() => { + const now = Date.now() + return _.range(NUM_MOCK_ACCOUNTS).map( + index => new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000), + ) +})() const MOCK_ACCOUNTS = _.range(NUM_MOCK_ACCOUNTS).map(index => ({ identityPoolId: `identityPoolId${index}`, From bcca9025a6b18349cee3fa46d2a7e49950e742f4 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 6 Aug 2019 17:24:15 -0700 Subject: [PATCH 16/19] AccountsTable: fix redundant/missing hook dependencies --- .../src/components/Admin/Accounts/AccountsTable.jsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx index dad722dec..01a026f38 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -97,13 +97,7 @@ export const AccountsTable = ({ else if (!filterableColumns.includes(filterColumn)) { setFilterColumn(NO_FILTER_COLUMN) } - }, [ - columns, - filterColumn, - setFilterColumn, - setFilterValue, - setFilterableColumns, - ]) + }, [columns, filterColumn]) /** * Sets `accountsView` to the filtered and sorted subset of `props.accounts`. @@ -140,7 +134,7 @@ export const AccountsTable = ({ } return pageItems }, - [accountsView], + [accountsView, pageSize], ) const totalPages = Math.ceil(accountsView.length / pageSize) From f5de0f643cd65c4cb6c0b657da72e4d48350af25 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Wed, 7 Aug 2019 14:03:26 -0700 Subject: [PATCH 17/19] Fix malformed prettier config --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 16fc00237..6af5d9e0b 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "prettier": { "trailingComma": "all", "tabWidth": 2, - "semi": "false", - "singleQuote": "true", - "jsxSingleQuote": "true" + "semi": false, + "singleQuote": true, + "jsxSingleQuote": true } } From c6542024a59310764319a81b18edecb4175474aa Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Thu, 8 Aug 2019 13:34:47 -0700 Subject: [PATCH 18/19] AccountsTable: simplify filter/order state hooks --- .../Admin/Accounts/AccountsTable.jsx | 151 +++++++++--------- 1 file changed, 75 insertions(+), 76 deletions(-) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx index 01a026f38..98a19f4b7 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -73,10 +73,14 @@ export const AccountsTable = ({ ) const [filterableColumns, setFilterableColumns] = useState([]) - const [filterColumn, setFilterColumn] = useState(NO_FILTER_COLUMN) - const [filterValue, setFilterValue] = useState(NO_FILTER_VALUE) - const [orderColumn, setOrderColumn] = useState(NO_ORDER_COLUMN) - const [orderDirectionIndex, setOrderDirectionIndex] = useState(0) + const [filter, setFilter] = useState({ + column: NO_FILTER_COLUMN, + value: NO_FILTER_VALUE, + }) + const [order, setOrder] = useState({ + column: NO_ORDER_COLUMN, + directionIndex: 0, + }) useEffect(() => { const filterableColumns = columns.filter(column => column.filtering) @@ -84,41 +88,44 @@ export const AccountsTable = ({ // Reset filtering state if no columns are filterable if (filterableColumns.length === 0) { - setFilterColumn(NO_FILTER_COLUMN) - setFilterValue(NO_FILTER_VALUE) + setFilter({ + column: NO_FILTER_COLUMN, + value: NO_FILTER_VALUE, + }) } // Pick the first filterable column if one is available - else if (filterColumn === NO_FILTER_COLUMN) { - setFilterColumn(filterableColumns[0]) + else if (filter.column === NO_FILTER_COLUMN) { + setFilter(filter => ({ ...filter, column: filterableColumns[0] })) } // Reset filterColumn if it's no longer among the available columns - else if (!filterableColumns.includes(filterColumn)) { - setFilterColumn(NO_FILTER_COLUMN) + else if (!filterableColumns.includes(filter.column)) { + setFilter(filter => ({ ...filter, column: NO_FILTER_COLUMN })) } - }, [columns, filterColumn]) + }, [columns, filter]) /** * Sets `accountsView` to the filtered and sorted subset of `props.accounts`. */ useEffect(() => { let view = _(accounts) - if (filterColumn !== NO_FILTER_COLUMN) { - const filterKey = filterColumn.filtering.accessor + if (filter.column !== NO_FILTER_COLUMN) { + const filterKey = filter.column.filtering.accessor view = view.filter( item => - !!item[filterKey] && item[filterKey].toString().includes(filterValue), + !!item[filterKey] && + item[filterKey].toString().includes(filter.value), ) } - if (orderColumn !== NO_ORDER_COLUMN) { + if (order.column !== NO_ORDER_COLUMN) { view = view.orderBy( - [orderColumn.ordering.iteratee], - [ORDER_DIRECTIONS[orderDirectionIndex].lodashDirection], + [order.column.ordering.iteratee], + [ORDER_DIRECTIONS[order.directionIndex].lodashDirection], ) } setAccountsView(view.value()) - }, [accounts, filterColumn, filterValue, orderColumn, orderDirectionIndex]) + }, [accounts, filter, order]) /** * Returns a page of accounts from `accountView` according to the given page @@ -178,10 +185,14 @@ export const AccountsTable = ({ ) const onFilterColumnDropdownChange = (_event, { value }) => - setFilterColumn( - filterableColumns.find(column => column.id === value) || NO_FILTER_COLUMN, - ) - const onSearchInputChange = (_event, { value }) => setFilterValue(value) + setFilter(filter => ({ + ...filter, + column: + filterableColumns.find(column => column.id === value) || + NO_FILTER_COLUMN, + })) + const onSearchInputChange = (_event, { value }) => + setFilter(filter => ({ ...filter, value })) const toolbar = ( <> @@ -193,7 +204,7 @@ export const AccountsTable = ({ iconPosition='left' icon='search' placeholder='Search by...' - value={filterValue} + value={filter.value} onChange={onSearchInputChange} style={{ maxWidth: '24em' }} /> @@ -206,7 +217,7 @@ export const AccountsTable = ({ onChange={onFilterColumnDropdownChange} options={filterColumnDropdownOptions} selection - value={filterColumn.id} + value={filter.column.id} data-testid='filterDropdown' /> @@ -218,13 +229,7 @@ export const AccountsTable = ({ const table = ( - + {tableRows} @@ -251,54 +256,48 @@ export const AccountsTable = ({ ) } -const TableHeader = React.memo( - ({ - columns, - orderColumn, - setOrderColumn, - orderDirectionIndex, - setOrderDirectionIndex, - }) => { - // Clicking on a column makes it the "orderColumn". If that column was - // already the "orderColumn", cycle between order directions (none, - // ascending, descending). Otherwise, start at the beginning of the cycle - // (ascending). - const onToggleOrder = column => () => { - if (column === orderColumn) { - const nextIndex = nextDirectionIndex(orderDirectionIndex) - if (nextIndex === 0) { - setOrderColumn(NO_ORDER_COLUMN) - } - setOrderDirectionIndex(nextIndex) - } else { - setOrderColumn(column) - setOrderDirectionIndex(nextDirectionIndex(0)) +const TableHeader = React.memo(({ columns, order, setOrder }) => { + // Clicking on a column makes it the "order column". If that column was + // already the "order column", cycle between order directions (none, + // ascending, descending). Otherwise, start at the beginning of the cycle + // (ascending). + const onToggleOrder = column => () => { + const nextOrder = { ...order } + + if (column === order.column) { + const nextIndex = nextDirectionIndex(order.directionIndex) + if (nextIndex === 0) { + nextOrder.column = NO_ORDER_COLUMN } + nextOrder.directionIndex = nextIndex + } else { + nextOrder.column = column + nextOrder.directionIndex = nextDirectionIndex(0) } - const orderDirection = ORDER_DIRECTIONS[orderDirectionIndex] - return ( - - - {columns.map((column, index) => ( - - {column.title} - {column === orderColumn && ( - - )} - {column.ordering && column !== orderColumn && ( - - )} - - ))} - - - ) - }, -) + setOrder(nextOrder) + } + + const orderDirection = ORDER_DIRECTIONS[order.directionIndex] + return ( + + + {columns.map((column, index) => ( + + {column.title} + {column === order.column && } + {column.ordering && column !== order.column && ( + + )} + + ))} + + + ) +}) const LoadingAccountRow = React.memo(({ columnCount }) => ( From f68c4540ea7926b952c3dc65397157efa9233721 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Thu, 8 Aug 2019 14:13:09 -0700 Subject: [PATCH 19/19] MessageList: simplify usage --- dev-portal/src/components/MessageList.jsx | 50 +++++------ .../Admin/Accounts/RegisteredAccounts.jsx | 85 +++++++------------ 2 files changed, 52 insertions(+), 83 deletions(-) diff --git a/dev-portal/src/components/MessageList.jsx b/dev-portal/src/components/MessageList.jsx index 2500ca2ac..bbcacb20b 100644 --- a/dev-portal/src/components/MessageList.jsx +++ b/dev-portal/src/components/MessageList.jsx @@ -1,35 +1,29 @@ import React, { useState } from 'react' -export const MessageList = ({ messages, dismissMessage, renderers }) => ( - <> - {messages.map((message, index) => { - const { type, ...payload } = message - if (renderers[type]) { - return ( - - {renderers[type](payload, () => dismissMessage(message))} - - ) - } - throw new Error(`Unknown message type: ${type.toString()}`) - })} - -) +export const MessageList = ({ messages }) => + messages.map((message, index) => ( + {message} + )) -export const useMessageQueue = initialMessages => { - const [messages, setMessages] = useState(initialMessages || []) +/** + * A Hook for operating a list of "messages" which should be self-dismissable. + * Returns `[messages, sendMessage]`, where: + * - `messages` is an array of renderable messages (of type `React.ReactNode`) + * - `sendMessage` is a function which accepts a renderer callback, and + * calls the callback to obtain a renderable message to append to + * `messages`. The renderer callback should accept a `dismiss` function as + * its sole argument, which removes the renderable message from `messages` + * when called. + */ +export const useMessages = () => { + const [messages, setMessages] = useState([]) - const sendMessage = target => setMessages([...messages, target]) - const dismissMessage = target => { - const deleteIndex = messages.findIndex(message => message === target) - if (deleteIndex === -1) { - throw new Error('Message not found') - } - setMessages([ - ...messages.slice(0, deleteIndex), - ...messages.slice(deleteIndex + 1), - ]) + const sendMessage = renderWithDismiss => { + const target = renderWithDismiss(() => { + setMessages(messages => messages.filter(message => message !== target)) + }) + setMessages(messages => [...messages, target]) } - return [messages, sendMessage, dismissMessage] + return [messages, sendMessage] } diff --git a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx index c2de56a99..7f4d290c5 100644 --- a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx @@ -6,18 +6,13 @@ import * as AccountService from 'services/accounts' import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' -const DELETE_SUCCESS = Symbol('DELETE_SUCCESS') -const DELETE_FAILURE = Symbol('DELETE_FAILURE') -const PROMOTE_SUCCESS = Symbol('PROMOTE_SUCCESS') -const PROMOTE_FAILURE = Symbol('PROMOTE_FAILURE') - const RegisteredAccounts = () => { const [accounts, setAccounts] = useState([]) const [loading, setLoading] = useState(true) const [selectedAccount, setSelectedAccount] = useState(undefined) const [deleteModalOpen, setDeleteModalOpen] = useState(false) const [promoteModalOpen, setPromoteModalOpen] = useState(false) - const [messages, sendMessage, dismissMessage] = MessageList.useMessageQueue() + const [messages, sendMessage] = MessageList.useMessages() const refreshAccounts = () => AccountService.fetchRegisteredAccounts().then(accounts => @@ -40,14 +35,18 @@ const RegisteredAccounts = () => { await AccountService.deleteAccountByIdentityPoolId( selectedAccount.identityPoolId, ) - sendMessage({ type: DELETE_SUCCESS, account: selectedAccount }) + sendMessage(dismiss => ( + + )) await refreshAccounts() } catch (error) { - sendMessage({ - type: DELETE_FAILURE, - account: selectedAccount, - errorMessage: error.message, - }) + sendMessage(dismiss => ( + + )) } finally { setLoading(false) } @@ -60,27 +59,26 @@ const RegisteredAccounts = () => { await AccountService.promoteAccountByIdentityPoolId( selectedAccount.identityPoolId, ) - sendMessage({ type: PROMOTE_SUCCESS, account: selectedAccount }) + sendMessage(dismiss => ( + + )) } catch (error) { - sendMessage({ - type: PROMOTE_FAILURE, - account: selectedAccount, - errorMessage: error.message, - }) + sendMessage(dismiss => ( + + )) } finally { setLoading(false) } }, [sendMessage, selectedAccount]) - return (
Registered accounts
- + ( - - ), - [DELETE_FAILURE]: ({ account, errorMessage }, onDismiss) => ( - - ), - [PROMOTE_SUCCESS]: ({ account }, onDismiss) => ( - - ), - [PROMOTE_FAILURE]: ({ account, errorMessage }, onDismiss) => ( - - ), -} - -const DeleteSuccessMessage = React.memo(({ account, onDismiss }) => ( - +const DeleteSuccessMessage = React.memo(({ account, dismiss }) => ( + Deleted account {account.emailAddress}. @@ -211,8 +186,8 @@ const DeleteSuccessMessage = React.memo(({ account, onDismiss }) => ( )) const DeleteFailureMessage = React.memo( - ({ account, errorMessage, onDismiss }) => ( - + ({ account, errorMessage, dismiss }) => ( +

Failed to delete account {account.emailAddress}. @@ -223,8 +198,8 @@ const DeleteFailureMessage = React.memo( ), ) -const PromoteSuccessMessage = React.memo(({ account, onDismiss }) => ( - +const PromoteSuccessMessage = React.memo(({ account, dismiss }) => ( + Promoted account {account.emailAddress}. @@ -232,8 +207,8 @@ const PromoteSuccessMessage = React.memo(({ account, onDismiss }) => ( )) const PromoteFailureMessage = React.memo( - ({ account, errorMessage, onDismiss }) => ( - + ({ account, errorMessage, dismiss }) => ( +

Failed to promote account {account.emailAddress}.