From ed40a87cdceb83ef29c60f9cce0316d47e8953df Mon Sep 17 00:00:00 2001 From: Alexandre Bourdin Date: Mon, 6 Feb 2023 13:30:57 +0100 Subject: [PATCH] [web] Migrate group management to React (#11293) * Rename manage group entry point * Migrate group management root page to React * Add cypress tests for the group management react page * Fix linting * Add checkbox labels for screen-readers + remove unused classes * Await on add/remove members calls * Display the export CSV link for a full group * Display error message when group is full * Sort locales * Handle the managers management page in React version * Fix missing type in GroupMemberRow * Split members and managers React pages * Build API paths on frontend side + add cypress tests for each page * Fix linting * Update unit tests * Review improvements * Type API errors GitOrigin-RevId: d124a9d24cbf33de8aacc5d69e9d46e7bcda93c5 --- .../UserMembershipController.js | 127 +++++++- .../UserMembership/UserMembershipRouter.js | 8 +- .../user_membership/group-managers-react.pug | 12 + .../user_membership/group-members-react.pug | 13 + .../app/views/user_membership/index-react.pug | 12 - .../institution-managers-react.pug | 12 + .../publisher-managers-react.pug | 12 + .../web/frontend/extracted-translations.json | 19 ++ .../components/error-alert.tsx | 31 ++ .../components/group-managers.tsx | 37 +++ .../components/group-member-row.tsx | 81 +++++ .../components/group-members.tsx | 293 ++++++++++++++++++ .../components/institution-managers.tsx | 37 +++ .../components/managers-table.tsx | 260 ++++++++++++++++ .../components/publisher-managers.tsx | 37 +++ .../hooks/use-user-selection.ts | 29 ++ .../features/group-management/utils/emails.ts | 9 + .../membership/components/groups-root.tsx | 5 - .../group-management/group-managers.js} | 5 +- .../group-management/group-members.js | 8 + .../group-management/institution-managers.js | 8 + .../group-management/publisher-managers.js | 8 + services/web/locales/en.json | 2 + .../components/group-managers.spec.tsx | 154 +++++++++ .../components/group-members.spec.tsx | 159 ++++++++++ .../components/institution-managers.spec.tsx | 154 +++++++++ .../components/publisher-managers.spec.tsx | 154 +++++++++ .../UserMembershipControllerTests.js | 29 +- services/web/types/group-management/user.ts | 8 + 29 files changed, 1678 insertions(+), 45 deletions(-) create mode 100644 services/web/app/views/user_membership/group-managers-react.pug create mode 100644 services/web/app/views/user_membership/group-members-react.pug delete mode 100644 services/web/app/views/user_membership/index-react.pug create mode 100644 services/web/app/views/user_membership/institution-managers-react.pug create mode 100644 services/web/app/views/user_membership/publisher-managers-react.pug create mode 100644 services/web/frontend/js/features/group-management/components/error-alert.tsx create mode 100644 services/web/frontend/js/features/group-management/components/group-managers.tsx create mode 100644 services/web/frontend/js/features/group-management/components/group-member-row.tsx create mode 100644 services/web/frontend/js/features/group-management/components/group-members.tsx create mode 100644 services/web/frontend/js/features/group-management/components/institution-managers.tsx create mode 100644 services/web/frontend/js/features/group-management/components/managers-table.tsx create mode 100644 services/web/frontend/js/features/group-management/components/publisher-managers.tsx create mode 100644 services/web/frontend/js/features/group-management/hooks/use-user-selection.ts create mode 100644 services/web/frontend/js/features/group-management/utils/emails.ts delete mode 100644 services/web/frontend/js/features/membership/components/groups-root.tsx rename services/web/frontend/js/pages/user/{membership/groups.js => subscription/group-management/group-managers.js} (58%) create mode 100644 services/web/frontend/js/pages/user/subscription/group-management/group-members.js create mode 100644 services/web/frontend/js/pages/user/subscription/group-management/institution-managers.js create mode 100644 services/web/frontend/js/pages/user/subscription/group-management/publisher-managers.js create mode 100644 services/web/test/frontend/features/group-management/components/group-managers.spec.tsx create mode 100644 services/web/test/frontend/features/group-management/components/group-members.spec.tsx create mode 100644 services/web/test/frontend/features/group-management/components/institution-managers.spec.tsx create mode 100644 services/web/test/frontend/features/group-management/components/publisher-managers.spec.tsx create mode 100644 services/web/types/group-management/user.ts diff --git a/services/web/app/src/Features/UserMembership/UserMembershipController.js b/services/web/app/src/Features/UserMembership/UserMembershipController.js index 547971d440..f0b700e7a7 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipController.js +++ b/services/web/app/src/Features/UserMembership/UserMembershipController.js @@ -20,7 +20,7 @@ const SplitTestHandler = require('../SplitTests/SplitTestHandler') const CSVParser = require('json2csv').Parser const logger = require('@overleaf/logger') -async function index(req, res, next) { +async function manageGroupMembers(req, res, next) { try { const assignment = await SplitTestHandler.promises.getAssignment( req, @@ -28,7 +28,7 @@ async function index(req, res, next) { 'subscription-pages-react' ) if (assignment.variant === 'active') { - await _indexReact(req, res, next) + await _manageGroupMembersReact(req, res, next) } else { await _indexAngular(req, res, next) } @@ -41,7 +41,85 @@ async function index(req, res, next) { } } -function _indexReact(req, res, next) { +async function manageGroupManagers(req, res, next) { + try { + const assignment = await SplitTestHandler.promises.getAssignment( + req, + res, + 'subscription-pages-react' + ) + if (assignment.variant === 'active') { + await _renderManagersPage( + req, + res, + next, + 'user_membership/group-managers-react' + ) + } else { + await _indexAngular(req, res, next) + } + } catch (error) { + logger.warn( + { err: error }, + 'failed to get "subscription-pages-react" split test assignment' + ) + await _indexAngular(req, res, next) + } +} + +async function manageInstitutionManagers(req, res, next) { + try { + const assignment = await SplitTestHandler.promises.getAssignment( + req, + res, + 'subscription-pages-react' + ) + if (assignment.variant === 'active') { + await _renderManagersPage( + req, + res, + next, + 'user_membership/institution-managers-react' + ) + } else { + await _indexAngular(req, res, next) + } + } catch (error) { + logger.warn( + { err: error }, + 'failed to get "subscription-pages-react" split test assignment' + ) + await _indexAngular(req, res, next) + } +} + +async function managePublisherManagers(req, res, next) { + try { + const assignment = await SplitTestHandler.promises.getAssignment( + req, + res, + 'subscription-pages-react' + ) + if (assignment.variant === 'active') { + await _renderManagersPage( + req, + res, + next, + 'user_membership/publisher-managers-react' + ) + } else { + await _indexAngular(req, res, next) + } + } catch (error) { + logger.warn( + { err: error }, + 'failed to get "subscription-pages-react" split test assignment' + ) + await _indexAngular(req, res, next) + } +} + +async function _manageGroupMembersReact(req, res, next) { const { entity, entityConfig } = req return entity.fetchV1Data(function (error, entity) { if (error != null) { @@ -60,14 +138,40 @@ function _indexReact(req, res, next) { if (entityConfig.fields.name) { entityName = entity[entityConfig.fields.name] } - return res.render('user_membership/index-react', { + return res.render('user_membership/group-members-react', { + name: entityName, + groupId: entityPrimaryKey, + users, + groupSize: entity.membersLimit, + }) + } + ) + }) +} + +async function _renderManagersPage(req, res, next, template) { + const { entity, entityConfig } = req + return entity.fetchV1Data(function (error, entity) { + if (error != null) { + return next(error) + } + return UserMembershipHandler.getUsers( + entity, + entityConfig, + function (error, users) { + let entityName + if (error != null) { + return next(error) + } + const entityPrimaryKey = + entity[entityConfig.fields.primaryKey].toString() + if (entityConfig.fields.name) { + entityName = entity[entityConfig.fields.name] + } + return res.render(template, { name: entityName, users, - groupSize: entityConfig.hasMembersLimit - ? entity.membersLimit - : undefined, - translations: entityConfig.translations, - paths: entityConfig.pathsFor(entityPrimaryKey), + groupId: entityPrimaryKey, }) } ) @@ -108,7 +212,10 @@ function _indexAngular(req, res, next) { } module.exports = { - index, + manageGroupMembers, + manageGroupManagers, + manageInstitutionManagers, + managePublisherManagers, add(req, res, next) { const { entity, entityConfig } = req const email = EmailHelper.parseEmail(req.body.email) diff --git a/services/web/app/src/Features/UserMembership/UserMembershipRouter.js b/services/web/app/src/Features/UserMembership/UserMembershipRouter.js index c88d53257b..39b54aed9d 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipRouter.js +++ b/services/web/app/src/Features/UserMembership/UserMembershipRouter.js @@ -22,7 +22,7 @@ module.exports = { webRouter.get( '/manage/groups/:id/members', UserMembershipMiddleware.requireGroupManagementAccess, - UserMembershipController.index + UserMembershipController.manageGroupMembers ) webRouter.post( '/manage/groups/:id/invites', @@ -51,7 +51,7 @@ module.exports = { webRouter.get( '/manage/groups/:id/managers', UserMembershipMiddleware.requireGroupManagersManagementAccess, - UserMembershipController.index + UserMembershipController.manageGroupManagers ) webRouter.post( '/manage/groups/:id/managers', @@ -68,7 +68,7 @@ module.exports = { webRouter.get( '/manage/institutions/:id/managers', UserMembershipMiddleware.requireInstitutionManagementAccess, - UserMembershipController.index + UserMembershipController.manageInstitutionManagers ) webRouter.post( '/manage/institutions/:id/managers', @@ -85,7 +85,7 @@ module.exports = { webRouter.get( '/manage/publishers/:id/managers', UserMembershipMiddleware.requirePublisherManagementAccess, - UserMembershipController.index + UserMembershipController.managePublisherManagers ) webRouter.post( '/manage/publishers/:id/managers', diff --git a/services/web/app/views/user_membership/group-managers-react.pug b/services/web/app/views/user_membership/group-managers-react.pug new file mode 100644 index 0000000000..f4d8c0e973 --- /dev/null +++ b/services/web/app/views/user_membership/group-managers-react.pug @@ -0,0 +1,12 @@ +extends ../layout-marketing + +block entrypointVar + - entrypoint = 'pages/user/subscription/group-management/group-managers' + +block append meta + meta(name="ol-users", data-type="json", content=users) + meta(name="ol-groupId", data-type="string", content=groupId) + meta(name="ol-groupName", data-type="string", content=name) + +block content + main.content.content-alt#subscription-manage-group-root diff --git a/services/web/app/views/user_membership/group-members-react.pug b/services/web/app/views/user_membership/group-members-react.pug new file mode 100644 index 0000000000..264b8a7222 --- /dev/null +++ b/services/web/app/views/user_membership/group-members-react.pug @@ -0,0 +1,13 @@ +extends ../layout-marketing + +block entrypointVar + - entrypoint = 'pages/user/subscription/group-management/group-members' + +block append meta + meta(name="ol-users", data-type="json", content=users) + meta(name="ol-groupId", data-type="string", content=groupId) + meta(name="ol-groupName", data-type="string", content=name) + meta(name="ol-groupSize", data-type="json", content=groupSize) + +block content + main.content.content-alt#subscription-manage-group-root diff --git a/services/web/app/views/user_membership/index-react.pug b/services/web/app/views/user_membership/index-react.pug deleted file mode 100644 index 1deb768f12..0000000000 --- a/services/web/app/views/user_membership/index-react.pug +++ /dev/null @@ -1,12 +0,0 @@ -extends ../layout-marketing - -block entrypointVar - - entrypoint = 'pages/user/membership/groups' - -block append meta - meta(name="ol-users", data-type="json", content=users) - meta(name="ol-paths", data-type="json", content=paths) - meta(name="ol-groupSize", data-type="json", content=groupSize) - -block content - main.content.content-alt#subscription-manage-groups-root diff --git a/services/web/app/views/user_membership/institution-managers-react.pug b/services/web/app/views/user_membership/institution-managers-react.pug new file mode 100644 index 0000000000..690e8409f2 --- /dev/null +++ b/services/web/app/views/user_membership/institution-managers-react.pug @@ -0,0 +1,12 @@ +extends ../layout-marketing + +block entrypointVar + - entrypoint = 'pages/user/subscription/group-management/institution-managers' + +block append meta + meta(name="ol-users", data-type="json", content=users) + meta(name="ol-groupId", data-type="string", content=groupId) + meta(name="ol-groupName", data-type="string", content=name) + +block content + main.content.content-alt#subscription-manage-group-root diff --git a/services/web/app/views/user_membership/publisher-managers-react.pug b/services/web/app/views/user_membership/publisher-managers-react.pug new file mode 100644 index 0000000000..793bdf9602 --- /dev/null +++ b/services/web/app/views/user_membership/publisher-managers-react.pug @@ -0,0 +1,12 @@ +extends ../layout-marketing + +block entrypointVar + - entrypoint = 'pages/user/subscription/group-management/publisher-managers' + +block append meta + meta(name="ol-users", data-type="json", content=users) + meta(name="ol-groupId", data-type="string", content=groupId) + meta(name="ol-groupName", data-type="string", content=name) + +block content + main.content.content-alt#subscription-manage-group-root diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index cbf7a0191e..2a3759df7e 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -6,6 +6,7 @@ "about_to_delete_the_following": "", "about_to_leave_projects": "", "about_to_trash_projects": "", + "accepted_invite": "", "access_denied": "", "access_your_projects_with_git": "", "account_has_been_link_to_institution_account": "", @@ -14,14 +15,18 @@ "account_settings": "", "acct_linked_to_institution_acct_2": "", "actions": "", + "add": "", "add_affiliation": "", "add_another_email": "", + "add_comma_separated_emails_help": "", "add_email_to_claim_features": "", "add_files": "", + "add_more_members": "", "add_new_email": "", "add_or_remove_project_from_tag": "", "add_role_and_department": "", "add_to_folder": "", + "adding": "", "additional_licenses": "", "all_projects": "", "also": "", @@ -201,6 +206,7 @@ "error_performing_request": "", "example_project": "", "expand": "", + "export_csv": "", "export_project_to_github": "", "fast": "", "faster_compiles_feedback_question": "", @@ -298,6 +304,7 @@ "go_to_pdf_location_in_code": "", "group_plan_tooltip": "", "group_plan_with_name_tooltip": "", + "group_subscription": "", "have_an_extra_backup": "", "headers": "", "help": "", @@ -359,6 +366,8 @@ "labs_program_already_participating": "", "labs_program_benefits": "<0>", "labs_program_not_participating": "", + "last_active": "", + "last_active_description": "", "last_modified": "", "last_name": "", "last_resort_trouble_shooting_guide": "", @@ -415,9 +424,11 @@ "manage_members": "", "manage_newsletter": "", "manage_sessions": "", + "managers_management": "", "math_display": "", "math_inline": "", "maximum_files_uploaded_together": "", + "members_management": "", "mendeley_groups_loading_error": "", "mendeley_groups_relink": "", "mendeley_integration": "", @@ -430,6 +441,7 @@ "more": "", "n_items": "", "n_items_plural": "", + "name": "", "navigate_log_source": "", "navigation": "", "need_to_add_new_primary_before_remove": "", @@ -445,6 +457,7 @@ "newsletter": "", "next_payment_of_x_collectected_on_y": "", "no_existing_password": "", + "no_members": "", "no_messages": "", "no_new_commits_in_github": "", "no_other_projects_found": "", @@ -573,7 +586,10 @@ "remote_service_error": "", "remove": "", "remove_collaborator": "", + "remove_from_group": "", + "remove_manager": "", "remove_tag": "", + "removing": "", "rename": "", "rename_folder": "", "rename_project": "", @@ -611,6 +627,7 @@ "search_whole_word": "", "select_a_file": "", "select_a_project": "", + "select_all": "", "select_all_projects": "", "select_an_output_file": "", "select_from_output_files": "", @@ -620,6 +637,7 @@ "select_project": "", "select_projects": "", "select_tag": "", + "select_user": "", "selected": "", "send": "", "send_first_message": "", @@ -789,6 +807,7 @@ "you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "", "you_can_now_log_in_sso": "", "you_dont_have_any_repositories": "", + "you_have_added_x_of_group_size_y": "", "your_affiliation_is_confirmed": "", "your_browser_does_not_support_this_feature": "", "your_message_to_collaborators": "", diff --git a/services/web/frontend/js/features/group-management/components/error-alert.tsx b/services/web/frontend/js/features/group-management/components/error-alert.tsx new file mode 100644 index 0000000000..a7f971dba5 --- /dev/null +++ b/services/web/frontend/js/features/group-management/components/error-alert.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next' + +export type APIError = { + message?: string +} + +type ErrorAlertProps = { + error?: APIError +} + +export default function ErrorAlert({ error }: ErrorAlertProps) { + const { t } = useTranslation() + + if (!error) { + return null + } + + if (error.message) { + return ( +
+ {t('error')}: {error.message} +
+ ) + } + + return ( +
+ {t('generic_something_went_wrong')} +
+ ) +} diff --git a/services/web/frontend/js/features/group-management/components/group-managers.tsx b/services/web/frontend/js/features/group-management/components/group-managers.tsx new file mode 100644 index 0000000000..af13423c47 --- /dev/null +++ b/services/web/frontend/js/features/group-management/components/group-managers.tsx @@ -0,0 +1,37 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n' +import getMeta from '../../../utils/meta' +import { ManagersTable } from './managers-table' + +export default function GroupManagers() { + const { isReady } = useWaitForI18n() + const { t } = useTranslation() + + const groupId: string = getMeta('ol-groupId') + const groupName: string = getMeta('ol-groupName') + + const paths = useMemo( + () => ({ + addMember: `/manage/groups/${groupId}/managers`, + removeMember: `/manage/groups/${groupId}/managers`, + }), + [groupId] + ) + + if (!isReady) { + return null + } + + return ( + + ) +} diff --git a/services/web/frontend/js/features/group-management/components/group-member-row.tsx b/services/web/frontend/js/features/group-management/components/group-member-row.tsx new file mode 100644 index 0000000000..04127b1b39 --- /dev/null +++ b/services/web/frontend/js/features/group-management/components/group-member-row.tsx @@ -0,0 +1,81 @@ +import moment from 'moment' +import { useCallback } from 'react' +import { Col, Row } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { User } from '../../../../../types/group-management/user' + +type GroupMemberRowProps = { + user: User + selectUser: (user: User) => void + unselectUser: (user: User) => void + selected: boolean +} + +export default function GroupMemberRow({ + user, + selectUser, + unselectUser, + selected, +}: GroupMemberRowProps) { + const { t } = useTranslation() + + const handleSelectUser = useCallback( + (event, user) => { + if (event.target.checked) { + selectUser(user) + } else { + unselectUser(user) + } + }, + [selectUser, unselectUser] + ) + + return ( +
  • + + + + handleSelectUser(e, user)} + /> + {user.email} + + + {user.first_name} {user.last_name} + + + {user.last_active_at + ? moment(user.last_active_at).format('Do MMM YYYY') + : 'N/A'} + + + {user.invite ? ( + <> +